返回 CodeWhale
runtime.ts
根目录 / crates / tui / extension-host / src / runtime.ts
1 /**
2 * Which JavaScript runtime this host runs on, its kernel memory limit on
3 * macOS, and the in-process native-code policy. Everything here runs before
4 * any plugin code loads.
5 *
6 * The host runs on Node (the default) or Bun (an opt-in). Bun emulates
7 * `process.versions.node`, so the runtime is read from `process.versions.bun`
8 * first: `host/hello` must report what is actually running.
9 */
10 import * as nodeModule from 'node:module'
11 import * as vm from 'node:vm'
12
13 const require = nodeModule.createRequire(import.meta.url)
14
15 export interface RuntimeInfo {
16 name: 'bun' | 'node'
17 version: string
18 }
19
20 export const RUNTIME: RuntimeInfo =
21 typeof process.versions.bun === 'string'
22 ? { name: 'bun', version: process.versions.bun }
23 : { name: 'node', version: process.versions.node }
24
25 function refuse(what: string): never {
26 throw new Error(`${what} is not available to extensions`)
27 }
28
29 // ---------------------------------------------------------------------------
30 // Kernel memory limit (macOS, Bun)
31 // ---------------------------------------------------------------------------
32
33 /** Set by the Rust core when it wants this host to limit itself, in MiB. */
34 const LIMIT_REQUEST = 'CODEWHALE_HOST_MEMORY_LIMIT_MIB'
35 /** Set only by the re-exec below, never by the core. */
36 const LIMIT_APPLIED = 'CODEWHALE_HOST_MEMORY_LIMIT_APPLIED'
37 /** Compile-time image mode; never an environment-controlled launch decision. */
38 declare const CODEWHALE_COMPILED_HOST: boolean | undefined
39
40 const POSIX_SPAWN_SETEXEC = 0x0040
41 const POSIX_SPAWN_JETSAM_MEMLIMIT_ACTIVE_FATAL = 0x04
42 const POSIX_SPAWN_JETSAM_MEMLIMIT_INACTIVE_FATAL = 0x08
43 /** `memorystatus_update` reads -1 as "the default jetsam priority". */
44 const JETSAM_PRIORITY_DEFAULT = -1
45
46 /**
47 * Apply the memory limit the core asked for, and return it in MiB once it
48 * holds (or `undefined`).
49 *
50 * macOS has no unprivileged `setrlimit` that bounds memory: `RLIMIT_AS` and
51 * `RLIMIT_DATA` below the current mapping size fail with `EINVAL`. A fatal
52 * per-process jetsam limit does work unprivileged, but only as a
53 * `posix_spawn` attribute (`posix_spawnattr_setjetsam_ext`, libSystem SPI),
54 * and any later `exec` clears it, so the core cannot set it on
55 * `sandbox-exec`. So the host re-executes itself in place
56 * (`POSIX_SPAWN_SETEXEC`: same pid, process group, stdio and Seatbelt
57 * sandbox) with the limit set, before any plugin code runs. Past the limit
58 * the kernel SIGKILLs the host. That needs FFI, so only Bun does this; the
59 * native-code lockdown takes FFI away afterwards (`denyNativeCode`).
60 *
61 * If the limit cannot be applied, the reason goes to stderr and `host/hello`
62 * reports no limit, so the core refuses initialization before plugins load.
63 */
64 export function applyMemoryLimit(): number | undefined {
65 const requested = Number(process.env[LIMIT_REQUEST] ?? '')
66 const applied = process.env[LIMIT_APPLIED]
67 delete process.env[LIMIT_REQUEST]
68 delete process.env[LIMIT_APPLIED]
69 if (!Number.isSafeInteger(requested) || requested <= 0) return undefined
70 if (applied === String(requested)) return requested
71 let failure: string
72 if (applied !== undefined) {
73 failure = `re-exec reported ${applied} MiB, not ${requested}`
74 } else if (RUNTIME.name !== 'bun' || process.platform !== 'darwin') {
75 failure = `only Bun on macOS applies its own limit (this is ${RUNTIME.name} on ${process.platform})`
76 } else {
77 // Returns only if the re-exec failed.
78 failure = execUnderJetsamLimit(requested)
79 }
80 process.stderr.write(`codewhale-extension-host: kernel memory limit not applied: ${failure}\n`)
81 return undefined
82 }
83
84 function execUnderJetsamLimit(mib: number): string {
85 const { dlopen, FFIType, ptr } = require('bun:ffi')
86 let lib: any
87 try {
88 lib = dlopen('/usr/lib/libSystem.B.dylib', {
89 posix_spawnattr_init: { args: [FFIType.ptr], returns: FFIType.i32 },
90 posix_spawnattr_destroy: { args: [FFIType.ptr], returns: FFIType.i32 },
91 posix_spawnattr_setflags: { args: [FFIType.ptr, FFIType.i16], returns: FFIType.i32 },
92 posix_spawnattr_setjetsam_ext: {
93 args: [FFIType.ptr, FFIType.i16, FFIType.i32, FFIType.i32, FFIType.i32],
94 returns: FFIType.i32,
95 },
96 posix_spawn: {
97 args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr],
98 returns: FFIType.i32,
99 },
100 })
101 } catch (error) {
102 return `libSystem: ${(error as Error).message}`
103 }
104 const call = lib.symbols
105 // Every buffer a pointer refers to stays referenced until the call returns.
106 const keep: unknown[] = []
107 const cString = (text: string) => {
108 const bytes = Buffer.from(`${text}\0`)
109 keep.push(bytes)
110 return ptr(bytes)
111 }
112 const cArray = (items: string[]) => {
113 const array = new BigUint64Array(items.length + 1)
114 items.forEach((item, index) => {
115 array[index] = BigInt(cString(item))
116 })
117 keep.push(array)
118 return ptr(array)
119 }
120 const attr = new BigUint64Array(1)
121 let code = call.posix_spawnattr_init(ptr(attr))
122 if (code !== 0) {
123 lib.close()
124 return `posix_spawnattr_init failed (${code})`
125 }
126 try {
127 code = call.posix_spawnattr_setflags(ptr(attr), POSIX_SPAWN_SETEXEC)
128 if (code !== 0) return `posix_spawnattr_setflags failed (${code})`
129 code = call.posix_spawnattr_setjetsam_ext(
130 ptr(attr),
131 POSIX_SPAWN_JETSAM_MEMLIMIT_ACTIVE_FATAL | POSIX_SPAWN_JETSAM_MEMLIMIT_INACTIVE_FATAL,
132 JETSAM_PRIORITY_DEFAULT,
133 mib,
134 mib,
135 )
136 if (code !== 0) return `posix_spawnattr_setjetsam_ext failed (${code})`
137 // A compiled image embeds its entry and its runtime flags. Repassing the
138 // virtual script path or execArgv would turn them into application args.
139 const argv = typeof CODEWHALE_COMPILED_HOST === 'boolean' && CODEWHALE_COMPILED_HOST
140 ? [process.execPath, ...process.argv.slice(2)]
141 : [process.execPath, ...process.execArgv, ...process.argv.slice(1)]
142 const env = { ...process.env, [LIMIT_REQUEST]: String(mib), [LIMIT_APPLIED]: String(mib) }
143 const envp = Object.entries(env)
144 .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
145 .map(([key, value]) => `${key}=${value}`)
146 code = call.posix_spawn(null, cString(process.execPath), null, ptr(attr), cArray(argv), cArray(envp))
147 return `posix_spawn failed (${code})`
148 } finally {
149 call.posix_spawnattr_destroy(ptr(attr))
150 lib.close()
151 }
152 }
153
154 // ---------------------------------------------------------------------------
155 // Native-code policy
156 // ---------------------------------------------------------------------------
157
158 function lockMethod(target: object, name: string, what: string) {
159 Object.defineProperty(target, name, {
160 value: () => refuse(what),
161 writable: false,
162 configurable: false,
163 })
164 }
165
166 /**
167 * Every export becomes a getter that throws, and the object is frozen. A
168 * builtin's export object is shared by `import` and `require`, and Bun builds
169 * a module's ESM namespace from it at the first `import`, so
170 * `import { dlopen } from 'bun:ffi'` fails at link time, and so do
171 * `require('bun:ffi')` and a dynamic `import()`. (`verifyBunLockdown` checks
172 * that this still holds on the running Bun.)
173 */
174 function lockExports(target: Record<string, unknown>, what: string) {
175 for (const name of Object.keys(target)) {
176 Object.defineProperty(target, name, {
177 get: () => refuse(what),
178 enumerable: true,
179 configurable: false,
180 })
181 }
182 Object.freeze(target)
183 }
184
185 /**
186 * Every method and accessor of a class's prototype throws. For a module whose
187 * ESM namespace Bun builds before the host can lock its export object (Bun's
188 * `node:sqlite`): the class stays importable, but nothing can be done with it.
189 */
190 function lockPrototype(target: { prototype: object }, what: string) {
191 const prototype = target.prototype
192 for (const key of Reflect.ownKeys(prototype)) {
193 if (key === 'constructor') continue
194 const accessor = Object.getOwnPropertyDescriptor(prototype, key)
195 Object.defineProperty(
196 prototype,
197 key,
198 accessor?.get || accessor?.set
199 ? { get: () => refuse(what), set: () => refuse(what), configurable: false }
200 : { value: () => refuse(what), writable: false, configurable: false },
201 )
202 }
203 Object.freeze(prototype)
204 }
205
206 /** A specifier esbuild leaves alone and tsc types as `any`. */
207 function importBuiltin(specifier: string): Promise<any> {
208 return import(specifier)
209 }
210
211 /** A builtin module's export object, or `undefined` where this build lacks it. */
212 function builtin(specifier: string): any {
213 try {
214 return require(specifier)
215 } catch {
216 return undefined
217 }
218 }
219
220 /**
221 * Fail the host's start if a lock does not hold on this Bun: the locks rely
222 * on how Bun builds builtin modules, and a newer Bun may build them
223 * differently. `locked` names what `denyNativeCode` found and locked.
224 */
225 async function verifyBunLockdown(locked: Set<string>) {
226 const probes: [string, () => Promise<unknown>][] = [
227 ['`bun:ffi`', () => importBuiltin('bun:ffi').then((ffi) => ffi.dlopen)],
228 ['`Bun.FFI`', async () => (globalThis as any).Bun.FFI.dlopen],
229 ['`bun:sqlite`', () => importBuiltin('bun:sqlite').then((sqlite) => sqlite.Database)],
230 ['`node:sqlite`', () => importBuiltin('node:sqlite').then(({ DatabaseSync }) => new DatabaseSync(':memory:', { open: false }).open())],
231 ]
232 for (const [what, probe] of probes) {
233 if (!locked.has(what)) continue
234 const outcome = await probe().then(
235 () => 'reachable',
236 (error) => String((error as Error)?.message ?? error),
237 )
238 if (outcome !== `${what} is not available to extensions`) {
239 throw new Error(`native-code lockdown does not hold on Bun ${RUNTIME.version}: ${what} (${outcome})`)
240 }
241 }
242 }
243
244 /**
245 * Take in-process native code away from plugins. Call once, after the host
246 * has started its own watchdog Worker and before any plugin loads. The OS
247 * sandbox (Seatbelt on macOS) stays the boundary; this is the loader-level
248 * policy on top of it, and a process a plugin starts is outside it (it runs
249 * under the same sandbox).
250 *
251 * Both runtimes:
252 * - `process.dlopen` loads a shared library in-process (`--no-addons` also
253 * covers it).
254 * - `process.execve` would replace the host image, dropping these flags and
255 * the macOS memory limit.
256 * - Worker threads: a Worker is a new realm. Under Bun it gets a fresh
257 * `bun:ffi` and `Bun.FFI` that the lockdown below never touched; under Node
258 * an explicit `execArgv` starts it without `--no-experimental-ffi` and
259 * `--no-experimental-sqlite`.
260 *
261 * Bun: `bun:ffi` and `Bun.FFI` (`dlopen`, `linkSymbols`, raw pointer reads and
262 * writes); `bun:sqlite` and `node:sqlite` (`setCustomSQLite` and SQLite
263 * extensions load native libraries); and `ShadowRealm`, whose realms import a
264 * fresh `bun:ffi`. The launcher disables ShadowRealm engine-wide
265 * (`BUN_JSC_useShadowRealm=0`, which also covers `node:vm` contexts). The host
266 * refuses to start if ShadowRealm is still there or a lock does not hold.
267 *
268 * Node: `node:ffi` and `node:sqlite` are switched off by launcher flags; the
269 * host refuses to start if either is still a builtin.
270 *
271 * Known limit: this is a list of the entry points found (Bun 1.4, Node 22 and
272 * 26). A native-code entry point a newer runtime adds is not covered until it
273 * is added here.
274 */
275 export async function denyNativeCode() {
276 lockMethod(process, 'dlopen', 'process.dlopen')
277 if (typeof (process as any).execve === 'function') lockMethod(process, 'execve', 'process.execve')
278
279 const denied = function Worker() {
280 refuse('`Worker`')
281 }
282 const threads = require('node:worker_threads')
283 Object.defineProperty(threads, 'Worker', { value: denied, writable: false, configurable: false, enumerable: true })
284 // Node: refresh an ESM namespace created before this patch. Bun has none
285 // yet (the host reads `node:worker_threads` through `require`).
286 ;(nodeModule as any).syncBuiltinESMExports?.()
287 if (typeof (globalThis as any).Worker === 'function') {
288 Object.defineProperty(globalThis, 'Worker', { value: denied, writable: false, configurable: false })
289 }
290
291 if (RUNTIME.name === 'bun') {
292 // A module this Bun build lacks has nothing to lock; every one that
293 // exists is locked and then verified.
294 const locked = new Set<string>()
295 const ffi = builtin('bun:ffi')
296 if (ffi) {
297 lockExports(ffi, '`bun:ffi`')
298 locked.add('`bun:ffi`')
299 }
300 const bun = (globalThis as any).Bun
301 if (bun.FFI) {
302 lockExports(bun.FFI, '`Bun.FFI`')
303 // A non-configurable data property can still be made read-only.
304 Object.defineProperty(bun, 'FFI', { writable: false })
305 locked.add('`Bun.FFI`')
306 }
307 const bunSqlite = builtin('bun:sqlite')
308 if (bunSqlite) {
309 lockExports(bunSqlite, '`bun:sqlite`')
310 locked.add('`bun:sqlite`')
311 }
312 const sqlite = builtin('node:sqlite')
313 if (sqlite) {
314 for (const name of ['DatabaseSync', 'StatementSync', 'Session']) {
315 if (typeof sqlite[name] === 'function') lockPrototype(sqlite[name], '`node:sqlite`')
316 }
317 lockExports(sqlite, '`node:sqlite`')
318 locked.add('`node:sqlite`')
319 }
320 if (typeof (globalThis as any).ShadowRealm !== 'undefined' || vm.runInNewContext('typeof ShadowRealm') !== 'undefined') {
321 throw new Error('ShadowRealm is enabled; the host must run with BUN_JSC_useShadowRealm=0')
322 }
323 await verifyBunLockdown(locked)
324 return
325 }
326 for (const [name, flag] of [
327 ['node:ffi', '--no-experimental-ffi'],
328 ['node:sqlite', '--no-experimental-sqlite'],
329 ]) {
330 if (nodeModule.isBuiltin(name)) throw new Error(`\`${name}\` is enabled; the host must run with ${flag}`)
331 }
332 }
333
333 lines TYPESCRIPT