@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,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
|
+
}
|