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