@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,1177 @@
1
+ import { cancel, note } from '@clack/prompts'
2
+ import {
3
+ type PromusNetwork,
4
+ type PromusPlugin,
5
+ NETWORK_CHAIN_ID,
6
+ type OperatorSigner,
7
+ type PermissionMode,
8
+ SANDBOX_DEFAULT_INITIAL_DEPOSIT_OG,
9
+ SANDBOX_PROVIDER_GALILEO,
10
+ SANDBOX_PROVIDER_URL_GALILEO,
11
+ SANDBOX_TEE_SIGNER_GALILEO,
12
+ SandboxProviderClient,
13
+ type SandboxRecord,
14
+ SandboxSettlementClient,
15
+ SannClient,
16
+ type ToolboxExecResponse,
17
+ agentPaths,
18
+ buildSandboxEndpoint,
19
+ encryptToPubkey,
20
+ fetchAndDecryptKeystore,
21
+ iNFTAgentId,
22
+ subnameNode,
23
+ waitForReceiptResilient,
24
+ } from '@promus/core'
25
+ import {
26
+ BOOTSTRAP_DONE_MARKER,
27
+ BOOTSTRAP_FAIL_KEYWORDS,
28
+ BOOTSTRAP_FAIL_MARKER,
29
+ BOOTSTRAP_PROGRESS_LOG,
30
+ BOOTSTRAP_SUCCESS_MARKER_PREFIX,
31
+ RELAUNCH_DONE_MARKER,
32
+ RELAUNCH_FAIL_MARKER,
33
+ RELAUNCH_PROGRESS_LOG,
34
+ buildBootstrapScript,
35
+ buildGatewayRelaunchScript,
36
+ } from '@promus/gateway'
37
+ import { type Address, type Hex, formatEther, hexToBytes, parseEther } from 'viem'
38
+ import type { LocalAccount } from 'viem/accounts'
39
+ import { SandboxClient } from '../../sandbox/client'
40
+ import { resolveBootstrapMode } from '../../util/bootstrap-mode'
41
+ import type { BootstrapStageId, BootstrapStageStatus } from '../../util/bootstrap-progress-box'
42
+ import { mapBootstrapMarkerToStage } from '../../util/bootstrap-progress-box'
43
+ import { resolveCliVersion } from '../../util/cli-version'
44
+ import { withSilencedConsole } from '../../util/silence-console'
45
+ import type { TelegramHandoffSecrets } from '../../util/telegram-secrets'
46
+
47
+ export type { BootstrapStageId, BootstrapStageStatus }
48
+
49
+ export interface SandboxProvisionOpts {
50
+ /** OperatorSigner. Used for both Galileo settlement txs AND provision sig. */
51
+ operator: OperatorSigner
52
+ /** Decrypted agent privkey (already saved to keystore + uploaded to 0G Storage). */
53
+ agentPrivkey: Hex
54
+ /** Agent EOA derived from privkey. Used in iNFTRef + RuntimeConfig.identity.agent. */
55
+ agentAddress: Address
56
+ /** iNFT identity for the harness's RuntimeConfig. */
57
+ iNFTRef: { contract: Address; tokenId: bigint }
58
+ /** Brain provider + model picked during init. */
59
+ brain: { provider: Address; model: string }
60
+ /** Plugins to load in the harness. Defaults to all 3 first-party. */
61
+ plugins?: PromusPlugin[]
62
+ /** Optional system-prompt append. */
63
+ promptAppend?: string
64
+ /** Optional .0g subname (e.g. "specter") forwarded into RuntimeConfig so the
65
+ * harness's TG pairing greeting addresses the agent by registered name. */
66
+ subname?: string | null
67
+ /**
68
+ * Optional telegram secrets (botToken + allowlist). Threaded into the
69
+ * secondary ECIES envelope inside `handoffAgentToGateway` so the freshly
70
+ * provisioned harness boots with `listeners.telegram: "active"`. Source
71
+ * via `loadTelegramHandoffSecrets` (util/telegram-secrets.ts).
72
+ */
73
+ telegramSecrets?: TelegramHandoffSecrets
74
+ /**
75
+ * v0.23.1: operator-derived PROFILE scope key (32 bytes hex with 0x prefix).
76
+ * Threaded into the same secondary ECIES envelope as telegramSecrets so the
77
+ * freshly provisioned harness boots with `slots.profile` ready to anchor
78
+ * instead of `{ status: 'skipped', reason: 'no-profile-key' }`. Source via
79
+ * `loadProfileScopeKeyHex` (util/profile-key.ts) when called from upgrade
80
+ * paths; init derives it inline as part of the operator-sign step.
81
+ */
82
+ profileScopeKeyHex?: `0x${string}`
83
+ /** Network the iNFT lives on (mainnet for hybrid path 1). */
84
+ iNFTNetwork: PromusNetwork
85
+ /** Sandbox name (sent to provider; surfaces in dashboards). */
86
+ name: string
87
+ /** Git tag the bootstrap script clones (e.g. 'v0.15.0'). Used in git mode. */
88
+ ref: string
89
+ /** Override repo URL (defaults to canonical promus repo). Used in git mode. */
90
+ repoUrl?: string
91
+ /**
92
+ * Bootstrap mode: 'git' clones monorepo from GitHub; 'npm' installs
93
+ * promus via `bun add -g`. Defaults to npm (since v0.21.20)
94
+ * because it's ~10x faster (~30-60 sec vs 5-8 min cold start). Falls back
95
+ * to git when PROMUS_BOOTSTRAP_REF is set or PROMUS_BOOTSTRAP_MODE=git
96
+ * (unreleased-code testing). See `resolveBootstrapMode` in
97
+ * `cli/src/util/bootstrap-mode.ts` for the full env resolution.
98
+ */
99
+ mode?: import('@promus/gateway').BootstrapMode
100
+ /**
101
+ * Npm mode: exact published version to install (e.g. '0.21.15'). Defaults
102
+ * to the CLI package's own version (so a v0.21.15 CLI deploys a v0.21.15
103
+ * gateway). Ignored in git mode.
104
+ */
105
+ packageVersion?: string
106
+ /** Override snapshot. Default `daytonaio/sandbox:0.5.0-slim`. */
107
+ snapshotName?: string
108
+ /** Initial deposit to provider contract (testnet 0G). Default 1.0 0G. */
109
+ depositOg?: number
110
+ /**
111
+ * GitHub PAT for cloning private promus repo from inside the container.
112
+ * Falls back to `PROMUS_GITHUB_TOKEN` env var. Public repos can leave unset.
113
+ */
114
+ githubToken?: string
115
+ /** Optional progress callback for spinner UX. */
116
+ onProgress?: (msg: string) => void
117
+ /**
118
+ * Structured stage-event callback. When set, the bootstrap phase + /healthz
119
+ * wait emit `(stage, status)` transitions instead of free-text progress
120
+ * messages, so callers can render a boxed multi-line UI. The pre-bootstrap
121
+ * phase (deposit/createSandbox) still uses `onProgress`.
122
+ */
123
+ onStageEvent?: (id: BootstrapStageId, status: BootstrapStageStatus) => void
124
+ /**
125
+ * Periodic tick from the 5s heartbeat during the launchScript upload + poll
126
+ * loop. Lets a box renderer refresh spinner glyphs and elapsed counters
127
+ * between marker transitions.
128
+ */
129
+ onTick?: () => void
130
+ }
131
+
132
+ export interface SandboxProvisionResult {
133
+ sandboxId: string
134
+ endpoint: string
135
+ providerAddress: Address
136
+ snapshotName: string
137
+ agentAddress: Address
138
+ bootstrapPubkey: Hex
139
+ depositTx?: Hex
140
+ acknowledgeTx?: Hex
141
+ }
142
+
143
+ /**
144
+ * Orchestrate the full sandbox-deploy handoff. Used by `promus init --target
145
+ * sandbox`, `promus deploy`, and `promus upgrade`.
146
+ *
147
+ * Steps:
148
+ * 1. Galileo testnet: deposit + acknowledge TEE signer (skip if already done)
149
+ * 2. provider.createSandbox + wait for state=started
150
+ * 3. provider.execInToolbox(bootstrap-script): apt-get install + bun + git
151
+ * clone + bun install + nohup harness daemon
152
+ * 4. Poll harness /bootstrap/pubkey via nip.io URL
153
+ * 5. ECIES-encrypt agentPrivkey to bootstrap pubkey + EIP-191-sign envelope
154
+ * 6. POST /bootstrap/provision (operator EIP-191 sig over the request hash)
155
+ * 7. Poll /healthz until state=Ready + runtimeReady=true
156
+ * 8. Return sandboxId + endpoint URL for caller to write into config + subname.
157
+ */
158
+ export async function runSandboxProvision(
159
+ opts: SandboxProvisionOpts,
160
+ ): Promise<SandboxProvisionResult> {
161
+ const progress = opts.onProgress ?? (() => {})
162
+ const stageEvent = opts.onStageEvent
163
+ const tick = opts.onTick
164
+ const snapshotName = opts.snapshotName ?? 'daytonaio/sandbox:0.5.0-slim'
165
+ const repoUrl = opts.repoUrl ?? 'https://github.com/JemIIahh/promus.git'
166
+ const depositWei = parseEther(String(opts.depositOg ?? SANDBOX_DEFAULT_INITIAL_DEPOSIT_OG))
167
+
168
+ const operatorAddress = await opts.operator.address()
169
+ const operatorAccount = await opts.operator.account()
170
+ const galileoPublic = await opts.operator.publicClient('0g-testnet')
171
+ const galileoWallet = await opts.operator.walletClient('0g-testnet')
172
+
173
+ if (galileoPublic.chain && galileoPublic.chain.id !== NETWORK_CHAIN_ID['0g-testnet']) {
174
+ throw new Error('operator publicClient bound to wrong chain — expected Galileo testnet')
175
+ }
176
+
177
+ const settlement = new SandboxSettlementClient({
178
+ publicClient: galileoPublic,
179
+ walletClient: galileoWallet,
180
+ })
181
+
182
+ // Reads (deposit balance + TEE ack state) are independent; run in parallel.
183
+ progress('checking provider deposit balance + TEE acknowledgement')
184
+ const [balanceBefore, ackd] = await Promise.all([
185
+ settlement.getBalance(operatorAddress, SANDBOX_PROVIDER_GALILEO),
186
+ settlement.isTEEAcknowledged(operatorAddress, SANDBOX_PROVIDER_GALILEO),
187
+ ])
188
+ let depositTx: Hex | undefined
189
+ if (balanceBefore < depositWei) {
190
+ const need = depositWei - balanceBefore
191
+ progress(`depositing ${formatOg(need)} 0G to provider`)
192
+ depositTx = await settlement.deposit({
193
+ recipient: operatorAddress,
194
+ provider: SANDBOX_PROVIDER_GALILEO,
195
+ amountWei: need,
196
+ })
197
+ await waitForReceiptResilient(galileoPublic, depositTx, { tries: 60, delayMs: 2000 })
198
+ }
199
+ let acknowledgeTx: Hex | undefined
200
+ if (!ackd) {
201
+ progress(`acknowledging TEE signer ${SANDBOX_TEE_SIGNER_GALILEO}`)
202
+ acknowledgeTx = await settlement.acknowledgeTEESigner({
203
+ provider: SANDBOX_PROVIDER_GALILEO,
204
+ acknowledged: true,
205
+ })
206
+ await waitForReceiptResilient(galileoPublic, acknowledgeTx, { tries: 60, delayMs: 2000 })
207
+ }
208
+
209
+ // Step 2: createSandbox
210
+ const provider = new SandboxProviderClient({
211
+ endpoint: SANDBOX_PROVIDER_URL_GALILEO,
212
+ operator: operatorAccount,
213
+ })
214
+
215
+ progress(`creating sandbox snapshot=${snapshotName}`)
216
+ const created = await createSandboxWithOrphanRetry(provider, snapshotName, opts.name, progress)
217
+ if (!created.id) throw new Error('createSandbox returned no id')
218
+ const sandboxId = created.id
219
+
220
+ // Wait for sandbox state=started (~10-30s typical, 120s ceiling).
221
+ progress(`waiting for sandbox ${sandboxId} to start`)
222
+ const startDeadline = Date.now() + 120_000
223
+ let lastState = created.state
224
+ let started = false
225
+ while (Date.now() < startDeadline) {
226
+ const sb = await provider.getSandbox(sandboxId).catch(() => null)
227
+ if (sb?.state) lastState = sb.state
228
+ if (sb?.state === 'started') {
229
+ started = true
230
+ break
231
+ }
232
+ await sleep(2000)
233
+ }
234
+ if (!started) {
235
+ throw new Error(
236
+ `sandbox ${sandboxId} did not reach state=started within 120s (last=${lastState})`,
237
+ )
238
+ }
239
+
240
+ // Step 3: launch bootstrap. The script detaches the slow apt+bun+install
241
+ // work into a nohup'd subshell and returns exit 0 in <2s; the Daytona exec
242
+ // 60s cap then doesn't bite. We poll the done/fail markers for actual
243
+ // completion before moving on to /bootstrap/pubkey.
244
+ //
245
+ // For private promus repos, pass a GitHub PAT via PROMUS_GITHUB_TOKEN env (or
246
+ // the explicit `githubToken` opt). Token is embedded in the clone URL inside
247
+ // the bootstrap script. Public repos skip auth entirely.
248
+ const githubToken = opts.githubToken ?? process.env.PROMUS_GITHUB_TOKEN
249
+ const mode = opts.mode ?? resolveBootstrapMode()
250
+ const packageVersion =
251
+ opts.packageVersion ?? (mode === 'npm' ? await resolveCliVersion() : undefined)
252
+ const { script } = buildBootstrapScript({
253
+ sandboxId,
254
+ operatorAddress,
255
+ ref: opts.ref,
256
+ repoUrl,
257
+ githubToken,
258
+ mode,
259
+ packageVersion,
260
+ })
261
+ const installLabel =
262
+ mode === 'npm'
263
+ ? 'apt + bun + npm install + browser deps + harness daemon, ~90-150s'
264
+ : 'apt + bun + git clone + harness daemon, runs ~3-5 min'
265
+ // v0.24.6: ticker keeps the spinner text alive while `execInToolbox` blocks
266
+ // in HTTP retries against Daytona (worst case ~252s = 3 retries x 60s timeout
267
+ // + linear backoff). Without this, the spinner sits on `launching bootstrap`
268
+ // for 30-180s before the poll-loop heartbeat (line 269+) ever runs.
269
+ //
270
+ // v0.24.7: when a stage-event consumer is registered, route the elapsed
271
+ // signal through `onTick()` so the boxed renderer advances its spinner +
272
+ // counters; the legacy text-based path still fires when callbacks are
273
+ // unset (CI / scripted flows).
274
+ const launchLabel = `launching bootstrap (${installLabel})`
275
+ if (stageEvent) {
276
+ stageEvent('launch-upload', 'running')
277
+ } else {
278
+ progress(launchLabel)
279
+ }
280
+ const launchStart = Date.now()
281
+ let launchTickerRunning = true
282
+ ;(async () => {
283
+ while (launchTickerRunning) {
284
+ await sleep(5000)
285
+ if (!launchTickerRunning) break
286
+ if (tick) tick()
287
+ if (!stageEvent) {
288
+ const elapsedSec = Math.round((Date.now() - launchStart) / 1000)
289
+ progress(`${launchLabel}, ${elapsedSec}s elapsed (uploading script)`)
290
+ }
291
+ }
292
+ })().catch(() => {})
293
+ let launchRes: ToolboxExecResponse
294
+ try {
295
+ launchRes = await provider.execInToolbox(sandboxId, { command: script, timeout: 60 })
296
+ } finally {
297
+ launchTickerRunning = false
298
+ }
299
+ if (launchRes.exitCode !== 0) {
300
+ const launchOut = extractExecOutput(launchRes)
301
+ throw new Error(
302
+ `bootstrap launch failed: exitCode=${launchRes.exitCode} output=${launchOut.slice(0, 400)}`,
303
+ )
304
+ }
305
+
306
+ // Poll the done/fail markers. Bootstrap runs ~3-8 min depending on the
307
+ // image cache; surface progress every 5s so the operator sees real
308
+ // movement instead of a static spinner.
309
+ if (stageEvent) {
310
+ // Box renderer owns the visual; emit no extra text. The launch-upload
311
+ // row stays "running" until the first STAGE marker arrives.
312
+ } else {
313
+ progress('waiting for bootstrap completion (apt + bun + git clone + harness ready)')
314
+ }
315
+ const bootstrapDeadline = Date.now() + 600_000 // 10 min max
316
+ // Lean poll: cheap `cat` of FAIL + DONE markers, plus a 20-line tail of
317
+ // the progress log so STAGE markers are findable. The progress surfacing
318
+ // logic prefers any `STAGE: ...` line over the raw tail line.
319
+ const execRead = makeExecRead(provider, sandboxId)
320
+ const POLL = `echo --F--; cat ${BOOTSTRAP_FAIL_MARKER} 2>/dev/null; echo --D--; cat ${BOOTSTRAP_DONE_MARKER} 2>/dev/null; echo --P--; tail -n 20 ${BOOTSTRAP_PROGRESS_LOG} 2>/dev/null`
321
+ const bootstrapStart = Date.now()
322
+ let lastDone = ''
323
+ let lastSurfaced = ''
324
+ let pollTick = 0
325
+ while (Date.now() < bootstrapDeadline) {
326
+ pollTick += 1
327
+ const out = await execRead(POLL)
328
+ if (tick) tick()
329
+ const fail = sliceBetween(out, '--F--', '--D--')
330
+ const done = sliceBetween(out, '--D--', '--P--')
331
+ const failKeyword = BOOTSTRAP_FAIL_KEYWORDS.find(k => fail.includes(k))
332
+ if (failKeyword) {
333
+ const log = await execRead(`tail -n 80 ${BOOTSTRAP_PROGRESS_LOG} 2>/dev/null`)
334
+ throw new Error(`bootstrap-failed: ${failKeyword} log-tail=${log.slice(-400)}`)
335
+ }
336
+ if (done.includes(BOOTSTRAP_SUCCESS_MARKER_PREFIX)) {
337
+ lastDone = done
338
+ const pidLine =
339
+ done
340
+ .split('\n')
341
+ .find(l => l.includes(BOOTSTRAP_SUCCESS_MARKER_PREFIX))
342
+ ?.trim() ?? done.trim()
343
+ if (stageEvent) {
344
+ stageEvent('harness-spawn', 'done')
345
+ } else {
346
+ progress(`bootstrap complete (${pidLine})`)
347
+ }
348
+ break
349
+ }
350
+ // v0.24.5: ALWAYS update progress every tick. Prefer a STAGE marker from
351
+ // the log; else fall back to an elapsed-time heartbeat so the spinner
352
+ // never sits silent. v0.24.7: when a stage-event consumer is registered,
353
+ // we promote STAGE transitions to structured events for the box renderer.
354
+ const real = extractBootstrapProgressLine(sliceAfter(out, '--P--'))
355
+ if (real && real !== lastSurfaced) {
356
+ lastSurfaced = real
357
+ if (stageEvent) {
358
+ const stageId = mapBootstrapMarkerToStage(real)
359
+ if (stageId) stageEvent(stageId, 'running')
360
+ } else {
361
+ progress(`bootstrap: ${real}`)
362
+ }
363
+ } else if (!stageEvent) {
364
+ const elapsedSec = Math.round((Date.now() - bootstrapStart) / 1000)
365
+ progress(`bootstrap waiting (${elapsedSec}s elapsed, tick ${pollTick})`)
366
+ }
367
+ await sleep(5000)
368
+ }
369
+ if (!lastDone.includes(BOOTSTRAP_SUCCESS_MARKER_PREFIX)) {
370
+ const log = await execRead(`tail -n 80 ${BOOTSTRAP_PROGRESS_LOG} 2>/dev/null`)
371
+ throw new Error(`bootstrap timeout (10 min): no done marker. log-tail=${log.slice(-400)}`)
372
+ }
373
+
374
+ // Steps 4-7: poll /bootstrap/pubkey → ECIES envelope → /bootstrap/provision
375
+ // → /healthz Ready. Shared with `runInPlaceUpgrade` (which skips the
376
+ // sandbox-provisioning steps above and only re-runs this handoff against
377
+ // the same endpoint after harness restart).
378
+ const endpoint = buildSandboxEndpoint({ sandboxId })
379
+ const sandboxClient = new SandboxClient({
380
+ endpoint,
381
+ sandboxId,
382
+ operator: operatorAccount,
383
+ })
384
+ const { bootstrapPubkey } = await handoffAgentToGateway({
385
+ sandboxClient,
386
+ agentPrivkey: opts.agentPrivkey,
387
+ agentAddress: opts.agentAddress,
388
+ iNFTRef: opts.iNFTRef,
389
+ iNFTNetwork: opts.iNFTNetwork,
390
+ brain: opts.brain,
391
+ plugins: opts.plugins,
392
+ promptAppend: opts.promptAppend,
393
+ subname: opts.subname,
394
+ telegramSecrets: opts.telegramSecrets,
395
+ profileScopeKeyHex: opts.profileScopeKeyHex,
396
+ onProgress: progress,
397
+ onStageEvent: stageEvent,
398
+ onTick: tick,
399
+ })
400
+
401
+ return {
402
+ sandboxId,
403
+ endpoint,
404
+ providerAddress: SANDBOX_PROVIDER_GALILEO,
405
+ snapshotName,
406
+ agentAddress: opts.agentAddress,
407
+ bootstrapPubkey,
408
+ depositTx,
409
+ acknowledgeTx,
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Post-restart handoff: poll the harness's bootstrap pubkey, encrypt the
415
+ * agent privkey to it via ECIES, EIP-191-sign the provision envelope, then
416
+ * wait until /healthz reports Ready. Used by:
417
+ *
418
+ * - `runSandboxProvision` after first-cold bootstrap (fresh container path)
419
+ * - `runInPlaceUpgrade` after harness restart inside an existing container
420
+ *
421
+ * Both paths talk to a `Bootstrapping` harness that has just generated a
422
+ * fresh ephemeral keypair, so the wire-level sequence is identical.
423
+ */
424
+ export interface HandoffAgentToGatewayOpts {
425
+ sandboxClient: SandboxClient
426
+ agentPrivkey: Hex
427
+ agentAddress: Address
428
+ iNFTRef: { contract: Address; tokenId: bigint }
429
+ iNFTNetwork: PromusNetwork
430
+ brain: { provider: Address; model: string }
431
+ plugins?: PromusPlugin[]
432
+ promptAppend?: string
433
+ /** Optional .0g subname (e.g. "specter") forwarded into RuntimeConfig so the
434
+ * harness's TG pairing greeting addresses the agent by registered name. */
435
+ subname?: string | null
436
+ /**
437
+ * Optional plaintext harness secrets (telegram bot token + allowlist) to
438
+ * ship via a second ECIES envelope. The handoff helper ECIES-encrypts to
439
+ * the bootstrap pubkey same as agentPrivkey. v0.18.2+ harness expects this
440
+ * field; older harnesses ignore it.
441
+ */
442
+ telegramSecrets?: TelegramHandoffSecrets
443
+ /**
444
+ * v0.23.0: operator-derived AES key for the PROFILE iNFT slot (32 bytes,
445
+ * hex-encoded with 0x prefix). Shipped via the same secondary envelope as
446
+ * telegramSecrets. Without it the sandbox skips profile flush + restore;
447
+ * the operator can ship one later via `promus profile init`. v0.23.0+
448
+ * harness picks it up; older harnesses ignore unknown fields.
449
+ */
450
+ profileScopeKeyHex?: `0x${string}`
451
+ /** Default 60_000. */
452
+ pubkeyTimeoutMs?: number
453
+ /** Default 180_000. */
454
+ readyTimeoutMs?: number
455
+ onProgress?: (msg: string) => void
456
+ /** Structured stage events for the boxed progress renderer. */
457
+ onStageEvent?: (id: BootstrapStageId, status: BootstrapStageStatus) => void
458
+ /** Periodic tick (5s) to refresh spinner glyphs. */
459
+ onTick?: () => void
460
+ }
461
+
462
+ export async function handoffAgentToGateway(
463
+ opts: HandoffAgentToGatewayOpts,
464
+ ): Promise<{ bootstrapPubkey: Hex }> {
465
+ const progress = opts.onProgress ?? (() => {})
466
+ const stageEvent = opts.onStageEvent
467
+ const endpoint = opts.sandboxClient.endpoint
468
+
469
+ if (!stageEvent) progress(`polling ${endpoint}/bootstrap/pubkey`)
470
+ const pubkeyRes = await pollPubkey(opts.sandboxClient, opts.pubkeyTimeoutMs ?? 60_000)
471
+
472
+ const agentPrivkeyBytes = hexToBytes(opts.agentPrivkey)
473
+ const envelope = encryptToPubkey({
474
+ recipientPubkey: pubkeyRes.pubkeyHex,
475
+ plaintext: agentPrivkeyBytes,
476
+ })
477
+ let secretsEnvelope: import('@promus/core').Option3Envelope | undefined
478
+ if (opts.telegramSecrets || opts.profileScopeKeyHex) {
479
+ const secretsPayload: Record<string, unknown> = {}
480
+ if (opts.telegramSecrets) secretsPayload.telegram = opts.telegramSecrets
481
+ if (opts.profileScopeKeyHex) secretsPayload.profileScopeKeyHex = opts.profileScopeKeyHex
482
+ const secretsJson = JSON.stringify(secretsPayload)
483
+ const secretsBytes = new TextEncoder().encode(secretsJson)
484
+ secretsEnvelope = encryptToPubkey({
485
+ recipientPubkey: pubkeyRes.pubkeyHex,
486
+ plaintext: secretsBytes,
487
+ })
488
+ const parts = [
489
+ opts.telegramSecrets && 'telegram',
490
+ opts.profileScopeKeyHex && 'profile-key',
491
+ ].filter(Boolean)
492
+ if (!stageEvent) progress(`shipping ${parts.join(' + ')} via secondary envelope`)
493
+ }
494
+
495
+ if (!stageEvent) progress('sending provision envelope to harness')
496
+ const finalPlugins = resolveHandoffPlugins(opts.plugins, Boolean(opts.telegramSecrets))
497
+ const runtimeConfig = {
498
+ network: opts.iNFTNetwork,
499
+ brain: opts.brain,
500
+ identity: {
501
+ iNFT: {
502
+ contract: opts.iNFTRef.contract,
503
+ tokenId: opts.iNFTRef.tokenId.toString(),
504
+ },
505
+ agent: opts.agentAddress,
506
+ },
507
+ plugins: finalPlugins,
508
+ permissions: pickPermissionMode(),
509
+ promptAppend: opts.promptAppend,
510
+ subname: opts.subname,
511
+ }
512
+ await opts.sandboxClient.provision(
513
+ {
514
+ envelope,
515
+ secretsEnvelope,
516
+ iNFTRef: { contract: opts.iNFTRef.contract, tokenId: opts.iNFTRef.tokenId.toString() },
517
+ config: runtimeConfig,
518
+ },
519
+ pubkeyRes.pubkeyHex,
520
+ )
521
+
522
+ if (stageEvent) {
523
+ stageEvent('healthz-ready', 'running')
524
+ } else {
525
+ progress(`polling ${endpoint}/healthz for Ready`)
526
+ }
527
+ await opts.sandboxClient.waitReady({ timeoutMs: opts.readyTimeoutMs ?? 180_000 })
528
+ if (stageEvent) stageEvent('healthz-ready', 'done')
529
+
530
+ return { bootstrapPubkey: pubkeyRes.pubkeyHex }
531
+ }
532
+
533
+ /**
534
+ * Ensure a sandbox is in `started` state. Handles every Daytona transition:
535
+ *
536
+ * - `started` → no-op
537
+ * - `stopped` → /start, poll up to 60s
538
+ * - `archived`/`archiving` → /start, poll up to 5min (filesystem restore from
539
+ * object storage takes minutes)
540
+ * - `restoring`/`starting`/`pulling_snapshot` → poll without re-issuing /start
541
+ * - `error` → throws
542
+ * - any unknown state → /start, poll up to 5min
543
+ *
544
+ * Pure state-machine wait: no /bootstrap/provision handoff. Use
545
+ * `resumeArchivedSandbox` for the full wake-and-handoff flow.
546
+ *
547
+ * Per the documented Daytona controller (`apps/api/src/sandbox/controllers/
548
+ * sandbox.controller.ts:487`), `/start` is the same endpoint for both
549
+ * `stopped → started` and `archived → restoring → started` transitions.
550
+ */
551
+ export interface EnsureSandboxStartedOpts {
552
+ /** Polling tick interval. Default 5000ms. */
553
+ intervalMs?: number
554
+ /** Max time to wait when source state is stopped. Default 60_000ms. */
555
+ stoppedDeadlineMs?: number
556
+ /** Max time to wait when source state is archived/archiving/restoring. Default 300_000ms. */
557
+ archivedDeadlineMs?: number
558
+ /** Progress callback for spinner UX. */
559
+ onProgress?: (msg: string) => void
560
+ }
561
+
562
+ export interface EnsureSandboxStartedResult {
563
+ /** State observed BEFORE we did anything. */
564
+ initialState: string
565
+ /** Whether the sandbox was already started (no /start was issued). */
566
+ alreadyStarted: boolean
567
+ /** Final state observed (always `started` on success). */
568
+ finalState: string
569
+ }
570
+
571
+ const ARCHIVE_LIKE_STATES = new Set(['archived', 'archiving', 'restoring'])
572
+
573
+ export async function ensureSandboxStarted(
574
+ provider: SandboxProviderClient,
575
+ sandboxId: string,
576
+ opts: EnsureSandboxStartedOpts = {},
577
+ ): Promise<EnsureSandboxStartedResult> {
578
+ const intervalMs = opts.intervalMs ?? 5000
579
+ const stoppedDeadlineMs = opts.stoppedDeadlineMs ?? 60_000
580
+ const archivedDeadlineMs = opts.archivedDeadlineMs ?? 300_000
581
+ const progress = opts.onProgress ?? (() => {})
582
+
583
+ const initial = await provider.getSandbox(sandboxId)
584
+ if (initial.state === 'started') {
585
+ return { initialState: initial.state, alreadyStarted: true, finalState: 'started' }
586
+ }
587
+ if (initial.state === 'error') {
588
+ throw new Error(`sandbox ${sandboxId} is in error state; cannot resume`)
589
+ }
590
+
591
+ const isArchiveLike = ARCHIVE_LIKE_STATES.has(initial.state)
592
+ const deadlineMs = isArchiveLike ? archivedDeadlineMs : stoppedDeadlineMs
593
+ const friendly = isArchiveLike ? 'archived' : initial.state
594
+
595
+ // Issue /start unless we're already in a transitional state.
596
+ // `restoring`/`starting`/`pulling_snapshot` mean a transition is in flight;
597
+ // re-issuing /start could confuse the state machine.
598
+ const transientStates = new Set(['starting', 'restoring', 'pulling_snapshot'])
599
+ if (!transientStates.has(initial.state)) {
600
+ progress(`sandbox state=${friendly}, calling startSandbox`)
601
+ try {
602
+ await provider.startSandbox(sandboxId)
603
+ } catch (e) {
604
+ throw new Error(`startSandbox(${sandboxId}) failed: ${(e as Error).message.slice(0, 200)}`)
605
+ }
606
+ } else {
607
+ progress(`sandbox state=${initial.state} (in transition, waiting)`)
608
+ }
609
+
610
+ const deadline = Date.now() + deadlineMs
611
+ let lastState = initial.state
612
+ while (Date.now() < deadline) {
613
+ const cur = await provider.getSandbox(sandboxId).catch(() => null)
614
+ if (cur) lastState = cur.state
615
+ if (cur?.state === 'started') {
616
+ return { initialState: initial.state, alreadyStarted: false, finalState: 'started' }
617
+ }
618
+ if (cur?.state === 'error') {
619
+ throw new Error(`sandbox ${sandboxId} transitioned to error state during resume`)
620
+ }
621
+ progress(`waiting for state=started (current=${cur?.state ?? 'unknown'})`)
622
+ await sleep(intervalMs)
623
+ }
624
+ throw new Error(
625
+ `sandbox ${sandboxId} did not reach started within ${Math.round(deadlineMs / 1000)}s (last=${lastState})`,
626
+ )
627
+ }
628
+
629
+ /**
630
+ * Drive a sandbox to `state=archived` from any valid starting state.
631
+ * Daytona requires the sandbox be `stopped` before `/archive` is accepted
632
+ * (verified live: `started + /archive` returns 400 "Sandbox is not stopped").
633
+ *
634
+ * Lifecycle handled:
635
+ * archived -> no-op
636
+ * archiving -> wait for archived
637
+ * stopped -> archive + wait
638
+ * started / starting -> stop + wait + archive + wait (two-phase)
639
+ * error -> throw
640
+ *
641
+ * Default deadlines per phase: 60s for stop, 5min for archive (Daytona snapshots
642
+ * the filesystem to object storage; verified live to take >60s sometimes).
643
+ * Used by `promus pause` to confirm Daytona acknowledges the full transition.
644
+ */
645
+ export interface EnsureSandboxArchivedOpts {
646
+ intervalMs?: number
647
+ /** Stop-phase deadline. Default 60_000ms. */
648
+ stopDeadlineMs?: number
649
+ /** Archive-phase deadline. Default 300_000ms (5 min). */
650
+ archiveDeadlineMs?: number
651
+ /**
652
+ * Legacy alias for stop+archive deadlines. If set, used for both phases.
653
+ * Prefer `stopDeadlineMs` / `archiveDeadlineMs` for asymmetric tuning.
654
+ */
655
+ deadlineMs?: number
656
+ onProgress?: (msg: string) => void
657
+ }
658
+
659
+ export interface EnsureSandboxArchivedResult {
660
+ initialState: string
661
+ alreadyArchived: boolean
662
+ finalState: string
663
+ /** True if the sandbox had to be stopped first (started → stopped → archived). */
664
+ stoppedFirst: boolean
665
+ }
666
+
667
+ export async function ensureSandboxArchived(
668
+ provider: SandboxProviderClient,
669
+ sandboxId: string,
670
+ opts: EnsureSandboxArchivedOpts = {},
671
+ ): Promise<EnsureSandboxArchivedResult> {
672
+ const intervalMs = opts.intervalMs ?? 5000
673
+ const stopDeadlineMs = opts.stopDeadlineMs ?? opts.deadlineMs ?? 60_000
674
+ const archiveDeadlineMs = opts.archiveDeadlineMs ?? opts.deadlineMs ?? 300_000
675
+ const progress = opts.onProgress ?? (() => {})
676
+
677
+ const initial = await provider.getSandbox(sandboxId)
678
+ if (initial.state === 'archived') {
679
+ return {
680
+ initialState: 'archived',
681
+ alreadyArchived: true,
682
+ finalState: 'archived',
683
+ stoppedFirst: false,
684
+ }
685
+ }
686
+ if (initial.state === 'error') {
687
+ throw new Error(`sandbox ${sandboxId} is in error state; cannot archive`)
688
+ }
689
+
690
+ // Phase 1: stop the sandbox if it's currently running.
691
+ // Daytona refuses `/archive` unless state=stopped (returns 400 "Sandbox is
692
+ // not stopped"). `started`/`starting`/`stopping` all need to land on
693
+ // `stopped` before we can issue `/archive`.
694
+ let stoppedFirst = false
695
+ const needsStop = initial.state === 'started' || initial.state === 'starting'
696
+ if (needsStop) {
697
+ stoppedFirst = true
698
+ progress(`sandbox state=${initial.state}, calling stopSandbox`)
699
+ try {
700
+ await provider.stopSandbox(sandboxId)
701
+ } catch (e) {
702
+ throw new Error(`stopSandbox(${sandboxId}) failed: ${(e as Error).message.slice(0, 200)}`)
703
+ }
704
+ const stopDeadline = Date.now() + stopDeadlineMs
705
+ while (Date.now() < stopDeadline) {
706
+ const cur = await provider.getSandbox(sandboxId).catch(() => null)
707
+ if (cur?.state === 'stopped') break
708
+ if (cur?.state === 'error') {
709
+ throw new Error(`sandbox ${sandboxId} transitioned to error during stop`)
710
+ }
711
+ progress(`waiting for state=stopped (current=${cur?.state ?? 'unknown'})`)
712
+ await sleep(intervalMs)
713
+ }
714
+ const afterStop = await provider.getSandbox(sandboxId)
715
+ if (afterStop.state !== 'stopped') {
716
+ throw new Error(
717
+ `sandbox ${sandboxId} did not reach stopped within ${Math.round(stopDeadlineMs / 1000)}s (last=${afterStop.state})`,
718
+ )
719
+ }
720
+ }
721
+
722
+ // Phase 2: archive the (now-)stopped sandbox.
723
+ // Skip the call if a previous archive is already in flight.
724
+ const stateBeforeArchive = stoppedFirst ? 'stopped' : (await provider.getSandbox(sandboxId)).state
725
+ if (stateBeforeArchive !== 'archiving') {
726
+ progress(`sandbox state=${stateBeforeArchive}, calling archiveSandbox`)
727
+ try {
728
+ await provider.archiveSandbox(sandboxId)
729
+ } catch (e) {
730
+ throw new Error(`archiveSandbox(${sandboxId}) failed: ${(e as Error).message.slice(0, 200)}`)
731
+ }
732
+ } else {
733
+ progress('sandbox state=archiving (in transition, waiting)')
734
+ }
735
+
736
+ const archiveDeadline = Date.now() + archiveDeadlineMs
737
+ let lastState = stateBeforeArchive
738
+ while (Date.now() < archiveDeadline) {
739
+ const cur = await provider.getSandbox(sandboxId).catch(() => null)
740
+ if (cur) lastState = cur.state
741
+ if (cur?.state === 'archived') {
742
+ return {
743
+ initialState: initial.state,
744
+ alreadyArchived: false,
745
+ finalState: 'archived',
746
+ stoppedFirst,
747
+ }
748
+ }
749
+ if (cur?.state === 'error') {
750
+ throw new Error(`sandbox ${sandboxId} transitioned to error during archive`)
751
+ }
752
+ progress(`waiting for state=archived (current=${cur?.state ?? 'unknown'})`)
753
+ await sleep(intervalMs)
754
+ }
755
+ throw new Error(
756
+ `sandbox ${sandboxId} did not reach archived within ${Math.round(archiveDeadlineMs / 1000)}s (last=${lastState})`,
757
+ )
758
+ }
759
+
760
+ /**
761
+ * Wake a stopped/archived sandbox AND re-handoff the agent privkey to the
762
+ * (newly restarted) harness. Idempotent: if the harness is already Ready
763
+ * with the correct agentAddress, returns without re-handoff.
764
+ *
765
+ * Used by `promus resume` (operator wakes their agent) and `runInPlaceUpgrade`
766
+ * after the upgrade-script restarts the harness in place.
767
+ */
768
+ export interface ResumeArchivedSandboxOpts {
769
+ provider: SandboxProviderClient
770
+ sandboxId: string
771
+ sandboxEndpoint: string
772
+ operatorAccount: LocalAccount
773
+ agentPrivkey: Hex
774
+ agentAddress: Address
775
+ iNFTRef: { contract: Address; tokenId: bigint }
776
+ iNFTNetwork: PromusNetwork
777
+ brain: { provider: Address; model: string }
778
+ plugins?: PromusPlugin[]
779
+ promptAppend?: string
780
+ /** Optional .0g subname (e.g. "specter") forwarded into RuntimeConfig so the
781
+ * harness's TG pairing greeting addresses the agent by registered name. */
782
+ subname?: string | null
783
+ /**
784
+ * Optional plaintext Telegram secrets (bot token + allowlist) shipped via
785
+ * a secondary ECIES envelope so the resumed harness can re-attach the
786
+ * grammY listener. Without this, every pause→resume cycle silently strips
787
+ * the TG bot — the gateway daemon comes back up with `plugins: ['telegram']`
788
+ * but no token, and `build-runtime.ts` skips listener registration. The
789
+ * `runResume` CLI loads this from `loadTelegramSecrets`; programmatic
790
+ * callers may pass `undefined` to keep the harness TG-less.
791
+ */
792
+ telegramSecrets?: TelegramHandoffSecrets
793
+ /**
794
+ * v0.23.1: operator-derived PROFILE scope key (32 bytes hex with 0x prefix).
795
+ * Threaded into the same secondary ECIES envelope as telegramSecrets so the
796
+ * resumed harness boots with `slots.profile` ready to anchor. Source via
797
+ * `loadProfileScopeKeyHex` (util/profile-key.ts). Without it the resumed
798
+ * daemon comes back with `slots.profile = no-profile-key` until the operator
799
+ * re-runs `promus profile init`.
800
+ */
801
+ profileScopeKeyHex?: `0x${string}`
802
+ onProgress?: (msg: string) => void
803
+ ensureStartedOpts?: EnsureSandboxStartedOpts
804
+ }
805
+
806
+ export interface ResumeArchivedSandboxResult {
807
+ initialState: string
808
+ alreadyReady: boolean
809
+ bootstrapPubkey?: Hex
810
+ }
811
+
812
+ export async function resumeArchivedSandbox(
813
+ opts: ResumeArchivedSandboxOpts,
814
+ ): Promise<ResumeArchivedSandboxResult> {
815
+ const progress = opts.onProgress ?? (() => {})
816
+ const sandboxClient = new SandboxClient({
817
+ endpoint: opts.sandboxEndpoint,
818
+ sandboxId: opts.sandboxId,
819
+ operator: opts.operatorAccount,
820
+ })
821
+
822
+ const ensureResult = await ensureSandboxStarted(opts.provider, opts.sandboxId, {
823
+ ...opts.ensureStartedOpts,
824
+ onProgress: progress,
825
+ })
826
+
827
+ // Fast-path: if the sandbox was already started, the harness MAY be Ready
828
+ // with the correct agentAddress already; skip the re-handoff cost in that case.
829
+ if (ensureResult.alreadyStarted) {
830
+ const h = await sandboxClient.health().catch(() => null)
831
+ if (
832
+ h?.state === 'Ready' &&
833
+ h.runtimeReady &&
834
+ h.agentAddress?.toLowerCase() === opts.agentAddress.toLowerCase()
835
+ ) {
836
+ progress('harness already Ready with matching agent; skipping handoff')
837
+ return { initialState: ensureResult.initialState, alreadyReady: true }
838
+ }
839
+ }
840
+
841
+ // The harness daemon may be missing in two scenarios:
842
+ // 1. archive→restore: Daytona kills every process when archiving, restore
843
+ // brings filesystem back but no daemons.
844
+ // 2. orphaned `started` sandbox where the harness died for some reason and
845
+ // Daytona didn't notice (the container is up, the process isn't).
846
+ // Probe /bootstrap/pubkey; if no response, fire the relaunch script.
847
+ progress('checking if harness daemon is alive')
848
+ const gatewayUp = await probeGatewayAlive(opts.sandboxEndpoint, 8_000)
849
+ if (!gatewayUp) {
850
+ progress('harness daemon unreachable; relaunching via toolbox exec')
851
+ await relaunchGatewayDaemon({
852
+ provider: opts.provider,
853
+ sandboxId: opts.sandboxId,
854
+ sandboxEndpoint: opts.sandboxEndpoint,
855
+ operatorAddress: opts.operatorAccount.address,
856
+ onProgress: progress,
857
+ })
858
+ }
859
+
860
+ // Re-handoff: pubkey + envelope + provision + waitReady.
861
+ progress('re-handing off agent privkey to harness')
862
+ const { bootstrapPubkey } = await handoffAgentToGateway({
863
+ sandboxClient,
864
+ agentPrivkey: opts.agentPrivkey,
865
+ agentAddress: opts.agentAddress,
866
+ iNFTRef: opts.iNFTRef,
867
+ iNFTNetwork: opts.iNFTNetwork,
868
+ brain: opts.brain,
869
+ plugins: opts.plugins,
870
+ promptAppend: opts.promptAppend,
871
+ subname: opts.subname,
872
+ telegramSecrets: opts.telegramSecrets,
873
+ profileScopeKeyHex: opts.profileScopeKeyHex,
874
+ onProgress: progress,
875
+ })
876
+
877
+ return { initialState: ensureResult.initialState, alreadyReady: false, bootstrapPubkey }
878
+ }
879
+
880
+ async function probeGatewayAlive(endpoint: string, timeoutMs: number): Promise<boolean> {
881
+ try {
882
+ const r = await fetch(`${endpoint}/bootstrap/pubkey`, {
883
+ method: 'GET',
884
+ signal: AbortSignal.timeout(timeoutMs),
885
+ })
886
+ return r.ok
887
+ } catch {
888
+ return false
889
+ }
890
+ }
891
+
892
+ interface RelaunchGatewayOpts {
893
+ provider: SandboxProviderClient
894
+ sandboxId: string
895
+ sandboxEndpoint: string
896
+ operatorAddress: Address
897
+ onProgress?: (msg: string) => void
898
+ }
899
+
900
+ async function relaunchGatewayDaemon(opts: RelaunchGatewayOpts): Promise<void> {
901
+ const progress = opts.onProgress ?? (() => {})
902
+ const { script } = buildGatewayRelaunchScript({
903
+ sandboxId: opts.sandboxId,
904
+ operatorAddress: opts.operatorAddress,
905
+ })
906
+
907
+ // Fire the relaunch script via the toolbox. The script forks the inner
908
+ // launcher into the background and returns immediately, so this exec
909
+ // completes in ~1s; the actual harness daemon comes up over the next
910
+ // ~10-15 seconds. Caller polls /bootstrap/pubkey to confirm.
911
+ const fired = await opts.provider
912
+ .execInToolbox(opts.sandboxId, { command: script, timeout: 30 })
913
+ .catch(e => ({
914
+ exitCode: -1,
915
+ result: (e as Error).message,
916
+ stdout: undefined as string | undefined,
917
+ }))
918
+ if (fired.exitCode !== 0) {
919
+ throw new Error(
920
+ `relaunch exec failed: exitCode=${fired.exitCode} ${(fired.result ?? fired.stdout ?? '').slice(0, 160)}`,
921
+ )
922
+ }
923
+
924
+ const exec = makeExecRead(opts.provider, opts.sandboxId)
925
+ const deadline = Date.now() + 60_000
926
+ while (Date.now() < deadline) {
927
+ if (await probeGatewayAlive(opts.sandboxEndpoint, 4_000)) {
928
+ progress('harness daemon back online')
929
+ return
930
+ }
931
+ const failBody = (await exec(`cat ${RELAUNCH_FAIL_MARKER} 2>/dev/null || true`)).trim()
932
+ if (failBody) {
933
+ const tail = (await exec(`tail -n 60 ${RELAUNCH_PROGRESS_LOG} 2>/dev/null || true`)).trim()
934
+ throw new Error(`relaunch failed: ${failBody}\n${tail.slice(0, 600)}`)
935
+ }
936
+ const doneBody = (await exec(`cat ${RELAUNCH_DONE_MARKER} 2>/dev/null || true`)).trim()
937
+ if (doneBody) progress(`relaunch marker: ${doneBody}`)
938
+ progress('waiting for harness daemon to come up')
939
+ await sleep(3_000)
940
+ }
941
+ throw new Error('harness daemon did not come back online within 60s after relaunch')
942
+ }
943
+
944
+ /**
945
+ * Read tool output from Daytona's `process/execute` endpoint, normalizing
946
+ * the `{exitCode, result}` (older docs claimed `{stdout, stderr}` but live
947
+ * runs return `result` for the combined stream). Wraps the command in
948
+ * `bash -c '<cmd>'` so pipes / redirects / `2>/dev/null` work — Daytona's
949
+ * exec splits argv-style without a shell.
950
+ *
951
+ * Used by the bootstrap poll loop, deploy/upgrade flows, and `promus logs`
952
+ * sandbox-mode tail.
953
+ */
954
+ export function makeExecRead(
955
+ provider: SandboxProviderClient,
956
+ sandboxId: string,
957
+ ): (cmd: string) => Promise<string> {
958
+ return async (cmd: string) => {
959
+ const r = await provider
960
+ .execInToolbox(sandboxId, { command: `bash -c '${cmd}'`, timeout: 30 })
961
+ .catch(() => null)
962
+ return r ? extractExecOutput(r) : ''
963
+ }
964
+ }
965
+
966
+ /**
967
+ * Pull combined stdout from a ToolboxExecResponse regardless of which shape
968
+ * the provider returned. Prefers `result` (Daytona's actual format); falls
969
+ * back to `stdout || stderr` for older endpoints.
970
+ */
971
+ export function extractExecOutput(r: ToolboxExecResponse): string {
972
+ if (typeof r.result === 'string') return r.result
973
+ return r.stdout ?? r.stderr ?? ''
974
+ }
975
+
976
+ /**
977
+ * Resolve the runtime plugin list for a sandbox handoff. Auto-includes
978
+ * `'telegram'` when telegram secrets are being shipped via the secondary
979
+ * envelope; otherwise `build-runtime.ts` would gate the listener on
980
+ * `pluginNames.includes('telegram')` and skip registration. Default base:
981
+ * `['system', 'comms', 'onchain']`. Idempotent.
982
+ */
983
+ export function resolveHandoffPlugins(
984
+ caller: PromusPlugin[] | undefined,
985
+ shipsTelegramSecrets: boolean,
986
+ ): PromusPlugin[] {
987
+ const base = caller ?? (['system', 'comms', 'onchain'] satisfies PromusPlugin[])
988
+ if (!shipsTelegramSecrets) return base
989
+ if (base.includes('telegram')) return base
990
+ return [...base, 'telegram']
991
+ }
992
+
993
+ /**
994
+ * Pull the most informative progress line from a chunk of the bootstrap log.
995
+ *
996
+ * v0.24.4: bootstrap.ts emits explicit `STAGE: ...` markers before each major
997
+ * step (apt update, apt install, bun install, promus install, browser deps,
998
+ * harness launch). If any tail line starts with `STAGE: ` we prefer that
999
+ * (last-wins, prefix stripped) so the operator sees the current stage instead
1000
+ * of whichever raw `[$(date) ...]` log line happened to land last. Falls back
1001
+ * to the existing filter/pop heuristic when no STAGE marker is present (older
1002
+ * gateway versions, or the gap between bootstrap-start and the first STAGE).
1003
+ */
1004
+ export function extractBootstrapProgressLine(tail: string): string | undefined {
1005
+ const lines = tail.trim().split('\n')
1006
+ for (let i = lines.length - 1; i >= 0; i--) {
1007
+ const line = lines[i]?.trim() ?? ''
1008
+ if (line.startsWith('STAGE: ')) return line.slice('STAGE: '.length)
1009
+ }
1010
+ return (
1011
+ lines
1012
+ .filter(l => !l.includes('setlocale'))
1013
+ .pop()
1014
+ ?.trim() || undefined
1015
+ )
1016
+ }
1017
+
1018
+ function sliceBetween(s: string, start: string, end: string): string {
1019
+ const i = s.indexOf(start)
1020
+ if (i < 0) return ''
1021
+ const j = s.indexOf(end, i + start.length)
1022
+ if (j < 0) return s.slice(i + start.length)
1023
+ return s.slice(i + start.length, j)
1024
+ }
1025
+
1026
+ function sliceAfter(s: string, marker: string): string {
1027
+ const i = s.indexOf(marker)
1028
+ return i < 0 ? '' : s.slice(i + marker.length)
1029
+ }
1030
+
1031
+ async function pollPubkey(
1032
+ client: SandboxClient,
1033
+ timeoutMs: number,
1034
+ ): Promise<Awaited<ReturnType<SandboxClient['pubkey']>>> {
1035
+ const deadline = Date.now() + timeoutMs
1036
+ while (Date.now() < deadline) {
1037
+ try {
1038
+ return await client.pubkey()
1039
+ } catch {
1040
+ await sleep(2000)
1041
+ }
1042
+ }
1043
+ throw new Error(`/bootstrap/pubkey did not respond within ${timeoutMs}ms`)
1044
+ }
1045
+
1046
+ function sleep(ms: number): Promise<void> {
1047
+ return new Promise(r => setTimeout(r, ms))
1048
+ }
1049
+
1050
+ /**
1051
+ * createSandbox + 409-orphan recovery. The Daytona provider rejects new
1052
+ * sandbox names that already exist with HTTP 409. This bites whenever a
1053
+ * prior `promus init` / `promus deploy` partially succeeded (sandbox created
1054
+ * but bootstrap failed) and the operator retries: the orphan is still on
1055
+ * the provider holding the name. Catch the 409 once, list-by-name + delete
1056
+ * the orphan, then retry create. Keeps OOB clean without exposing operators
1057
+ * to raw provider API or a manual cleanup CLI.
1058
+ */
1059
+ export async function createSandboxWithOrphanRetry(
1060
+ provider: Pick<SandboxProviderClient, 'createSandbox' | 'listSandboxes' | 'deleteSandbox'>,
1061
+ snapshot: string,
1062
+ name: string | undefined,
1063
+ progress: (m: string) => void,
1064
+ ): Promise<SandboxRecord> {
1065
+ try {
1066
+ return await provider.createSandbox({ snapshot, name })
1067
+ } catch (e) {
1068
+ const msg = (e as Error).message
1069
+ if (!name || !/\b409\b/.test(msg) || !/already exists/i.test(msg)) {
1070
+ throw e
1071
+ }
1072
+ progress(`sandbox name '${name}' collides with an orphan; cleaning up + retrying`)
1073
+ const orphans = (await provider.listSandboxes().catch(() => [])).filter(s => s.name === name)
1074
+ if (orphans.length === 0) throw e
1075
+ for (const o of orphans) {
1076
+ await provider.deleteSandbox(o.id).catch(() => {})
1077
+ }
1078
+ return await provider.createSandbox({ snapshot, name })
1079
+ }
1080
+ }
1081
+
1082
+ function formatOg(wei: bigint): string {
1083
+ const og = Number(wei) / 1e18
1084
+ return og.toFixed(4)
1085
+ }
1086
+
1087
+ /** PROMUS_PERMISSIONS env override; unknown / unset → `off` (autonomous default). */
1088
+ export function pickPermissionMode(): PermissionMode {
1089
+ const raw = process.env.PROMUS_PERMISSIONS?.trim().toLowerCase()
1090
+ if (raw === 'prompt' || raw === 'strict' || raw === 'off') return raw
1091
+ return 'off'
1092
+ }
1093
+
1094
+ /**
1095
+ * Pre-flight check on the operator's Galileo provider deposit. The May 2 2026
1096
+ * enigma archive was caused by this balance dropping below `min_balance` mid
1097
+ * settlement, so the safe floor is 2× min_balance (= 0.12 0G). Returns true
1098
+ * if the operator may proceed; returns false (and surfaces a `cancel(...)`
1099
+ * with a `topup --sandbox` suggestion) otherwise.
1100
+ *
1101
+ * Best-effort: a chain RPC failure surfaces as a `note` warning and returns
1102
+ * true (don't block on read failures).
1103
+ */
1104
+ export async function preflightProviderDeposit(operator: OperatorSigner): Promise<boolean> {
1105
+ try {
1106
+ const operatorAddress = await operator.address()
1107
+ const galileoPublic = await operator.publicClient('0g-testnet')
1108
+ const settle = new SandboxSettlementClient({ publicClient: galileoPublic })
1109
+ const balance = await settle.getBalance(operatorAddress, SANDBOX_PROVIDER_GALILEO)
1110
+ const safeFloor = parseEther('0.12')
1111
+ if (balance < safeFloor) {
1112
+ cancel(
1113
+ [
1114
+ `Galileo provider deposit ${formatEther(balance)} 0G is below safe threshold (0.12 0G).`,
1115
+ 'Run `promus topup --sandbox 1` to deposit 1 0G first (~11h runway).',
1116
+ ].join('\n'),
1117
+ )
1118
+ return false
1119
+ }
1120
+ return true
1121
+ } catch (e) {
1122
+ note(
1123
+ `pre-flight balance check failed: ${(e as Error).message.slice(0, 120)}`,
1124
+ 'continuing without check',
1125
+ )
1126
+ return true
1127
+ }
1128
+ }
1129
+
1130
+ /**
1131
+ * Decrypt the agent keystore via the operator wallet. Used by both
1132
+ * `promus deploy` (Local→Sandbox migration) and `promus upgrade` (re-handoff
1133
+ * to a new container). The keystore lives encrypted on 0G Storage; the
1134
+ * operator's signature derives the AEAD key (Phase 6.6).
1135
+ */
1136
+ export async function unlockAgentKeystore(params: {
1137
+ operator: OperatorSigner
1138
+ network: PromusNetwork
1139
+ contractAddress: Address
1140
+ tokenId: bigint
1141
+ agentAddress: Address
1142
+ }): Promise<Hex> {
1143
+ const agentId = iNFTAgentId({
1144
+ contractAddress: params.contractAddress,
1145
+ tokenId: params.tokenId,
1146
+ })
1147
+ const paths = agentPaths.agent(agentId)
1148
+ const decrypted = await withSilencedConsole(() =>
1149
+ fetchAndDecryptKeystore({
1150
+ network: params.network,
1151
+ contractAddress: params.contractAddress,
1152
+ tokenId: params.tokenId,
1153
+ signer: params.operator,
1154
+ agentAddress: params.agentAddress,
1155
+ cachePath: paths.keystore,
1156
+ }),
1157
+ )
1158
+ return decrypted.privkeyHex
1159
+ }
1160
+
1161
+ /**
1162
+ * Publish or update the `agent:endpoint` text record on the agent's
1163
+ * `<subname>.promus.0g`. Idempotent: writes the latest endpoint URL each
1164
+ * call. Best-effort — caller decides whether to surface the failure.
1165
+ */
1166
+ export async function publishSandboxEndpoint(params: {
1167
+ subname: string
1168
+ agentPrivkey: Hex
1169
+ endpoint: string
1170
+ }): Promise<Hex> {
1171
+ return withSilencedConsole(async () => {
1172
+ const sann = new SannClient({ privkeyHex: params.agentPrivkey })
1173
+ const tx = await sann.setText(subnameNode(params.subname), 'agent:endpoint', params.endpoint)
1174
+ await sann.waitForReceipt(tx)
1175
+ return tx
1176
+ })
1177
+ }