@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,77 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { resolvePromusRef } from './ref-resolver'
3
+
4
+ function jsonResponse(status: number, body: unknown): Response {
5
+ return new Response(JSON.stringify(body), {
6
+ status,
7
+ headers: { 'content-type': 'application/json' },
8
+ })
9
+ }
10
+
11
+ const latestFetch = (tagName: string) =>
12
+ (() =>
13
+ Promise.resolve(
14
+ jsonResponse(200, {
15
+ tag_name: tagName,
16
+ published_at: '2026-05-03T04:00:00Z',
17
+ html_url: `https://github.com/JemIIahh/promus/releases/tag/${tagName}`,
18
+ }),
19
+ )) as unknown as typeof fetch
20
+
21
+ describe('resolvePromusRef', () => {
22
+ it('defaults to latest when rawRef + env both unset', async () => {
23
+ const r = await resolvePromusRef(undefined, { fetchImpl: latestFetch('v0.17.8'), env: {} })
24
+ expect(r.ref).toBe('v0.17.8')
25
+ expect(r.isTag).toBe(true)
26
+ expect(r.resolvedFromLatest).toBe(true)
27
+ })
28
+ it('resolves explicit "latest" keyword', async () => {
29
+ const r = await resolvePromusRef('latest', { fetchImpl: latestFetch('v0.17.9'), env: {} })
30
+ expect(r.ref).toBe('v0.17.9')
31
+ expect(r.resolvedFromLatest).toBe(true)
32
+ })
33
+ it('passes through tag-shaped ref without API call', async () => {
34
+ let called = false
35
+ const fetchImpl = (() => {
36
+ called = true
37
+ return Promise.resolve(jsonResponse(200, {}))
38
+ }) as unknown as typeof fetch
39
+ const r = await resolvePromusRef('v0.17.8', { fetchImpl, env: {} })
40
+ expect(r.ref).toBe('v0.17.8')
41
+ expect(r.isTag).toBe(true)
42
+ expect(r.resolvedFromLatest).toBe(false)
43
+ expect(called).toBe(false)
44
+ })
45
+ it('passes through branch refs as non-tag', async () => {
46
+ const r = await resolvePromusRef('main', { env: {} })
47
+ expect(r.ref).toBe('main')
48
+ expect(r.isTag).toBe(false)
49
+ expect(r.resolvedFromLatest).toBe(false)
50
+ })
51
+ it('passes through SHA refs as non-tag', async () => {
52
+ const r = await resolvePromusRef('3d6d10f', { env: {} })
53
+ expect(r.ref).toBe('3d6d10f')
54
+ expect(r.isTag).toBe(false)
55
+ })
56
+ it('respects PROMUS_BOOTSTRAP_REF env override', async () => {
57
+ let called = false
58
+ const fetchImpl = (() => {
59
+ called = true
60
+ return Promise.resolve(jsonResponse(200, {}))
61
+ }) as unknown as typeof fetch
62
+ const r = await resolvePromusRef(undefined, {
63
+ fetchImpl,
64
+ env: { PROMUS_BOOTSTRAP_REF: 'main' },
65
+ })
66
+ expect(r.ref).toBe('main')
67
+ expect(r.isTag).toBe(false)
68
+ expect(called).toBe(false)
69
+ })
70
+ it('rawRef takes priority over env', async () => {
71
+ const r = await resolvePromusRef('v0.17.8', {
72
+ env: { PROMUS_BOOTSTRAP_REF: 'main' },
73
+ })
74
+ expect(r.ref).toBe('v0.17.8')
75
+ expect(r.isTag).toBe(true)
76
+ })
77
+ })
@@ -0,0 +1,55 @@
1
+ import { type GitHubFetchOpts, resolveLatestRelease } from './github-releases'
2
+
3
+ /** Canonical Promus repo. Override via {@link ResolvePromusRefOpts.repoUrl}. */
4
+ export const PROMUS_REPO_URL = 'https://github.com/JemIIahh/promus.git'
5
+
6
+ /** Magic ref keyword that triggers GitHub `releases/latest` resolution. */
7
+ export const LATEST_KEYWORD = 'latest'
8
+
9
+ export interface ResolvedRef {
10
+ ref: string
11
+ /** True if `ref` looks like `vX.Y.Z`. Drives pre/post-flight version verification. */
12
+ isTag: boolean
13
+ /** True if user said `latest` and we resolved via API — caller skips pre-flight (already source of truth). */
14
+ resolvedFromLatest: boolean
15
+ }
16
+
17
+ export interface ResolvePromusRefOpts extends GitHubFetchOpts {
18
+ repoUrl?: string
19
+ /** Test seam. Defaults to `process.env`. */
20
+ env?: Record<string, string | undefined>
21
+ }
22
+
23
+ const TAG_RE = /^v\d+\.\d+\.\d+/
24
+
25
+ /**
26
+ * Resolve user ref. Priority: rawRef → PROMUS_BOOTSTRAP_REF env → `latest`.
27
+ * Tag-shaped refs pass through. Branch / SHA refs return isTag=false (no
28
+ * version verification possible).
29
+ */
30
+ export async function resolvePromusRef(
31
+ rawRef: string | undefined,
32
+ opts: ResolvePromusRefOpts = {},
33
+ ): Promise<ResolvedRef> {
34
+ const env = opts.env ?? process.env
35
+ const arg = rawRef ?? env.PROMUS_BOOTSTRAP_REF ?? LATEST_KEYWORD
36
+
37
+ if (arg === LATEST_KEYWORD) {
38
+ const release = await resolveLatestRelease(opts.repoUrl ?? PROMUS_REPO_URL, opts)
39
+ return { ref: release.tagName, isTag: true, resolvedFromLatest: true }
40
+ }
41
+ if (TAG_RE.test(arg)) {
42
+ return { ref: arg, isTag: true, resolvedFromLatest: false }
43
+ }
44
+ return { ref: arg, isTag: false, resolvedFromLatest: false }
45
+ }
46
+
47
+ /** Pretty form of a ResolvedRef for prompts and outros. Adds `(resolved from latest)` suffix when applicable. */
48
+ export function formatResolvedRef(resolved: ResolvedRef): string {
49
+ return resolved.resolvedFromLatest ? `${resolved.ref} (resolved from latest)` : resolved.ref
50
+ }
51
+
52
+ /** Expected `package.json` version for a ResolvedRef, or null when no strict expectation (branch / SHA). */
53
+ export function expectedVersionFromRef(resolved: ResolvedRef): string | null {
54
+ return resolved.isTag ? resolved.ref.replace(/^v/, '') : null
55
+ }
@@ -0,0 +1,53 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { withSilencedConsole } from './silence-console'
3
+
4
+ describe('withSilencedConsole', () => {
5
+ let writes: string[] = []
6
+ let originalWrite: typeof process.stdout.write
7
+ beforeEach(() => {
8
+ writes = []
9
+ originalWrite = process.stdout.write
10
+ process.stdout.write = ((chunk: unknown) => {
11
+ writes.push(typeof chunk === 'string' ? chunk : (chunk?.toString?.() ?? ''))
12
+ return true
13
+ }) as typeof process.stdout.write
14
+ })
15
+ afterEach(() => {
16
+ process.stdout.write = originalWrite
17
+ })
18
+
19
+ test('mutes console.log/info/warn/error/debug for the duration of fn', async () => {
20
+ await withSilencedConsole(async () => {
21
+ console.log('SHOULD_BE_MUTED_LOG')
22
+ console.info('SHOULD_BE_MUTED_INFO')
23
+ console.warn('SHOULD_BE_MUTED_WARN')
24
+ console.error('SHOULD_BE_MUTED_ERROR')
25
+ console.debug('SHOULD_BE_MUTED_DEBUG')
26
+ })
27
+ expect(writes.join('')).not.toContain('SHOULD_BE_MUTED')
28
+ })
29
+
30
+ test('restores originals after fn resolves', async () => {
31
+ const before = console.log
32
+ await withSilencedConsole(async () => {})
33
+ expect(console.log).toBe(before)
34
+ })
35
+
36
+ test('restores originals after fn throws', async () => {
37
+ const before = console.log
38
+ await expect(
39
+ withSilencedConsole(async () => {
40
+ throw new Error('boom')
41
+ }),
42
+ ).rejects.toThrow('boom')
43
+ expect(console.log).toBe(before)
44
+ })
45
+
46
+ test('returns the value produced by fn', async () => {
47
+ const result = await withSilencedConsole(async () => {
48
+ console.log('noise')
49
+ return 42
50
+ })
51
+ expect(result).toBe(42)
52
+ })
53
+ })
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Run `fn` with `console.log/info/warn/error/debug` swapped for no-ops so they
3
+ * cannot interleave with clack's in-place spinner re-render. Originals are
4
+ * restored even if `fn` throws.
5
+ *
6
+ * Why: 0G Storage SDK and 0G Compute broker SDK both `console.log` directly
7
+ * during their work (selected nodes, upload progress, broker tx hashes, etc).
8
+ * When a clack spinner is running, every leaked log line pushes the spinner
9
+ * down and the next animation frame draws a new spinner row, creating the
10
+ * "100x stacked spinner" visual we saw on the WC init test. Suppressing these
11
+ * during the spinner-active phases keeps the wizard output clean.
12
+ *
13
+ * Note: `chat.tsx` does its own process-lifetime console redirect to a chat
14
+ * log file. That cannot use this helper because its lifetime is the whole
15
+ * session, not a scoped wrap. Keep the two pathways separate.
16
+ */
17
+ export async function withSilencedConsole<T>(fn: () => Promise<T>): Promise<T> {
18
+ const orig = {
19
+ log: console.log,
20
+ info: console.info,
21
+ warn: console.warn,
22
+ error: console.error,
23
+ debug: console.debug,
24
+ }
25
+ const noop = (() => {}) as (...args: unknown[]) => void
26
+ console.log = noop as typeof console.log
27
+ console.info = noop as typeof console.info
28
+ console.warn = noop as typeof console.warn
29
+ console.error = noop as typeof console.error
30
+ console.debug = noop as typeof console.debug
31
+ try {
32
+ return await fn()
33
+ } finally {
34
+ console.log = orig.log
35
+ console.info = orig.info
36
+ console.warn = orig.warn
37
+ console.error = orig.error
38
+ console.debug = orig.debug
39
+ }
40
+ }
@@ -0,0 +1,227 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ OPERATOR_BLOB_SCOPES,
7
+ RawPrivkeyOperatorSigner,
8
+ agentPaths,
9
+ deriveBlobKey,
10
+ iNFTAgentId,
11
+ } from '@promus/core'
12
+ import { type Address, generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
13
+ import {
14
+ loadTelegramHandoffSecrets,
15
+ looksLikeBotToken,
16
+ parseAllowedUserIds,
17
+ saveTelegramSecrets,
18
+ telegramSecretsPath,
19
+ } from './telegram-secrets'
20
+
21
+ describe('looksLikeBotToken', () => {
22
+ it('accepts a real-shaped token', () => {
23
+ expect(looksLikeBotToken('8776805236:AAGgfvp2AwYBvDc3COYfjC9m8w2s0e4t4hw')).toBe(true)
24
+ })
25
+
26
+ it('rejects empty / wrong delimiters', () => {
27
+ expect(looksLikeBotToken('')).toBe(false)
28
+ expect(looksLikeBotToken('8776805236-AAGgfvp2AwYBvDc3COYfjC9m8w2s0e4t4hw')).toBe(false)
29
+ expect(looksLikeBotToken('AAGgfvp2AwYBvDc3COYfjC9m8w2s0e4t4hw')).toBe(false)
30
+ })
31
+
32
+ it('rejects too-short secret half', () => {
33
+ expect(looksLikeBotToken('1234567890:short')).toBe(false)
34
+ })
35
+
36
+ it('trims surrounding whitespace before checking', () => {
37
+ expect(looksLikeBotToken(' 8731160904:AAH8FQ3CLrE8-WAfZtDeOTqmpVgOFLg8GyU\n')).toBe(true)
38
+ })
39
+ })
40
+
41
+ describe('parseAllowedUserIds', () => {
42
+ it('returns empty list for blank input', () => {
43
+ const r = parseAllowedUserIds('')
44
+ expect(r.ok).toBe(true)
45
+ if (r.ok) expect(r.ids).toEqual([])
46
+ })
47
+
48
+ it('parses a comma-separated list', () => {
49
+ const r = parseAllowedUserIds('123, 456, 789')
50
+ expect(r.ok).toBe(true)
51
+ if (r.ok) expect(r.ids).toEqual([123, 456, 789])
52
+ })
53
+
54
+ it('parses whitespace-only delimiters', () => {
55
+ const r = parseAllowedUserIds('123 456\t789')
56
+ expect(r.ok).toBe(true)
57
+ if (r.ok) expect(r.ids).toEqual([123, 456, 789])
58
+ })
59
+
60
+ it('dedupes preserving first-seen order', () => {
61
+ const r = parseAllowedUserIds('123, 456, 123')
62
+ expect(r.ok).toBe(true)
63
+ if (r.ok) expect(r.ids).toEqual([123, 456])
64
+ })
65
+
66
+ it('rejects non-numeric ids', () => {
67
+ const r = parseAllowedUserIds('123, abc')
68
+ expect(r.ok).toBe(false)
69
+ if (!r.ok) expect(r.reason).toContain('abc')
70
+ })
71
+
72
+ it('rejects negative ids', () => {
73
+ const r = parseAllowedUserIds('-123')
74
+ expect(r.ok).toBe(false)
75
+ })
76
+
77
+ it('rejects zero', () => {
78
+ const r = parseAllowedUserIds('0')
79
+ expect(r.ok).toBe(false)
80
+ })
81
+ })
82
+
83
+ describe('loadTelegramHandoffSecrets', () => {
84
+ // Each test gets a fresh PROMUS_ROOT tmpdir so `agentPaths.agent(id).dir`
85
+ // resolves somewhere isolated, and `afterEach` cleans it up even on failure.
86
+ const TEST_CONTRACT = '0x9e71d79f06f956d4d2666b5c93dafab721c84721' as Address
87
+ const TEST_TOKEN_ID = 6n
88
+ const TEST_AGENT_ID = iNFTAgentId({
89
+ contractAddress: TEST_CONTRACT,
90
+ tokenId: TEST_TOKEN_ID,
91
+ })
92
+
93
+ let prevPromusRoot: string | undefined
94
+ let tmpRoot: string
95
+
96
+ beforeAll(() => {
97
+ prevPromusRoot = process.env.PROMUS_ROOT
98
+ })
99
+ afterAll(() => {
100
+ if (prevPromusRoot === undefined) Reflect.deleteProperty(process.env, 'PROMUS_ROOT')
101
+ else process.env.PROMUS_ROOT = prevPromusRoot
102
+ })
103
+ beforeEach(() => {
104
+ tmpRoot = mkdtempSync(join(tmpdir(), 'promus-tg-secrets-test-'))
105
+ process.env.PROMUS_ROOT = tmpRoot
106
+ })
107
+ afterEach(() => {
108
+ rmSync(tmpRoot, { recursive: true, force: true })
109
+ })
110
+
111
+ it('returns undefined when no blob exists on disk', async () => {
112
+ const operatorPrivkey = generatePrivateKey()
113
+ const signer = new RawPrivkeyOperatorSigner({ privkey: operatorPrivkey })
114
+ const agentAddress = privateKeyToAccount(generatePrivateKey()).address
115
+ let notices = 0
116
+ const result = await loadTelegramHandoffSecrets({
117
+ signer,
118
+ agentAddress,
119
+ contractAddress: TEST_CONTRACT,
120
+ tokenId: TEST_TOKEN_ID,
121
+ onNotice: () => {
122
+ notices += 1
123
+ },
124
+ })
125
+ expect(result).toBeUndefined()
126
+ expect(notices).toBe(0)
127
+ })
128
+
129
+ it('round-trips through saveTelegramSecrets and returns handoff subset', async () => {
130
+ const operatorPrivkey = generatePrivateKey()
131
+ const signer = new RawPrivkeyOperatorSigner({ privkey: operatorPrivkey })
132
+ const agentAddress = privateKeyToAccount(generatePrivateKey()).address
133
+ await saveTelegramSecrets({
134
+ signer,
135
+ agentAddress,
136
+ agentId: TEST_AGENT_ID,
137
+ plaintext: {
138
+ botToken: '8731160904:AAH8FQ3CLrE8-WAfZtDeOTqmpVgOFLg8GyU',
139
+ botUsername: 'promus_test_bot',
140
+ botId: 8731160904,
141
+ allowedUserIds: [1140813034, 222333444],
142
+ },
143
+ })
144
+ expect(existsSync(telegramSecretsPath(TEST_AGENT_ID))).toBe(true)
145
+
146
+ const result = await loadTelegramHandoffSecrets({
147
+ signer,
148
+ agentAddress,
149
+ contractAddress: TEST_CONTRACT,
150
+ tokenId: TEST_TOKEN_ID,
151
+ })
152
+ expect(result).toEqual({
153
+ botToken: '8731160904:AAH8FQ3CLrE8-WAfZtDeOTqmpVgOFLg8GyU',
154
+ allowedUserIds: [1140813034, 222333444],
155
+ })
156
+ })
157
+
158
+ it('swallows decrypt errors via onNotice and returns undefined', async () => {
159
+ const operatorPrivkey = generatePrivateKey()
160
+ const signer = new RawPrivkeyOperatorSigner({ privkey: operatorPrivkey })
161
+ const agentAddress = privateKeyToAccount(generatePrivateKey()).address
162
+ // Write a malformed blob: file exists but contents fail decode.
163
+ const path = telegramSecretsPath(TEST_AGENT_ID)
164
+ const agentDir = agentPaths.agent(TEST_AGENT_ID).dir
165
+ mkdirSync(agentDir, { recursive: true })
166
+ writeFileSync(path, 'not-a-valid-operator-blob-payload')
167
+
168
+ const notices: string[] = []
169
+ const result = await loadTelegramHandoffSecrets({
170
+ signer,
171
+ agentAddress,
172
+ contractAddress: TEST_CONTRACT,
173
+ tokenId: TEST_TOKEN_ID,
174
+ onNotice: msg => {
175
+ notices.push(msg)
176
+ },
177
+ })
178
+ expect(result).toBeUndefined()
179
+ expect(notices.length).toBe(1)
180
+ expect(notices[0]).toMatch(/telegram secrets read failed:/)
181
+ })
182
+
183
+ // v0.24.3: precomputedKey path — used by init wizard so the derived TELEGRAM
184
+ // scope key can be passed to saveTelegramSecrets AND stashed in
185
+ // `.operator-session` in a single derive. Without this, the daemon
186
+ // fail-louds at boot ("telegram secrets present but no telegram scope key").
187
+ it('round-trips with precomputedKey (init-wizard fast path)', async () => {
188
+ const operatorPrivkey = generatePrivateKey()
189
+ const signer = new RawPrivkeyOperatorSigner({ privkey: operatorPrivkey })
190
+ const agentAddress = privateKeyToAccount(generatePrivateKey()).address
191
+ // Derive the TELEGRAM scope key explicitly (mirrors what runTelegramStep
192
+ // does so it can both encrypt AND stash the key in operator-session).
193
+ const tgKey = await deriveBlobKey(signer, agentAddress, OPERATOR_BLOB_SCOPES.TELEGRAM)
194
+ expect(tgKey.length).toBe(32)
195
+
196
+ await saveTelegramSecrets({
197
+ signer,
198
+ agentAddress,
199
+ agentId: TEST_AGENT_ID,
200
+ plaintext: {
201
+ botToken: '8152506307:AAFbXSJ0qnfJNbLWkxbmzYEM9fc74uaznJs',
202
+ botUsername: 'promus_init_test_bot',
203
+ botId: 8152506307,
204
+ allowedUserIds: [1140813034],
205
+ },
206
+ precomputedKey: tgKey,
207
+ })
208
+ expect(existsSync(telegramSecretsPath(TEST_AGENT_ID))).toBe(true)
209
+
210
+ const result = await loadTelegramHandoffSecrets({
211
+ signer,
212
+ agentAddress,
213
+ contractAddress: TEST_CONTRACT,
214
+ tokenId: TEST_TOKEN_ID,
215
+ })
216
+ expect(result).toEqual({
217
+ botToken: '8152506307:AAFbXSJ0qnfJNbLWkxbmzYEM9fc74uaznJs',
218
+ allowedUserIds: [1140813034],
219
+ })
220
+
221
+ // Independent re-derive must produce the SAME 32-byte key (deterministic
222
+ // HKDF output). This is what lets the daemon load the cached key from
223
+ // `.operator-session` and successfully decrypt the blob the wizard wrote.
224
+ const tgKey2 = await deriveBlobKey(signer, agentAddress, OPERATOR_BLOB_SCOPES.TELEGRAM)
225
+ expect(Buffer.compare(tgKey, tgKey2)).toBe(0)
226
+ })
227
+ })
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Local persistence for telegram bot secrets, encrypted via the operator's
3
+ * sign-derived AEAD key (scope `OPERATOR_BLOB_SCOPES.TELEGRAM`).
4
+ *
5
+ * On-disk file: `~/.promus/agents/<id>/telegram-secrets.encrypted`
6
+ *
7
+ * {
8
+ * version: 2,
9
+ * scope: 'promus-telegram-v1',
10
+ * blob: <base64(iv|tag|ciphertext)>,
11
+ * }
12
+ *
13
+ * Plaintext shape inside the blob:
14
+ *
15
+ * {
16
+ * botToken: string, // from @BotFather
17
+ * botUsername?: string, // cached at setup-time getMe
18
+ * botId?: number, // cached at setup-time getMe
19
+ * allowedUserIds: number[],
20
+ * }
21
+ */
22
+ import { existsSync } from 'node:fs'
23
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
24
+ import { dirname, join } from 'node:path'
25
+ import {
26
+ OPERATOR_BLOB_SCOPES,
27
+ type OperatorEncryptedBlob,
28
+ type OperatorSigner,
29
+ agentPaths,
30
+ decodeOperatorBlobBytes,
31
+ decryptOperatorBlob,
32
+ encodeOperatorBlobBytes,
33
+ encryptOperatorBlob,
34
+ iNFTAgentId,
35
+ } from '@promus/core'
36
+ import type { Address } from 'viem'
37
+
38
+ export interface TelegramSecretsPlaintext {
39
+ botToken: string
40
+ botUsername?: string
41
+ botId?: number
42
+ allowedUserIds: number[]
43
+ }
44
+
45
+ /**
46
+ * Subset of `TelegramSecretsPlaintext` that the CLI ships into the harness
47
+ * provision envelope on init / upgrade / resume. The harness doesn't need
48
+ * the operator-side metadata (`botUsername`, `botId`); it only needs the
49
+ * token + allowlist + optional pairing list. Centralising this as a named
50
+ * type avoids drift between the four CLI handoff sites that previously
51
+ * inlined the literal shape (init wizard, upgrade, resume, the
52
+ * `HandoffAgentToGateway` + `ResumeArchivedSandbox` interfaces).
53
+ */
54
+ export interface TelegramHandoffSecrets {
55
+ botToken: string
56
+ allowedUserIds: number[]
57
+ pairingApproved?: number[]
58
+ }
59
+
60
+ export function telegramSecretsPath(agentId: string): string {
61
+ return join(agentPaths.agent(agentId).dir, 'telegram-secrets.encrypted')
62
+ }
63
+
64
+ export function telegramSecretsExist(agentId: string): boolean {
65
+ return existsSync(telegramSecretsPath(agentId))
66
+ }
67
+
68
+ export async function loadTelegramSecrets(opts: {
69
+ signer: OperatorSigner
70
+ agentAddress: Address
71
+ agentId: string
72
+ }): Promise<TelegramSecretsPlaintext | null> {
73
+ const path = telegramSecretsPath(opts.agentId)
74
+ if (!existsSync(path)) return null
75
+ const fileBytes = await readFile(path)
76
+ const blob: OperatorEncryptedBlob = decodeOperatorBlobBytes(new Uint8Array(fileBytes))
77
+ const ptBytes = await decryptOperatorBlob({
78
+ signer: opts.signer,
79
+ scope: OPERATOR_BLOB_SCOPES.TELEGRAM,
80
+ agentAddress: opts.agentAddress,
81
+ blob,
82
+ })
83
+ const parsed = JSON.parse(new TextDecoder().decode(ptBytes)) as TelegramSecretsPlaintext
84
+ if (typeof parsed.botToken !== 'string' || !Array.isArray(parsed.allowedUserIds)) {
85
+ throw new Error('telegram-secrets: malformed plaintext (missing botToken or allowedUserIds)')
86
+ }
87
+ return parsed
88
+ }
89
+
90
+ /**
91
+ * Load + project telegram secrets into the shape the gateway provision envelope
92
+ * expects. Used by every sandbox-handoff flow that ships TG (init/upgrade/
93
+ * resume/deploy/chat-sandbox auto-resume); centralises the try/decrypt/swallow
94
+ * pattern so future TG-secret schema changes touch one place.
95
+ *
96
+ * Errors are non-fatal: TG is opt-in. Failure fires `onNotice` (so the operator
97
+ * sees the reason in the spinner) and returns undefined.
98
+ *
99
+ * `chat.tsx` keeps its own loader because it needs the full plaintext (including
100
+ * `botUsername` for the unlock spinner UX).
101
+ */
102
+ export async function loadTelegramHandoffSecrets(opts: {
103
+ signer: OperatorSigner
104
+ agentAddress: Address
105
+ contractAddress: Address
106
+ tokenId: bigint
107
+ onNotice?: (msg: string) => void
108
+ }): Promise<TelegramHandoffSecrets | undefined> {
109
+ const agentId = iNFTAgentId({
110
+ contractAddress: opts.contractAddress,
111
+ tokenId: opts.tokenId,
112
+ })
113
+ try {
114
+ const tg = await loadTelegramSecrets({
115
+ signer: opts.signer,
116
+ agentAddress: opts.agentAddress,
117
+ agentId,
118
+ })
119
+ if (!tg) return undefined
120
+ return { botToken: tg.botToken, allowedUserIds: tg.allowedUserIds }
121
+ } catch (err) {
122
+ opts.onNotice?.(`telegram secrets read failed: ${(err as Error).message.slice(0, 120)}`)
123
+ return undefined
124
+ }
125
+ }
126
+
127
+ export async function saveTelegramSecrets(opts: {
128
+ signer: OperatorSigner
129
+ agentAddress: Address
130
+ agentId: string
131
+ plaintext: TelegramSecretsPlaintext
132
+ /**
133
+ * v0.24.3: pre-derived TELEGRAM scope key (32 bytes). The init wizard
134
+ * derives this once and passes it both here AND into `.operator-session`,
135
+ * so encryptOperatorBlob skips the redundant sign it would otherwise make.
136
+ * Threads through to encryptOperatorBlob; see that helper for fallback.
137
+ */
138
+ precomputedKey?: Buffer
139
+ }): Promise<void> {
140
+ const path = telegramSecretsPath(opts.agentId)
141
+ await mkdir(dirname(path), { recursive: true })
142
+ const ptBytes = new TextEncoder().encode(JSON.stringify(opts.plaintext))
143
+ const blob = await encryptOperatorBlob({
144
+ signer: opts.signer,
145
+ scope: OPERATOR_BLOB_SCOPES.TELEGRAM,
146
+ agentAddress: opts.agentAddress,
147
+ plaintext: ptBytes,
148
+ precomputedKey: opts.precomputedKey,
149
+ })
150
+ await writeFile(path, encodeOperatorBlobBytes(blob))
151
+ }
152
+
153
+ export async function removeTelegramSecrets(agentId: string): Promise<boolean> {
154
+ const path = telegramSecretsPath(agentId)
155
+ if (!existsSync(path)) return false
156
+ await rm(path, { force: true })
157
+ return true
158
+ }
159
+
160
+ const BOT_TOKEN_RE = /^\d{6,15}:[A-Za-z0-9_-]{30,}$/
161
+
162
+ export function looksLikeBotToken(s: string): boolean {
163
+ return BOT_TOKEN_RE.test(s.trim())
164
+ }
165
+
166
+ export interface ValidatedBotInfo {
167
+ id: number
168
+ username: string
169
+ firstName: string
170
+ }
171
+
172
+ /**
173
+ * Telegram Bot API getMe — cheap, free, no message side-effect. Used by
174
+ * `promus telegram setup` to validate the token before persisting it AND by
175
+ * `promus telegram status` to confirm the stored token still works.
176
+ *
177
+ * Throws on non-200 / `ok: false` with a clean error message; caller wraps
178
+ * the throw in a clack spinner.stop().
179
+ */
180
+ export async function fetchBotInfo(
181
+ botToken: string,
182
+ opts?: { signal?: AbortSignal },
183
+ ): Promise<ValidatedBotInfo> {
184
+ const url = `https://api.telegram.org/bot${encodeURIComponent(botToken)}/getMe`
185
+ const res = await fetch(url, { signal: opts?.signal })
186
+ if (!res.ok) {
187
+ throw new Error(`getMe HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`)
188
+ }
189
+ const body = (await res.json()) as {
190
+ ok: boolean
191
+ description?: string
192
+ result?: { id: number; username?: string; first_name?: string }
193
+ }
194
+ if (!body.ok || !body.result) {
195
+ throw new Error(`getMe rejected: ${body.description ?? 'unknown error'}`)
196
+ }
197
+ if (!body.result.username) throw new Error('bot has no username; create one in @BotFather')
198
+ return {
199
+ id: body.result.id,
200
+ username: body.result.username,
201
+ firstName: body.result.first_name ?? body.result.username,
202
+ }
203
+ }
204
+
205
+ export function parseAllowedUserIds(
206
+ input: string,
207
+ ): { ok: true; ids: number[] } | { ok: false; reason: string } {
208
+ const trimmed = input.trim()
209
+ if (trimmed.length === 0) return { ok: true, ids: [] }
210
+ const parts = trimmed
211
+ .split(/[,\s]+/)
212
+ .map(p => p.trim())
213
+ .filter(p => p.length > 0)
214
+ const ids: number[] = []
215
+ for (const p of parts) {
216
+ if (!/^\d+$/.test(p)) return { ok: false, reason: `not a numeric id: "${p}"` }
217
+ const n = Number(p)
218
+ if (!Number.isFinite(n) || n <= 0) return { ok: false, reason: `not a positive id: "${p}"` }
219
+ ids.push(n)
220
+ }
221
+ // Dedupe, preserve first-seen order.
222
+ return { ok: true, ids: [...new Set(ids)] }
223
+ }