lyra-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 +65 -0
- package/src/brain/compaction.test.ts +156 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/demo-endpoint.ts +13 -0
- package/src/brain/frozen-prefix.test.ts +235 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.test.ts +129 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +44 -0
- package/src/brain/openai-brain.test.ts +61 -0
- package/src/brain/openai-brain.ts +544 -0
- package/src/brain/sanitize.test.ts +27 -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 +35 -0
- package/src/claude-plugins/discovery.test.ts +71 -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.test.ts +186 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +201 -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.test.ts +43 -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 +13 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +314 -0
- package/src/locks.test.ts +259 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.test.ts +97 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.test.ts +97 -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.test.ts +41 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.test.ts +68 -0
- package/src/memory/encryption.ts +94 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.test.ts +81 -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.test.ts +90 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.test.ts +110 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.test.ts +29 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/path-drift.test.ts +71 -0
- package/src/memory/read-tool-fallback.test.ts +54 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.test.ts +193 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.test.ts +24 -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.test.ts +106 -0
- package/src/migration/option3-crypto.ts +143 -0
- package/src/pairing.test.ts +212 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +105 -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/permission.test.ts +299 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +226 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/plugins.test.ts +196 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.test.ts +70 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +7 -0
- package/src/runtime/runtime.test.ts +55 -0
- package/src/runtime/runtime.ts +126 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +389 -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/sandbox.test.ts +386 -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.test.ts +137 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.test.ts +77 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.test.ts +30 -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.test.ts +29 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.test.ts +348 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.test.ts +70 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.test.ts +63 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.test.ts +41 -0
- package/src/wallet/drain.ts +76 -0
- package/src/wallet/eoa.ts +61 -0
- package/src/wallet/index.ts +14 -0
- package/src/wallet/keystore.test.ts +17 -0
- package/src/wallet/keystore.ts +50 -0
|
@@ -0,0 +1,389 @@
|
|
|
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
|
+
* lyra's RPC/storage/compute/WC traffic to escape the container).
|
|
17
|
+
* - Cold-start cost ~1s on the FIRST tool call after lyra 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 lyra 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; lyra 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[] = ['run', '-d', '--rm', '--label', 'lyra-sandbox=1', ...HARDENING_ARGS]
|
|
259
|
+
// Run as host UID so files created in a mounted workspace are owned by
|
|
260
|
+
// the host user. Podman rootless on macOS handles this automatically; we
|
|
261
|
+
// only force --user on docker/podman where the default would be root.
|
|
262
|
+
if (this.runtime.runtime === 'docker') {
|
|
263
|
+
runArgs.push('--user', `${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`)
|
|
264
|
+
}
|
|
265
|
+
// Optional resource caps. `--cpus` and `--memory` work cross-platform.
|
|
266
|
+
// `--storage-opt size` only works on Linux + overlay2 with pquota; we
|
|
267
|
+
// skip it on darwin to match hermes' behavior (silently a no-op there).
|
|
268
|
+
if (this.cpu && this.cpu > 0) runArgs.push('--cpus', String(this.cpu))
|
|
269
|
+
if (this.memoryMb && this.memoryMb > 0) runArgs.push('--memory', `${this.memoryMb}m`)
|
|
270
|
+
if (this.diskMb && this.diskMb > 0 && process.platform !== 'darwin') {
|
|
271
|
+
runArgs.push('--storage-opt', `size=${this.diskMb}m`)
|
|
272
|
+
}
|
|
273
|
+
if (this.noNetwork) runArgs.push('--network=none')
|
|
274
|
+
if (this.mountWorkspace) {
|
|
275
|
+
runArgs.push('-v', `${this.workspaceRoot}:/workspace`)
|
|
276
|
+
runArgs.push('-w', '/workspace')
|
|
277
|
+
}
|
|
278
|
+
// Mount the host's tmpdir READ-ONLY at the same path inside the container
|
|
279
|
+
// so code.execute's host-written snippet (mkdtemp + writeFile happen on
|
|
280
|
+
// host, then `python3 <hostpath>` runs in container) is actually readable.
|
|
281
|
+
// RO so the container can't write back — the container's own /tmp stays
|
|
282
|
+
// isolated and `rm /var/folders/...` from inside fails with EROFS.
|
|
283
|
+
const hostTmp = tmpdir()
|
|
284
|
+
runArgs.push('-v', `${hostTmp}:${hostTmp}:ro`)
|
|
285
|
+
runArgs.push(this.image, 'tail', '-f', '/dev/null')
|
|
286
|
+
|
|
287
|
+
const { stdout } = await exec(this.runtime.path, runArgs, {
|
|
288
|
+
timeout: this.startTimeoutMs,
|
|
289
|
+
})
|
|
290
|
+
const containerId = stdout.toString().trim()
|
|
291
|
+
if (!containerId || containerId.length < 12) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`${this.runtime.runtime} run returned unexpected output: "${containerId.slice(0, 200)}"`,
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
return containerId
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async wrapSpawn(req: SandboxSpawnRequest): Promise<WrappedSpawn> {
|
|
300
|
+
let containerId = await this.ensureContainer()
|
|
301
|
+
// Self-heal stale cache: container may have died since last call (external
|
|
302
|
+
// `podman kill`, OOM, daemon restart, --rm cleanup after host crash).
|
|
303
|
+
// Probe is rate-limited to ALIVE_PROBE_TTL_MS so the happy path doesn't
|
|
304
|
+
// pay the ~30-70ms inspect tax on every spawn — only the first call after
|
|
305
|
+
// the TTL window pays. Worst case after external kill: one failed exec
|
|
306
|
+
// before the next probe re-spawns.
|
|
307
|
+
const now = Date.now()
|
|
308
|
+
if (now - this.lastAliveProbeMs > DockerBackend.ALIVE_PROBE_TTL_MS) {
|
|
309
|
+
if (!(await this.isContainerAlive(containerId))) {
|
|
310
|
+
this.containerId = null
|
|
311
|
+
this.starting = null
|
|
312
|
+
this.lastAliveProbeMs = 0
|
|
313
|
+
containerId = await this.ensureContainer()
|
|
314
|
+
}
|
|
315
|
+
this.lastAliveProbeMs = Date.now()
|
|
316
|
+
}
|
|
317
|
+
// `exec -i` (interactive stdin), preserve env subset, run inside the
|
|
318
|
+
// container. We pass env explicitly via `-e` rather than relying on
|
|
319
|
+
// container env so redactedEnv from the tool layer actually reaches the
|
|
320
|
+
// inner process.
|
|
321
|
+
const envArgs: string[] = []
|
|
322
|
+
if (req.options.env) {
|
|
323
|
+
for (const [k, v] of Object.entries(req.options.env)) {
|
|
324
|
+
if (typeof v === 'string') envArgs.push('-e', `${k}=${v}`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const cwdArg: string[] = []
|
|
328
|
+
if (this.mountWorkspace && req.options.cwd === this.workspaceRoot) {
|
|
329
|
+
cwdArg.push('-w', '/workspace')
|
|
330
|
+
}
|
|
331
|
+
// Strip cwd + env from passed-through options because we redirected both
|
|
332
|
+
// into exec flags. Keep stdio/etc.
|
|
333
|
+
const {
|
|
334
|
+
cwd: _cwd,
|
|
335
|
+
env: _env,
|
|
336
|
+
...passOptions
|
|
337
|
+
} = req.options as SpawnOptions & {
|
|
338
|
+
cwd?: unknown
|
|
339
|
+
env?: unknown
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
command: this.runtime.path,
|
|
343
|
+
args: ['exec', '-i', ...envArgs, ...cwdArg, containerId, req.command, ...req.args],
|
|
344
|
+
options: passOptions,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Fast liveness probe. `inspect --format '{{.State.Running}}'` returns
|
|
350
|
+
* "true" / "false" when the container exists, fails non-zero when missing.
|
|
351
|
+
* 3s timeout prevents a wedged daemon from stalling every spawn.
|
|
352
|
+
*/
|
|
353
|
+
private async isContainerAlive(id: string): Promise<boolean> {
|
|
354
|
+
try {
|
|
355
|
+
const { stdout } = await exec(
|
|
356
|
+
this.runtime.path,
|
|
357
|
+
['inspect', '--format', '{{.State.Running}}', id],
|
|
358
|
+
{ timeout: 3_000 },
|
|
359
|
+
)
|
|
360
|
+
return stdout.toString().trim() === 'true'
|
|
361
|
+
} catch {
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async dispose(): Promise<void> {
|
|
367
|
+
if (!this.containerId) return
|
|
368
|
+
const id = this.containerId
|
|
369
|
+
this.containerId = null
|
|
370
|
+
this.starting = null
|
|
371
|
+
this.lastAliveProbeMs = 0
|
|
372
|
+
try {
|
|
373
|
+
await exec(this.runtime.path, ['kill', id], { timeout: 5_000 })
|
|
374
|
+
} catch {
|
|
375
|
+
// Container may already be dead; --rm cleaned up. Best-effort.
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
envHint(): SandboxEnvHint {
|
|
380
|
+
return {
|
|
381
|
+
mode: 'docker',
|
|
382
|
+
label: this.label,
|
|
383
|
+
innerOs: 'linux',
|
|
384
|
+
workspaceMount: this.mountWorkspace ? '/workspace' : null,
|
|
385
|
+
scope:
|
|
386
|
+
'shell.run, code.execute, shell.process_start run inside the container; fs.*, browser.*, memory.* run on the host',
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
@@ -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. Lyra 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
|
+
`lyra: 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
|
+
`lyra: 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
|
+
`lyra: 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
|
+
`lyra: 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'
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linux bubblewrap (`bwrap`) backend. Mirrors the macOS sandbox-exec backend
|
|
3
|
+
* for Linux operators. Wraps every tool spawn in:
|
|
4
|
+
*
|
|
5
|
+
* bwrap <profile-args...> <orig-command> <orig-args...>
|
|
6
|
+
*
|
|
7
|
+
* `bwrap` is unprivileged user-namespace sandboxing — the same primitive
|
|
8
|
+
* Flatpak / Bubblewrap / chromium use. No setuid required (kernel must have
|
|
9
|
+
* unprivileged user namespaces enabled, which is the default on every modern
|
|
10
|
+
* distro: Ubuntu 22+, Fedora, Arch, Debian 11+).
|
|
11
|
+
*
|
|
12
|
+
* Profile policy mirrors macOS seatbelt:
|
|
13
|
+
* - read-only bind of / (so commands like `cat`, `ls`, `find` work)
|
|
14
|
+
* - writable bind of agentDir + workspaceRoot
|
|
15
|
+
* - writable bind of /tmp (lyra-* dirs land there)
|
|
16
|
+
* - tmpfs overlay of credential dirs (~/.ssh, ~/.aws, ~/Library/Keychains
|
|
17
|
+
* [doesn't exist on Linux but cheap to include for portability],
|
|
18
|
+
* ~/.config/gcloud) — reads return empty
|
|
19
|
+
* - --unshare-all --share-net keeps network reachable so lyra can still
|
|
20
|
+
* hit Sui RPC, the indexer, etc.
|
|
21
|
+
* - --die-with-parent kills the child if lyra crashes, no zombies
|
|
22
|
+
* - --new-session puts the child in its own session (Ctrl-C from lyra
|
|
23
|
+
* doesn't propagate to the inner command)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync } from 'node:fs'
|
|
27
|
+
import { credentialDirs } from './credentials'
|
|
28
|
+
import type {
|
|
29
|
+
SandboxBackend,
|
|
30
|
+
SandboxBackendOpts,
|
|
31
|
+
SandboxEnvHint,
|
|
32
|
+
SandboxSpawnRequest,
|
|
33
|
+
WrappedSpawn,
|
|
34
|
+
} from './types'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Probe order for bwrap binary. Most distros put it at /usr/bin/bwrap; some
|
|
38
|
+
* via pkg-managed systems at /usr/local/bin. First existing path wins.
|
|
39
|
+
*/
|
|
40
|
+
const BWRAP_CANDIDATES: ReadonlyArray<string> = ['/usr/bin/bwrap', '/usr/local/bin/bwrap']
|
|
41
|
+
|
|
42
|
+
function findBwrap(): string | null {
|
|
43
|
+
for (const path of BWRAP_CANDIDATES) {
|
|
44
|
+
if (existsSync(path)) return path
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the bwrap argv prefix that wraps the user command. Returns a flat
|
|
51
|
+
* argv array; the actual command + args are appended after.
|
|
52
|
+
*/
|
|
53
|
+
export function buildBwrapArgs(opts: SandboxBackendOpts): string[] {
|
|
54
|
+
const args: string[] = []
|
|
55
|
+
|
|
56
|
+
// Base filesystem: read-only bind of /. The container can read system tools
|
|
57
|
+
// (cat, ls, etc.) but cannot modify them. Override specific subdirs below.
|
|
58
|
+
args.push('--ro-bind', '/', '/')
|
|
59
|
+
|
|
60
|
+
// Writable subdirs: agentDir + workspaceRoot + /tmp (for lyra-* test dirs)
|
|
61
|
+
args.push('--bind', opts.agentDir, opts.agentDir)
|
|
62
|
+
args.push('--bind', opts.workspaceRoot, opts.workspaceRoot)
|
|
63
|
+
args.push('--bind', '/tmp', '/tmp')
|
|
64
|
+
|
|
65
|
+
// Optional extra-writable subpaths (test sandbox dirs, custom workspaces).
|
|
66
|
+
if (opts.extraWriteAllow) {
|
|
67
|
+
for (const path of opts.extraWriteAllow) {
|
|
68
|
+
args.push('--bind', path, path)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Credential blackouts: empty tmpfs overlays so reads return ENOENT-equivalent.
|
|
73
|
+
// Shared list with seatbelt-profile.ts to prevent platform drift.
|
|
74
|
+
for (const dir of credentialDirs(opts.homedir)) {
|
|
75
|
+
args.push('--tmpfs', dir)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Optional extra denies via tmpfs.
|
|
79
|
+
if (opts.extraWriteDeny) {
|
|
80
|
+
for (const path of opts.extraWriteDeny) {
|
|
81
|
+
args.push('--tmpfs', path)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// System pseudo-filesystems.
|
|
86
|
+
args.push('--proc', '/proc')
|
|
87
|
+
args.push('--dev', '/dev')
|
|
88
|
+
|
|
89
|
+
// Namespace isolation: unshare everything except network (lyra needs network
|
|
90
|
+
// for Sui RPC, indexer, brain inference). PID namespace isolates process tree
|
|
91
|
+
// from host. UTS namespace gives the sandbox its own hostname.
|
|
92
|
+
args.push('--unshare-all', '--share-net')
|
|
93
|
+
|
|
94
|
+
// Lifetime + signal handling.
|
|
95
|
+
args.push('--die-with-parent') // child dies if lyra dies
|
|
96
|
+
args.push('--new-session') // Ctrl-C from lyra doesn't kill the child directly
|
|
97
|
+
|
|
98
|
+
return args
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class LinuxBubblewrapBackend implements SandboxBackend {
|
|
102
|
+
readonly mode = 'os' as const
|
|
103
|
+
readonly label = 'os:linux'
|
|
104
|
+
private readonly bwrapPath: string
|
|
105
|
+
private readonly bwrapArgs: string[]
|
|
106
|
+
|
|
107
|
+
constructor(opts: SandboxBackendOpts) {
|
|
108
|
+
const path = findBwrap()
|
|
109
|
+
if (!path) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`bwrap not found in ${BWRAP_CANDIDATES.join(', ')}. Linux sandbox backend requires bubblewrap (apt install bubblewrap / dnf install bubblewrap).`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
this.bwrapPath = path
|
|
115
|
+
this.bwrapArgs = buildBwrapArgs(opts)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Test-only accessor for the bwrap argv prefix. */
|
|
119
|
+
getBwrapArgs(): readonly string[] {
|
|
120
|
+
return this.bwrapArgs
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
envHint(): SandboxEnvHint {
|
|
124
|
+
return {
|
|
125
|
+
mode: 'os',
|
|
126
|
+
label: this.label,
|
|
127
|
+
innerOs: 'linux',
|
|
128
|
+
workspaceMount: null,
|
|
129
|
+
scope:
|
|
130
|
+
'shell.run, code.execute, shell.process_start are wrapped in a bubblewrap profile; writes outside agentDir + cwd + /tmp/lyra-* are denied',
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async wrapSpawn(req: SandboxSpawnRequest): Promise<WrappedSpawn> {
|
|
135
|
+
return {
|
|
136
|
+
command: this.bwrapPath,
|
|
137
|
+
args: [...this.bwrapArgs, '--', req.command, ...req.args],
|
|
138
|
+
options: req.options,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passthrough backend. Used when `sandbox.mode = 'none'` (today's default for
|
|
3
|
+
* back-compat) or when the platform doesn't support `os` mode.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SandboxBackend, SandboxSpawnRequest, WrappedSpawn } from './types'
|
|
7
|
+
|
|
8
|
+
export class LocalBackend implements SandboxBackend {
|
|
9
|
+
readonly mode = 'none' as const
|
|
10
|
+
readonly label = 'none'
|
|
11
|
+
|
|
12
|
+
async wrapSpawn(req: SandboxSpawnRequest): Promise<WrappedSpawn> {
|
|
13
|
+
return {
|
|
14
|
+
command: req.command,
|
|
15
|
+
args: req.args,
|
|
16
|
+
options: req.options,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|