| 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 |