nebula-treasury 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 (53) hide show
  1. package/README.md +39 -0
  2. package/bin/nebula +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_unlock.ts +66 -0
  6. package/src/commands/chat-telegram.ts +398 -0
  7. package/src/commands/chat.tsx +1293 -0
  8. package/src/commands/drain.ts +90 -0
  9. package/src/commands/gateway-logs.ts +49 -0
  10. package/src/commands/gateway-run.ts +42 -0
  11. package/src/commands/gateway-start.ts +216 -0
  12. package/src/commands/gateway-status.ts +90 -0
  13. package/src/commands/gateway-stop.ts +133 -0
  14. package/src/commands/gateway.ts +101 -0
  15. package/src/commands/identity.ts +178 -0
  16. package/src/commands/init/cost.ts +40 -0
  17. package/src/commands/init/funding-gate.ts +64 -0
  18. package/src/commands/init/model-picker.ts +25 -0
  19. package/src/commands/init/operator-picker.ts +233 -0
  20. package/src/commands/init/telegram-step.ts +245 -0
  21. package/src/commands/init/wizard-state.ts +94 -0
  22. package/src/commands/init.ts +439 -0
  23. package/src/commands/logs.ts +37 -0
  24. package/src/commands/model.ts +48 -0
  25. package/src/commands/pairing-approve.ts +65 -0
  26. package/src/commands/pairing-clear.ts +39 -0
  27. package/src/commands/pairing-list.ts +55 -0
  28. package/src/commands/pairing-revoke.ts +49 -0
  29. package/src/commands/pairing.ts +81 -0
  30. package/src/commands/status.ts +44 -0
  31. package/src/commands/telegram-remove.ts +62 -0
  32. package/src/commands/telegram-setup.ts +64 -0
  33. package/src/commands/telegram-status.ts +87 -0
  34. package/src/commands/telegram.ts +44 -0
  35. package/src/config/load.ts +35 -0
  36. package/src/config/render.ts +99 -0
  37. package/src/index.ts +153 -0
  38. package/src/ui/app.tsx +673 -0
  39. package/src/ui/approval-summary.ts +32 -0
  40. package/src/ui/markdown-parse.ts +219 -0
  41. package/src/ui/markdown.tsx +37 -0
  42. package/src/ui/state.ts +181 -0
  43. package/src/util/bootstrap-mode.ts +25 -0
  44. package/src/util/bootstrap-progress-box.ts +378 -0
  45. package/src/util/cli-version.ts +28 -0
  46. package/src/util/format.ts +11 -0
  47. package/src/util/gateway-spawn.ts +125 -0
  48. package/src/util/gateway-version.ts +154 -0
  49. package/src/util/github-releases.ts +79 -0
  50. package/src/util/profile-key.ts +25 -0
  51. package/src/util/ref-resolver.ts +55 -0
  52. package/src/util/silence-console.ts +40 -0
  53. package/src/util/telegram-secrets.ts +218 -0
@@ -0,0 +1,49 @@
1
+ import { confirm, isCancel } from '@clack/prompts'
2
+ import { PairingStore, agentPaths, placeholderAgentId } from 'nebula-ai-core'
3
+ import { findAndLoadConfig } from '../config/load'
4
+
5
+ export interface RunPairingRevokeOpts {
6
+ platform: string
7
+ userId: string
8
+ yes?: boolean
9
+ }
10
+
11
+ export async function runPairingRevoke(opts: RunPairingRevokeOpts): Promise<void> {
12
+ const loaded = await findAndLoadConfig()
13
+ if (!loaded) {
14
+ console.error('No nebula.config.ts found. Run `nebula init` first.')
15
+ process.exit(1)
16
+ }
17
+ const { config } = loaded
18
+ if (!config.identity.agent) {
19
+ console.error('Config has no agent. Run `nebula init` first.')
20
+ process.exit(1)
21
+ }
22
+ const agentId = placeholderAgentId(config.identity.agent)
23
+ const dir = agentPaths.agent(agentId).pairingDir
24
+ const store = new PairingStore({ dir })
25
+
26
+ if (!store.isApproved(opts.platform, opts.userId)) {
27
+ console.error(`User ${opts.userId} is not on the ${opts.platform} approved list.`)
28
+ process.exit(1)
29
+ }
30
+
31
+ if (!opts.yes) {
32
+ const ok = await confirm({
33
+ message: `Revoke ${opts.platform} access for user id ${opts.userId}?`,
34
+ initialValue: false,
35
+ })
36
+ if (isCancel(ok) || !ok) {
37
+ console.log('Aborted.')
38
+ return
39
+ }
40
+ }
41
+
42
+ const removed = store.revoke(opts.platform, opts.userId)
43
+ if (removed) {
44
+ console.log(`✓ Revoked: ${opts.platform} id=${opts.userId}`)
45
+ } else {
46
+ console.error('Revoke failed (concurrent removal?)')
47
+ process.exit(1)
48
+ }
49
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `nebula pairing <subcommand>` — argv dispatcher for the DM pairing flow.
3
+ *
4
+ * Subcommands:
5
+ * list show pending codes + approved users
6
+ * approve <platform> <code> approve a pairing code (case-insensitive)
7
+ * revoke <platform> <userId> revoke an approved user
8
+ * clear-pending [platform] drop all pending codes
9
+ *
10
+ * Platform is `telegram` for Phase 12. Future platforms (discord, slack) will
11
+ * reuse the same command surface.
12
+ */
13
+
14
+ export interface PairingArgs {
15
+ sub: 'list' | 'approve' | 'revoke' | 'clear-pending'
16
+ platform?: string
17
+ code?: string
18
+ userId?: string
19
+ yes?: boolean
20
+ }
21
+
22
+ const VALID_SUBS = ['list', 'approve', 'revoke', 'clear-pending'] as const
23
+
24
+ export type PairingParseResult = PairingArgs | { error: string }
25
+
26
+ export function parsePairingArgs(argv: string[]): PairingParseResult {
27
+ const sub = argv[0]
28
+ if (!sub) {
29
+ return {
30
+ error:
31
+ 'usage: nebula pairing <list | approve <platform> <code> | revoke <platform> <userId> | clear-pending [platform]>',
32
+ }
33
+ }
34
+ if (!(VALID_SUBS as readonly string[]).includes(sub)) {
35
+ return { error: `unknown subcommand '${sub}' (expected: ${VALID_SUBS.join(' | ')})` }
36
+ }
37
+ const positional = argv.slice(1).filter(a => !a.startsWith('-'))
38
+ const yes = argv.includes('--yes') || argv.includes('-y')
39
+
40
+ if (sub === 'approve') {
41
+ if (positional.length < 2) {
42
+ return { error: 'usage: nebula pairing approve <platform> <code>' }
43
+ }
44
+ return { sub: 'approve', platform: positional[0], code: positional[1], yes }
45
+ }
46
+ if (sub === 'revoke') {
47
+ if (positional.length < 2) {
48
+ return { error: 'usage: nebula pairing revoke <platform> <userId>' }
49
+ }
50
+ return { sub: 'revoke', platform: positional[0], userId: positional[1], yes }
51
+ }
52
+ if (sub === 'clear-pending') {
53
+ return { sub: 'clear-pending', platform: positional[0], yes }
54
+ }
55
+ return { sub: 'list', platform: positional[0], yes }
56
+ }
57
+
58
+ export async function runPairing(args: PairingArgs): Promise<void> {
59
+ switch (args.sub) {
60
+ case 'list': {
61
+ const { runPairingList } = await import('./pairing-list')
62
+ await runPairingList({ platform: args.platform })
63
+ return
64
+ }
65
+ case 'approve': {
66
+ const { runPairingApprove } = await import('./pairing-approve')
67
+ await runPairingApprove({ platform: args.platform!, code: args.code! })
68
+ return
69
+ }
70
+ case 'revoke': {
71
+ const { runPairingRevoke } = await import('./pairing-revoke')
72
+ await runPairingRevoke({ platform: args.platform!, userId: args.userId!, yes: args.yes })
73
+ return
74
+ }
75
+ case 'clear-pending': {
76
+ const { runPairingClear } = await import('./pairing-clear')
77
+ await runPairingClear({ platform: args.platform, yes: args.yes })
78
+ return
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { NETWORK_CHAIN_ID, NETWORK_RPC, agentPaths } from 'nebula-ai-core'
3
+ import { http, createPublicClient } from 'viem'
4
+ import { findAndLoadConfig } from '../config/load'
5
+ import { listAgentIds } from './_agents'
6
+
7
+ export async function runStatus(opts?: { cwd?: string }): Promise<void> {
8
+ const cwd = opts?.cwd ?? process.cwd()
9
+ const found = await findAndLoadConfig(cwd)
10
+ if (!found) {
11
+ console.log('No nebula.config.ts found. Run `nebula init` first.')
12
+ process.exit(1)
13
+ }
14
+ const { config, path } = found
15
+ console.log(`config ${path}`)
16
+ console.log(`network ${config.network} (chain ${NETWORK_CHAIN_ID[config.network]})`)
17
+ console.log(`rpc ${NETWORK_RPC[config.network]}`)
18
+ console.log(`plugins ${config.plugins.join(', ')}`)
19
+ if (config.identity.operator) console.log(`operator ${config.identity.operator}`)
20
+ if (config.identity.agent) console.log(`agent EOA ${config.identity.agent}`)
21
+ console.log(`brain ${config.brain.provider ?? '(not picked)'}`)
22
+
23
+ const ids = await listAgentIds()
24
+ if (ids.length === 0) {
25
+ console.log('\nNo agents found in ~/.nebula/agents. Re-run `nebula init`.')
26
+ return
27
+ }
28
+
29
+ const client = createPublicClient({
30
+ transport: http(NETWORK_RPC[config.network]),
31
+ })
32
+
33
+ for (const id of ids) {
34
+ console.log('')
35
+ console.log(`agent ${id}`)
36
+ console.log(`dir ${agentPaths.agent(id).dir}`)
37
+ const activityPath = agentPaths.agent(id).activityLog
38
+ if (existsSync(activityPath)) {
39
+ const sz = statSync(activityPath).size
40
+ console.log(`activity ${sz} bytes`)
41
+ }
42
+ void client
43
+ }
44
+ }
@@ -0,0 +1,62 @@
1
+ import { cancel, confirm, intro, isCancel, note, outro } from '@clack/prompts'
2
+ import { placeholderAgentId } from 'nebula-ai-core'
3
+ import { findAndLoadConfig } from '../config/load'
4
+ import { writeConfigTs } from '../config/render'
5
+ import {
6
+ removeTelegramSecrets,
7
+ telegramSecretsExist,
8
+ telegramSecretsPath,
9
+ } from '../util/telegram-secrets'
10
+
11
+ export interface TelegramRemoveOpts {
12
+ yes?: boolean
13
+ }
14
+
15
+ export async function runTelegramRemove(opts: TelegramRemoveOpts = {}): Promise<void> {
16
+ intro('nebula telegram remove')
17
+
18
+ const loaded = await findAndLoadConfig()
19
+ if (!loaded) {
20
+ cancel('No nebula.config.ts found. Run `nebula init` first.')
21
+ return
22
+ }
23
+ const { config, path: configPath } = loaded
24
+ if (!config.identity.agent) {
25
+ cancel('Config has no agent. Run `nebula init` first.')
26
+ return
27
+ }
28
+
29
+ const agentId = placeholderAgentId(config.identity.agent)
30
+
31
+ if (!telegramSecretsExist(agentId)) {
32
+ note('Nothing to remove.')
33
+ outro('not configured')
34
+ return
35
+ }
36
+
37
+ if (!opts.yes) {
38
+ const ok = (await confirm({
39
+ message: `Delete encrypted telegram-secrets for ${agentId}?`,
40
+ initialValue: false,
41
+ })) as boolean | symbol
42
+ if (isCancel(ok) || !ok) {
43
+ cancel('Aborted.')
44
+ return
45
+ }
46
+ }
47
+
48
+ await removeTelegramSecrets(agentId)
49
+
50
+ const plugins = (config.plugins ?? []).filter(p => p !== 'telegram')
51
+ if (plugins.length !== (config.plugins ?? []).length) {
52
+ const updated = { ...config, plugins }
53
+ await writeConfigTs(configPath, updated)
54
+ }
55
+
56
+ note(
57
+ `Local blob deleted: ${telegramSecretsPath(agentId)}\nThe bot token at @BotFather is STILL VALID. To fully revoke, run /token in\n@BotFather and pick "Revoke" for this bot.`,
58
+ 'reminder',
59
+ )
60
+
61
+ outro('telegram removed')
62
+ }
@@ -0,0 +1,64 @@
1
+ import { cancel, intro, note, outro } from '@clack/prompts'
2
+ import { placeholderAgentId } from 'nebula-ai-core'
3
+ import { type Address, getAddress } from 'viem'
4
+ import { findAndLoadConfig } from '../config/load'
5
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
6
+ import { runTelegramStep } from './init/telegram-step'
7
+
8
+ /**
9
+ * `nebula telegram setup` — standalone entry. Loads the operator wallet, then
10
+ * delegates to `runTelegramStep` (the same helper bundled into `nebula init`'s
11
+ * Phase E). Owns its own intro/outro framing.
12
+ */
13
+ export async function runTelegramSetup(): Promise<void> {
14
+ intro('nebula telegram setup')
15
+
16
+ const loaded = await findAndLoadConfig()
17
+ if (!loaded) {
18
+ cancel('No nebula.config.ts found. Run `nebula init` first.')
19
+ return
20
+ }
21
+ const { config, path: configPath } = loaded
22
+ if (!config.identity.agent) {
23
+ cancel('Config has no agent. Run `nebula init` first.')
24
+ return
25
+ }
26
+
27
+ const agentAddress = getAddress(config.identity.agent) as Address
28
+ const agentId = placeholderAgentId(agentAddress)
29
+
30
+ const operator = await loadOrPickOperatorSigner({
31
+ network: config.network,
32
+ hint: config.operator,
33
+ })
34
+ if (!operator) {
35
+ cancel('No operator wallet available; cannot encrypt secrets.')
36
+ return
37
+ }
38
+
39
+ let result: Awaited<ReturnType<typeof runTelegramStep>>
40
+ try {
41
+ result = await runTelegramStep({
42
+ signer: operator,
43
+ agentId,
44
+ agentAddress,
45
+ configPath,
46
+ config,
47
+ network: config.network,
48
+ })
49
+ } finally {
50
+ await operator.close?.()
51
+ }
52
+
53
+ if (!result.configured) {
54
+ cancel(result.cancelled ? 'Aborted.' : 'Setup failed.')
55
+ return
56
+ }
57
+
58
+ note(
59
+ `Open https://t.me/${result.botUsername} in Telegram and send any message.\nThen run \`nebula\` (or \`nebula gateway start\`) to bring the agent online.`,
60
+ 'next step',
61
+ )
62
+
63
+ outro(`telegram setup complete (@${result.botUsername}, mode: ${result.modeUsed})`)
64
+ }
@@ -0,0 +1,87 @@
1
+ import { cancel, intro, log, outro, spinner } from '@clack/prompts'
2
+ import { placeholderAgentId } from 'nebula-ai-core'
3
+ import { type Address, getAddress } from 'viem'
4
+ import { findAndLoadConfig } from '../config/load'
5
+ import {
6
+ fetchBotInfo,
7
+ loadTelegramSecrets,
8
+ telegramSecretsExist,
9
+ telegramSecretsPath,
10
+ } from '../util/telegram-secrets'
11
+ import { loadOrPickOperatorSigner } from './init/operator-picker'
12
+
13
+ export async function runTelegramStatus(): Promise<void> {
14
+ intro('nebula telegram status')
15
+
16
+ const loaded = await findAndLoadConfig()
17
+ if (!loaded) {
18
+ cancel('No nebula.config.ts found. Run `nebula init` first.')
19
+ return
20
+ }
21
+ const { config } = loaded
22
+ if (!config.identity.agent) {
23
+ cancel('Config has no agent. Run `nebula init` first.')
24
+ return
25
+ }
26
+
27
+ const agentAddress = getAddress(config.identity.agent) as Address
28
+ const agentId = placeholderAgentId(agentAddress)
29
+ const path = telegramSecretsPath(agentId)
30
+
31
+ if (!telegramSecretsExist(agentId)) {
32
+ log.warn(`No telegram secrets stored for ${agentId}.`)
33
+ log.info(`Expected at: ${path}\nRun \`nebula telegram setup\` to configure.`)
34
+ outro('not configured')
35
+ return
36
+ }
37
+
38
+ const operator = await loadOrPickOperatorSigner({
39
+ network: config.network,
40
+ hint: config.operator,
41
+ })
42
+ if (!operator) {
43
+ cancel('No operator wallet available; cannot decrypt secrets.')
44
+ return
45
+ }
46
+
47
+ const sLoad = spinner()
48
+ sLoad.start('Decrypting telegram secrets via operator wallet')
49
+ let secrets: Awaited<ReturnType<typeof loadTelegramSecrets>>
50
+ try {
51
+ secrets = await loadTelegramSecrets({ signer: operator, agentAddress, agentId })
52
+ sLoad.stop('decrypted')
53
+ } catch (e) {
54
+ sLoad.stop(`decrypt failed: ${(e as Error).message.slice(0, 200)}`)
55
+ await operator.close?.()
56
+ return
57
+ } finally {
58
+ await operator.close?.()
59
+ }
60
+ if (!secrets) {
61
+ cancel('Empty telegram-secrets blob.')
62
+ return
63
+ }
64
+
65
+ const sPing = spinner()
66
+ sPing.start('Pinging Telegram getMe')
67
+ try {
68
+ const info = await fetchBotInfo(secrets.botToken)
69
+ sPing.stop(`bot ok: @${info.username} (id ${info.id})`)
70
+ } catch (e) {
71
+ sPing.stop(`getMe failed: ${(e as Error).message.slice(0, 200)}`)
72
+ log.warn('Token may have been revoked at @BotFather. Re-run `nebula telegram setup`.')
73
+ return
74
+ }
75
+
76
+ log.info(
77
+ [
78
+ `path ${path}`,
79
+ `bot username @${secrets.botUsername ?? '(unknown)'}`,
80
+ `bot id ${secrets.botId ?? '(unknown)'}`,
81
+ `allowed user ids ${secrets.allowedUserIds.length === 0 ? '(open access)' : secrets.allowedUserIds.join(', ')}`,
82
+ `plugin enabled ${(config.plugins ?? []).includes('telegram') ? 'yes' : 'no — add `telegram` to plugins'}`,
83
+ ].join('\n'),
84
+ )
85
+
86
+ outro(`telegram configured for ${agentId}`)
87
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `nebula 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: nebula 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,35 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { type NebulaConfig, agentPaths } from 'nebula-ai-core'
4
+
5
+ /**
6
+ * Load the user's `nebula.config.ts`.
7
+ *
8
+ * Phase 6.6: canonical location is `~/.nebula/config.ts` (returned by
9
+ * `agentPaths.config`). If that file exists, it wins. Otherwise, fall back
10
+ * to walking upward from cwd looking for `nebula.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: NebulaConfig; path: string } | null> {
16
+ const canonical = agentPaths.config
17
+ if (existsSync(canonical)) {
18
+ const mod = (await import(canonical)) as { default: NebulaConfig }
19
+ if (!mod.default) throw new Error(`nebula 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, 'nebula.config.ts')
26
+ if (existsSync(candidate)) {
27
+ const mod = (await import(candidate)) as { default: NebulaConfig }
28
+ if (!mod.default) throw new Error(`nebula.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,99 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import type { NebulaConfig } from 'nebula-ai-core'
4
+
5
+ export interface RenderConfigOpts {
6
+ header?: string
7
+ }
8
+
9
+ /**
10
+ * Serialize an NebulaConfig into a `~/.nebula/config.ts` file body.
11
+ *
12
+ * Phase 6.6: the config lives at `~/.nebula/config.ts` which is outside any
13
+ * workspace, so it MUST NOT import `nebula-ai-core` (the import won't
14
+ * resolve from `~/.nebula/`). We emit a plain `export default { ... }` object;
15
+ * the runtime loader treats it as `NebulaConfig` directly.
16
+ */
17
+ export function renderConfigTs(cfg: NebulaConfig, 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: NebulaConfig['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 NEBULA_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/nebula-* + /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: NebulaConfig,
95
+ opts: RenderConfigOpts = {},
96
+ ): Promise<void> {
97
+ await mkdir(dirname(path), { recursive: true })
98
+ await writeFile(path, renderConfigTs(cfg, opts), 'utf8')
99
+ }