lyra-ai-agent 0.1.0
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 +39 -0
- package/bin/lyra +11 -0
- package/package.json +65 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1220 -0
- package/src/commands/demo.ts +177 -0
- package/src/commands/gateway-logs.ts +49 -0
- package/src/commands/gateway-run.ts +42 -0
- package/src/commands/gateway-start.ts +89 -0
- package/src/commands/gateway-status.ts +73 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/init/model-picker.ts +17 -0
- package/src/commands/init.ts +153 -0
- package/src/commands/logs.ts +37 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +65 -0
- package/src/commands/pairing-clear.ts +39 -0
- package/src/commands/pairing-list.ts +55 -0
- package/src/commands/pairing-revoke.ts +49 -0
- package/src/commands/pairing.test.ts +88 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/telegram-remove.ts +45 -0
- package/src/commands/telegram-setup.ts +84 -0
- package/src/commands/telegram-status.ts +48 -0
- package/src/commands/telegram.test.ts +50 -0
- package/src/commands/telegram.ts +44 -0
- package/src/commands/whoami.ts +62 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.test.ts +96 -0
- package/src/config/render.ts +99 -0
- package/src/index.ts +147 -0
- package/src/ui/app.tsx +719 -0
- package/src/ui/approval-summary.test.ts +132 -0
- package/src/ui/approval-summary.ts +28 -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 +176 -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/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 +125 -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/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/sui-runtime.ts +74 -0
- package/src/util/telegram-secrets.test.ts +104 -0
- package/src/util/telegram-secrets.ts +99 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { agentPaths, formatSui, getSuiBalanceMist, makeSuiClient, suiRpcUrl } from 'lyra-core'
|
|
3
|
+
import { type SuiPolicy, policyFromEnv } from 'lyra-plugin-onchain'
|
|
4
|
+
import { findAndLoadConfig } from '../config/load'
|
|
5
|
+
import { loadAgentFromEnv } from '../util/sui-runtime'
|
|
6
|
+
import { listAgentIds } from './_agents'
|
|
7
|
+
|
|
8
|
+
export async function runStatus(opts?: { cwd?: string }): Promise<void> {
|
|
9
|
+
const cwd = opts?.cwd ?? process.cwd()
|
|
10
|
+
const found = await findAndLoadConfig(cwd)
|
|
11
|
+
if (!found) {
|
|
12
|
+
console.log('No lyra.config.ts found. Run `lyra init` first.')
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
const { config, path } = found
|
|
16
|
+
|
|
17
|
+
const agent = loadAgentFromEnv()
|
|
18
|
+
const agentAddress = agent?.address ?? config.identity.agent ?? '(no LYRA_AGENT_KEY)'
|
|
19
|
+
|
|
20
|
+
console.log(`config ${path}`)
|
|
21
|
+
console.log(`network ${config.network}`)
|
|
22
|
+
console.log(`rpc ${suiRpcUrl(config.network)}`)
|
|
23
|
+
console.log(`plugins ${config.plugins.join(', ')}`)
|
|
24
|
+
console.log(`agent ${agentAddress}`)
|
|
25
|
+
console.log(`brain ${config.brain.provider ?? '(not picked)'}`)
|
|
26
|
+
|
|
27
|
+
// lyra::policy package + active deterministic policy summary.
|
|
28
|
+
const packageId = process.env.LYRA_PACKAGE_ID
|
|
29
|
+
console.log(`policy pkg ${packageId ?? '(LYRA_PACKAGE_ID unset)'}`)
|
|
30
|
+
const policyObjectId = process.env.LYRA_POLICY_OBJECT_ID
|
|
31
|
+
if (policyObjectId) console.log(`policy obj ${policyObjectId}`)
|
|
32
|
+
const policy = policyFromEnv()
|
|
33
|
+
if (policy) {
|
|
34
|
+
console.log(`policy ${summarizePolicy(policy)}`)
|
|
35
|
+
} else {
|
|
36
|
+
console.log('policy (none configured — set LYRA_POLICY_* to bound the agent)')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Live SUI balance for the agent (pays gas for every PTB).
|
|
40
|
+
if (agent) {
|
|
41
|
+
try {
|
|
42
|
+
const client = makeSuiClient(config.network)
|
|
43
|
+
const mist = await getSuiBalanceMist(client, agent.address)
|
|
44
|
+
console.log(`balance ${formatSui(mist)} SUI`)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.log(`balance (rpc error: ${(e as Error).message.slice(0, 80)})`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ids = await listAgentIds()
|
|
51
|
+
if (ids.length === 0) {
|
|
52
|
+
console.log('\nNo agents found in ~/.lyra/agents. Re-run `lyra init`.')
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
console.log('')
|
|
58
|
+
console.log(`agent dir ${id}`)
|
|
59
|
+
console.log(`dir ${agentPaths.agent(id).dir}`)
|
|
60
|
+
const activityPath = agentPaths.agent(id).activityLog
|
|
61
|
+
if (existsSync(activityPath)) {
|
|
62
|
+
const sz = statSync(activityPath).size
|
|
63
|
+
console.log(`activity ${sz} bytes`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One-line summary of the deterministic off-chain policy mirror. */
|
|
69
|
+
function summarizePolicy(policy: SuiPolicy): string {
|
|
70
|
+
const parts: string[] = []
|
|
71
|
+
const autonomy = policy.autonomy ?? (policy.readOnly ? 'readonly' : 'auto')
|
|
72
|
+
parts.push(`autonomy=${autonomy}`)
|
|
73
|
+
if (policy.maxMistPerTx !== undefined)
|
|
74
|
+
parts.push(`maxPerTx=${formatSui(policy.maxMistPerTx)} SUI`)
|
|
75
|
+
if (policy.autoMaxMistPerTx !== undefined)
|
|
76
|
+
parts.push(`autoMax=${formatSui(policy.autoMaxMistPerTx)} SUI`)
|
|
77
|
+
if (policy.maxSlippageBps !== undefined) parts.push(`slippage=${policy.maxSlippageBps}bps`)
|
|
78
|
+
if (policy.coinAllowlist?.length) parts.push(`coins=${policy.coinAllowlist.length}`)
|
|
79
|
+
if (policy.protocolAllowlist?.length) parts.push(`protocols=${policy.protocolAllowlist.length}`)
|
|
80
|
+
if (policy.recipientAllowlist?.length)
|
|
81
|
+
parts.push(`recipients=${policy.recipientAllowlist.length}`)
|
|
82
|
+
if (policy.expiryMs) parts.push(`expires=${new Date(policy.expiryMs).toISOString().slice(0, 16)}`)
|
|
83
|
+
return parts.join(', ')
|
|
84
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cancel, intro, note, outro } from '@clack/prompts'
|
|
2
|
+
import { findAndLoadConfig } from '../config/load'
|
|
3
|
+
import { writeConfigTs } from '../config/render'
|
|
4
|
+
|
|
5
|
+
export interface TelegramRemoveOpts {
|
|
6
|
+
yes?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `lyra telegram remove` — disable the telegram plugin in config and remind the
|
|
11
|
+
* operator to unset the env vars. On Sui the token lives only in the
|
|
12
|
+
* environment, so there is no on-disk blob to delete.
|
|
13
|
+
*/
|
|
14
|
+
export async function runTelegramRemove(_opts: TelegramRemoveOpts = {}): Promise<void> {
|
|
15
|
+
intro('lyra telegram remove')
|
|
16
|
+
|
|
17
|
+
const loaded = await findAndLoadConfig()
|
|
18
|
+
if (!loaded) {
|
|
19
|
+
cancel('No lyra.config.ts found. Run `lyra init` first.')
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
const { config, path: configPath } = loaded
|
|
23
|
+
|
|
24
|
+
const plugins = (config.plugins ?? []).filter(p => p !== 'telegram')
|
|
25
|
+
if (plugins.length !== (config.plugins ?? []).length) {
|
|
26
|
+
await writeConfigTs(configPath, { ...config, plugins })
|
|
27
|
+
note('Removed `telegram` from config.plugins.')
|
|
28
|
+
} else {
|
|
29
|
+
note('telegram plugin was not enabled.')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
note(
|
|
33
|
+
[
|
|
34
|
+
'Unset the env vars to fully disable the bot:',
|
|
35
|
+
'',
|
|
36
|
+
' unset TELEGRAM_BOT_TOKEN TELEGRAM_USERNAME TELEGRAM_CHAT_ID',
|
|
37
|
+
'',
|
|
38
|
+
'The bot token at @BotFather is STILL VALID. To fully revoke, run /token',
|
|
39
|
+
'in @BotFather and pick "Revoke" for this bot.',
|
|
40
|
+
].join('\n'),
|
|
41
|
+
'reminder',
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
outro('telegram removed')
|
|
45
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { cancel, intro, isCancel, note, outro, password, spinner, text } from '@clack/prompts'
|
|
2
|
+
import { findAndLoadConfig } from '../config/load'
|
|
3
|
+
import { writeConfigTs } from '../config/render'
|
|
4
|
+
import { fetchBotInfo, looksLikeBotToken, parseAllowedUserIds } from '../util/telegram-secrets'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `lyra telegram setup` — validate a bot token and print the env vars to set.
|
|
8
|
+
*
|
|
9
|
+
* On Sui the bot token is NOT stored on disk (there is no operator wallet to
|
|
10
|
+
* encrypt it with). It lives only in the environment, so this command's job is
|
|
11
|
+
* to validate the token + allowlist and hand back the exact `export` lines.
|
|
12
|
+
*/
|
|
13
|
+
export async function runTelegramSetup(): Promise<void> {
|
|
14
|
+
intro('lyra telegram setup')
|
|
15
|
+
|
|
16
|
+
const loaded = await findAndLoadConfig()
|
|
17
|
+
if (!loaded) {
|
|
18
|
+
cancel('No lyra.config.ts found. Run `lyra init` first.')
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
const { config, path: configPath } = loaded
|
|
22
|
+
|
|
23
|
+
const token = await password({
|
|
24
|
+
message: 'Bot token (from @BotFather)',
|
|
25
|
+
validate: v => (v && looksLikeBotToken(v) ? undefined : 'Expected a token like 123456:ABC...'),
|
|
26
|
+
})
|
|
27
|
+
if (isCancel(token)) {
|
|
28
|
+
cancel('Aborted.')
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sPing = spinner()
|
|
33
|
+
sPing.start('Validating token via getMe')
|
|
34
|
+
let info: Awaited<ReturnType<typeof fetchBotInfo>>
|
|
35
|
+
try {
|
|
36
|
+
info = await fetchBotInfo(String(token))
|
|
37
|
+
sPing.stop(`bot ok: @${info.username} (id ${info.id})`)
|
|
38
|
+
} catch (e) {
|
|
39
|
+
sPing.stop(`getMe failed: ${(e as Error).message.slice(0, 200)}`)
|
|
40
|
+
cancel('Token rejected.')
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const idsRaw = await text({
|
|
45
|
+
message: 'Allowed Telegram user id (blank = open access). Sets TELEGRAM_CHAT_ID.',
|
|
46
|
+
placeholder: '',
|
|
47
|
+
validate: v => {
|
|
48
|
+
const r = parseAllowedUserIds(v ?? '')
|
|
49
|
+
return r.ok ? undefined : r.reason
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
if (isCancel(idsRaw)) {
|
|
53
|
+
cancel('Aborted.')
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const parsed = parseAllowedUserIds(String(idsRaw ?? ''))
|
|
57
|
+
const chatId = parsed.ok && parsed.ids.length > 0 ? parsed.ids[0] : null
|
|
58
|
+
|
|
59
|
+
// Enable the telegram plugin in config so `lyra` loads the listener when the
|
|
60
|
+
// env token is present.
|
|
61
|
+
if (!(config.plugins ?? []).includes('telegram')) {
|
|
62
|
+
await writeConfigTs(configPath, {
|
|
63
|
+
...config,
|
|
64
|
+
plugins: [...(config.plugins ?? []), 'telegram'],
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
note(
|
|
69
|
+
[
|
|
70
|
+
'Set these in your environment (e.g. .env), then run `lyra`:',
|
|
71
|
+
'',
|
|
72
|
+
` export TELEGRAM_BOT_TOKEN=${token}`,
|
|
73
|
+
` export TELEGRAM_USERNAME=${info.username}`,
|
|
74
|
+
...(chatId
|
|
75
|
+
? [` export TELEGRAM_CHAT_ID=${chatId}`]
|
|
76
|
+
: [' # TELEGRAM_CHAT_ID unset = open access']),
|
|
77
|
+
'',
|
|
78
|
+
`Then open https://t.me/${info.username} and send any message.`,
|
|
79
|
+
].join('\n'),
|
|
80
|
+
'next step',
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
outro(`telegram setup validated (@${info.username})`)
|
|
84
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { cancel, intro, log, outro, spinner } from '@clack/prompts'
|
|
2
|
+
import { findAndLoadConfig } from '../config/load'
|
|
3
|
+
import { fetchBotInfo, telegramSecretsFromEnv } from '../util/telegram-secrets'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `lyra telegram status` — read the env-configured bot token, ping getMe, and
|
|
7
|
+
* report the resolved config.
|
|
8
|
+
*/
|
|
9
|
+
export async function runTelegramStatus(): Promise<void> {
|
|
10
|
+
intro('lyra telegram status')
|
|
11
|
+
|
|
12
|
+
const loaded = await findAndLoadConfig()
|
|
13
|
+
if (!loaded) {
|
|
14
|
+
cancel('No lyra.config.ts found. Run `lyra init` first.')
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
const { config } = loaded
|
|
18
|
+
|
|
19
|
+
const secrets = telegramSecretsFromEnv()
|
|
20
|
+
if (!secrets) {
|
|
21
|
+
log.warn('No TELEGRAM_BOT_TOKEN in the environment.')
|
|
22
|
+
log.info('Run `lyra telegram setup` for the env vars to set.')
|
|
23
|
+
outro('not configured')
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sPing = spinner()
|
|
28
|
+
sPing.start('Pinging Telegram getMe')
|
|
29
|
+
try {
|
|
30
|
+
const info = await fetchBotInfo(secrets.botToken)
|
|
31
|
+
sPing.stop(`bot ok: @${info.username} (id ${info.id})`)
|
|
32
|
+
} catch (e) {
|
|
33
|
+
sPing.stop(`getMe failed: ${(e as Error).message.slice(0, 200)}`)
|
|
34
|
+
log.warn('Token may have been revoked at @BotFather. Re-run `lyra telegram setup`.')
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
log.info(
|
|
39
|
+
[
|
|
40
|
+
'source env (TELEGRAM_BOT_TOKEN)',
|
|
41
|
+
`bot username @${secrets.botUsername ?? '(set TELEGRAM_USERNAME)'}`,
|
|
42
|
+
`allowed user ids ${secrets.allowedUserIds.length === 0 ? '(open access)' : secrets.allowedUserIds.join(', ')}`,
|
|
43
|
+
`plugin enabled ${(config.plugins ?? []).includes('telegram') ? 'yes' : 'no — add `telegram` to plugins'}`,
|
|
44
|
+
].join('\n'),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
outro('telegram configured via env')
|
|
48
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { parseTelegramArgs } from './telegram'
|
|
3
|
+
|
|
4
|
+
describe('parseTelegramArgs', () => {
|
|
5
|
+
it('errors on missing subcommand', () => {
|
|
6
|
+
const r = parseTelegramArgs([])
|
|
7
|
+
expect('error' in r).toBe(true)
|
|
8
|
+
if ('error' in r) expect(r.error).toContain('usage')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('errors on unknown subcommand', () => {
|
|
12
|
+
const r = parseTelegramArgs(['nuke'])
|
|
13
|
+
expect('error' in r).toBe(true)
|
|
14
|
+
if ('error' in r) expect(r.error).toContain('nuke')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('parses setup', () => {
|
|
18
|
+
const r = parseTelegramArgs(['setup'])
|
|
19
|
+
expect('error' in r).toBe(false)
|
|
20
|
+
if (!('error' in r)) expect(r.sub).toBe('setup')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('parses status', () => {
|
|
24
|
+
const r = parseTelegramArgs(['status'])
|
|
25
|
+
if (!('error' in r)) expect(r.sub).toBe('status')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('parses remove without --yes', () => {
|
|
29
|
+
const r = parseTelegramArgs(['remove'])
|
|
30
|
+
if (!('error' in r)) {
|
|
31
|
+
expect(r.sub).toBe('remove')
|
|
32
|
+
expect(r.yes).toBeFalsy()
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('parses remove --yes', () => {
|
|
37
|
+
const r = parseTelegramArgs(['remove', '--yes'])
|
|
38
|
+
if (!('error' in r)) {
|
|
39
|
+
expect(r.sub).toBe('remove')
|
|
40
|
+
expect(r.yes).toBe(true)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('parses remove -y', () => {
|
|
45
|
+
const r = parseTelegramArgs(['remove', '-y'])
|
|
46
|
+
if (!('error' in r)) {
|
|
47
|
+
expect(r.yes).toBe(true)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lyra telegram <subcommand>` — argv dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* setup interactive wizard: validate token, encrypt + persist locally
|
|
6
|
+
* status confirm token still valid + show stored config
|
|
7
|
+
* remove delete the encrypted local blob (does NOT revoke at @BotFather)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface TelegramArgs {
|
|
11
|
+
sub: 'setup' | 'status' | 'remove'
|
|
12
|
+
yes?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const VALID_SUBS = ['setup', 'status', 'remove'] as const
|
|
16
|
+
|
|
17
|
+
export function parseTelegramArgs(argv: string[]): TelegramArgs | { error: string } {
|
|
18
|
+
const sub = argv[0]
|
|
19
|
+
if (!sub) return { error: 'usage: lyra telegram <setup | status | remove>' }
|
|
20
|
+
const valid = (VALID_SUBS as readonly string[]).includes(sub)
|
|
21
|
+
if (!valid) return { error: `unknown subcommand '${sub}' (expected: ${VALID_SUBS.join(' | ')})` }
|
|
22
|
+
const yes = argv.includes('--yes') || argv.includes('-y')
|
|
23
|
+
return { sub: sub as TelegramArgs['sub'], yes }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runTelegram(args: TelegramArgs): Promise<void> {
|
|
27
|
+
switch (args.sub) {
|
|
28
|
+
case 'setup': {
|
|
29
|
+
const { runTelegramSetup } = await import('./telegram-setup')
|
|
30
|
+
await runTelegramSetup()
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
case 'status': {
|
|
34
|
+
const { runTelegramStatus } = await import('./telegram-status')
|
|
35
|
+
await runTelegramStatus()
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
case 'remove': {
|
|
39
|
+
const { runTelegramRemove } = await import('./telegram-remove')
|
|
40
|
+
await runTelegramRemove({ yes: args.yes })
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lyra whoami [--owner 0x…]` — resolve which agent wallet belongs to an owner.
|
|
3
|
+
*
|
|
4
|
+
* Multi-tenant identity: an owner's personal wallet deterministically maps to one
|
|
5
|
+
* agent wallet (the SAME derivation the web + Telegram use). This command shows
|
|
6
|
+
* that mapping + the agent's balance so the owner can fund it. The owner proves
|
|
7
|
+
* identity differently per surface (SIWS on web, /link on Telegram, this config
|
|
8
|
+
* on CLI), but they all resolve to the same agent.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type SuiNetwork,
|
|
13
|
+
deriveAgentAddress,
|
|
14
|
+
makeSuiClient,
|
|
15
|
+
resolveOwnerVault,
|
|
16
|
+
} from 'lyra-plugin-onchain'
|
|
17
|
+
|
|
18
|
+
export async function runWhoami(opts: { owner?: string }): Promise<void> {
|
|
19
|
+
const owner = opts.owner ?? process.env.LYRA_OWNER_ADDRESS
|
|
20
|
+
if (!owner) {
|
|
21
|
+
console.error('lyra whoami: pass --owner <0x…> (your wallet) or set LYRA_OWNER_ADDRESS')
|
|
22
|
+
process.exit(1)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
let agent: string
|
|
26
|
+
try {
|
|
27
|
+
agent = deriveAgentAddress(owner)
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.error(`lyra whoami: ${(e as Error).message}`)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
const network = (process.env.LYRA_NETWORK as SuiNetwork) ?? 'mainnet'
|
|
34
|
+
const client = makeSuiClient(network)
|
|
35
|
+
const [bal, ov] = await Promise.all([
|
|
36
|
+
client.getBalance({ owner: agent }).catch(() => null),
|
|
37
|
+
resolveOwnerVault(owner, network).catch(() => null),
|
|
38
|
+
])
|
|
39
|
+
const sui = bal ? (Number(bal.totalBalance) / 1e9).toFixed(6) : '?'
|
|
40
|
+
|
|
41
|
+
console.log('')
|
|
42
|
+
console.log(` owner ${owner}`)
|
|
43
|
+
console.log(` agent ${agent} (gas float ${sui} SUI)`)
|
|
44
|
+
console.log(` network ${network}`)
|
|
45
|
+
if (ov) {
|
|
46
|
+
console.log(` vault ${ov.vaultId}`)
|
|
47
|
+
console.log(
|
|
48
|
+
` treasury ${(Number(ov.vaultMist) / 1e9).toFixed(6)} SUI (policy ${ov.policyId.slice(0, 10)}…)`,
|
|
49
|
+
)
|
|
50
|
+
console.log('')
|
|
51
|
+
console.log(' The agent spends the treasury from the vault via policy-enforced')
|
|
52
|
+
console.log(' vault_spend; you (owner) can withdraw or revoke any time.')
|
|
53
|
+
} else {
|
|
54
|
+
console.log(' vault not provisioned')
|
|
55
|
+
console.log('')
|
|
56
|
+
console.log(' Provision a non-custodial vault from the web console (owner-signed),')
|
|
57
|
+
console.log(' then the agent spends the treasury under your on-chain AgentPolicy.')
|
|
58
|
+
}
|
|
59
|
+
console.log('')
|
|
60
|
+
console.log(' Same owner → same agent + vault on web + CLI + Telegram (deterministic).')
|
|
61
|
+
console.log('')
|
|
62
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { type LyraConfig, agentPaths } from 'lyra-core'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load the user's `lyra.config.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Phase 6.6: canonical location is `~/.lyra/config.ts` (returned by
|
|
9
|
+
* `agentPaths.config`). If that file exists, it wins. Otherwise, fall back
|
|
10
|
+
* to walking upward from cwd looking for `lyra.config.ts` (legacy v0.5.0
|
|
11
|
+
* pattern, kept so existing dev setups still work without a migration step).
|
|
12
|
+
*/
|
|
13
|
+
export async function findAndLoadConfig(
|
|
14
|
+
startDir: string = process.cwd(),
|
|
15
|
+
): Promise<{ config: LyraConfig; path: string } | null> {
|
|
16
|
+
const canonical = agentPaths.config
|
|
17
|
+
if (existsSync(canonical)) {
|
|
18
|
+
const mod = (await import(canonical)) as { default: LyraConfig }
|
|
19
|
+
if (!mod.default) throw new Error(`lyra config at ${canonical} has no default export`)
|
|
20
|
+
return { config: mod.default, path: canonical }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let dir = resolve(startDir)
|
|
24
|
+
while (true) {
|
|
25
|
+
const candidate = resolve(dir, 'lyra.config.ts')
|
|
26
|
+
if (existsSync(candidate)) {
|
|
27
|
+
const mod = (await import(candidate)) as { default: LyraConfig }
|
|
28
|
+
if (!mod.default) throw new Error(`lyra.config.ts at ${candidate} has no default export`)
|
|
29
|
+
return { config: mod.default, path: candidate }
|
|
30
|
+
}
|
|
31
|
+
const parent = resolve(dir, '..')
|
|
32
|
+
if (parent === dir) return null
|
|
33
|
+
dir = parent
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { LyraConfig } from 'lyra-core'
|
|
3
|
+
import { renderConfigTs } from './render'
|
|
4
|
+
|
|
5
|
+
const baseConfig: LyraConfig = {
|
|
6
|
+
identity: { operator: null, agent: null },
|
|
7
|
+
network: 'mainnet',
|
|
8
|
+
storage: { network: 'mainnet' },
|
|
9
|
+
brain: { provider: null, model: null },
|
|
10
|
+
plugins: ['system'],
|
|
11
|
+
tools: {},
|
|
12
|
+
imports: { claudeCode: true },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('renderConfigTs sandbox block', () => {
|
|
16
|
+
test('fresh config (no sandbox set) emits default + commented examples for each tier', () => {
|
|
17
|
+
const out = renderConfigTs(baseConfig)
|
|
18
|
+
// Active default
|
|
19
|
+
expect(out).toContain(`sandbox: { mode: 'none' }`)
|
|
20
|
+
// Commented OPTION 2 (os)
|
|
21
|
+
expect(out).toContain('OPTION 2: os')
|
|
22
|
+
expect(out).toContain(`// sandbox: { mode: 'os' }`)
|
|
23
|
+
// Commented OPTION 3 (docker)
|
|
24
|
+
expect(out).toContain('OPTION 3: docker')
|
|
25
|
+
expect(out).toContain(`// mode: 'docker'`)
|
|
26
|
+
expect(out).toContain(`// dockerImage: 'nikolaik/python-nodejs:python3.11-nodejs20'`)
|
|
27
|
+
expect(out).toContain('// dockerMountWorkspace: false')
|
|
28
|
+
// LYRA_SANDBOX_MODE override hint
|
|
29
|
+
expect(out).toContain('LYRA_SANDBOX_MODE')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('config with sandbox.mode="os" already set emits the chosen value, not the template', () => {
|
|
33
|
+
const out = renderConfigTs({ ...baseConfig, sandbox: { mode: 'os' } })
|
|
34
|
+
expect(out).toContain(`"mode": "os"`)
|
|
35
|
+
// No verbose template when operator already chose
|
|
36
|
+
expect(out).not.toContain('OPTION 2: os')
|
|
37
|
+
expect(out).not.toContain('OPTION 3: docker')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('config with sandbox.mode="docker" + image emits the full chosen object', () => {
|
|
41
|
+
const out = renderConfigTs({
|
|
42
|
+
...baseConfig,
|
|
43
|
+
sandbox: {
|
|
44
|
+
mode: 'docker',
|
|
45
|
+
dockerImage: 'custom/img:tag',
|
|
46
|
+
dockerMountWorkspace: true,
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
expect(out).toContain(`"mode": "docker"`)
|
|
50
|
+
expect(out).toContain(`"dockerImage": "custom/img:tag"`)
|
|
51
|
+
expect(out).toContain(`"dockerMountWorkspace": true`)
|
|
52
|
+
expect(out).not.toContain('OPTION')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('annotated template documents resource caps with hermes default values', () => {
|
|
56
|
+
const out = renderConfigTs(baseConfig)
|
|
57
|
+
expect(out).toContain('// dockerCpu: 1')
|
|
58
|
+
expect(out).toContain('// dockerMemoryMb: 5120')
|
|
59
|
+
expect(out).toContain('// dockerDiskMb: 51200')
|
|
60
|
+
expect(out).toContain('// dockerNoNetwork: true')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('config with sandbox docker + resource caps emits chosen numeric values', () => {
|
|
64
|
+
const out = renderConfigTs({
|
|
65
|
+
...baseConfig,
|
|
66
|
+
sandbox: {
|
|
67
|
+
mode: 'docker',
|
|
68
|
+
dockerCpu: 2,
|
|
69
|
+
dockerMemoryMb: 4096,
|
|
70
|
+
dockerNoNetwork: true,
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
expect(out).toContain(`"dockerCpu": 2`)
|
|
74
|
+
expect(out).toContain(`"dockerMemoryMb": 4096`)
|
|
75
|
+
expect(out).toContain(`"dockerNoNetwork": true`)
|
|
76
|
+
expect(out).not.toContain('OPTION')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('output is valid TypeScript (parses as a default-export module)', async () => {
|
|
80
|
+
const out = renderConfigTs(baseConfig)
|
|
81
|
+
const { writeFile, rm, mkdtemp } = await import('node:fs/promises')
|
|
82
|
+
const { tmpdir } = await import('node:os')
|
|
83
|
+
const { join } = await import('node:path')
|
|
84
|
+
const dir = await mkdtemp(join(tmpdir(), 'lyra-render-test-'))
|
|
85
|
+
const path = join(dir, 'config.ts')
|
|
86
|
+
try {
|
|
87
|
+
await writeFile(path, out, 'utf8')
|
|
88
|
+
const mod = await import(path)
|
|
89
|
+
expect(mod.default).toBeDefined()
|
|
90
|
+
expect(mod.default.sandbox).toEqual({ mode: 'none' })
|
|
91
|
+
expect(mod.default.network).toBe('mainnet')
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(dir, { recursive: true, force: true })
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import type { LyraConfig } from 'lyra-core'
|
|
4
|
+
|
|
5
|
+
export interface RenderConfigOpts {
|
|
6
|
+
header?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Serialize an LyraConfig into a `~/.lyra/config.ts` file body.
|
|
11
|
+
*
|
|
12
|
+
* Phase 6.6: the config lives at `~/.lyra/config.ts` which is outside any
|
|
13
|
+
* workspace, so it MUST NOT import `lyra-core` (the import won't
|
|
14
|
+
* resolve from `~/.lyra/`). We emit a plain `export default { ... }` object;
|
|
15
|
+
* the runtime loader treats it as `LyraConfig` directly.
|
|
16
|
+
*/
|
|
17
|
+
export function renderConfigTs(cfg: LyraConfig, opts: RenderConfigOpts = {}): string {
|
|
18
|
+
const header = opts.header ?? ''
|
|
19
|
+
const operatorLine = cfg.operator ? ` operator: ${JSON.stringify(cfg.operator)},\n` : ''
|
|
20
|
+
// Emit either the operator's chosen sandbox config OR an annotated
|
|
21
|
+
// "OPTION 1/2/3" block so the operator can opt in by uncommenting. Default
|
|
22
|
+
// mode stays `none` (passthrough); documentation IS the UX.
|
|
23
|
+
const sandboxBlock = renderSandboxBlock(cfg.sandbox)
|
|
24
|
+
return `${header ? `${header}\n\n` : ''}export default {
|
|
25
|
+
identity: ${JSON.stringify(cfg.identity)},
|
|
26
|
+
network: ${JSON.stringify(cfg.network)},
|
|
27
|
+
storage: { network: ${JSON.stringify(cfg.storage.network)} },
|
|
28
|
+
brain: {
|
|
29
|
+
provider: ${JSON.stringify(cfg.brain.provider)},
|
|
30
|
+
model: ${JSON.stringify(cfg.brain.model)},
|
|
31
|
+
},
|
|
32
|
+
plugins: ${JSON.stringify(cfg.plugins)},
|
|
33
|
+
tools: ${JSON.stringify(cfg.tools)},
|
|
34
|
+
imports: { claudeCode: ${cfg.imports.claudeCode} },
|
|
35
|
+
${operatorLine}${sandboxBlock}}
|
|
36
|
+
`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderSandboxBlock(sandbox: LyraConfig['sandbox']): string {
|
|
40
|
+
// Phase 11: deploy-target sandbox metadata (id/providerAddress/endpoint/
|
|
41
|
+
// snapshotName) OR Phase 9.5 limb-sandbox mode = anything non-default → emit
|
|
42
|
+
// verbatim. Only surface the doc-comment template when the operator has
|
|
43
|
+
// touched neither.
|
|
44
|
+
const hasNonDefaultLimbMode = sandbox?.mode && sandbox.mode !== 'none'
|
|
45
|
+
if (hasNonDefaultLimbMode) {
|
|
46
|
+
return ` sandbox: ${JSON.stringify(sandbox, null, 2).replace(/\n/g, '\n ')},\n`
|
|
47
|
+
}
|
|
48
|
+
// Fresh install: write the active default + commented examples for the
|
|
49
|
+
// other tiers. Operator can opt in by uncommenting and editing.
|
|
50
|
+
return ` sandbox: { mode: 'none' },
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Limb sandbox (Phase 9.5). Defense-in-depth beneath the permission floor:
|
|
53
|
+
// even when the modal grants 'allow session' or YOLO disables prompts,
|
|
54
|
+
// the sandbox profile/container blocks writes outside an allowlist.
|
|
55
|
+
// All shell.run / code.execute / shell.process_start spawns route through
|
|
56
|
+
// the chosen backend. fs.* and browser.* still run on the host (PathGuard
|
|
57
|
+
// applies). Override at runtime via LYRA_SANDBOX_MODE=os|docker|none.
|
|
58
|
+
//
|
|
59
|
+
// OPTION 1: none (default): passthrough, fastest, permission floor only.
|
|
60
|
+
//
|
|
61
|
+
// OPTION 2: os (macOS sandbox-exec / seatbelt). Allows writes to agentDir +
|
|
62
|
+
// cwd + /tmp/lyra-* + /var/folders. Denies reads of ~/.ssh, ~/.aws,
|
|
63
|
+
// ~/Library/Keychains, ~/.config/gcloud. Linux bubblewrap pending.
|
|
64
|
+
// sandbox: { mode: 'os' },
|
|
65
|
+
//
|
|
66
|
+
// OPTION 3: docker (long-lived container per session), every shell-class
|
|
67
|
+
// spawn through 'docker exec'. Auto-detects Docker Desktop or Podman.
|
|
68
|
+
// Default image 'nikolaik/python-nodejs:python3.11-nodejs20' (matches
|
|
69
|
+
// hermes; ~700 MB; bash + python3 + node + npm + git on standard PATH).
|
|
70
|
+
// Override with 'oven/bun:1' (~250 MB) if you only need bun/ts.
|
|
71
|
+
// 'dockerMountWorkspace: true' bind-mounts your launch cwd into
|
|
72
|
+
// /workspace (off by default for max isolation). 'dockerRuntimePath'
|
|
73
|
+
// forces a specific runtime binary. Resource caps are unset by default
|
|
74
|
+
// (container competes fairly with host work). Set them to mirror hermes'
|
|
75
|
+
// production hardening: dockerCpu=1, dockerMemoryMb=5120, dockerDiskMb=
|
|
76
|
+
// 51200 (Linux+overlay2 only). dockerNoNetwork=true blocks all internet
|
|
77
|
+
// access from the container (max paranoia for code.execute).
|
|
78
|
+
// sandbox: {
|
|
79
|
+
// mode: 'docker',
|
|
80
|
+
// dockerImage: 'nikolaik/python-nodejs:python3.11-nodejs20',
|
|
81
|
+
// dockerMountWorkspace: false,
|
|
82
|
+
// // dockerRuntimePath: '/opt/homebrew/bin/podman',
|
|
83
|
+
// // dockerCpu: 1, // CPU cores cap
|
|
84
|
+
// // dockerMemoryMb: 5120, // 5 GB memory cap
|
|
85
|
+
// // dockerDiskMb: 51200, // 50 GB disk cap (Linux + overlay2 only)
|
|
86
|
+
// // dockerNoNetwork: true, // block all network from inside container
|
|
87
|
+
// },
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function writeConfigTs(
|
|
93
|
+
path: string,
|
|
94
|
+
cfg: LyraConfig,
|
|
95
|
+
opts: RenderConfigOpts = {},
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
await mkdir(dirname(path), { recursive: true })
|
|
98
|
+
await writeFile(path, renderConfigTs(cfg, opts), 'utf8')
|
|
99
|
+
}
|