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