@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,251 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import type http from 'node:http'
3
+ import { encryptToPubkey, generateBootstrapKeypair } from '@promus/core'
4
+ import {
5
+ ApprovalRelay,
6
+ EventHub,
7
+ type RuntimeConfig,
8
+ StubRuntime,
9
+ createGatewayServer,
10
+ createSession,
11
+ } from '@promus/gateway'
12
+ import { type Hex, hexToBytes } from 'viem'
13
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
14
+ import { SandboxClient } from './client'
15
+
16
+ const INFT_REF = {
17
+ contract: '0x9e71d79f06f956d4d2666b5c93dafab721c84721' as const,
18
+ tokenId: '6',
19
+ }
20
+
21
+ const CONFIG: RuntimeConfig = {
22
+ network: '0g-mainnet',
23
+ brain: { provider: '0x0000000000000000000000000000000000000111', model: 'glm-5' },
24
+ identity: {
25
+ iNFT: INFT_REF,
26
+ agent: '0x1111111111111111111111111111111111111111',
27
+ },
28
+ }
29
+
30
+ interface Fixture {
31
+ server: http.Server
32
+ base: string
33
+ client: SandboxClient
34
+ sandboxId: string
35
+ operatorPriv: Hex
36
+ agentPriv: Hex
37
+ bootstrapPubkey: Hex
38
+ }
39
+
40
+ async function listenOnRandomPort(server: http.Server): Promise<number> {
41
+ return new Promise((resolve, reject) => {
42
+ server.once('error', reject)
43
+ server.listen(0, '127.0.0.1', () => {
44
+ const addr = server.address()
45
+ if (addr && typeof addr !== 'string') resolve(addr.port)
46
+ else reject(new Error('no-port'))
47
+ })
48
+ })
49
+ }
50
+
51
+ async function setupFixture(): Promise<Fixture> {
52
+ const operatorPriv = generatePrivateKey()
53
+ const operator = privateKeyToAccount(operatorPriv)
54
+ const events = new EventHub()
55
+ const session = createSession({
56
+ bootstrap: generateBootstrapKeypair(),
57
+ expectedOperatorAddress: operator.address,
58
+ sandboxId: 'sbx-cli-test',
59
+ events,
60
+ approvals: new ApprovalRelay(events),
61
+ runtime: new StubRuntime(),
62
+ })
63
+ const server = createGatewayServer({ session })
64
+ const port = await listenOnRandomPort(server)
65
+ const base = `http://127.0.0.1:${port}`
66
+ const client = new SandboxClient({
67
+ endpoint: base,
68
+ sandboxId: session.sandboxId,
69
+ operator,
70
+ })
71
+ return {
72
+ server,
73
+ base,
74
+ client,
75
+ sandboxId: session.sandboxId,
76
+ operatorPriv,
77
+ agentPriv: generatePrivateKey(),
78
+ bootstrapPubkey: session.bootstrap.pubkeyHexCompressed,
79
+ }
80
+ }
81
+
82
+ async function provisionViaClient(fix: Fixture): Promise<void> {
83
+ const envelope = encryptToPubkey({
84
+ recipientPubkey: fix.bootstrapPubkey,
85
+ plaintext: hexToBytes(fix.agentPriv),
86
+ })
87
+ await fix.client.provision({ envelope, iNFTRef: INFT_REF, config: CONFIG }, fix.bootstrapPubkey)
88
+ await fix.client.waitReady({ timeoutMs: 5000, intervalMs: 30 })
89
+ }
90
+
91
+ describe('SandboxClient', () => {
92
+ let fix: Fixture
93
+
94
+ beforeEach(async () => {
95
+ fix = await setupFixture()
96
+ })
97
+ afterEach(() => {
98
+ fix.server.close()
99
+ })
100
+
101
+ test('pubkey + health round-trip', async () => {
102
+ const pk = await fix.client.pubkey()
103
+ expect(pk.pubkeyHex).toBe(fix.bootstrapPubkey)
104
+ const h = await fix.client.health()
105
+ expect(h.state).toBe('Bootstrapping')
106
+ expect(h.runtimeReady).toBe(false)
107
+ })
108
+
109
+ test('provision + waitReady transitions to Ready', async () => {
110
+ await provisionViaClient(fix)
111
+ const h = await fix.client.health()
112
+ expect(h.state).toBe('Ready')
113
+ expect(h.runtimeReady).toBe(true)
114
+ expect(h.agentAddress).toBe(privateKeyToAccount(fix.agentPriv).address)
115
+ })
116
+
117
+ test('chat returns response from runtime', async () => {
118
+ await provisionViaClient(fix)
119
+ const result = await fix.client.chat('hello sandbox')
120
+ expect(result.response).toContain('hello sandbox')
121
+ expect(result.toolCalls.length).toBeGreaterThan(0)
122
+ })
123
+
124
+ test('events iterator yields chat-turn indicators', async () => {
125
+ await provisionViaClient(fix)
126
+ const controller = new AbortController()
127
+ const collected: string[] = []
128
+ const collect = (async () => {
129
+ for await (const ev of fix.client.events({ signal: controller.signal })) {
130
+ collected.push(ev.kind)
131
+ if (collected.includes('turn-end')) {
132
+ controller.abort()
133
+ break
134
+ }
135
+ }
136
+ })()
137
+ // give SSE a tick to subscribe before sending the chat
138
+ await new Promise(resolve => setTimeout(resolve, 100))
139
+ await fix.client.chat('event test')
140
+ await Promise.race([collect, new Promise(resolve => setTimeout(resolve, 3000))])
141
+ expect(collected).toContain('turn-start')
142
+ expect(collected).toContain('tool-call-start')
143
+ expect(collected).toContain('turn-end')
144
+ })
145
+
146
+ test('approve resolves a pending request', async () => {
147
+ await provisionViaClient(fix)
148
+ const session = await fix.client.health()
149
+ expect(session.state).toBe('Ready')
150
+
151
+ // Direct relay-driven approval round-trip is exercised in
152
+ // harness/src/server.test.ts where the relay is reachable from the test
153
+ // fixture. Here we just confirm /healthz reports ready, since the SSE
154
+ // events iterator is already covered above.
155
+ const resp = await fix.client.health()
156
+ expect(resp.runtimeReady).toBe(true)
157
+ })
158
+
159
+ // v0.24.4: approvePairing signs the same `adminTickHash('pairing-approve',
160
+ // ts, sandboxId)` payload as autotopup-tick / profile-key. We assert by
161
+ // intercepting fetch and confirming the signature recovers to the operator
162
+ // address — this proves the SandboxClient is signing what the server expects
163
+ // without needing a live PairingStore in the gateway test fixture.
164
+ test('approvePairing posts signed body to /admin/pairing/approve', async () => {
165
+ interface CapturedBody {
166
+ platform: string
167
+ code: string
168
+ ts: number
169
+ signature: Hex
170
+ }
171
+ const captured: { body: CapturedBody | null; url: string | null } = { body: null, url: null }
172
+ const mockFetch = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
173
+ captured.url = typeof input === 'string' ? input : input.toString()
174
+ captured.body = JSON.parse(String(init?.body ?? '{}')) as CapturedBody
175
+ return new Response(JSON.stringify({ ok: true, userId: '42', userName: 'bob' }), {
176
+ status: 200,
177
+ headers: { 'content-type': 'application/json' },
178
+ })
179
+ }) as unknown as typeof fetch
180
+ const operator = privateKeyToAccount(fix.operatorPriv)
181
+ const client = new SandboxClient({
182
+ endpoint: 'http://stub.local',
183
+ sandboxId: 'sbx-pair-sign',
184
+ operator,
185
+ fetchImpl: mockFetch,
186
+ })
187
+
188
+ const result = await client.approvePairing('telegram', 'ABCDEFGH')
189
+ expect(result.ok).toBe(true)
190
+ expect(result.userId).toBe('42')
191
+ expect(result.userName).toBe('bob')
192
+ expect(captured.url).toBe('http://stub.local/admin/pairing/approve')
193
+ expect(captured.body).not.toBeNull()
194
+ const body = captured.body
195
+ if (!body) throw new Error('captured.body is null')
196
+ expect(body.platform).toBe('telegram')
197
+ expect(body.code).toBe('ABCDEFGH')
198
+ expect(typeof body.ts).toBe('number')
199
+ expect(body.signature).toMatch(/^0x[0-9a-fA-F]+$/)
200
+
201
+ // Recover address from signature + reconstructed hash; assert operator.
202
+ const { adminTickHash } = await import('@promus/gateway')
203
+ const { recoverMessageAddress } = await import('viem')
204
+ const hash = adminTickHash({
205
+ action: 'pairing-approve',
206
+ ts: body.ts,
207
+ sandboxId: 'sbx-pair-sign',
208
+ })
209
+ const recovered = await recoverMessageAddress({
210
+ message: { raw: hash },
211
+ signature: body.signature,
212
+ })
213
+ expect(recovered.toLowerCase()).toBe(operator.address.toLowerCase())
214
+ })
215
+
216
+ test('approvePairing surfaces ok:false reason from gateway', async () => {
217
+ const mockFetch = (async () => {
218
+ return new Response(JSON.stringify({ ok: false, reason: 'locked-out' }), {
219
+ status: 200,
220
+ headers: { 'content-type': 'application/json' },
221
+ })
222
+ }) as unknown as typeof fetch
223
+ const operator = privateKeyToAccount(fix.operatorPriv)
224
+ const client = new SandboxClient({
225
+ endpoint: 'http://stub.local',
226
+ sandboxId: 'sbx-pair-lock',
227
+ operator,
228
+ fetchImpl: mockFetch,
229
+ })
230
+ const result = await client.approvePairing('telegram', 'ABCDEFGH')
231
+ expect(result.ok).toBe(false)
232
+ expect(result.reason).toBe('locked-out')
233
+ })
234
+
235
+ test('approvePairing throws on 401', async () => {
236
+ const mockFetch = (async () => {
237
+ return new Response(JSON.stringify({ error: 'unauthorized', reason: 'sig-mismatch' }), {
238
+ status: 401,
239
+ headers: { 'content-type': 'application/json' },
240
+ })
241
+ }) as unknown as typeof fetch
242
+ const operator = privateKeyToAccount(fix.operatorPriv)
243
+ const client = new SandboxClient({
244
+ endpoint: 'http://stub.local',
245
+ sandboxId: 'sbx-pair-401',
246
+ operator,
247
+ fetchImpl: mockFetch,
248
+ })
249
+ await expect(client.approvePairing('telegram', 'ABCDEFGH')).rejects.toThrow(/auth failed/)
250
+ })
251
+ })
@@ -0,0 +1,424 @@
1
+ import type { PermissionDecision } from '@promus/core'
2
+ import {
3
+ type GatewayEventKind,
4
+ type ProvisionEnvelope,
5
+ type RuntimeConfig,
6
+ adminTickHash,
7
+ approvalResponseHash,
8
+ chatMessageHash,
9
+ provisionMessageHash,
10
+ } from '@promus/gateway'
11
+ import type { Address, Hex, LocalAccount } from 'viem'
12
+
13
+ export interface SandboxClientOpts {
14
+ /** Full base URL of the sandbox harness, e.g. `http://8080-<id>.43.106.147.28.nip.io:4000`. */
15
+ endpoint: string
16
+ /** Sandbox id (also embedded in nip.io URL). Used in chat sig anchoring. */
17
+ sandboxId: string
18
+ /** Operator wallet account that signs chat / approval / provision messages. */
19
+ operator: LocalAccount
20
+ /** Optional custom fetch implementation (used in tests). */
21
+ fetchImpl?: typeof fetch
22
+ /**
23
+ * Optional unix socket path. When set, every fetch call routes via the
24
+ * socket using Bun's `fetch(url, {unix: '/path'})` option. The endpoint
25
+ * URL's host doesn't matter (kernel routes via socket); we use
26
+ * `http://localhost` as the conventional placeholder. Used by the local
27
+ * gateway path where chat.tsx talks to `~/.promus/agents/<id>/gateway.sock`.
28
+ */
29
+ unixSocketPath?: string
30
+ }
31
+
32
+ export interface ChatResponse {
33
+ response: string
34
+ toolCalls: Array<{ name: string; ok: boolean; durationMs: number }>
35
+ durationMs: number
36
+ syncTx?: string
37
+ }
38
+
39
+ export interface BootstrapPubkeyResponse {
40
+ pubkeyHex: Hex
41
+ version: string
42
+ sandboxId: string
43
+ state: string
44
+ bootedAt: number
45
+ }
46
+
47
+ export interface HealthzResponse {
48
+ state: string
49
+ sandboxId: string
50
+ version: string
51
+ uptimeMs: number
52
+ bootedAt: number
53
+ provisionedAt: number | null
54
+ readyAt: number | null
55
+ agentAddress: Address | null
56
+ runtimeReady: boolean
57
+ eventsLastSeq: number
58
+ subscribers: number
59
+ pendingApprovals: number
60
+ /** v0.21.12: per-listener state. */
61
+ listeners?: Record<string, 'active' | 'disabled' | 'failed'>
62
+ /** v0.21.13: current permission mode (used by TUI thin client to seed statusline). */
63
+ permsMode?: 'off' | 'prompt' | 'strict'
64
+ }
65
+
66
+ export interface ProvisionPayload {
67
+ envelope: ProvisionEnvelope
68
+ /** Optional second envelope for harness secrets (telegram bot token etc). */
69
+ secretsEnvelope?: ProvisionEnvelope
70
+ iNFTRef: { contract: Address; tokenId: string }
71
+ config: RuntimeConfig
72
+ }
73
+
74
+ export interface ParsedSseEvent {
75
+ seq: number
76
+ kind: GatewayEventKind
77
+ ts: number
78
+ data: unknown
79
+ }
80
+
81
+ /**
82
+ * Thin client the laptop CLI uses to talk to the sandbox-resident harness.
83
+ *
84
+ * - Wraps EIP-191 signing for /chat, /provision, /approval/:id/respond
85
+ * - Provides an async iterator over /events SSE
86
+ * - Reconnects on stream drop using the last-event-id header
87
+ */
88
+ export class SandboxClient {
89
+ #fetch: typeof fetch
90
+ endpoint: string
91
+ sandboxId: string
92
+ operator: LocalAccount
93
+
94
+ constructor(opts: SandboxClientOpts) {
95
+ this.endpoint = opts.endpoint.replace(/\/$/, '')
96
+ this.sandboxId = opts.sandboxId
97
+ this.operator = opts.operator
98
+ const baseFetch = opts.fetchImpl ?? globalThis.fetch
99
+ if (opts.unixSocketPath) {
100
+ const sock = opts.unixSocketPath
101
+ // Bun's fetch supports `{unix: '/path'}` to route over a unix socket.
102
+ // The URL's host portion is ignored once unix is set; we still pass
103
+ // the full URL so Bun can parse the path component.
104
+ this.#fetch = ((input: Parameters<typeof fetch>[0], init?: RequestInit) => {
105
+ const url = typeof input === 'string' ? input : input.toString()
106
+ const merged = { ...(init ?? {}), unix: sock } as RequestInit & { unix: string }
107
+ return baseFetch(url, merged as RequestInit)
108
+ }) as typeof fetch
109
+ } else {
110
+ this.#fetch = baseFetch
111
+ }
112
+ }
113
+
114
+ async pubkey(): Promise<BootstrapPubkeyResponse> {
115
+ const r = await this.#fetch(`${this.endpoint}/bootstrap/pubkey`)
116
+ if (!r.ok) throw new Error(`pubkey: ${r.status}`)
117
+ return (await r.json()) as BootstrapPubkeyResponse
118
+ }
119
+
120
+ async health(): Promise<HealthzResponse> {
121
+ const r = await this.#fetch(`${this.endpoint}/healthz`)
122
+ if (!r.ok) throw new Error(`healthz: ${r.status}`)
123
+ return (await r.json()) as HealthzResponse
124
+ }
125
+
126
+ async waitReady(
127
+ opts: { timeoutMs?: number; intervalMs?: number } = {},
128
+ ): Promise<HealthzResponse> {
129
+ const timeoutMs = opts.timeoutMs ?? 120_000
130
+ const intervalMs = opts.intervalMs ?? 1000
131
+ const deadline = Date.now() + timeoutMs
132
+ while (Date.now() < deadline) {
133
+ try {
134
+ const h = await this.health()
135
+ if (h.state === 'Ready' && h.runtimeReady) return h
136
+ } catch {
137
+ // ignore transient errors during boot
138
+ }
139
+ await new Promise(resolve => setTimeout(resolve, intervalMs))
140
+ }
141
+ throw new Error(`waitReady timeout (${timeoutMs}ms)`)
142
+ }
143
+
144
+ async provision(
145
+ payload: ProvisionPayload,
146
+ bootstrapPubkey: Hex,
147
+ ): Promise<{
148
+ ok: boolean
149
+ agentAddress: Address
150
+ state: string
151
+ }> {
152
+ const ts = Date.now()
153
+ const request = {
154
+ envelope: payload.envelope,
155
+ secretsEnvelope: payload.secretsEnvelope,
156
+ operatorAddress: this.operator.address,
157
+ iNFTRef: payload.iNFTRef,
158
+ config: payload.config,
159
+ ts,
160
+ }
161
+ const hash = provisionMessageHash(request, bootstrapPubkey)
162
+ const signature = await this.operator.signMessage({ message: { raw: hash } })
163
+
164
+ const r = await this.#fetch(`${this.endpoint}/bootstrap/provision`, {
165
+ method: 'POST',
166
+ headers: { 'content-type': 'application/json' },
167
+ body: JSON.stringify({ ...request, signature }),
168
+ })
169
+ if (!r.ok) {
170
+ const detail = await r.text().catch(() => '')
171
+ throw new Error(`provision failed (${r.status}): ${detail}`)
172
+ }
173
+ return (await r.json()) as { ok: boolean; agentAddress: Address; state: string }
174
+ }
175
+
176
+ async chat(message: string): Promise<ChatResponse> {
177
+ const ts = Date.now()
178
+ const hash = chatMessageHash(message, ts, this.sandboxId)
179
+ const signature = await this.operator.signMessage({ message: { raw: hash } })
180
+
181
+ const r = await this.#fetch(`${this.endpoint}/chat`, {
182
+ method: 'POST',
183
+ headers: { 'content-type': 'application/json' },
184
+ body: JSON.stringify({ message, ts, signature }),
185
+ })
186
+ if (!r.ok) {
187
+ const detail = await r.text().catch(() => '')
188
+ throw new Error(`chat failed (${r.status}): ${detail}`)
189
+ }
190
+ return (await r.json()) as ChatResponse
191
+ }
192
+
193
+ async sync(): Promise<{ tx?: string; slots: string[] }> {
194
+ // /sync uploads the full activity log + memory tree to 0G Storage and
195
+ // anchors via one updateSlots tx. For large activity logs (multi-MB)
196
+ // the upload can take minutes per segment. Bun's default fetch timeout
197
+ // is 5 min, which was tripping on real-size logs. Bump to 15 min — the
198
+ // gateway side never blocks indefinitely (0G SDK retries with backoff),
199
+ // so a long client wait is the right ceiling.
200
+ const r = await this.#fetch(`${this.endpoint}/sync`, {
201
+ method: 'POST',
202
+ signal: AbortSignal.timeout(15 * 60 * 1000),
203
+ })
204
+ if (!r.ok) throw new Error(`sync failed (${r.status})`)
205
+ return (await r.json()) as { tx?: string; slots: string[] }
206
+ }
207
+
208
+ /**
209
+ * v0.21.9: live-fire the AutoTopupManager to skip the 5-min poll wait.
210
+ * Signs `adminTickHash('autotopup-tick', ts, sandboxId)` over EIP-191 so
211
+ * the sandbox endpoint accepts it without trustLocal. Returns the tick
212
+ * result (`{ ok: true }` or `{ ok: false, reason }`).
213
+ */
214
+ async triggerAutoTopupTick(): Promise<{ ok: boolean; reason?: string }> {
215
+ const ts = Date.now()
216
+ const hash = adminTickHash({ action: 'autotopup-tick', ts, sandboxId: this.sandboxId })
217
+ const signature = await this.operator.signMessage({ message: { raw: hash } })
218
+ const r = await this.#fetch(`${this.endpoint}/admin/autotopup/tick`, {
219
+ method: 'POST',
220
+ headers: { 'content-type': 'application/json' },
221
+ body: JSON.stringify({ ts, signature }),
222
+ })
223
+ if (r.status === 401) {
224
+ const detail = (await r.json().catch(() => null)) as { reason?: string } | null
225
+ throw new Error(`autotopup-tick auth failed: ${detail?.reason ?? '401'}`)
226
+ }
227
+ if (r.status === 501) {
228
+ throw new Error('autotopup-tick not supported (runtime missing triggerTopupTick)')
229
+ }
230
+ return (await r.json()) as { ok: boolean; reason?: string }
231
+ }
232
+
233
+ /**
234
+ * v0.23.0: ship the operator-scoped PROFILE AES key into the sandbox. Same
235
+ * EIP-191 auth as `triggerAutoTopupTick` but with action='profile-key'.
236
+ * The 32-byte key is sent in the body — sandbox endpoints are operator-only
237
+ * via network policy.
238
+ */
239
+ async setProfileKey(
240
+ profileScopeKeyHex: `0x${string}`,
241
+ ): Promise<{ ok: boolean; reason?: string }> {
242
+ const ts = Date.now()
243
+ const hash = adminTickHash({ action: 'profile-key', ts, sandboxId: this.sandboxId })
244
+ const signature = await this.operator.signMessage({ message: { raw: hash } })
245
+ const r = await this.#fetch(`${this.endpoint}/admin/profile-key`, {
246
+ method: 'POST',
247
+ headers: { 'content-type': 'application/json' },
248
+ body: JSON.stringify({ ts, signature, profileScopeKeyHex }),
249
+ })
250
+ if (r.status === 401) {
251
+ const detail = (await r.json().catch(() => null)) as { reason?: string } | null
252
+ throw new Error(`profile-key auth failed: ${detail?.reason ?? '401'}`)
253
+ }
254
+ if (r.status === 501) {
255
+ throw new Error('profile-key not supported (runtime missing setProfileKey)')
256
+ }
257
+ return (await r.json()) as { ok: boolean; reason?: string }
258
+ }
259
+
260
+ /**
261
+ * v0.24.4: forward an operator pair-mode approval to the sandbox so the
262
+ * container's pairing dir gets the approved user. Same EIP-191 auth as
263
+ * `triggerAutoTopupTick` / `setProfileKey` but with action='pairing-approve'.
264
+ * Returns the result shape from `BuiltRuntime.approvePairing` so the
265
+ * `promus pairing approve` command can print user details on success or
266
+ * a clean error on locked-out / unknown-code.
267
+ */
268
+ async approvePairing(
269
+ platform: string,
270
+ code: string,
271
+ ): Promise<{ ok: boolean; userId?: string; userName?: string; reason?: string }> {
272
+ const ts = Date.now()
273
+ const hash = adminTickHash({ action: 'pairing-approve', ts, sandboxId: this.sandboxId })
274
+ const signature = await this.operator.signMessage({ message: { raw: hash } })
275
+ const r = await this.#fetch(`${this.endpoint}/admin/pairing/approve`, {
276
+ method: 'POST',
277
+ headers: { 'content-type': 'application/json' },
278
+ body: JSON.stringify({ platform, code, ts, signature }),
279
+ })
280
+ if (r.status === 401) {
281
+ const detail = (await r.json().catch(() => null)) as { reason?: string } | null
282
+ throw new Error(`pairing-approve auth failed: ${detail?.reason ?? '401'}`)
283
+ }
284
+ if (r.status === 501) {
285
+ throw new Error('pairing-approve not supported (runtime missing approvePairing)')
286
+ }
287
+ if (r.status === 400) {
288
+ const detail = (await r.json().catch(() => null)) as {
289
+ error?: string
290
+ reason?: string
291
+ } | null
292
+ throw new Error(
293
+ `pairing-approve bad-request: ${detail?.error ?? '400'}${detail?.reason ? ` (${detail.reason})` : ''}`,
294
+ )
295
+ }
296
+ if (r.status === 409) {
297
+ throw new Error('pairing-approve gateway not Ready')
298
+ }
299
+ return (await r.json()) as {
300
+ ok: boolean
301
+ userId?: string
302
+ userName?: string
303
+ reason?: string
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Forward an operator approval decision to the harness. Maps promus's
309
+ * `PermissionDecision` (`allow-once | allow-session | deny`) onto the
310
+ * sandbox-server wire format (`allow | allow-session | deny`) since
311
+ * `allow-once` collapses to `allow` over the wire — the harness's
312
+ * ApprovalRelay only sees a once-shot resolve and the permission service
313
+ * already handled session-allow caching there.
314
+ */
315
+ async approve(approvalId: string, decision: PermissionDecision): Promise<void> {
316
+ const wireDecision: 'allow' | 'allow-session' | 'deny' =
317
+ decision === 'allow-once' ? 'allow' : decision
318
+ const ts = Date.now()
319
+ const hash = approvalResponseHash({
320
+ approvalId,
321
+ decision: wireDecision,
322
+ ts,
323
+ sandboxId: this.sandboxId,
324
+ })
325
+ const signature = await this.operator.signMessage({ message: { raw: hash } })
326
+ const r = await this.#fetch(`${this.endpoint}/approval/${approvalId}/respond`, {
327
+ method: 'POST',
328
+ headers: { 'content-type': 'application/json' },
329
+ body: JSON.stringify({ decision: wireDecision, ts, signature }),
330
+ })
331
+ if (!r.ok) {
332
+ const detail = await r.text().catch(() => '')
333
+ throw new Error(`approve failed (${r.status}): ${detail}`)
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Subscribe to /events SSE. Yields parsed events. On stream drop, reconnects
339
+ * with the last seen seq via the last-event-id header so we don't miss events.
340
+ * Cancel via the AbortSignal in opts.
341
+ */
342
+ async *events(
343
+ opts: {
344
+ signal?: AbortSignal
345
+ sinceSeq?: number
346
+ clientKind?: 'tui' | 'dashboard' | 'other'
347
+ } = {},
348
+ ): AsyncGenerator<ParsedSseEvent> {
349
+ let lastSeq = opts.sinceSeq
350
+ const signal = opts.signal
351
+ // v0.24.14: tag subscriber kind so the daemon's TG forward gate can
352
+ // distinguish a live operator TUI from passive web dashboards.
353
+ // Default `other` keeps back-compat for callers that don't set it.
354
+ const clientKind = opts.clientKind ?? 'other'
355
+ while (true) {
356
+ if (signal?.aborted) return
357
+ const headers: Record<string, string> = { accept: 'text/event-stream' }
358
+ if (typeof lastSeq === 'number') headers['last-event-id'] = String(lastSeq)
359
+ let res: Response
360
+ try {
361
+ res = await this.#fetch(`${this.endpoint}/events?client=${clientKind}`, { headers, signal })
362
+ } catch {
363
+ if (signal?.aborted) return
364
+ await new Promise(resolve => setTimeout(resolve, 1000))
365
+ continue
366
+ }
367
+ if (!res.ok || !res.body) {
368
+ await new Promise(resolve => setTimeout(resolve, 1000))
369
+ continue
370
+ }
371
+ const reader = res.body.getReader()
372
+ const decoder = new TextDecoder()
373
+ let buf = ''
374
+ try {
375
+ while (true) {
376
+ if (signal?.aborted) return
377
+ const { value, done } = await reader.read()
378
+ if (done) break
379
+ buf += decoder.decode(value, { stream: true })
380
+ for (;;) {
381
+ const sep = buf.indexOf('\n\n')
382
+ if (sep === -1) break
383
+ const chunk = buf.slice(0, sep)
384
+ buf = buf.slice(sep + 2)
385
+ const ev = parseSseChunk(chunk)
386
+ if (!ev) continue
387
+ lastSeq = ev.seq
388
+ yield ev
389
+ }
390
+ }
391
+ } catch {
392
+ // fallthrough: reconnect
393
+ }
394
+ if (signal?.aborted) return
395
+ await new Promise(resolve => setTimeout(resolve, 500))
396
+ }
397
+ }
398
+ }
399
+
400
+ function parseSseChunk(chunk: string): ParsedSseEvent | null {
401
+ let id: number | null = null
402
+ let kind: GatewayEventKind | null = null
403
+ let dataLine = ''
404
+ for (const rawLine of chunk.split('\n')) {
405
+ const line = rawLine.trimEnd()
406
+ if (!line || line.startsWith(':')) continue
407
+ if (line.startsWith('id: ')) {
408
+ const n = Number.parseInt(line.slice(4), 10)
409
+ if (Number.isFinite(n)) id = n
410
+ } else if (line.startsWith('event: ')) {
411
+ kind = line.slice(7) as GatewayEventKind
412
+ } else if (line.startsWith('data: ')) {
413
+ dataLine = dataLine ? `${dataLine}\n${line.slice(6)}` : line.slice(6)
414
+ }
415
+ }
416
+ if (id == null || !kind || !dataLine) return null
417
+ let parsed: { ts: number; data: unknown }
418
+ try {
419
+ parsed = JSON.parse(dataLine)
420
+ } catch {
421
+ return null
422
+ }
423
+ return { seq: id, kind, ts: parsed.ts, data: parsed.data }
424
+ }