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.
- package/README.md +24 -0
- package/package.json +69 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +43 -0
- package/src/brain/openai-brain.ts +533 -0
- package/src/brain/sanitize.ts +23 -0
- package/src/brain/stub.ts +20 -0
- package/src/brain/types.ts +129 -0
- package/src/chain.ts +75 -0
- package/src/claude-plugins/discovery.ts +152 -0
- package/src/claude-plugins/index.ts +6 -0
- package/src/claude-plugins/types.ts +38 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +213 -0
- package/src/economy/index.ts +6 -0
- package/src/events/index.ts +4 -0
- package/src/events/listeners.ts +37 -0
- package/src/events/queue.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +12 -0
- package/src/identity/agent-card.ts +110 -0
- package/src/identity/deployments.ts +20 -0
- package/src/identity/erc8004.ts +161 -0
- package/src/identity/index.ts +29 -0
- package/src/identity/keystore-blob.ts +60 -0
- package/src/identity/receipt.ts +27 -0
- package/src/identity/stub.ts +29 -0
- package/src/identity/types.ts +20 -0
- package/src/index.ts +372 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.ts +110 -0
- package/src/mcp/stdio-client.ts +154 -0
- package/src/mcp/types.ts +44 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.ts +88 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.ts +99 -0
- package/src/memory/index.ts +58 -0
- package/src/memory/list-tool.ts +105 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.ts +63 -0
- package/src/memory/topic.ts +32 -0
- package/src/memory/types.ts +49 -0
- package/src/migration/index.ts +6 -0
- package/src/migration/option3-crypto.ts +127 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/keychain.ts +53 -0
- package/src/operator/keystore-file.ts +33 -0
- package/src/operator/privkey-base.ts +60 -0
- package/src/operator/raw-privkey.ts +39 -0
- package/src/operator/signer.ts +46 -0
- package/src/operator/walletconnect.ts +454 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +108 -0
- package/src/permission/env-redact.ts +54 -0
- package/src/permission/index.ts +16 -0
- package/src/permission/path-guard.ts +114 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +225 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/runtime.ts +113 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +396 -0
- package/src/sandbox/factory.ts +99 -0
- package/src/sandbox/index.ts +15 -0
- package/src/sandbox/linux.ts +141 -0
- package/src/sandbox/local.ts +19 -0
- package/src/sandbox/macos.ts +71 -0
- package/src/sandbox/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.ts +87 -0
- package/src/storage/factory.ts +31 -0
- package/src/storage/index.ts +11 -0
- package/src/storage/local-stub.ts +70 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.ts +79 -0
- package/src/wallet/eoa.ts +51 -0
- package/src/wallet/index.ts +47 -0
- package/src/wallet/keystore.ts +50 -0
- package/src/wallet/operator-keystore-crypto.ts +530 -0
- 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'
|