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.
- package/README.md +39 -0
- package/bin/nebula +11 -0
- package/package.json +65 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_unlock.ts +66 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1293 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +49 -0
- package/src/commands/gateway-run.ts +42 -0
- package/src/commands/gateway-start.ts +216 -0
- package/src/commands/gateway-status.ts +90 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/identity.ts +178 -0
- package/src/commands/init/cost.ts +40 -0
- package/src/commands/init/funding-gate.ts +64 -0
- package/src/commands/init/model-picker.ts +25 -0
- package/src/commands/init/operator-picker.ts +233 -0
- package/src/commands/init/telegram-step.ts +245 -0
- package/src/commands/init/wizard-state.ts +94 -0
- package/src/commands/init.ts +439 -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.ts +81 -0
- package/src/commands/status.ts +44 -0
- package/src/commands/telegram-remove.ts +62 -0
- package/src/commands/telegram-setup.ts +64 -0
- package/src/commands/telegram-status.ts +87 -0
- package/src/commands/telegram.ts +44 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.ts +99 -0
- package/src/index.ts +153 -0
- package/src/ui/app.tsx +673 -0
- package/src/ui/approval-summary.ts +32 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.ts +181 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.ts +125 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.ts +218 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { cancel, confirm, intro, isCancel, log, outro, spinner } from '@clack/prompts'
|
|
2
|
+
import { NETWORK_RPC, drainAgentEOA, explorerTxUrl } from 'nebula-ai-core'
|
|
3
|
+
import { http, type Address, createPublicClient, formatEther, isAddress } from 'viem'
|
|
4
|
+
import { findAndLoadConfig } from '../config/load'
|
|
5
|
+
import { withSilencedConsole } from '../util/silence-console'
|
|
6
|
+
import { unlockAgentSigner } from './_unlock'
|
|
7
|
+
|
|
8
|
+
export interface DrainOpts {
|
|
9
|
+
/** Target address. If omitted, defaults to the operator wallet on this config. */
|
|
10
|
+
to?: string
|
|
11
|
+
/** Skip the destructive confirmation prompt. */
|
|
12
|
+
yes?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function runDrain(opts: DrainOpts): Promise<void> {
|
|
16
|
+
intro('nebula drain')
|
|
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 } = loaded
|
|
24
|
+
if (!config.identity.agent) {
|
|
25
|
+
cancel('Config has no agent. Run `nebula init` first.')
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const network = config.network
|
|
30
|
+
const agentAddress = config.identity.agent as Address
|
|
31
|
+
|
|
32
|
+
const targetRaw = opts.to ?? (config.identity.operator as string | undefined)
|
|
33
|
+
if (!targetRaw) {
|
|
34
|
+
cancel('No --to address provided and config has no operator. Pass --to <0x...>.')
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
if (!isAddress(targetRaw)) {
|
|
38
|
+
cancel(`--to is not a valid address: ${targetRaw}`)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const to = targetRaw as Address
|
|
42
|
+
|
|
43
|
+
const publicClient = createPublicClient({ transport: http(NETWORK_RPC[network]) })
|
|
44
|
+
const before = await publicClient.getBalance({ address: agentAddress })
|
|
45
|
+
log.info(
|
|
46
|
+
[
|
|
47
|
+
`agent ${agentAddress}`,
|
|
48
|
+
`balance ${formatEther(before)} Mantle`,
|
|
49
|
+
`target ${to}`,
|
|
50
|
+
`network ${network}`,
|
|
51
|
+
].join('\n'),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if (before === 0n) {
|
|
55
|
+
log.warn('Agent EOA already empty.')
|
|
56
|
+
outro('nothing to drain')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!opts.yes) {
|
|
61
|
+
const ok = (await confirm({
|
|
62
|
+
message: `Sweep agent EOA balance (${formatEther(before)} Mantle minus gas) to ${to}?`,
|
|
63
|
+
initialValue: false,
|
|
64
|
+
})) as boolean | symbol
|
|
65
|
+
if (isCancel(ok) || !ok) {
|
|
66
|
+
cancel('Aborted.')
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const unlocked = await unlockAgentSigner(config)
|
|
72
|
+
if (!unlocked) return
|
|
73
|
+
try {
|
|
74
|
+
const sSweep = spinner()
|
|
75
|
+
sSweep.start(`Sweeping agent EOA → ${to}`)
|
|
76
|
+
try {
|
|
77
|
+
const result = await withSilencedConsole(() =>
|
|
78
|
+
drainAgentEOA({ network, privkeyHex: unlocked.agentPrivkey, to }),
|
|
79
|
+
)
|
|
80
|
+
sSweep.stop(
|
|
81
|
+
`swept ${formatEther(result.amountSent)} Mantle (gas reserved ${formatEther(result.gasReserved)} Mantle) → ${explorerTxUrl(network, result.txHash)}`,
|
|
82
|
+
)
|
|
83
|
+
outro(`agent ${agentAddress} drained to ${to}`)
|
|
84
|
+
} catch (e) {
|
|
85
|
+
sSweep.stop(`sweep failed: ${(e as Error).message.slice(0, 160)}`)
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
await unlocked.close()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nebula gateway logs [--tail N] [-f]` — tail the gateway log.
|
|
3
|
+
*
|
|
4
|
+
* v0.19.x: gateway daemon logs to stdout/stderr only (inherited by `gateway run`
|
|
5
|
+
* or backgrounded by `gateway start`). v0.19.3 wires a log file at
|
|
6
|
+
* `~/.nebula/agents/<id>/gateway.log` for tailing. Until then, this command
|
|
7
|
+
* informs the user where to look.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { agentPaths, placeholderAgentId } from 'nebula-ai-core'
|
|
14
|
+
import { findAndLoadConfig } from '../config/load'
|
|
15
|
+
|
|
16
|
+
export interface GatewayLogsOpts {
|
|
17
|
+
agentId?: string
|
|
18
|
+
tail: number
|
|
19
|
+
follow: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runGatewayLogs(opts: GatewayLogsOpts): Promise<void> {
|
|
23
|
+
let agentId = opts.agentId
|
|
24
|
+
if (!agentId) {
|
|
25
|
+
const found = await findAndLoadConfig()
|
|
26
|
+
if (!found?.config) {
|
|
27
|
+
console.error('nebula gateway logs: no nebula.config.ts and no --agent provided')
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
const agentEoa = found.config.identity.agent
|
|
31
|
+
if (!agentEoa) {
|
|
32
|
+
console.error('nebula gateway logs: config has no agent EOA; run `nebula init` first')
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
agentId = placeholderAgentId(agentEoa)
|
|
36
|
+
}
|
|
37
|
+
const logFile = join(agentPaths.agent(agentId).dir, 'gateway.log')
|
|
38
|
+
if (!existsSync(logFile)) {
|
|
39
|
+
console.log(`gateway log not found at ${logFile}`)
|
|
40
|
+
console.log('v0.19.x: gateway daemon logs to stdout when run via `nebula gateway run`.')
|
|
41
|
+
console.log(
|
|
42
|
+
'Background it with: nohup bun packages/gateway/bin/nebula-gateway-local > ~/nebula-logs/gateway.log 2>&1 &',
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const args = ['-n', String(opts.tail), ...(opts.follow ? ['-f'] : []), logFile]
|
|
47
|
+
const proc = spawn('tail', args, { stdio: 'inherit' })
|
|
48
|
+
proc.on('exit', code => process.exit(code ?? 0))
|
|
49
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nebula gateway run` — foreground daemon (blocks; Ctrl+C to stop).
|
|
3
|
+
*
|
|
4
|
+
* Spawns `nebula-gateway-local` (the bin in nebula-ai-gateway) with
|
|
5
|
+
* inherit stdio so the user sees logs live. Reads operator-session for the
|
|
6
|
+
* cached AES keys; fails loud if no session exists ("run nebula gateway start
|
|
7
|
+
* first").
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { resolveLocalBin } from '../util/gateway-spawn'
|
|
13
|
+
|
|
14
|
+
export interface GatewayRunOpts {
|
|
15
|
+
agentId?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runGatewayForeground(opts: GatewayRunOpts): Promise<void> {
|
|
19
|
+
const env = { ...process.env }
|
|
20
|
+
if (opts.agentId) env.NEBULA_AGENT_ID = opts.agentId
|
|
21
|
+
// Default NEBULA_CONFIG to ~/.nebula/config.ts if not already set.
|
|
22
|
+
if (!env.NEBULA_CONFIG) {
|
|
23
|
+
env.NEBULA_CONFIG = join(env.HOME ?? '', '.nebula', 'config.ts')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const localBin = resolveLocalBin()
|
|
27
|
+
const proc = spawn('bun', [localBin], {
|
|
28
|
+
env,
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
})
|
|
31
|
+
proc.on('exit', code => process.exit(code ?? 0))
|
|
32
|
+
proc.on('error', err => {
|
|
33
|
+
console.error(`nebula gateway run: spawn failed — ${err.message}`)
|
|
34
|
+
process.exit(1)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const forwardSignal = (sig: NodeJS.Signals): void => {
|
|
38
|
+
if (!proc.killed) proc.kill(sig)
|
|
39
|
+
}
|
|
40
|
+
process.on('SIGINT', () => forwardSignal('SIGINT'))
|
|
41
|
+
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
|
|
42
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nebula gateway start` — interactive Touch ID + write operator-session,
|
|
3
|
+
* then fork the gateway daemon detached.
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. Load config from ~/.nebula/config.ts
|
|
7
|
+
* 2. Resolve agentId (override via --agent or first agent in config)
|
|
8
|
+
* 3. Check if gateway already running (lock file). If yes, error.
|
|
9
|
+
* 4. Pick operator signer + interactive Touch ID via existing operator-picker
|
|
10
|
+
* 5. Pre-derive scope keys via precomputeAllScopes (keystore + telegram)
|
|
11
|
+
* 6. Write operator-session file (perm 0600, 24h TTL)
|
|
12
|
+
* 7. Spawn nebula-gateway-local detached + wait for socket to become readable
|
|
13
|
+
* (proves the daemon booted cleanly)
|
|
14
|
+
* 8. Print pid + socket path
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { spinner } from '@clack/prompts'
|
|
20
|
+
import {
|
|
21
|
+
OPERATOR_BLOB_SCOPES,
|
|
22
|
+
type OperatorBlobScope,
|
|
23
|
+
agentPaths,
|
|
24
|
+
buildOperatorSession,
|
|
25
|
+
decodeKeystoreBytes,
|
|
26
|
+
decodeOperatorBlobBytes,
|
|
27
|
+
isOperatorSessionComplete,
|
|
28
|
+
placeholderAgentId,
|
|
29
|
+
precomputeAllScopes,
|
|
30
|
+
readOperatorSession,
|
|
31
|
+
requiredScopesForAgent,
|
|
32
|
+
tryDecryptKeystoreWithKey,
|
|
33
|
+
tryDecryptOperatorBlobWithKey,
|
|
34
|
+
writeOperatorSession,
|
|
35
|
+
} from 'nebula-ai-core'
|
|
36
|
+
import { type Address, getAddress } from 'viem'
|
|
37
|
+
import { findAndLoadConfig } from '../config/load'
|
|
38
|
+
import { spawnGatewayDaemon } from '../util/gateway-spawn'
|
|
39
|
+
import { telegramSecretsPath } from '../util/telegram-secrets'
|
|
40
|
+
import { loadOrPickOperatorSigner } from './init/operator-picker'
|
|
41
|
+
|
|
42
|
+
export interface GatewayStartOpts {
|
|
43
|
+
agentId?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runGatewayStart(opts: GatewayStartOpts): Promise<void> {
|
|
47
|
+
const found = await findAndLoadConfig()
|
|
48
|
+
if (!found?.config) {
|
|
49
|
+
console.error('nebula gateway start: no nebula.config.ts found in cwd or ~/.nebula/')
|
|
50
|
+
process.exit(1)
|
|
51
|
+
}
|
|
52
|
+
const config = found.config
|
|
53
|
+
const agentAddress = getAddress(config.identity.agent as Address)
|
|
54
|
+
const agentId = opts.agentId ?? placeholderAgentId(agentAddress)
|
|
55
|
+
const paths = agentPaths.agent(agentId)
|
|
56
|
+
const socketPath = join(paths.dir, 'gateway.sock')
|
|
57
|
+
|
|
58
|
+
// v0.23.2: if the socket exists, check for version drift. If the running
|
|
59
|
+
// daemon's version differs from the on-disk CLI binary, auto-restart so
|
|
60
|
+
// operators don't have to remember `nebula gateway restart` after every
|
|
61
|
+
// `bun add -g nebula-ai-cli@N`. If versions match, bail with the
|
|
62
|
+
// legacy "already running" error.
|
|
63
|
+
if (existsSync(socketPath)) {
|
|
64
|
+
const { createHash } = await import('node:crypto')
|
|
65
|
+
const { homedir } = await import('node:os')
|
|
66
|
+
const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
|
|
67
|
+
const lockFile = join(homedir(), '.nebula', 'locks', `nebula-gateway-${identityHash}.lock`)
|
|
68
|
+
const { ensureGatewayVersionMatchesCli } = await import('../util/gateway-version')
|
|
69
|
+
const drift = await ensureGatewayVersionMatchesCli({ socketPath, lockFile })
|
|
70
|
+
if (drift.action === 'ok' || drift.action === 'no-cli-version') {
|
|
71
|
+
console.error(
|
|
72
|
+
`nebula gateway start: socket already exists at ${socketPath} — gateway may be running (version ${drift.daemonVersion ?? 'unknown'}). Try \`nebula gateway stop\` first.`,
|
|
73
|
+
)
|
|
74
|
+
process.exit(1)
|
|
75
|
+
}
|
|
76
|
+
console.log(`note: ${drift.note}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// v0.21.12: derive the set of scope keys this agent's daemon will need
|
|
80
|
+
// based on what's on disk (always 'keystore'; adds 'telegram' when
|
|
81
|
+
// telegram-secrets.encrypted is present, etc.). The cached session is only
|
|
82
|
+
// "complete enough to skip Touch ID" when it contains every required key.
|
|
83
|
+
// Pre-fix, this used the binary `isOperatorSessionFresh` which returned
|
|
84
|
+
// true for any non-expired session, even one written by a path that didn't
|
|
85
|
+
// derive TELEGRAM. The daemon then booted, found no telegram scope key,
|
|
86
|
+
// and silently dropped all inbound TG messages.
|
|
87
|
+
const required = requiredScopesForAgent(agentId)
|
|
88
|
+
const extraScopes = required.filter((s): s is Exclude<typeof s, 'keystore'> => s !== 'keystore')
|
|
89
|
+
const complete = isOperatorSessionComplete(agentId, required)
|
|
90
|
+
if (!complete) {
|
|
91
|
+
const sUnlock = spinner()
|
|
92
|
+
sUnlock.start('Unlocking operator wallet for session-key derivation')
|
|
93
|
+
let operator: Awaited<ReturnType<typeof loadOrPickOperatorSigner>>
|
|
94
|
+
try {
|
|
95
|
+
operator = await loadOrPickOperatorSigner({
|
|
96
|
+
network: config.network,
|
|
97
|
+
hint: config.operator,
|
|
98
|
+
})
|
|
99
|
+
} catch (e) {
|
|
100
|
+
sUnlock.stop(`unlock failed: ${(e as Error).message.slice(0, 160)}`)
|
|
101
|
+
process.exit(1)
|
|
102
|
+
}
|
|
103
|
+
if (!operator) {
|
|
104
|
+
sUnlock.stop('operator unlock cancelled')
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
sUnlock.message(`Deriving scope keys (${required.join(' + ')})`)
|
|
109
|
+
try {
|
|
110
|
+
// v0.24.10: verify each derived canonical key against the on-disk
|
|
111
|
+
// encrypted artifact. If verify fails (e.g. fox's pre-v0.24.9 WC
|
|
112
|
+
// keystore was encrypted with the legacy empty-EIP712Domain hash),
|
|
113
|
+
// `precomputeAllScopes` falls back to the legacy variant via the WC
|
|
114
|
+
// signer's escape hatch and caches the WORKING key. Without this,
|
|
115
|
+
// the daemon would boot with a stale canonical key + the
|
|
116
|
+
// `precomputedKey skips fallback` semantic and panic on first
|
|
117
|
+
// AES-GCM decrypt.
|
|
118
|
+
const verifyKey = buildKeystoreVerifier(agentId)
|
|
119
|
+
const keys = await precomputeAllScopes(operator, agentAddress, extraScopes, { verifyKey })
|
|
120
|
+
const sess = buildOperatorSession({ agent: agentAddress, keys })
|
|
121
|
+
writeOperatorSession(agentId, sess)
|
|
122
|
+
sUnlock.stop('operator-session written (24h TTL)')
|
|
123
|
+
} catch (e) {
|
|
124
|
+
sUnlock.stop(`derive failed: ${(e as Error).message.slice(0, 160)}`)
|
|
125
|
+
await operator.close?.()
|
|
126
|
+
process.exit(1)
|
|
127
|
+
}
|
|
128
|
+
await operator.close?.()
|
|
129
|
+
} else {
|
|
130
|
+
console.log(`operator-session complete (${required.join(' + ')}); skipping Touch ID`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Spawn gateway daemon detached. Inherit stdio for the first ~3s so the
|
|
134
|
+
// user sees boot errors, then redirect to log file when ready.
|
|
135
|
+
const sBoot = spinner()
|
|
136
|
+
sBoot.start(`Spawning gateway daemon (agent=${agentId.slice(0, 8)}…)`)
|
|
137
|
+
|
|
138
|
+
const result = await spawnGatewayDaemon({
|
|
139
|
+
agentId,
|
|
140
|
+
configPath: found.path ?? '',
|
|
141
|
+
socketPath,
|
|
142
|
+
timeoutMs: 10_000,
|
|
143
|
+
// v0.21.12: redirect daemon stdout/stderr to gateway.log (default
|
|
144
|
+
// 'log-file' mode) so boot errors survive the parent's exit. Operators
|
|
145
|
+
// see the log via `nebula gateway logs` or by tailing
|
|
146
|
+
// ~/.nebula/agents/<id>/gateway.log directly.
|
|
147
|
+
})
|
|
148
|
+
if (result.ready) {
|
|
149
|
+
sBoot.stop(`gateway running pid=${result.pid} socket=${socketPath}`)
|
|
150
|
+
console.log('stop with: nebula gateway stop')
|
|
151
|
+
console.log('logs: nebula gateway logs -f')
|
|
152
|
+
} else {
|
|
153
|
+
const reason = result.reason ?? 'unknown'
|
|
154
|
+
const detail = result.error ? `: ${result.error}` : ''
|
|
155
|
+
sBoot.stop(
|
|
156
|
+
`gateway did not bind socket within 10s (reason=${reason} pid=${result.pid ?? '?'})${detail}; check above output`,
|
|
157
|
+
)
|
|
158
|
+
process.exit(1)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Stub — wired by gateway-status when needed.
|
|
163
|
+
export function _operatorSessionPresent(agentId: string): boolean {
|
|
164
|
+
return readOperatorSession(agentId) !== null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* v0.24.10: returns a verifier that `precomputeAllScopes` calls after each
|
|
169
|
+
* canonical key derive. The verifier:
|
|
170
|
+
*
|
|
171
|
+
* - For 'keystore': trial-decrypts `<agentDir>/keystore.json` with the
|
|
172
|
+
* candidate key. Returns true on success, false on AES-GCM auth failure.
|
|
173
|
+
* False triggers the legacy empty-EIP712Domain fallback inside
|
|
174
|
+
* `precomputeAllScopes` so pre-v0.24.9 WC-init'd keystores (only known
|
|
175
|
+
* instance is fox, tokenId #5) can still flip to the correct AES key on
|
|
176
|
+
* first boot under v0.24.10+.
|
|
177
|
+
*
|
|
178
|
+
* - For TELEGRAM: trial-decrypts `<agentDir>/telegram-secrets.encrypted`
|
|
179
|
+
* when present. Same legacy-fallback semantic.
|
|
180
|
+
*
|
|
181
|
+
* - For PROFILE / unknown: returns true unconditionally. PROFILE has no
|
|
182
|
+
* on-disk artifact to verify against (the encrypted blob lives in iNFT
|
|
183
|
+
* slot 3 on chain); the keystore-scope detection above already cascades
|
|
184
|
+
* the legacy flag to PROFILE via `precomputeAllScopes`'s
|
|
185
|
+
* `useLegacyForRest` branch, so the PROFILE key is derived via the
|
|
186
|
+
* matching variant without needing a verify here.
|
|
187
|
+
*
|
|
188
|
+
* - On missing keystore (init flow never reached this code path, so this
|
|
189
|
+
* is a defensive fallback): returns true so the derive completes; the
|
|
190
|
+
* daemon's own decrypt at boot will surface the real error.
|
|
191
|
+
*/
|
|
192
|
+
function buildKeystoreVerifier(agentId: string) {
|
|
193
|
+
const keystorePath = agentPaths.agent(agentId).keystore
|
|
194
|
+
const tgSecretsPath = telegramSecretsPath(agentId)
|
|
195
|
+
return async (scope: 'keystore' | OperatorBlobScope, key: Buffer): Promise<boolean> => {
|
|
196
|
+
if (scope === 'keystore') {
|
|
197
|
+
if (!existsSync(keystorePath)) return true
|
|
198
|
+
try {
|
|
199
|
+
const ks = decodeKeystoreBytes(new TextEncoder().encode(readFileSync(keystorePath, 'utf8')))
|
|
200
|
+
return tryDecryptKeystoreWithKey(ks, key)
|
|
201
|
+
} catch {
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (scope === OPERATOR_BLOB_SCOPES.TELEGRAM) {
|
|
206
|
+
if (!existsSync(tgSecretsPath)) return true
|
|
207
|
+
try {
|
|
208
|
+
const blob = decodeOperatorBlobBytes(new Uint8Array(readFileSync(tgSecretsPath)))
|
|
209
|
+
return tryDecryptOperatorBlobWithKey(blob, key, OPERATOR_BLOB_SCOPES.TELEGRAM)
|
|
210
|
+
} catch {
|
|
211
|
+
return true
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nebula gateway status` — show PID, uptime, socket path, lock state,
|
|
3
|
+
* operator-session freshness.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import {
|
|
11
|
+
agentPaths,
|
|
12
|
+
isOperatorSessionFresh,
|
|
13
|
+
placeholderAgentId,
|
|
14
|
+
readOperatorSession,
|
|
15
|
+
} from 'nebula-ai-core'
|
|
16
|
+
import { findAndLoadConfig } from '../config/load'
|
|
17
|
+
|
|
18
|
+
export interface GatewayStatusOpts {
|
|
19
|
+
agentId?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fmtAge(ms: number): string {
|
|
23
|
+
const s = Math.floor(ms / 1000)
|
|
24
|
+
if (s < 60) return `${s}s`
|
|
25
|
+
const m = Math.floor(s / 60)
|
|
26
|
+
if (m < 60) return `${m}m`
|
|
27
|
+
const h = Math.floor(m / 60)
|
|
28
|
+
return `${h}h${m % 60}m`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runGatewayStatus(opts: GatewayStatusOpts): Promise<void> {
|
|
32
|
+
let agentId = opts.agentId
|
|
33
|
+
if (!agentId) {
|
|
34
|
+
const found = await findAndLoadConfig()
|
|
35
|
+
if (!found?.config) {
|
|
36
|
+
console.error('nebula gateway status: no nebula.config.ts and no --agent provided')
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
const agentEoa = found.config.identity.agent
|
|
40
|
+
if (!agentEoa) {
|
|
41
|
+
console.error('nebula gateway status: config has no agent EOA; run `nebula init` first')
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
agentId = placeholderAgentId(agentEoa)
|
|
45
|
+
}
|
|
46
|
+
const paths = agentPaths.agent(agentId)
|
|
47
|
+
const socketPath = join(paths.dir, 'gateway.sock')
|
|
48
|
+
const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
|
|
49
|
+
const lockFile = join(homedir(), '.nebula', 'locks', `nebula-gateway-${identityHash}.lock`)
|
|
50
|
+
|
|
51
|
+
console.log(`agent: ${agentId}`)
|
|
52
|
+
console.log(`socket: ${socketPath} ${existsSync(socketPath) ? '(present)' : '(absent)'}`)
|
|
53
|
+
console.log(`lock: ${lockFile} ${existsSync(lockFile) ? '(present)' : '(absent)'}`)
|
|
54
|
+
|
|
55
|
+
// PID + uptime via lock file.
|
|
56
|
+
if (existsSync(lockFile)) {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(readFileSync(lockFile, 'utf8')) as { pid?: number }
|
|
59
|
+
if (typeof parsed.pid === 'number') {
|
|
60
|
+
let alive = false
|
|
61
|
+
try {
|
|
62
|
+
process.kill(parsed.pid, 0)
|
|
63
|
+
alive = true
|
|
64
|
+
} catch {
|
|
65
|
+
/* dead */
|
|
66
|
+
}
|
|
67
|
+
const stat = statSync(lockFile)
|
|
68
|
+
const ageMs = Date.now() - stat.mtimeMs
|
|
69
|
+
console.log(`pid: ${parsed.pid} ${alive ? '(alive)' : '(dead — stale lock)'}`)
|
|
70
|
+
console.log(`lock-age: ${fmtAge(ageMs)}`)
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
console.log('pid: (lock file unreadable)')
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
console.log('pid: (not running)')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Operator-session freshness.
|
|
80
|
+
const fresh = isOperatorSessionFresh(agentId)
|
|
81
|
+
console.log(`session: ${fresh ? 'fresh' : 'absent or expired'}`)
|
|
82
|
+
if (fresh) {
|
|
83
|
+
const sess = readOperatorSession(agentId)
|
|
84
|
+
if (sess) {
|
|
85
|
+
const remaining = sess.expiresAt - Date.now()
|
|
86
|
+
const scopes = Object.keys(sess.keys).filter(k => sess.keys[k as keyof typeof sess.keys])
|
|
87
|
+
console.log(`session-ttl: ${fmtAge(remaining)} remaining (scopes: ${scopes.join(', ')})`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nebula gateway stop` — SIGTERM the running gateway daemon via the lock
|
|
3
|
+
* file's PID. Falls through to SIGKILL after a 5s grace period.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { agentPaths, placeholderAgentId } from 'nebula-ai-core'
|
|
10
|
+
import { findAndLoadConfig } from '../config/load'
|
|
11
|
+
|
|
12
|
+
export interface GatewayStopOpts {
|
|
13
|
+
agentId?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function lockPath(_agentId: string): string {
|
|
17
|
+
// Mirror packages/core/src/locks.ts — `~/.nebula/locks/<scope>-<sha256(identity).slice(0,16)>.lock`
|
|
18
|
+
// For 'nebula-gateway' scope. We compute the same hash as the lock module.
|
|
19
|
+
// Easiest: read all lock files and find one matching the agent.
|
|
20
|
+
return join(homedir(), '.nebula', 'locks')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function findGatewayLock(agentId: string): string | null {
|
|
24
|
+
// The lock filename embeds sha256(agentId).slice(0, 16). Compute it.
|
|
25
|
+
const { createHash } = require('node:crypto')
|
|
26
|
+
const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
|
|
27
|
+
const lockFile = join(lockPath(agentId), `nebula-gateway-${identityHash}.lock`)
|
|
28
|
+
return existsSync(lockFile) ? lockFile : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runGatewayStop(opts: GatewayStopOpts): Promise<void> {
|
|
32
|
+
let agentId = opts.agentId
|
|
33
|
+
if (!agentId) {
|
|
34
|
+
const found = await findAndLoadConfig()
|
|
35
|
+
if (!found?.config) {
|
|
36
|
+
console.error('nebula gateway stop: no nebula.config.ts and no --agent provided')
|
|
37
|
+
process.exit(1)
|
|
38
|
+
}
|
|
39
|
+
const agentEoa = found.config.identity?.agent ?? null
|
|
40
|
+
if (!agentEoa) {
|
|
41
|
+
console.error('nebula gateway stop: config has no agent EOA; run `nebula init` first')
|
|
42
|
+
process.exit(1)
|
|
43
|
+
}
|
|
44
|
+
agentId = placeholderAgentId(agentEoa)
|
|
45
|
+
const label = `agent ${agentId.slice(0, 8)}…`
|
|
46
|
+
const eoaLabel = ` (EOA ${agentEoa.slice(0, 6)}…${agentEoa.slice(-4)})`
|
|
47
|
+
const configPath = found.path ?? '<unknown>'
|
|
48
|
+
console.log(`nebula gateway stop → ${label}${eoaLabel}`)
|
|
49
|
+
console.log(` config: ${configPath}`)
|
|
50
|
+
console.log(
|
|
51
|
+
' if this is not the agent you meant, set NEBULA_ROOT or pass --agent <id> before re-running.',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
const lockFile = findGatewayLock(agentId)
|
|
55
|
+
if (!lockFile) {
|
|
56
|
+
console.log(`gateway not running (no lock at ${lockPath(agentId)})`)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
let pid: number
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(lockFile, 'utf8').trim()
|
|
62
|
+
// Lock files are JSON with shape `{pid, scope, identityHash, expiresAt}`.
|
|
63
|
+
const parsed = JSON.parse(raw) as { pid?: number }
|
|
64
|
+
if (typeof parsed.pid !== 'number') {
|
|
65
|
+
console.error('nebula gateway stop: lock file has no pid field')
|
|
66
|
+
process.exit(1)
|
|
67
|
+
}
|
|
68
|
+
pid = parsed.pid
|
|
69
|
+
} catch (e) {
|
|
70
|
+
console.error(`nebula gateway stop: lock file unreadable — ${(e as Error).message}`)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Verify the PID is alive.
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0)
|
|
77
|
+
} catch {
|
|
78
|
+
console.log(`gateway not running (stale lock pid=${pid}); cleaning up`)
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(lockFile)
|
|
81
|
+
} catch {
|
|
82
|
+
/* ignore */
|
|
83
|
+
}
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Send SIGTERM, wait up to 5s, then SIGKILL.
|
|
88
|
+
console.log(`stopping gateway pid=${pid} ...`)
|
|
89
|
+
try {
|
|
90
|
+
process.kill(pid, 'SIGTERM')
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.error(`nebula gateway stop: SIGTERM failed — ${(e as Error).message}`)
|
|
93
|
+
process.exit(1)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const start = Date.now()
|
|
97
|
+
while (Date.now() - start < 5_000) {
|
|
98
|
+
try {
|
|
99
|
+
process.kill(pid, 0)
|
|
100
|
+
} catch {
|
|
101
|
+
console.log(`gateway stopped pid=${pid}`)
|
|
102
|
+
// Lock file is auto-removed by daemon's shutdown handler. Belt + suspenders:
|
|
103
|
+
try {
|
|
104
|
+
if (existsSync(lockFile)) unlinkSync(lockFile)
|
|
105
|
+
} catch {
|
|
106
|
+
/* ignore */
|
|
107
|
+
}
|
|
108
|
+
// Also clean up the socket file in case the daemon didn't.
|
|
109
|
+
const socketPath = join(agentPaths.agent(agentId).dir, 'gateway.sock')
|
|
110
|
+
try {
|
|
111
|
+
if (existsSync(socketPath)) unlinkSync(socketPath)
|
|
112
|
+
} catch {
|
|
113
|
+
/* ignore */
|
|
114
|
+
}
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
await new Promise(r => setTimeout(r, 200))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log('gateway did not exit in 5s; sending SIGKILL')
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 'SIGKILL')
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.error(`nebula gateway stop: SIGKILL failed — ${(e as Error).message}`)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
if (existsSync(lockFile)) unlinkSync(lockFile)
|
|
129
|
+
} catch {
|
|
130
|
+
/* ignore */
|
|
131
|
+
}
|
|
132
|
+
console.log(`gateway force-killed pid=${pid}`)
|
|
133
|
+
}
|