@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.
- package/README.md +18 -0
- package/bin/promus +33 -0
- package/package.json +51 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_inft-ref.ts +43 -0
- package/src/commands/_unlock.ts +74 -0
- package/src/commands/admin-autotopup-tick.ts +73 -0
- package/src/commands/admin.test.ts +34 -0
- package/src/commands/admin.ts +32 -0
- package/src/commands/balance.test.ts +10 -0
- package/src/commands/balance.ts +112 -0
- package/src/commands/chat-sandbox.tsx +520 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1916 -0
- package/src/commands/deploy.ts +204 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +47 -0
- package/src/commands/gateway-run.ts +54 -0
- package/src/commands/gateway-start.ts +218 -0
- package/src/commands/gateway-status.ts +88 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/init/cost.test.ts +169 -0
- package/src/commands/init/cost.ts +154 -0
- package/src/commands/init/funding-gate.ts +67 -0
- package/src/commands/init/model-picker.ts +81 -0
- package/src/commands/init/operator-picker.ts +263 -0
- package/src/commands/init/resume.ts +136 -0
- package/src/commands/init/sandbox-provision.test.ts +497 -0
- package/src/commands/init/sandbox-provision.ts +1177 -0
- package/src/commands/init/telegram-step.ts +229 -0
- package/src/commands/init/wizard-state.ts +95 -0
- package/src/commands/init.ts +612 -0
- package/src/commands/inspect.ts +529 -0
- package/src/commands/ledger.ts +176 -0
- package/src/commands/logs.ts +86 -0
- package/src/commands/migrate-keystore.ts +155 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +114 -0
- package/src/commands/pairing-clear.ts +42 -0
- package/src/commands/pairing-list.ts +58 -0
- package/src/commands/pairing-revoke.ts +52 -0
- package/src/commands/pairing.test.ts +88 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/pause.ts +99 -0
- package/src/commands/profile.ts +184 -0
- package/src/commands/restore.ts +221 -0
- package/src/commands/resume.ts +181 -0
- package/src/commands/status.ts +119 -0
- package/src/commands/sync.ts +147 -0
- package/src/commands/telegram-remove.ts +65 -0
- package/src/commands/telegram-setup.ts +74 -0
- package/src/commands/telegram-status.ts +89 -0
- package/src/commands/telegram.test.ts +50 -0
- package/src/commands/telegram.ts +44 -0
- package/src/commands/topup.ts +303 -0
- package/src/commands/transfer.test.ts +111 -0
- package/src/commands/transfer.ts +520 -0
- package/src/commands/upgrade.test.ts +137 -0
- package/src/commands/upgrade.ts +690 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.test.ts +96 -0
- package/src/config/render.ts +110 -0
- package/src/index.ts +378 -0
- package/src/sandbox/client.test.ts +251 -0
- package/src/sandbox/client.ts +424 -0
- package/src/ui/app.tsx +677 -0
- package/src/ui/approval-summary.test.ts +154 -0
- package/src/ui/approval-summary.ts +34 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.test.ts +146 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.test.ts +74 -0
- package/src/ui/state.ts +198 -0
- package/src/util/bootstrap-mode.test.ts +40 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.test.ts +190 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/brain-secrets.ts +96 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.test.ts +16 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.test.ts +86 -0
- package/src/util/gateway-spawn.ts +128 -0
- package/src/util/gateway-version.test.ts +113 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.test.ts +116 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.test.ts +60 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.test.ts +77 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.test.ts +53 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.test.ts +227 -0
- 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
|
+
}
|