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.
Files changed (62) hide show
  1. package/README.md +39 -0
  2. package/bin/lyra +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/chat-telegram.ts +398 -0
  6. package/src/commands/chat.tsx +1220 -0
  7. package/src/commands/demo.ts +177 -0
  8. package/src/commands/gateway-logs.ts +49 -0
  9. package/src/commands/gateway-run.ts +42 -0
  10. package/src/commands/gateway-start.ts +89 -0
  11. package/src/commands/gateway-status.ts +73 -0
  12. package/src/commands/gateway-stop.ts +133 -0
  13. package/src/commands/gateway.ts +101 -0
  14. package/src/commands/init/model-picker.ts +17 -0
  15. package/src/commands/init.ts +153 -0
  16. package/src/commands/logs.ts +37 -0
  17. package/src/commands/model.ts +48 -0
  18. package/src/commands/pairing-approve.ts +65 -0
  19. package/src/commands/pairing-clear.ts +39 -0
  20. package/src/commands/pairing-list.ts +55 -0
  21. package/src/commands/pairing-revoke.ts +49 -0
  22. package/src/commands/pairing.test.ts +88 -0
  23. package/src/commands/pairing.ts +81 -0
  24. package/src/commands/status.ts +84 -0
  25. package/src/commands/telegram-remove.ts +45 -0
  26. package/src/commands/telegram-setup.ts +84 -0
  27. package/src/commands/telegram-status.ts +48 -0
  28. package/src/commands/telegram.test.ts +50 -0
  29. package/src/commands/telegram.ts +44 -0
  30. package/src/commands/whoami.ts +62 -0
  31. package/src/config/load.ts +35 -0
  32. package/src/config/render.test.ts +96 -0
  33. package/src/config/render.ts +99 -0
  34. package/src/index.ts +147 -0
  35. package/src/ui/app.tsx +719 -0
  36. package/src/ui/approval-summary.test.ts +132 -0
  37. package/src/ui/approval-summary.ts +28 -0
  38. package/src/ui/markdown-parse.ts +219 -0
  39. package/src/ui/markdown.test.ts +146 -0
  40. package/src/ui/markdown.tsx +37 -0
  41. package/src/ui/state.test.ts +74 -0
  42. package/src/ui/state.ts +176 -0
  43. package/src/util/bootstrap-mode.test.ts +40 -0
  44. package/src/util/bootstrap-mode.ts +25 -0
  45. package/src/util/bootstrap-progress-box.test.ts +190 -0
  46. package/src/util/bootstrap-progress-box.ts +378 -0
  47. package/src/util/cli-version.ts +28 -0
  48. package/src/util/format.test.ts +16 -0
  49. package/src/util/format.ts +11 -0
  50. package/src/util/gateway-spawn.test.ts +86 -0
  51. package/src/util/gateway-spawn.ts +125 -0
  52. package/src/util/gateway-version.test.ts +113 -0
  53. package/src/util/gateway-version.ts +154 -0
  54. package/src/util/github-releases.test.ts +116 -0
  55. package/src/util/github-releases.ts +79 -0
  56. package/src/util/ref-resolver.test.ts +77 -0
  57. package/src/util/ref-resolver.ts +55 -0
  58. package/src/util/silence-console.test.ts +53 -0
  59. package/src/util/silence-console.ts +40 -0
  60. package/src/util/sui-runtime.ts +74 -0
  61. package/src/util/telegram-secrets.test.ts +104 -0
  62. 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
+ }