@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,204 @@
|
|
|
1
|
+
import { cancel, intro, isCancel, note, outro, select, spinner } from '@clack/prompts'
|
|
2
|
+
import { NETWORK_CHAIN_ID, iNFTAgentId } from '@promus/core'
|
|
3
|
+
import type { Address, Hex } from 'viem'
|
|
4
|
+
import { findAndLoadConfig } from '../config/load'
|
|
5
|
+
import { writeConfigTs } from '../config/render'
|
|
6
|
+
import { loadProfileScopeKeyHex } from '../util/profile-key'
|
|
7
|
+
import { loadTelegramHandoffSecrets } from '../util/telegram-secrets'
|
|
8
|
+
import { loadOrPickOperatorSigner } from './init/operator-picker'
|
|
9
|
+
import {
|
|
10
|
+
publishSandboxEndpoint,
|
|
11
|
+
runSandboxProvision,
|
|
12
|
+
unlockAgentKeystore,
|
|
13
|
+
} from './init/sandbox-provision'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `promus deploy` — migrate an existing local-mode agent into 0G Sandbox via
|
|
17
|
+
* Option 3 ECIES handoff.
|
|
18
|
+
*
|
|
19
|
+
* Pre-conditions:
|
|
20
|
+
* - Config exists, deployTarget is `local`
|
|
21
|
+
* - iNFT minted, agent EOA funded, keystore on 0G Storage
|
|
22
|
+
* - Operator wallet can decrypt the keystore (Phase 6.6 sign-derived-key)
|
|
23
|
+
*
|
|
24
|
+
* Flow:
|
|
25
|
+
* 1. Decrypt agent privkey (operator wallet, Phase 6.6 keystore-blob)
|
|
26
|
+
* 2. Galileo testnet: deposit + acknowledge TEE signer (idempotent)
|
|
27
|
+
* 3. createSandbox + bootstrap + poll /bootstrap/pubkey
|
|
28
|
+
* 4. encryptToPubkey(agentPrivkey, bootstrapPubkey) + operator-sign envelope
|
|
29
|
+
* 5. POST /bootstrap/provision → harness adopts the agent privkey
|
|
30
|
+
* 6. Wait for /healthz Ready
|
|
31
|
+
* 7. Update `agent:endpoint` text record on subname (if registered)
|
|
32
|
+
* 8. Rewrite config with deployTarget=sandbox + sandbox.id/endpoint/etc
|
|
33
|
+
*
|
|
34
|
+
* Local mode keystore + mainnet iNFT + agent EOA all stay valid; if the
|
|
35
|
+
* sandbox container is later deleted, operator can re-`promus deploy`.
|
|
36
|
+
*/
|
|
37
|
+
export async function runDeploy(): Promise<void> {
|
|
38
|
+
intro('promus deploy')
|
|
39
|
+
|
|
40
|
+
const loaded = await findAndLoadConfig()
|
|
41
|
+
if (!loaded) {
|
|
42
|
+
cancel('No promus.config.ts found. Run `promus init` first.')
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
let { config } = loaded
|
|
46
|
+
|
|
47
|
+
if (!config.identity.iNFT || !config.identity.agent) {
|
|
48
|
+
cancel('Config has no iNFT or agent. Run `promus init` first.')
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
if (config.deployTarget === 'sandbox' && config.sandbox?.id) {
|
|
52
|
+
note(
|
|
53
|
+
`Already deployed: sandbox=${config.sandbox.id}\nEndpoint: ${config.sandbox.endpoint}\nTo move to a new container, run \`promus upgrade\` instead.`,
|
|
54
|
+
'sandbox already attached',
|
|
55
|
+
)
|
|
56
|
+
cancel('No-op.')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (!config.brain.provider) {
|
|
60
|
+
cancel('Brain provider not configured. Run `promus model` first.')
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const target = (await select({
|
|
65
|
+
message: 'Migrate to which target?',
|
|
66
|
+
options: [
|
|
67
|
+
{
|
|
68
|
+
value: 'sandbox-galileo' as const,
|
|
69
|
+
label: '0G Sandbox (Galileo testnet, TDX TEE)',
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
initialValue: 'sandbox-galileo',
|
|
73
|
+
})) as 'sandbox-galileo' | symbol
|
|
74
|
+
if (isCancel(target)) {
|
|
75
|
+
cancel('Aborted.')
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const contractAddress = config.identity.iNFT.contract as Address
|
|
80
|
+
const tokenId = BigInt(config.identity.iNFT.tokenId)
|
|
81
|
+
const agentAddress = config.identity.agent as Address
|
|
82
|
+
|
|
83
|
+
const operator = await loadOrPickOperatorSigner({
|
|
84
|
+
network: config.network,
|
|
85
|
+
hint: config.operator,
|
|
86
|
+
})
|
|
87
|
+
if (!operator) {
|
|
88
|
+
cancel('No operator wallet available; cannot decrypt keystore.')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sUnlock = spinner()
|
|
93
|
+
sUnlock.start('Fetching keystore + decrypting via operator wallet')
|
|
94
|
+
let agentPrivkey: Hex
|
|
95
|
+
try {
|
|
96
|
+
agentPrivkey = await unlockAgentKeystore({
|
|
97
|
+
operator,
|
|
98
|
+
network: config.network,
|
|
99
|
+
contractAddress,
|
|
100
|
+
tokenId,
|
|
101
|
+
agentAddress,
|
|
102
|
+
})
|
|
103
|
+
sUnlock.stop('unlocked')
|
|
104
|
+
} catch (e) {
|
|
105
|
+
sUnlock.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
|
|
106
|
+
await operator.close?.()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sBox = spinner()
|
|
111
|
+
sBox.start('Provisioning 0G Sandbox container (Galileo testnet)')
|
|
112
|
+
const telegramSecretsPlain = await loadTelegramHandoffSecrets({
|
|
113
|
+
signer: operator,
|
|
114
|
+
agentAddress,
|
|
115
|
+
contractAddress,
|
|
116
|
+
tokenId,
|
|
117
|
+
onNotice: msg => sBox.message(msg),
|
|
118
|
+
})
|
|
119
|
+
const deployAgentId = iNFTAgentId({ contractAddress, tokenId })
|
|
120
|
+
const deployProfileKeyHex = loadProfileScopeKeyHex(deployAgentId)
|
|
121
|
+
if (!deployProfileKeyHex) {
|
|
122
|
+
sBox.message('no cached PROFILE key; sandbox will boot without profile-slot anchoring')
|
|
123
|
+
}
|
|
124
|
+
let sandboxResult: Awaited<ReturnType<typeof runSandboxProvision>>
|
|
125
|
+
try {
|
|
126
|
+
sandboxResult = await runSandboxProvision({
|
|
127
|
+
operator,
|
|
128
|
+
agentPrivkey,
|
|
129
|
+
agentAddress,
|
|
130
|
+
iNFTRef: { contract: contractAddress, tokenId },
|
|
131
|
+
brain: {
|
|
132
|
+
provider: config.brain.provider as Address,
|
|
133
|
+
model: config.brain.model ?? '',
|
|
134
|
+
},
|
|
135
|
+
iNFTNetwork: config.network,
|
|
136
|
+
name: config.subname || 'promus',
|
|
137
|
+
ref: process.env.PROMUS_BOOTSTRAP_REF ?? 'main',
|
|
138
|
+
subname: config.subname,
|
|
139
|
+
telegramSecrets: telegramSecretsPlain,
|
|
140
|
+
profileScopeKeyHex: deployProfileKeyHex,
|
|
141
|
+
onProgress: msg => sBox.message(msg),
|
|
142
|
+
})
|
|
143
|
+
sBox.stop(`sandbox ${sandboxResult.sandboxId} ready @ ${sandboxResult.endpoint}`)
|
|
144
|
+
} catch (e) {
|
|
145
|
+
sBox.stop(`sandbox deploy failed: ${(e as Error).message.slice(0, 200)}`)
|
|
146
|
+
note(
|
|
147
|
+
[
|
|
148
|
+
'Local agent untouched; iNFT + EOA + keystore remain on 0G Storage.',
|
|
149
|
+
'Common causes:',
|
|
150
|
+
' - insufficient testnet 0G at operator wallet',
|
|
151
|
+
' - provider 504 / Daytona upstream timeout',
|
|
152
|
+
' - npm mode (default): bun add -g failed (registry transient or missing version)',
|
|
153
|
+
' - git mode: bootstrap script git clone failed (pin a different ref via PROMUS_BOOTSTRAP_REF)',
|
|
154
|
+
' - try forcing the other mode: PROMUS_BOOTSTRAP_MODE=git promus deploy (for unreleased commits)',
|
|
155
|
+
].join('\n'),
|
|
156
|
+
'recoverable',
|
|
157
|
+
)
|
|
158
|
+
await operator.close?.()
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (config.subname) {
|
|
163
|
+
const sEp = spinner()
|
|
164
|
+
sEp.start(`Updating agent:endpoint on ${config.subname}.promus.0g`)
|
|
165
|
+
try {
|
|
166
|
+
await publishSandboxEndpoint({
|
|
167
|
+
subname: config.subname,
|
|
168
|
+
agentPrivkey,
|
|
169
|
+
endpoint: sandboxResult.endpoint,
|
|
170
|
+
})
|
|
171
|
+
sEp.stop('agent:endpoint published')
|
|
172
|
+
} catch (e) {
|
|
173
|
+
sEp.stop(`agent:endpoint publish failed: ${(e as Error).message.slice(0, 120)}`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
config = {
|
|
178
|
+
...config,
|
|
179
|
+
deployTarget: 'sandbox' as const,
|
|
180
|
+
sandbox: {
|
|
181
|
+
...(config.sandbox ?? {}),
|
|
182
|
+
id: sandboxResult.sandboxId,
|
|
183
|
+
providerAddress: sandboxResult.providerAddress,
|
|
184
|
+
endpoint: sandboxResult.endpoint,
|
|
185
|
+
snapshotName: sandboxResult.snapshotName,
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
await writeConfigTs(loaded.path, config, { subname: config.subname ?? null })
|
|
189
|
+
|
|
190
|
+
await operator.close?.()
|
|
191
|
+
|
|
192
|
+
outro(
|
|
193
|
+
[
|
|
194
|
+
'',
|
|
195
|
+
` sandbox id ${sandboxResult.sandboxId}`,
|
|
196
|
+
` endpoint ${sandboxResult.endpoint}`,
|
|
197
|
+
` agent (in TEE) ${agentAddress}`,
|
|
198
|
+
` iNFT #${tokenId.toString()} on chain ${NETWORK_CHAIN_ID[config.network]}`,
|
|
199
|
+
'',
|
|
200
|
+
'Next: `promus` to chat (now routes through the sandbox harness)',
|
|
201
|
+
' `promus upgrade` to swap the container while preserving identity',
|
|
202
|
+
].join('\n'),
|
|
203
|
+
)
|
|
204
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { cancel, confirm, intro, isCancel, log, outro, spinner } from '@clack/prompts'
|
|
2
|
+
import { NETWORK_RPC, drainAgentEOA, explorerTxUrl } from '@promus/core'
|
|
3
|
+
import { http, type Address, createPublicClient, formatEther, isAddress } from 'viem'
|
|
4
|
+
import { findAndLoadConfig } from '../config/load'
|
|
5
|
+
import { withSilencedConsole } from '../util/silence-console'
|
|
6
|
+
import { unlockAgentSigner } from './_unlock'
|
|
7
|
+
|
|
8
|
+
export interface DrainOpts {
|
|
9
|
+
/** Target address. If omitted, defaults to the operator wallet on this config. */
|
|
10
|
+
to?: string
|
|
11
|
+
/** Skip the destructive confirmation prompt. */
|
|
12
|
+
yes?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runDrain(opts: DrainOpts): Promise<void> {
|
|
16
|
+
intro('promus drain')
|
|
17
|
+
|
|
18
|
+
const loaded = await findAndLoadConfig()
|
|
19
|
+
if (!loaded) {
|
|
20
|
+
cancel('No promus.config.ts found. Run `promus init` first.')
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
const { config } = loaded
|
|
24
|
+
if (!config.identity.iNFT || !config.identity.agent) {
|
|
25
|
+
cancel('Config has no iNFT or agent. Run `promus init` first.')
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const network = config.network
|
|
30
|
+
const agentAddress = config.identity.agent as Address
|
|
31
|
+
|
|
32
|
+
const targetRaw = opts.to ?? (config.identity.operator as string | undefined)
|
|
33
|
+
if (!targetRaw) {
|
|
34
|
+
cancel('No --to address provided and config has no operator. Pass --to <0x...>.')
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if (!isAddress(targetRaw)) {
|
|
38
|
+
cancel(`--to is not a valid address: ${targetRaw}`)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const to = targetRaw as Address
|
|
42
|
+
|
|
43
|
+
const publicClient = createPublicClient({ transport: http(NETWORK_RPC[network]) })
|
|
44
|
+
const before = await publicClient.getBalance({ address: agentAddress })
|
|
45
|
+
log.info(
|
|
46
|
+
[
|
|
47
|
+
`agent ${agentAddress}`,
|
|
48
|
+
`balance ${formatEther(before)} 0G`,
|
|
49
|
+
`target ${to}`,
|
|
50
|
+
`network ${network}`,
|
|
51
|
+
].join('\n'),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if (before === 0n) {
|
|
55
|
+
log.warn('Agent EOA already empty.')
|
|
56
|
+
outro('nothing to drain')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!opts.yes) {
|
|
61
|
+
const ok = (await confirm({
|
|
62
|
+
message: `Sweep agent EOA balance (${formatEther(before)} 0G minus gas) to ${to}?`,
|
|
63
|
+
initialValue: false,
|
|
64
|
+
})) as boolean | symbol
|
|
65
|
+
if (isCancel(ok) || !ok) {
|
|
66
|
+
cancel('Aborted.')
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const unlocked = await unlockAgentSigner(config)
|
|
72
|
+
if (!unlocked) return
|
|
73
|
+
try {
|
|
74
|
+
const sSweep = spinner()
|
|
75
|
+
sSweep.start(`Sweeping agent EOA → ${to}`)
|
|
76
|
+
try {
|
|
77
|
+
const result = await withSilencedConsole(() =>
|
|
78
|
+
drainAgentEOA({ network, privkeyHex: unlocked.agentPrivkey, to }),
|
|
79
|
+
)
|
|
80
|
+
sSweep.stop(
|
|
81
|
+
`swept ${formatEther(result.amountSent)} 0G (gas reserved ${formatEther(result.gasReserved)} 0G) → ${explorerTxUrl(network, result.txHash)}`,
|
|
82
|
+
)
|
|
83
|
+
outro(`agent ${agentAddress} drained to ${to}`)
|
|
84
|
+
} catch (e) {
|
|
85
|
+
sSweep.stop(`sweep failed: ${(e as Error).message.slice(0, 160)}`)
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
await unlocked.close()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `promus gateway logs [--tail N] [-f]` — tail the gateway log.
|
|
3
|
+
*
|
|
4
|
+
* v0.19.x: gateway daemon logs to stdout/stderr only (inherited by `gateway run`
|
|
5
|
+
* or backgrounded by `gateway start`). v0.19.3 wires a log file at
|
|
6
|
+
* `~/.promus/agents/<id>/gateway.log` for tailing. Until then, this command
|
|
7
|
+
* informs the user where to look.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { agentPaths, iNFTAgentId } from '@promus/core'
|
|
14
|
+
import { type Address, getAddress } from 'viem'
|
|
15
|
+
import { findAndLoadConfig } from '../config/load'
|
|
16
|
+
|
|
17
|
+
export interface GatewayLogsOpts {
|
|
18
|
+
agentId?: string
|
|
19
|
+
tail: number
|
|
20
|
+
follow: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runGatewayLogs(opts: GatewayLogsOpts): Promise<void> {
|
|
24
|
+
let agentId = opts.agentId
|
|
25
|
+
if (!agentId) {
|
|
26
|
+
const found = await findAndLoadConfig()
|
|
27
|
+
if (!found?.config) {
|
|
28
|
+
console.error('promus gateway logs: no promus.config.ts and no --agent provided')
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
const contractAddress = getAddress(found.config.identity.iNFT!.contract as Address)
|
|
32
|
+
const tokenId = BigInt(found.config.identity.iNFT!.tokenId)
|
|
33
|
+
agentId = iNFTAgentId({ contractAddress, tokenId })
|
|
34
|
+
}
|
|
35
|
+
const logFile = join(agentPaths.agent(agentId).dir, 'gateway.log')
|
|
36
|
+
if (!existsSync(logFile)) {
|
|
37
|
+
console.log(`gateway log not found at ${logFile}`)
|
|
38
|
+
console.log('v0.19.x: gateway daemon logs to stdout when run via `promus gateway run`.')
|
|
39
|
+
console.log(
|
|
40
|
+
'Background it with: nohup bun packages/gateway/bin/@promus/gateway-local > ~/promus-logs/gateway.log 2>&1 &',
|
|
41
|
+
)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const args = ['-n', String(opts.tail), ...(opts.follow ? ['-f'] : []), logFile]
|
|
45
|
+
const proc = spawn('tail', args, { stdio: 'inherit' })
|
|
46
|
+
proc.on('exit', code => process.exit(code ?? 0))
|
|
47
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `promus gateway run` — foreground daemon (blocks; Ctrl+C to stop).
|
|
3
|
+
*
|
|
4
|
+
* Spawns `@promus/gateway-local` (the bin in @promus/gateway) with
|
|
5
|
+
* inherit stdio so the user sees logs live. Reads operator-session for the
|
|
6
|
+
* cached AES keys; fails loud if no session exists ("run promus gateway start
|
|
7
|
+
* first").
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { agentPaths } from '@promus/core'
|
|
12
|
+
import { resolveLocalBin } from '../util/gateway-spawn'
|
|
13
|
+
|
|
14
|
+
export interface GatewayRunOpts {
|
|
15
|
+
agentId?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runGatewayForeground(opts: GatewayRunOpts): Promise<void> {
|
|
19
|
+
const env = { ...process.env }
|
|
20
|
+
if (opts.agentId) env.PROMUS_AGENT_ID = opts.agentId
|
|
21
|
+
// Default PROMUS_CONFIG to the resolved agent config path. agentPaths honors
|
|
22
|
+
// PROMUS_ROOT, so a custom root (e.g. an existing ~/.anima from before the
|
|
23
|
+
// rename) is respected instead of hard-coding ~/.promus/config.ts.
|
|
24
|
+
if (!env.PROMUS_CONFIG) {
|
|
25
|
+
env.PROMUS_CONFIG = agentPaths.config
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const localBin = resolveLocalBin()
|
|
29
|
+
// AWAIT the child for the lifetime of the foreground daemon. Returning early
|
|
30
|
+
// lets the CLI's top-level `main().then(() => process.exit(0))` fire and kill
|
|
31
|
+
// the daemon the instant it spawns (the "silent immediate exit" bug). Use the
|
|
32
|
+
// current bun binary (process.execPath) instead of `bun` on PATH, which the
|
|
33
|
+
// user's shell may not include (~/.bun/bin).
|
|
34
|
+
await new Promise<void>(resolve => {
|
|
35
|
+
const proc = spawn(process.execPath, [localBin], {
|
|
36
|
+
env,
|
|
37
|
+
stdio: 'inherit',
|
|
38
|
+
})
|
|
39
|
+
const forwardSignal = (sig: NodeJS.Signals): void => {
|
|
40
|
+
if (!proc.killed) proc.kill(sig)
|
|
41
|
+
}
|
|
42
|
+
process.on('SIGINT', () => forwardSignal('SIGINT'))
|
|
43
|
+
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
|
|
44
|
+
proc.on('exit', code => {
|
|
45
|
+
process.exitCode = code ?? 0
|
|
46
|
+
resolve()
|
|
47
|
+
})
|
|
48
|
+
proc.on('error', err => {
|
|
49
|
+
console.error(`promus gateway run: spawn failed — ${err.message}`)
|
|
50
|
+
process.exitCode = 1
|
|
51
|
+
resolve()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `promus gateway start` — interactive Touch ID + write operator-session,
|
|
3
|
+
* then fork the gateway daemon detached.
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. Load config from ~/.promus/config.ts
|
|
7
|
+
* 2. Resolve agentId (override via --agent or first agent in config)
|
|
8
|
+
* 3. Check if gateway already running (lock file). If yes, error.
|
|
9
|
+
* 4. Pick operator signer + interactive Touch ID via existing operator-picker
|
|
10
|
+
* 5. Pre-derive scope keys via precomputeAllScopes (keystore + telegram)
|
|
11
|
+
* 6. Write operator-session file (perm 0600, 24h TTL)
|
|
12
|
+
* 7. Spawn @promus/gateway-local detached + wait for socket to become readable
|
|
13
|
+
* (proves the daemon booted cleanly)
|
|
14
|
+
* 8. Print pid + socket path
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { spinner } from '@clack/prompts'
|
|
20
|
+
import {
|
|
21
|
+
OPERATOR_BLOB_SCOPES,
|
|
22
|
+
type OperatorBlobScope,
|
|
23
|
+
agentPaths,
|
|
24
|
+
buildOperatorSession,
|
|
25
|
+
decodeKeystoreBytes,
|
|
26
|
+
decodeOperatorBlobBytes,
|
|
27
|
+
iNFTAgentId,
|
|
28
|
+
isOperatorSessionComplete,
|
|
29
|
+
precomputeAllScopes,
|
|
30
|
+
readOperatorSession,
|
|
31
|
+
requiredScopesForAgent,
|
|
32
|
+
tryDecryptKeystoreWithKey,
|
|
33
|
+
tryDecryptOperatorBlobWithKey,
|
|
34
|
+
writeOperatorSession,
|
|
35
|
+
} from '@promus/core'
|
|
36
|
+
import { type Address, getAddress } from 'viem'
|
|
37
|
+
import { findAndLoadConfig } from '../config/load'
|
|
38
|
+
import { spawnGatewayDaemon } from '../util/gateway-spawn'
|
|
39
|
+
import { telegramSecretsPath } from '../util/telegram-secrets'
|
|
40
|
+
import { loadOrPickOperatorSigner } from './init/operator-picker'
|
|
41
|
+
|
|
42
|
+
export interface GatewayStartOpts {
|
|
43
|
+
agentId?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runGatewayStart(opts: GatewayStartOpts): Promise<void> {
|
|
47
|
+
const found = await findAndLoadConfig()
|
|
48
|
+
if (!found?.config) {
|
|
49
|
+
console.error('promus gateway start: no promus.config.ts found in cwd or ~/.promus/')
|
|
50
|
+
process.exit(1)
|
|
51
|
+
}
|
|
52
|
+
const config = found.config
|
|
53
|
+
const contractAddress = getAddress(config.identity.iNFT!.contract as Address)
|
|
54
|
+
const tokenId = BigInt(config.identity.iNFT!.tokenId)
|
|
55
|
+
const agentId = opts.agentId ?? iNFTAgentId({ contractAddress, tokenId })
|
|
56
|
+
const paths = agentPaths.agent(agentId)
|
|
57
|
+
const agentAddress = getAddress(config.identity.agent as Address)
|
|
58
|
+
const socketPath = join(paths.dir, 'gateway.sock')
|
|
59
|
+
|
|
60
|
+
// v0.23.2: if the socket exists, check for version drift. If the running
|
|
61
|
+
// daemon's version differs from the on-disk CLI binary, auto-restart so
|
|
62
|
+
// operators don't have to remember `promus gateway restart` after every
|
|
63
|
+
// `bun add -g promus@N`. If versions match, bail with the
|
|
64
|
+
// legacy "already running" error.
|
|
65
|
+
if (existsSync(socketPath)) {
|
|
66
|
+
const { createHash } = await import('node:crypto')
|
|
67
|
+
const { homedir } = await import('node:os')
|
|
68
|
+
const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
|
|
69
|
+
const lockFile = join(homedir(), '.promus', 'locks', `@promus/gateway-${identityHash}.lock`)
|
|
70
|
+
const { ensureGatewayVersionMatchesCli } = await import('../util/gateway-version')
|
|
71
|
+
const drift = await ensureGatewayVersionMatchesCli({ socketPath, lockFile })
|
|
72
|
+
if (drift.action === 'ok' || drift.action === 'no-cli-version') {
|
|
73
|
+
console.error(
|
|
74
|
+
`promus gateway start: socket already exists at ${socketPath} — gateway may be running (version ${drift.daemonVersion ?? 'unknown'}). Try \`promus gateway stop\` first.`,
|
|
75
|
+
)
|
|
76
|
+
process.exit(1)
|
|
77
|
+
}
|
|
78
|
+
console.log(`note: ${drift.note}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// v0.21.12: derive the set of scope keys this agent's daemon will need
|
|
82
|
+
// based on what's on disk (always 'keystore'; adds 'telegram' when
|
|
83
|
+
// telegram-secrets.encrypted is present, etc.). The cached session is only
|
|
84
|
+
// "complete enough to skip Touch ID" when it contains every required key.
|
|
85
|
+
// Pre-fix, this used the binary `isOperatorSessionFresh` which returned
|
|
86
|
+
// true for any non-expired session, even one written by a path that didn't
|
|
87
|
+
// derive TELEGRAM. The daemon then booted, found no telegram scope key,
|
|
88
|
+
// and silently dropped all inbound TG messages.
|
|
89
|
+
const required = requiredScopesForAgent(agentId)
|
|
90
|
+
const extraScopes = required.filter((s): s is Exclude<typeof s, 'keystore'> => s !== 'keystore')
|
|
91
|
+
const complete = isOperatorSessionComplete(agentId, required)
|
|
92
|
+
if (!complete) {
|
|
93
|
+
const sUnlock = spinner()
|
|
94
|
+
sUnlock.start('Unlocking operator wallet for session-key derivation')
|
|
95
|
+
let operator: Awaited<ReturnType<typeof loadOrPickOperatorSigner>>
|
|
96
|
+
try {
|
|
97
|
+
operator = await loadOrPickOperatorSigner({
|
|
98
|
+
network: config.network,
|
|
99
|
+
hint: config.operator,
|
|
100
|
+
})
|
|
101
|
+
} catch (e) {
|
|
102
|
+
sUnlock.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
|
|
103
|
+
process.exit(1)
|
|
104
|
+
}
|
|
105
|
+
if (!operator) {
|
|
106
|
+
sUnlock.stop('operator unlock cancelled')
|
|
107
|
+
process.exit(1)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
sUnlock.message(`Deriving scope keys (${required.join(' + ')})`)
|
|
111
|
+
try {
|
|
112
|
+
// v0.24.10: verify each derived canonical key against the on-disk
|
|
113
|
+
// encrypted artifact. If verify fails (e.g. fox's pre-v0.24.9 WC
|
|
114
|
+
// keystore was encrypted with the legacy empty-EIP712Domain hash),
|
|
115
|
+
// `precomputeAllScopes` falls back to the legacy variant via the WC
|
|
116
|
+
// signer's escape hatch and caches the WORKING key. Without this,
|
|
117
|
+
// the daemon would boot with a stale canonical key + the
|
|
118
|
+
// `precomputedKey skips fallback` semantic and panic on first
|
|
119
|
+
// AES-GCM decrypt.
|
|
120
|
+
const verifyKey = buildKeystoreVerifier(agentId)
|
|
121
|
+
const keys = await precomputeAllScopes(operator, agentAddress, extraScopes, { verifyKey })
|
|
122
|
+
const sess = buildOperatorSession({ agent: agentAddress, keys })
|
|
123
|
+
writeOperatorSession(agentId, sess)
|
|
124
|
+
sUnlock.stop('operator-session written (24h TTL)')
|
|
125
|
+
} catch (e) {
|
|
126
|
+
sUnlock.stop(`derive failed: ${(e as Error).message.slice(0, 160)}`)
|
|
127
|
+
await operator.close?.()
|
|
128
|
+
process.exit(1)
|
|
129
|
+
}
|
|
130
|
+
await operator.close?.()
|
|
131
|
+
} else {
|
|
132
|
+
console.log(`operator-session complete (${required.join(' + ')}); skipping Touch ID`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Spawn gateway daemon detached. Inherit stdio for the first ~3s so the
|
|
136
|
+
// user sees boot errors, then redirect to log file when ready.
|
|
137
|
+
const sBoot = spinner()
|
|
138
|
+
sBoot.start(`Spawning gateway daemon (agent=${agentId.slice(0, 8)}…)`)
|
|
139
|
+
|
|
140
|
+
const result = await spawnGatewayDaemon({
|
|
141
|
+
agentId,
|
|
142
|
+
configPath: found.path ?? '',
|
|
143
|
+
socketPath,
|
|
144
|
+
timeoutMs: 10_000,
|
|
145
|
+
// v0.21.12: redirect daemon stdout/stderr to gateway.log (default
|
|
146
|
+
// 'log-file' mode) so boot errors survive the parent's exit. Operators
|
|
147
|
+
// see the log via `promus gateway logs` or by tailing
|
|
148
|
+
// ~/.promus/agents/<id>/gateway.log directly.
|
|
149
|
+
})
|
|
150
|
+
if (result.ready) {
|
|
151
|
+
sBoot.stop(`gateway running pid=${result.pid} socket=${socketPath}`)
|
|
152
|
+
console.log('stop with: promus gateway stop')
|
|
153
|
+
console.log('logs: promus gateway logs -f')
|
|
154
|
+
} else {
|
|
155
|
+
const reason = result.reason ?? 'unknown'
|
|
156
|
+
const detail = result.error ? `: ${result.error}` : ''
|
|
157
|
+
sBoot.stop(
|
|
158
|
+
`gateway did not bind socket within 10s (reason=${reason} pid=${result.pid ?? '?'})${detail}; check above output`,
|
|
159
|
+
)
|
|
160
|
+
process.exit(1)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Stub — wired by gateway-status when needed.
|
|
165
|
+
export function _operatorSessionPresent(agentId: string): boolean {
|
|
166
|
+
return readOperatorSession(agentId) !== null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* v0.24.10: returns a verifier that `precomputeAllScopes` calls after each
|
|
171
|
+
* canonical key derive. The verifier:
|
|
172
|
+
*
|
|
173
|
+
* - For 'keystore': trial-decrypts `<agentDir>/keystore.json` with the
|
|
174
|
+
* candidate key. Returns true on success, false on AES-GCM auth failure.
|
|
175
|
+
* False triggers the legacy empty-EIP712Domain fallback inside
|
|
176
|
+
* `precomputeAllScopes` so pre-v0.24.9 WC-init'd keystores (only known
|
|
177
|
+
* instance is fox, tokenId #5) can still flip to the correct AES key on
|
|
178
|
+
* first boot under v0.24.10+.
|
|
179
|
+
*
|
|
180
|
+
* - For TELEGRAM: trial-decrypts `<agentDir>/telegram-secrets.encrypted`
|
|
181
|
+
* when present. Same legacy-fallback semantic.
|
|
182
|
+
*
|
|
183
|
+
* - For PROFILE / unknown: returns true unconditionally. PROFILE has no
|
|
184
|
+
* on-disk artifact to verify against (the encrypted blob lives in iNFT
|
|
185
|
+
* slot 3 on chain); the keystore-scope detection above already cascades
|
|
186
|
+
* the legacy flag to PROFILE via `precomputeAllScopes`'s
|
|
187
|
+
* `useLegacyForRest` branch, so the PROFILE key is derived via the
|
|
188
|
+
* matching variant without needing a verify here.
|
|
189
|
+
*
|
|
190
|
+
* - On missing keystore (init flow never reached this code path, so this
|
|
191
|
+
* is a defensive fallback): returns true so the derive completes; the
|
|
192
|
+
* daemon's own decrypt at boot will surface the real error.
|
|
193
|
+
*/
|
|
194
|
+
function buildKeystoreVerifier(agentId: string) {
|
|
195
|
+
const keystorePath = agentPaths.agent(agentId).keystore
|
|
196
|
+
const tgSecretsPath = telegramSecretsPath(agentId)
|
|
197
|
+
return async (scope: 'keystore' | OperatorBlobScope, key: Buffer): Promise<boolean> => {
|
|
198
|
+
if (scope === 'keystore') {
|
|
199
|
+
if (!existsSync(keystorePath)) return true
|
|
200
|
+
try {
|
|
201
|
+
const ks = decodeKeystoreBytes(new TextEncoder().encode(readFileSync(keystorePath, 'utf8')))
|
|
202
|
+
return tryDecryptKeystoreWithKey(ks, key)
|
|
203
|
+
} catch {
|
|
204
|
+
return true
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (scope === OPERATOR_BLOB_SCOPES.TELEGRAM) {
|
|
208
|
+
if (!existsSync(tgSecretsPath)) return true
|
|
209
|
+
try {
|
|
210
|
+
const blob = decodeOperatorBlobBytes(new Uint8Array(readFileSync(tgSecretsPath)))
|
|
211
|
+
return tryDecryptOperatorBlobWithKey(blob, key, OPERATOR_BLOB_SCOPES.TELEGRAM)
|
|
212
|
+
} catch {
|
|
213
|
+
return true
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
}
|