nebula-ai-core 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 (109) hide show
  1. package/README.md +24 -0
  2. package/package.json +69 -0
  3. package/src/brain/compaction.ts +131 -0
  4. package/src/brain/frozen-prefix.ts +320 -0
  5. package/src/brain/history-persist.ts +154 -0
  6. package/src/brain/index.ts +43 -0
  7. package/src/brain/openai-brain.ts +533 -0
  8. package/src/brain/sanitize.ts +23 -0
  9. package/src/brain/stub.ts +20 -0
  10. package/src/brain/types.ts +129 -0
  11. package/src/chain.ts +75 -0
  12. package/src/claude-plugins/discovery.ts +152 -0
  13. package/src/claude-plugins/index.ts +6 -0
  14. package/src/claude-plugins/types.ts +38 -0
  15. package/src/commands/index.ts +16 -0
  16. package/src/commands/registry.ts +255 -0
  17. package/src/config.ts +213 -0
  18. package/src/economy/index.ts +6 -0
  19. package/src/events/index.ts +4 -0
  20. package/src/events/listeners.ts +37 -0
  21. package/src/events/queue.ts +63 -0
  22. package/src/events/router.ts +42 -0
  23. package/src/events/types.ts +28 -0
  24. package/src/format.ts +12 -0
  25. package/src/identity/agent-card.ts +110 -0
  26. package/src/identity/deployments.ts +20 -0
  27. package/src/identity/erc8004.ts +161 -0
  28. package/src/identity/index.ts +29 -0
  29. package/src/identity/keystore-blob.ts +60 -0
  30. package/src/identity/receipt.ts +27 -0
  31. package/src/identity/stub.ts +29 -0
  32. package/src/identity/types.ts +20 -0
  33. package/src/index.ts +372 -0
  34. package/src/locks.ts +233 -0
  35. package/src/mcp/discovery.ts +150 -0
  36. package/src/mcp/index.ts +10 -0
  37. package/src/mcp/manager.ts +110 -0
  38. package/src/mcp/stdio-client.ts +154 -0
  39. package/src/mcp/types.ts +44 -0
  40. package/src/memory/edit.ts +53 -0
  41. package/src/memory/encryption.ts +88 -0
  42. package/src/memory/fs-util.ts +15 -0
  43. package/src/memory/index-file.ts +74 -0
  44. package/src/memory/index-sync.ts +99 -0
  45. package/src/memory/index.ts +58 -0
  46. package/src/memory/list-tool.ts +105 -0
  47. package/src/memory/pack-blob.ts +120 -0
  48. package/src/memory/pack-gather.ts +112 -0
  49. package/src/memory/parser.ts +20 -0
  50. package/src/memory/read-tool.ts +198 -0
  51. package/src/memory/save-tool.ts +189 -0
  52. package/src/memory/scan.ts +63 -0
  53. package/src/memory/topic.ts +32 -0
  54. package/src/memory/types.ts +49 -0
  55. package/src/migration/index.ts +6 -0
  56. package/src/migration/option3-crypto.ts +127 -0
  57. package/src/operator/index.ts +9 -0
  58. package/src/operator/keychain.ts +53 -0
  59. package/src/operator/keystore-file.ts +33 -0
  60. package/src/operator/privkey-base.ts +60 -0
  61. package/src/operator/raw-privkey.ts +39 -0
  62. package/src/operator/signer.ts +46 -0
  63. package/src/operator/walletconnect.ts +454 -0
  64. package/src/pairing.ts +285 -0
  65. package/src/paths.ts +70 -0
  66. package/src/permission/dangerous.ts +108 -0
  67. package/src/permission/env-redact.ts +54 -0
  68. package/src/permission/index.ts +16 -0
  69. package/src/permission/path-guard.ts +114 -0
  70. package/src/permission/service.ts +191 -0
  71. package/src/plugins/context.ts +225 -0
  72. package/src/plugins/hooks.ts +81 -0
  73. package/src/plugins/index.ts +24 -0
  74. package/src/plugins/tool-search.ts +49 -0
  75. package/src/public/card.ts +67 -0
  76. package/src/runtime/activity.ts +29 -0
  77. package/src/runtime/index.ts +2 -0
  78. package/src/runtime/runtime.ts +113 -0
  79. package/src/sandbox/credentials.ts +25 -0
  80. package/src/sandbox/docker.ts +396 -0
  81. package/src/sandbox/factory.ts +99 -0
  82. package/src/sandbox/index.ts +15 -0
  83. package/src/sandbox/linux.ts +141 -0
  84. package/src/sandbox/local.ts +19 -0
  85. package/src/sandbox/macos.ts +71 -0
  86. package/src/sandbox/seatbelt-profile.ts +139 -0
  87. package/src/sandbox/types.ts +129 -0
  88. package/src/skills/index.ts +8 -0
  89. package/src/skills/scanner.ts +257 -0
  90. package/src/skills/triggers.ts +78 -0
  91. package/src/skills/types.ts +37 -0
  92. package/src/storage/encryption.ts +87 -0
  93. package/src/storage/factory.ts +31 -0
  94. package/src/storage/index.ts +11 -0
  95. package/src/storage/local-stub.ts +70 -0
  96. package/src/storage/sqlite.ts +95 -0
  97. package/src/storage/types.ts +21 -0
  98. package/src/tools/escalation.ts +200 -0
  99. package/src/tools/index.ts +11 -0
  100. package/src/tools/registry.ts +152 -0
  101. package/src/tools/types.ts +65 -0
  102. package/src/tools/zod-helpers.ts +36 -0
  103. package/src/tools/zod-schema.ts +99 -0
  104. package/src/wallet/drain.ts +79 -0
  105. package/src/wallet/eoa.ts +51 -0
  106. package/src/wallet/index.ts +47 -0
  107. package/src/wallet/keystore.ts +50 -0
  108. package/src/wallet/operator-keystore-crypto.ts +530 -0
  109. package/src/wallet/operator-session.ts +344 -0
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Operator session: a per-agent on-disk cache of the operator-derived AES
3
+ * keys (one per scope) so the headless gateway daemon can boot without
4
+ * prompting Touch ID. Written once via `nebula gateway start` after an
5
+ * interactive Touch ID unlock; read by the daemon at boot.
6
+ *
7
+ * Security model:
8
+ * - File at `~/.nebula/agents/<id>/.operator-session` with permission 0600.
9
+ * - Same threat surface as hermes's `~/.hermes/.env` (which holds API keys
10
+ * in plaintext for daemon use). An attacker with read access to the user's
11
+ * home directory can extract these keys and decrypt the agent keystore +
12
+ * telegram secrets.
13
+ * - 24-hour default TTL. Caller can override via `expiresInMs`.
14
+ * - Atomic temp+rename writes.
15
+ * - The keys themselves are RFC-6979 deterministic — same operator privkey +
16
+ * same agent address always produce the same key.
17
+ */
18
+
19
+ import { chmodSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { type Address, type Hex, bytesToHex, hexToBytes } from 'viem'
22
+ import type { OperatorSigner } from '../operator/signer'
23
+ import { agentPaths } from '../paths'
24
+ import {
25
+ OPERATOR_BLOB_SCOPES,
26
+ type OperatorBlobScope,
27
+ deriveBlobKey,
28
+ deriveKeystoreKey,
29
+ deriveLegacyEmptyDomainKey,
30
+ } from './operator-keystore-crypto'
31
+
32
+ export const OPERATOR_SESSION_VERSION = 1 as const
33
+ export const DEFAULT_OPERATOR_SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
34
+
35
+ /** Plain-object scope-keyed map; `keystore` is the canonical legacy slot. */
36
+ export type OperatorSessionKeys = Partial<Record<'keystore' | OperatorBlobScope, Hex>> & {
37
+ keystore: Hex
38
+ }
39
+
40
+ export interface OperatorSession {
41
+ version: typeof OPERATOR_SESSION_VERSION
42
+ agent: Address
43
+ keys: OperatorSessionKeys
44
+ expiresAt: number
45
+ createdAt: number
46
+ }
47
+
48
+ /** Path to the session file for a given agent id. */
49
+ export function operatorSessionPath(agentId: string): string {
50
+ return join(agentPaths.agent(agentId).dir, '.operator-session')
51
+ }
52
+
53
+ /**
54
+ * Atomically write the session file at perm 0600. Overwrites any existing
55
+ * session.
56
+ */
57
+ export function writeOperatorSession(agentId: string, session: OperatorSession): void {
58
+ const path = operatorSessionPath(agentId)
59
+ const tmp = `${path}.tmp-${process.pid}-${Date.now().toString(36)}`
60
+ writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 0o600 })
61
+ renameSync(tmp, path)
62
+ // rename preserves source perms but be belt-and-suspenders explicit.
63
+ // Wrapped in try/catch for non-POSIX hosts (Windows) where chmod is advisory.
64
+ try {
65
+ chmodSync(path, 0o600)
66
+ } catch {
67
+ /* non-POSIX: permissions are advisory only */
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Read the session file. Returns null when the file is missing, malformed,
73
+ * or expired. Stale sessions are auto-deleted to keep the on-disk surface
74
+ * small.
75
+ */
76
+ export function readOperatorSession(agentId: string): OperatorSession | null {
77
+ const path = operatorSessionPath(agentId)
78
+ let raw: string
79
+ try {
80
+ raw = readFileSync(path, 'utf8')
81
+ } catch {
82
+ return null
83
+ }
84
+ try {
85
+ const parsed = JSON.parse(raw) as Partial<OperatorSession>
86
+ if (parsed.version !== OPERATOR_SESSION_VERSION) return null
87
+ if (typeof parsed.agent !== 'string' || !parsed.agent.startsWith('0x')) return null
88
+ if (typeof parsed.expiresAt !== 'number') return null
89
+ if (typeof parsed.createdAt !== 'number') return null
90
+ if (typeof parsed.keys !== 'object' || parsed.keys === null) return null
91
+ if (typeof parsed.keys.keystore !== 'string') return null
92
+ if (Date.now() > parsed.expiresAt) {
93
+ // Stale; clean up so we don't keep reading expired bytes.
94
+ try {
95
+ unlinkSync(path)
96
+ } catch {
97
+ /* race or perm issue; ignore */
98
+ }
99
+ return null
100
+ }
101
+ return parsed as OperatorSession
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ /** Best-effort delete; race-tolerant. */
108
+ export function clearOperatorSession(agentId: string): void {
109
+ try {
110
+ unlinkSync(operatorSessionPath(agentId))
111
+ } catch {
112
+ /* ENOENT or perm; ignore */
113
+ }
114
+ }
115
+
116
+ /** True if a non-expired session exists on disk. */
117
+ export function isOperatorSessionFresh(agentId: string): boolean {
118
+ return readOperatorSession(agentId) !== null
119
+ }
120
+
121
+ /**
122
+ * Map encrypted blob filename → required scope. Extend when new blob types
123
+ * are added so `requiredScopesForAgent` picks them up automatically.
124
+ */
125
+ const SCOPE_BLOB_FILES: ReadonlyArray<readonly [filename: string, scope: OperatorBlobScope]> = [
126
+ ['telegram-secrets.encrypted', OPERATOR_BLOB_SCOPES.TELEGRAM],
127
+ ] as const
128
+
129
+ /**
130
+ * Map memory-file path → required scope. Used the same way as
131
+ * `SCOPE_BLOB_FILES` but matches on a path under `<agentDir>/memory/` instead
132
+ * of `<agentDir>/`. v0.23.0: the PROFILE slot is operator-keyed, so its scope
133
+ * is required whenever profile.md exists (which is always after init).
134
+ */
135
+ const SCOPE_MEMORY_FILES: ReadonlyArray<readonly [path: string, scope: OperatorBlobScope]> = [
136
+ ['memory/user/profile.md', OPERATOR_BLOB_SCOPES.PROFILE],
137
+ ] as const
138
+
139
+ /**
140
+ * Inspect the agent dir on disk and return the set of scopes the operator
141
+ * session must contain to fully boot the daemon. Always includes 'keystore'
142
+ * (the canonical legacy slot). Adds extra scopes when their corresponding
143
+ * encrypted blob is present.
144
+ *
145
+ * Used by `nebula gateway start` and TUI auto-spawn to decide whether the
146
+ * cached session is "complete enough" or whether re-derivation via Touch ID
147
+ * is needed.
148
+ */
149
+ export function requiredScopesForAgent(agentId: string): Array<'keystore' | OperatorBlobScope> {
150
+ const dir = agentPaths.agent(agentId).dir
151
+ const required: Array<'keystore' | OperatorBlobScope> = ['keystore']
152
+ for (const [filename, scope] of SCOPE_BLOB_FILES) {
153
+ if (existsSync(join(dir, filename))) {
154
+ required.push(scope)
155
+ }
156
+ }
157
+ for (const [relPath, scope] of SCOPE_MEMORY_FILES) {
158
+ if (existsSync(join(dir, relPath))) {
159
+ required.push(scope)
160
+ }
161
+ }
162
+ return required
163
+ }
164
+
165
+ /**
166
+ * Stricter sibling of `isOperatorSessionFresh`: true only when (a) a
167
+ * non-expired session exists AND (b) the session contains every scope key
168
+ * required by the agent's on-disk state. A session can be "fresh" by
169
+ * timestamp but missing a scope (e.g. written before telegram-secrets was
170
+ * configured), in which case this returns false so the caller knows to
171
+ * re-derive via Touch ID.
172
+ *
173
+ * The closing the gap on the v0.21.12 regression where `nebula gateway start`
174
+ * skipped re-derivation because the session was timestamp-fresh, but the
175
+ * gateway daemon then booted without the TELEGRAM scope key and silently
176
+ * dropped all inbound TG messages.
177
+ */
178
+ export function isOperatorSessionComplete(
179
+ agentId: string,
180
+ required: Array<'keystore' | OperatorBlobScope>,
181
+ ): boolean {
182
+ const sess = readOperatorSession(agentId)
183
+ if (!sess) return false
184
+ for (const scope of required) {
185
+ if (!sess.keys[scope]) return false
186
+ }
187
+ return true
188
+ }
189
+
190
+ /**
191
+ * Pull a key from the session by scope. Returns null when no session exists
192
+ * or the scope is missing. Throws on disk corruption (length mismatch) so
193
+ * silent fallback to Touch ID prompts doesn't mask data integrity bugs.
194
+ */
195
+ export function getSessionKey(
196
+ agentId: string,
197
+ which: 'keystore' | OperatorBlobScope,
198
+ ): Buffer | null {
199
+ const sess = readOperatorSession(agentId)
200
+ if (!sess) return null
201
+ const hex = sess.keys[which]
202
+ if (!hex) return null
203
+ const buf = Buffer.from(hexToBytes(hex))
204
+ if (buf.length !== 32) {
205
+ throw new Error(
206
+ `operator-session: corrupt key for scope '${which}' (length ${buf.length}, expected 32)`,
207
+ )
208
+ }
209
+ return buf
210
+ }
211
+
212
+ /**
213
+ * Optional verifier called by `precomputeAllScopes` after each scope's
214
+ * canonical key is derived. Returning false triggers the v0.24.10 legacy
215
+ * empty-EIP712Domain fallback for that scope (and propagates legacy
216
+ * derivation to any later scope keys, since the EIP712Domain trap is a
217
+ * signer-wide property, not a per-scope one).
218
+ */
219
+ export type PrecomputeVerifyKey = (
220
+ scope: 'keystore' | OperatorBlobScope,
221
+ key: Buffer,
222
+ ) => boolean | Promise<boolean>
223
+
224
+ export interface PrecomputeAllScopesOpts {
225
+ /**
226
+ * v0.24.10: Optional verifier. When unset, `precomputeAllScopes` behaves
227
+ * exactly as it did in v0.24.9 (parallel canonical-only derivation). When
228
+ * set, the verifier runs after each derive — failure swaps to the legacy
229
+ * variant via the signer's `signTypedDataLegacyEmptyDomain` escape hatch.
230
+ *
231
+ * The verifier is supplied by the caller because it owns the disk layout:
232
+ * gateway-start verifies against `keystore.json` + `telegram-secrets.encrypted`
233
+ * on disk; `init` doesn't pass a verifier because the keystore is being
234
+ * freshly encrypted under the just-derived canonical key.
235
+ */
236
+ verifyKey?: PrecomputeVerifyKey
237
+ }
238
+
239
+ /**
240
+ * Derive all requested scope keys from the operator signer in parallel.
241
+ * Each derive triggers `signer.signTypedData`. Many signer backends (keychain)
242
+ * serialize the underlying transport, so parallel is a free win for backends
243
+ * that don't (raw-privkey, in-memory) and a no-op for those that do.
244
+ *
245
+ * `extraScopes` — additional scopes to derive beyond the always-on
246
+ * `keystore`. Phase 12 telegram passes [OPERATOR_BLOB_SCOPES.TELEGRAM];
247
+ * v0.23.0 PROFILE adds [OPERATOR_BLOB_SCOPES.PROFILE] when the agent's
248
+ * user-partition memory exists.
249
+ *
250
+ * v0.24.10: when `opts.verifyKey` is supplied, the keystore canonical key is
251
+ * trial-decrypted against the on-disk blob; if the verifier rejects it, the
252
+ * signer's `signTypedDataLegacyEmptyDomain` escape hatch is invoked to derive
253
+ * the pre-v0.24.9 WC variant. The detection cascades to remaining scopes —
254
+ * once the signer is known to be in legacy mode, every scope key is derived
255
+ * via the legacy method so the daemon boots with keys that actually decrypt
256
+ * the on-disk artifacts (single MM popup per scope on the first launch
257
+ * post-v0.24.9 for legacy WC agents; canonical-success agents see zero
258
+ * behavior change).
259
+ */
260
+ export async function precomputeAllScopes(
261
+ signer: OperatorSigner,
262
+ agent: Address,
263
+ extraScopes: OperatorBlobScope[] = [],
264
+ opts: PrecomputeAllScopesOpts = {},
265
+ ): Promise<OperatorSessionKeys> {
266
+ if (!opts.verifyKey) {
267
+ const [keystore, ...extras] = await Promise.all([
268
+ deriveKeystoreKey(signer, agent),
269
+ ...extraScopes.map(scope => deriveBlobKey(signer, agent, scope)),
270
+ ])
271
+ const result: OperatorSessionKeys = { keystore: bytesToHex(keystore) }
272
+ extraScopes.forEach((scope, i) => {
273
+ const buf = extras[i]
274
+ if (buf) result[scope] = bytesToHex(buf)
275
+ })
276
+ return result
277
+ }
278
+
279
+ // Verify-and-swap path. Serialize keystore first so the legacy detection
280
+ // cascades to remaining scopes uniformly.
281
+ const verifyKey = opts.verifyKey
282
+ let keystoreKey = await deriveKeystoreKey(signer, agent)
283
+ let useLegacyForRest = false
284
+ if (!(await verifyKey('keystore', keystoreKey))) {
285
+ const legacyKey = await deriveLegacyEmptyDomainKey(signer, agent, 'keystore')
286
+ if (!legacyKey) {
287
+ throw new Error(
288
+ 'precomputeAllScopes: keystore decrypt verification failed with canonical key and signer does not expose a legacy variant. Verify the operator wallet matches the agent keystore.',
289
+ )
290
+ }
291
+ if (!(await verifyKey('keystore', legacyKey))) {
292
+ throw new Error(
293
+ 'precomputeAllScopes: keystore decrypt verification failed with both canonical and legacy variants. The operator wallet may not match the agent keystore.',
294
+ )
295
+ }
296
+ keystoreKey = legacyKey
297
+ useLegacyForRest = true
298
+ }
299
+
300
+ const extras = await Promise.all(
301
+ extraScopes.map(async (scope): Promise<{ scope: OperatorBlobScope; key: Buffer | null }> => {
302
+ let key: Buffer | null = useLegacyForRest
303
+ ? await deriveLegacyEmptyDomainKey(signer, agent, scope)
304
+ : await deriveBlobKey(signer, agent, scope)
305
+ if (key && !(await verifyKey(scope, key))) {
306
+ const altKey: Buffer | null = useLegacyForRest
307
+ ? await deriveBlobKey(signer, agent, scope)
308
+ : await deriveLegacyEmptyDomainKey(signer, agent, scope)
309
+ if (altKey && (await verifyKey(scope, altKey))) {
310
+ key = altKey
311
+ } else {
312
+ key = null
313
+ }
314
+ }
315
+ return { scope, key }
316
+ }),
317
+ )
318
+
319
+ const result: OperatorSessionKeys = { keystore: bytesToHex(keystoreKey) }
320
+ for (const { scope, key } of extras) {
321
+ if (key) result[scope] = bytesToHex(key)
322
+ }
323
+ return result
324
+ }
325
+
326
+ /**
327
+ * Build an OperatorSession from a keys object plus an optional TTL override.
328
+ * Convenience composer used by `nebula gateway start`.
329
+ */
330
+ export function buildOperatorSession(opts: {
331
+ agent: Address
332
+ keys: OperatorSessionKeys
333
+ expiresInMs?: number
334
+ }): OperatorSession {
335
+ const now = Date.now()
336
+ const ttl = opts.expiresInMs ?? DEFAULT_OPERATOR_SESSION_TTL_MS
337
+ return {
338
+ version: OPERATOR_SESSION_VERSION,
339
+ agent: opts.agent,
340
+ keys: opts.keys,
341
+ expiresAt: now + ttl,
342
+ createdAt: now,
343
+ }
344
+ }