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,113 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import type { Brain } from '../brain/types'
3
+ import type { NebulaConfig } from '../config'
4
+ import { EventQueue, listeners, newEventId, routeLoop } from '../events'
5
+ import type { NebulaEvent } from '../events/types'
6
+ import type { IdentityProvider } from '../identity/types'
7
+ import { addEntryLine, readIndexFile, writeIndexFile } from '../memory/index-file'
8
+ import { agentPaths } from '../paths'
9
+ import type { Storage } from '../storage/types'
10
+ import { ToolRegistry } from '../tools/registry'
11
+ import { type ActivityEntry, ActivityLog } from './activity'
12
+
13
+ export interface RuntimeDeps {
14
+ config: NebulaConfig
15
+ identity: IdentityProvider
16
+ brain: Brain
17
+ storage: Storage
18
+ }
19
+
20
+ export class Runtime {
21
+ readonly queue: EventQueue
22
+ readonly tools: ToolRegistry
23
+ private activity?: ActivityLog
24
+ private running = false
25
+ private routeTask?: Promise<void>
26
+
27
+ constructor(private readonly deps: RuntimeDeps) {
28
+ this.queue = new EventQueue()
29
+ this.tools = new ToolRegistry(deps.config.tools)
30
+ }
31
+
32
+ /** Ensure per-agent filesystem exists and boot the event loop. */
33
+ async start(): Promise<void> {
34
+ if (this.running) return
35
+ const id = (await this.deps.identity.current()).agentId
36
+ const paths = agentPaths.agent(id)
37
+
38
+ await mkdir(paths.memoryDir, { recursive: true })
39
+ await mkdir(paths.agentMemoryDir, { recursive: true })
40
+ await mkdir(paths.userMemoryDir, { recursive: true })
41
+ await mkdir(paths.publicDir, { recursive: true })
42
+ await mkdir(paths.cache, { recursive: true })
43
+
44
+ this.activity = new ActivityLog(paths.activityLog)
45
+
46
+ // Initialize MEMORY.md if missing.
47
+ let index = await readIndexFile(paths.memoryIndex)
48
+ if (index.lines.length === 0) {
49
+ index = {
50
+ lines: [
51
+ `# ${id} — Memory Index`,
52
+ '',
53
+ 'Self-contained memory for this agent. Topic files live under `agent/` (transfers with iNFT) and `user/` (purges on transfer).',
54
+ '',
55
+ '## Memories',
56
+ '',
57
+ ],
58
+ entries: new Map(),
59
+ }
60
+ index = addEntryLine(index, {
61
+ file: 'agent/identity.md',
62
+ title: 'Agent identity',
63
+ hook: 'Seed record of this agent — tokenId, creation block, operator history.',
64
+ })
65
+ await writeIndexFile(paths.memoryIndex, index)
66
+ }
67
+
68
+ this.routeTask = routeLoop(this.queue, {
69
+ brain: this.deps.brain,
70
+ tools: this.tools,
71
+ onTurn: async (ev, turn) => {
72
+ await this.activity?.append({
73
+ ts: Date.now(),
74
+ kind: 'brain-response',
75
+ data: {
76
+ event: { id: ev.id, source: ev.source },
77
+ content: turn.content,
78
+ toolCalls: turn.toolCalls,
79
+ finishReason: turn.finishReason,
80
+ usage: turn.usage,
81
+ },
82
+ })
83
+ },
84
+ })
85
+ this.running = true
86
+
87
+ await listeners.startAll(this.queue)
88
+ }
89
+
90
+ /** Push an event onto the queue from outside the listener system. */
91
+ async fire(event: Omit<NebulaEvent, 'id' | 'ts'>): Promise<string> {
92
+ const ev: NebulaEvent = { ...event, id: newEventId(), ts: Date.now() }
93
+ await this.activity?.append({
94
+ ts: ev.ts,
95
+ kind: 'wake',
96
+ data: { id: ev.id, source: ev.source, label: ev.payload.label },
97
+ })
98
+ this.queue.enqueue(ev)
99
+ return ev.id
100
+ }
101
+
102
+ async logActivity(entry: ActivityEntry): Promise<void> {
103
+ await this.activity?.append(entry)
104
+ }
105
+
106
+ async stop(): Promise<void> {
107
+ if (!this.running) return
108
+ this.running = false
109
+ this.queue.close()
110
+ await listeners.stopAll()
111
+ await this.routeTask
112
+ }
113
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared credential-dir blocklist used by every sandbox backend (macOS
3
+ * seatbelt, Linux bubblewrap). Centralized so the platforms don't drift —
4
+ * earlier the bwrap profile included `~/.config/anthropic` + `~/.gnupg`
5
+ * while the seatbelt profile didn't. Centralizing closes that gap.
6
+ */
7
+
8
+ /**
9
+ * Cross-platform credential paths to blackhole. Relative to homedir; backends
10
+ * format the absolute path. `Library/Keychains` is macOS-only but keeping it
11
+ * here is harmless on Linux (the path won't exist; `--tmpfs` no-ops).
12
+ */
13
+ export const CREDENTIAL_DIR_RELATIVE_PATHS: readonly string[] = [
14
+ '.ssh',
15
+ '.aws',
16
+ 'Library/Keychains',
17
+ '.config/gcloud',
18
+ '.config/anthropic', // claude-code config
19
+ '.gnupg',
20
+ ] as const
21
+
22
+ /** Build the absolute paths of credential dirs to deny under `homedir`. */
23
+ export function credentialDirs(homedir: string): string[] {
24
+ return CREDENTIAL_DIR_RELATIVE_PATHS.map(rel => `${homedir}/${rel}`)
25
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Container sandbox backend (Tier 3, Phase 9.5 follow-up to sandbox-exec).
3
+ *
4
+ * Works with EITHER Docker Desktop OR Podman — they provide the same CLI.
5
+ * Auto-detects the runtime; same default config works for both. Operator
6
+ * can force a specific binary via `runtimePath` opt.
7
+ *
8
+ * Same isolation shape as hermes-agent's `TERMINAL_ENV=docker` mode (full Linux
9
+ * container). Differences from Tier 2 (sandbox-exec):
10
+ *
11
+ * - Container has its own filesystem (chroot-like). Host fs invisible to the
12
+ * sandboxed processes unless explicitly mounted via `mountWorkspace=true`.
13
+ * - Container has its own /tmp, /etc, /home — `rm -rf /tmp/*` only nukes the
14
+ * container's tmpdir, never the host's.
15
+ * - Network goes through the runtime's bridge by default (still allowed for
16
+ * nebula's RPC/storage/compute/WC traffic to escape the container).
17
+ * - Cold-start cost ~1s on the FIRST tool call after nebula boot (longer if
18
+ * the image is being pulled). Subsequent `exec` calls are ~50-100ms.
19
+ *
20
+ * Hybrid MVP: only shell.run / shell.process_start / code.execute go through
21
+ * the container. fs.* tools still run on host (gated by PathGuard). browser.*
22
+ * still runs on host. A future bundle would re-exec all of nebula inside the
23
+ * container; this is the lower-risk incremental step.
24
+ *
25
+ * Lifecycle:
26
+ * - `wrapSpawn` lazy-starts the container on first call.
27
+ * - Container runs `nikolaik/python-nodejs:python3.11-nodejs20` by default
28
+ * (matches hermes' default; has bash, python3, node, npm, git, curl on
29
+ * standard PATH).
30
+ * - Container is detached (`run -d`), idle-loops on `tail -f /dev/null` so it
31
+ * stays alive between exec calls.
32
+ * - `dispose()` kills the container. chat.tsx wires this to process exit
33
+ * handlers.
34
+ *
35
+ * Failure modes:
36
+ * - Runtime not installed → constructor throws clear error; factory falls
37
+ * back to LocalBackend with stderr warning.
38
+ * - Daemon/machine not running → first call surfaces "daemon unreachable"
39
+ * error from the runtime. With podman on macOS, requires `podman machine
40
+ * start` once.
41
+ * - Image pull on first run → 30-60s, surfaced as "starting container" log.
42
+ * - Container crash mid-session (external `podman kill`, OOM, daemon
43
+ * restart) → wrapSpawn detects the stale cache via a fast
44
+ * `podman inspect --format '{{.State.Running}}'` probe and self-heals by
45
+ * invalidating containerId + re-running startContainer. Cost: one extra
46
+ * inspect (~5-15ms on the warm Podman API socket) per shell-class call.
47
+ * Worth the latency vs. the alternative of leaving the brain stuck on
48
+ * "no such container" errors with no recovery path.
49
+ */
50
+
51
+ import { type SpawnOptions, execFile } from 'node:child_process'
52
+ import { existsSync } from 'node:fs'
53
+ import { tmpdir } from 'node:os'
54
+ import { promisify } from 'node:util'
55
+ import type {
56
+ SandboxBackend,
57
+ SandboxBackendOpts,
58
+ SandboxEnvHint,
59
+ SandboxSpawnRequest,
60
+ WrappedSpawn,
61
+ } from './types'
62
+
63
+ const exec = promisify(execFile)
64
+
65
+ /**
66
+ * Probe order for container runtime auto-detect. First existing path wins.
67
+ * macOS Homebrew Podman lives at /opt/homebrew/bin/podman; Docker Desktop
68
+ * symlinks /usr/local/bin/docker to its CLI (or to podman, on machines
69
+ * with both). Linux paths included for completeness.
70
+ */
71
+ const RUNTIME_CANDIDATES: ReadonlyArray<{ path: string; runtime: 'docker' | 'podman' }> = [
72
+ { path: '/usr/local/bin/docker', runtime: 'docker' },
73
+ { path: '/opt/homebrew/bin/docker', runtime: 'docker' },
74
+ { path: '/opt/homebrew/bin/podman', runtime: 'podman' },
75
+ { path: '/usr/bin/docker', runtime: 'docker' },
76
+ { path: '/usr/bin/podman', runtime: 'podman' },
77
+ ]
78
+
79
+ interface RuntimeInfo {
80
+ path: string
81
+ runtime: 'docker' | 'podman'
82
+ }
83
+
84
+ function detectRuntime(override?: string): RuntimeInfo {
85
+ if (override) {
86
+ if (!existsSync(override)) {
87
+ throw new Error(`container runtime override path does not exist: ${override}`)
88
+ }
89
+ const runtime = override.includes('podman') ? 'podman' : 'docker'
90
+ return { path: override, runtime }
91
+ }
92
+ for (const cand of RUNTIME_CANDIDATES) {
93
+ if (existsSync(cand.path)) return cand
94
+ }
95
+ throw new Error(
96
+ 'no container runtime found. Install Docker Desktop or Podman (`brew install podman`) and ensure the daemon/machine is running.',
97
+ )
98
+ }
99
+
100
+ export interface DockerBackendOpts extends SandboxBackendOpts {
101
+ /**
102
+ * Container image. Default: `nikolaik/python-nodejs:python3.11-nodejs20`
103
+ * (matches hermes-agent's TERMINAL_DOCKER_IMAGE default; has bash, python3,
104
+ * node, npm, git, curl on standard PATH so every code.execute language and
105
+ * shell tool works out of the box). Switch to `oven/bun:1` (~250MB vs 700MB)
106
+ * if you only need bun/ts and don't care about python.
107
+ */
108
+ image?: string
109
+ /**
110
+ * Mount the host's workspaceRoot into the container at /workspace. Default
111
+ * `false` for max isolation (container has no view of host fs). Set true
112
+ * when the operator wants the agent to read/edit host project files.
113
+ */
114
+ mountWorkspace?: boolean
115
+ /**
116
+ * Override container runtime binary path. Default: auto-detect (docker, then
117
+ * podman). Set this to force one or the other, e.g. `/opt/homebrew/bin/podman`.
118
+ */
119
+ runtimePath?: string
120
+ /** Override container start timeout in ms. Default 60000 (60s for image pull). */
121
+ startTimeoutMs?: number
122
+ /**
123
+ * CPU cores cap (passed to runtime as `--cpus`). Float (e.g. 0.5, 2). Unset =
124
+ * unlimited (runtime default). Hermes default is 1; nebula leaves UNSET so
125
+ * the container competes fairly with host work unless the operator opts in.
126
+ */
127
+ cpu?: number
128
+ /**
129
+ * Memory cap in MB (`--memory <N>m`). Unset = unlimited. Hermes default is
130
+ * 5120 (5GB). OOM kills happen at this cap, so prefer leaving it unset
131
+ * unless the operator wants a hard guard against runaway pip installs.
132
+ */
133
+ memoryMb?: number
134
+ /**
135
+ * Per-container disk cap in MB (`--storage-opt size=<N>m`). Linux + overlay2
136
+ * with pquota only — silently dropped on macOS (Docker Desktop / podman
137
+ * machine). Unset = unlimited.
138
+ */
139
+ diskMb?: number
140
+ /**
141
+ * Block all network access from inside the container (`--network=none`).
142
+ * Default false (container's bridge network reaches the internet). Useful
143
+ * for max-paranoia code.execute runs that should never reach out.
144
+ */
145
+ noNetwork?: boolean
146
+ }
147
+
148
+ /**
149
+ * Always-on hardening flags ported from hermes-agent's `_SECURITY_ARGS`. Drop
150
+ * every Linux capability then re-add the minimum needed by package managers
151
+ * (pip / npm / apt set ownership and override DAC), block setuid escalation,
152
+ * cap process count to stop fork bombs, and replace tmpfs /tmp /var/tmp /run
153
+ * with size-limited writable tmpfs that doesn't bleed into the host. `--init`
154
+ * gives the container a real PID 1 (tini) that reaps zombies — without it,
155
+ * background tools that orphan children leak file descriptors.
156
+ */
157
+ const HARDENING_ARGS: ReadonlyArray<string> = [
158
+ '--init',
159
+ '--cap-drop',
160
+ 'ALL',
161
+ '--cap-add',
162
+ 'DAC_OVERRIDE',
163
+ '--cap-add',
164
+ 'CHOWN',
165
+ '--cap-add',
166
+ 'FOWNER',
167
+ '--security-opt',
168
+ 'no-new-privileges',
169
+ '--pids-limit',
170
+ '256',
171
+ '--tmpfs',
172
+ '/tmp:rw,nosuid,size=512m',
173
+ '--tmpfs',
174
+ '/var/tmp:rw,noexec,nosuid,size=256m',
175
+ '--tmpfs',
176
+ '/run:rw,noexec,nosuid,size=64m',
177
+ ]
178
+
179
+ export class DockerBackend implements SandboxBackend {
180
+ readonly mode = 'docker' as const
181
+ readonly label: string
182
+ private readonly image: string
183
+ private readonly mountWorkspace: boolean
184
+ private readonly runtime: RuntimeInfo
185
+ private readonly startTimeoutMs: number
186
+ private readonly workspaceRoot: string
187
+ private readonly cpu?: number
188
+ private readonly memoryMb?: number
189
+ private readonly diskMb?: number
190
+ private readonly noNetwork: boolean
191
+ private containerId: string | null = null
192
+ private starting: Promise<string> | null = null
193
+ /**
194
+ * Last `Date.now()` at which `isContainerAlive` returned true. Used to
195
+ * cache the result and skip the ~30-70ms `inspect` probe when the container
196
+ * was confirmed alive recently. The window is narrow enough that a stale
197
+ * cache only delays self-heal by ALIVE_PROBE_TTL_MS in the rare case where
198
+ * the container died externally.
199
+ */
200
+ private lastAliveProbeMs = 0
201
+ private static readonly ALIVE_PROBE_TTL_MS = 30_000
202
+
203
+ constructor(opts: DockerBackendOpts) {
204
+ this.image = opts.image ?? 'nikolaik/python-nodejs:python3.11-nodejs20'
205
+ this.mountWorkspace = opts.mountWorkspace ?? false
206
+ this.runtime = detectRuntime(opts.runtimePath)
207
+ this.startTimeoutMs = opts.startTimeoutMs ?? 60_000
208
+ this.workspaceRoot = opts.workspaceRoot
209
+ this.cpu = opts.cpu
210
+ this.memoryMb = opts.memoryMb
211
+ this.diskMb = opts.diskMb
212
+ this.noNetwork = opts.noNetwork ?? false
213
+ this.label = `${this.runtime.runtime}:${this.image}${this.mountWorkspace ? '+workspace' : ''}`
214
+ }
215
+
216
+ /**
217
+ * Lazy-starts the container on first call. Reuses on subsequent calls.
218
+ * Synchronous assignment to `this.starting` BEFORE the first await ensures
219
+ * concurrent first-callers all wait on the same Promise (otherwise each
220
+ * read `this.starting === null`, each kicked off `startContainer`, and only
221
+ * the last wrote — leaking N-1 orphan containers).
222
+ */
223
+ private ensureContainer(): Promise<string> {
224
+ if (this.containerId) return Promise.resolve(this.containerId)
225
+ if (this.starting) return this.starting
226
+ const promise = this.startContainer().then(
227
+ id => {
228
+ this.containerId = id
229
+ return id
230
+ },
231
+ err => {
232
+ this.starting = null
233
+ throw err
234
+ },
235
+ )
236
+ this.starting = promise
237
+ return promise
238
+ }
239
+
240
+ private async startContainer(): Promise<string> {
241
+ // Verify the runtime daemon/machine is reachable. Fast-fail with a clear
242
+ // error if not. Podman on macOS needs `podman machine start` once before
243
+ // the API responds.
244
+ try {
245
+ await exec(this.runtime.path, ['version', '--format', '{{.Server.Version}}'], {
246
+ timeout: 5_000,
247
+ })
248
+ } catch (err) {
249
+ const hint =
250
+ this.runtime.runtime === 'podman'
251
+ ? "Run `podman machine start` if you haven't yet."
252
+ : 'Start Docker Desktop.'
253
+ throw new Error(
254
+ `${this.runtime.runtime} daemon unreachable (${(err as Error).message}). ${hint} Or set sandbox.mode='os' / 'none'.`,
255
+ )
256
+ }
257
+
258
+ const runArgs: string[] = [
259
+ 'run',
260
+ '-d',
261
+ '--rm',
262
+ '--label',
263
+ 'nebula-sandbox=1',
264
+ ...HARDENING_ARGS,
265
+ ]
266
+ // Run as host UID so files created in a mounted workspace are owned by
267
+ // the host user. Podman rootless on macOS handles this automatically; we
268
+ // only force --user on docker/podman where the default would be root.
269
+ if (this.runtime.runtime === 'docker') {
270
+ runArgs.push('--user', `${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`)
271
+ }
272
+ // Optional resource caps. `--cpus` and `--memory` work cross-platform.
273
+ // `--storage-opt size` only works on Linux + overlay2 with pquota; we
274
+ // skip it on darwin to match hermes' behavior (silently a no-op there).
275
+ if (this.cpu && this.cpu > 0) runArgs.push('--cpus', String(this.cpu))
276
+ if (this.memoryMb && this.memoryMb > 0) runArgs.push('--memory', `${this.memoryMb}m`)
277
+ if (this.diskMb && this.diskMb > 0 && process.platform !== 'darwin') {
278
+ runArgs.push('--storage-opt', `size=${this.diskMb}m`)
279
+ }
280
+ if (this.noNetwork) runArgs.push('--network=none')
281
+ if (this.mountWorkspace) {
282
+ runArgs.push('-v', `${this.workspaceRoot}:/workspace`)
283
+ runArgs.push('-w', '/workspace')
284
+ }
285
+ // Mount the host's tmpdir READ-ONLY at the same path inside the container
286
+ // so code.execute's host-written snippet (mkdtemp + writeFile happen on
287
+ // host, then `python3 <hostpath>` runs in container) is actually readable.
288
+ // RO so the container can't write back — the container's own /tmp stays
289
+ // isolated and `rm /var/folders/...` from inside fails with EROFS.
290
+ const hostTmp = tmpdir()
291
+ runArgs.push('-v', `${hostTmp}:${hostTmp}:ro`)
292
+ runArgs.push(this.image, 'tail', '-f', '/dev/null')
293
+
294
+ const { stdout } = await exec(this.runtime.path, runArgs, {
295
+ timeout: this.startTimeoutMs,
296
+ })
297
+ const containerId = stdout.toString().trim()
298
+ if (!containerId || containerId.length < 12) {
299
+ throw new Error(
300
+ `${this.runtime.runtime} run returned unexpected output: "${containerId.slice(0, 200)}"`,
301
+ )
302
+ }
303
+ return containerId
304
+ }
305
+
306
+ async wrapSpawn(req: SandboxSpawnRequest): Promise<WrappedSpawn> {
307
+ let containerId = await this.ensureContainer()
308
+ // Self-heal stale cache: container may have died since last call (external
309
+ // `podman kill`, OOM, daemon restart, --rm cleanup after host crash).
310
+ // Probe is rate-limited to ALIVE_PROBE_TTL_MS so the happy path doesn't
311
+ // pay the ~30-70ms inspect tax on every spawn — only the first call after
312
+ // the TTL window pays. Worst case after external kill: one failed exec
313
+ // before the next probe re-spawns.
314
+ const now = Date.now()
315
+ if (now - this.lastAliveProbeMs > DockerBackend.ALIVE_PROBE_TTL_MS) {
316
+ if (!(await this.isContainerAlive(containerId))) {
317
+ this.containerId = null
318
+ this.starting = null
319
+ this.lastAliveProbeMs = 0
320
+ containerId = await this.ensureContainer()
321
+ }
322
+ this.lastAliveProbeMs = Date.now()
323
+ }
324
+ // `exec -i` (interactive stdin), preserve env subset, run inside the
325
+ // container. We pass env explicitly via `-e` rather than relying on
326
+ // container env so redactedEnv from the tool layer actually reaches the
327
+ // inner process.
328
+ const envArgs: string[] = []
329
+ if (req.options.env) {
330
+ for (const [k, v] of Object.entries(req.options.env)) {
331
+ if (typeof v === 'string') envArgs.push('-e', `${k}=${v}`)
332
+ }
333
+ }
334
+ const cwdArg: string[] = []
335
+ if (this.mountWorkspace && req.options.cwd === this.workspaceRoot) {
336
+ cwdArg.push('-w', '/workspace')
337
+ }
338
+ // Strip cwd + env from passed-through options because we redirected both
339
+ // into exec flags. Keep stdio/etc.
340
+ const {
341
+ cwd: _cwd,
342
+ env: _env,
343
+ ...passOptions
344
+ } = req.options as SpawnOptions & {
345
+ cwd?: unknown
346
+ env?: unknown
347
+ }
348
+ return {
349
+ command: this.runtime.path,
350
+ args: ['exec', '-i', ...envArgs, ...cwdArg, containerId, req.command, ...req.args],
351
+ options: passOptions,
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Fast liveness probe. `inspect --format '{{.State.Running}}'` returns
357
+ * "true" / "false" when the container exists, fails non-zero when missing.
358
+ * 3s timeout prevents a wedged daemon from stalling every spawn.
359
+ */
360
+ private async isContainerAlive(id: string): Promise<boolean> {
361
+ try {
362
+ const { stdout } = await exec(
363
+ this.runtime.path,
364
+ ['inspect', '--format', '{{.State.Running}}', id],
365
+ { timeout: 3_000 },
366
+ )
367
+ return stdout.toString().trim() === 'true'
368
+ } catch {
369
+ return false
370
+ }
371
+ }
372
+
373
+ async dispose(): Promise<void> {
374
+ if (!this.containerId) return
375
+ const id = this.containerId
376
+ this.containerId = null
377
+ this.starting = null
378
+ this.lastAliveProbeMs = 0
379
+ try {
380
+ await exec(this.runtime.path, ['kill', id], { timeout: 5_000 })
381
+ } catch {
382
+ // Container may already be dead; --rm cleaned up. Best-effort.
383
+ }
384
+ }
385
+
386
+ envHint(): SandboxEnvHint {
387
+ return {
388
+ mode: 'docker',
389
+ label: this.label,
390
+ innerOs: 'linux',
391
+ workspaceMount: this.mountWorkspace ? '/workspace' : null,
392
+ scope:
393
+ 'shell.run, code.execute, shell.process_start run inside the container; fs.*, browser.*, memory.* run on the host',
394
+ }
395
+ }
396
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Factory: build the right backend for the configured mode + current platform.
3
+ *
4
+ * mode='none' → LocalBackend (passthrough)
5
+ * mode='os' on darwin → MacOSSandboxExecBackend (sandbox-exec wrapper)
6
+ * mode='os' on linux → LocalBackend + warning (bubblewrap impl pending)
7
+ * mode='os' elsewhere → LocalBackend + warning
8
+ * mode='docker' → throws "not yet implemented" (separate bundle)
9
+ *
10
+ * Failure mode: misconfiguration silently degrades to LocalBackend with a
11
+ * stderr warning rather than crashing init. Nebula MUST boot even on
12
+ * unsupported platforms; the sandbox is a defense-in-depth layer, not a hard
13
+ * requirement.
14
+ */
15
+
16
+ import { DockerBackend } from './docker'
17
+ import { LinuxBubblewrapBackend } from './linux'
18
+ import { LocalBackend } from './local'
19
+ import { MacOSSandboxExecBackend } from './macos'
20
+ import type { SandboxBackend, SandboxBackendOpts, SandboxMode } from './types'
21
+
22
+ export interface MakeSandboxOpts extends SandboxBackendOpts {
23
+ mode: SandboxMode
24
+ /** Override platform detection. Defaults to process.platform. Test hook. */
25
+ platform?: NodeJS.Platform
26
+ /** Sink for the platform-fallback warning. Defaults to process.stderr.write. */
27
+ warn?: (msg: string) => void
28
+ /** docker mode: container image override (default `nikolaik/python-nodejs:python3.11-nodejs20`). */
29
+ dockerImage?: string
30
+ /** docker mode: bind-mount workspaceRoot into container at /workspace (default false). */
31
+ dockerMountWorkspace?: boolean
32
+ /** docker mode: force a specific runtime binary path (auto-detect by default). */
33
+ dockerRuntimePath?: string
34
+ /** docker mode: CPU cores cap (`--cpus`). Unset = unlimited. */
35
+ dockerCpu?: number
36
+ /** docker mode: memory cap in MB (`--memory <N>m`). Unset = unlimited. */
37
+ dockerMemoryMb?: number
38
+ /** docker mode: per-container disk cap in MB. Linux+overlay2 only; ignored on darwin. */
39
+ dockerDiskMb?: number
40
+ /** docker mode: block all network from inside container (`--network=none`). Default false. */
41
+ dockerNoNetwork?: boolean
42
+ }
43
+
44
+ export function makeSandboxBackend(opts: MakeSandboxOpts): SandboxBackend {
45
+ const platform = opts.platform ?? process.platform
46
+ const warn = opts.warn ?? ((m: string) => process.stderr.write(m))
47
+
48
+ if (opts.mode === 'none') return new LocalBackend()
49
+
50
+ if (opts.mode === 'docker') {
51
+ try {
52
+ return new DockerBackend({
53
+ agentDir: opts.agentDir,
54
+ workspaceRoot: opts.workspaceRoot,
55
+ homedir: opts.homedir,
56
+ image: opts.dockerImage,
57
+ mountWorkspace: opts.dockerMountWorkspace,
58
+ runtimePath: opts.dockerRuntimePath,
59
+ cpu: opts.dockerCpu,
60
+ memoryMb: opts.dockerMemoryMb,
61
+ diskMb: opts.dockerDiskMb,
62
+ noNetwork: opts.dockerNoNetwork,
63
+ })
64
+ } catch (err) {
65
+ warn(
66
+ `nebula: docker sandbox failed to initialize, falling back to passthrough: ${(err as Error).message}\n`,
67
+ )
68
+ return new LocalBackend()
69
+ }
70
+ }
71
+
72
+ // mode === 'os'
73
+ if (platform === 'darwin') {
74
+ try {
75
+ return new MacOSSandboxExecBackend(opts)
76
+ } catch (err) {
77
+ warn(
78
+ `nebula: macOS sandbox-exec failed to initialize, falling back to passthrough: ${(err as Error).message}\n`,
79
+ )
80
+ return new LocalBackend()
81
+ }
82
+ }
83
+
84
+ if (platform === 'linux') {
85
+ try {
86
+ return new LinuxBubblewrapBackend(opts)
87
+ } catch (err) {
88
+ warn(
89
+ `nebula: linux bubblewrap sandbox failed to initialize, falling back to passthrough: ${(err as Error).message}\n`,
90
+ )
91
+ return new LocalBackend()
92
+ }
93
+ }
94
+
95
+ warn(
96
+ `nebula: sandbox.mode="os" not supported on platform "${platform}", falling back to passthrough\n`,
97
+ )
98
+ return new LocalBackend()
99
+ }
@@ -0,0 +1,15 @@
1
+ export { LocalBackend } from './local'
2
+ export { MacOSSandboxExecBackend } from './macos'
3
+ export { LinuxBubblewrapBackend, buildBwrapArgs } from './linux'
4
+ export { DockerBackend, type DockerBackendOpts } from './docker'
5
+ export { makeSandboxBackend, type MakeSandboxOpts } from './factory'
6
+ export { buildSeatbeltProfile, type SeatbeltProfileOpts } from './seatbelt-profile'
7
+ export type {
8
+ SandboxBackend,
9
+ SandboxBackendOpts,
10
+ SandboxEnvHint,
11
+ SandboxMode,
12
+ SandboxSpawnRequest,
13
+ WrappedSpawn,
14
+ } from './types'
15
+ export { credentialDirs, CREDENTIAL_DIR_RELATIVE_PATHS } from './credentials'