| 1 | /** |
| 2 | * codewhale-extension-host entry point. |
| 3 | * |
| 4 | * Boot order matters: stdout is the protocol channel, so it is captured and |
| 5 | * every other writer is rebound to stderr *before* any plugin code can load. |
| 6 | */ |
| 7 | import { createHash } from 'node:crypto' |
| 8 | import { readFileSync } from 'node:fs' |
| 9 | import { createRequire } from 'node:module' |
| 10 | import { fileURLToPath } from 'node:url' |
| 11 | import * as cordis from '@deepseek-ai/cordis' |
| 12 | import * as schemastery from '@deepseek-ai/schemastery' |
| 13 | import * as cosmokit from '@deepseek-ai/cosmokit' |
| 14 | import * as dshUtilValues from '@deepseek-ai/dsh-util-values' |
| 15 | import * as dshToolsCompat from './dsh/dsh-tools-compat.js' |
| 16 | import * as dshComposition from './dsh/composition.ts' |
| 17 | import * as dshLoader from './dsh/upstream/loader/src/index.ts' |
| 18 | import * as dshInclude from './dsh/upstream/include/src/index.ts' |
| 19 | import { installResolveHooks } from './dsh/resolve-hooks.ts' |
| 20 | import { ErrorCode, FrameDecoder, encodeFrame, PROTOCOL_VERSION, type Message, type HarnessRunParams, type McpOpenParams, type McpRequestParams, type McpCloseParams } from './protocol.ts' |
| 21 | import { RpcError, RpcPeer } from './rpc.ts' |
| 22 | import { HostRoot, ownerStorage } from './root.ts' |
| 23 | import { RUNTIME, applyMemoryLimit, denyNativeCode } from './runtime.ts' |
| 24 | import { builtinModuleDigests, parseTier } from './tier.ts' |
| 25 | import { windowsSandboxProbe } from './windows-sandbox-probe.mjs' |
| 26 | |
| 27 | export const HOST_VERSION = '0.1.0' |
| 28 | |
| 29 | // 0a. Which trust tier this process serves (`--tier=plugin|builtin`). Anything |
| 30 | // else is refused before the host does any work. `process.exit` is still |
| 31 | // the real one here; step 2 replaces it. |
| 32 | const TIER = (() => { |
| 33 | try { |
| 34 | return parseTier(process.argv.slice(2)) |
| 35 | } catch (error) { |
| 36 | process.stderr.write(`codewhale-extension-host: ${(error as Error).message}\n`) |
| 37 | return process.exit(64) |
| 38 | } |
| 39 | })() |
| 40 | |
| 41 | // Fixed diagnostic mode is selected only by Core before Native admission. It |
| 42 | // works in the compiled image too, without inventing a second host executable. |
| 43 | if (process.argv.includes('--windows-sandbox-probe')) { |
| 44 | if (process.platform !== 'win32' || TIER !== 'plugin') process.exit(64) |
| 45 | try { |
| 46 | process.stdout.write(JSON.stringify(await windowsSandboxProbe()) + '\n') |
| 47 | process.exit(0) |
| 48 | } catch (error) { |
| 49 | process.stderr.write(`Windows sandbox probe: ${(error as Error).message}\n`) |
| 50 | process.exit(70) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | // 0b. The kernel memory limit the core asked for (macOS + Bun). This may |
| 55 | // re-execute the process in place (argv, and so the tier, are kept), so it |
| 56 | // runs before anything else that has an effect. |
| 57 | const MEMORY_LIMIT_MIB = applyMemoryLimit() |
| 58 | |
| 59 | // 1. Own the protocol channel; rebind every other stdout writer to stderr. |
| 60 | const channelWrite = process.stdout.write.bind(process.stdout) |
| 61 | const stderrWrite = process.stderr.write.bind(process.stderr) |
| 62 | process.stdout.write = ((chunk: any, encoding?: any, callback?: any) => |
| 63 | stderrWrite(chunk, encoding, callback)) as typeof process.stdout.write |
| 64 | for (const method of ['log', 'info', 'debug', 'trace', 'dir'] as const) { |
| 65 | ;(console as any)[method] = (...args: unknown[]) => console.error(...args) |
| 66 | } |
| 67 | |
| 68 | // 2. A plugin bug must not be able to end the host. (Not a security control: |
| 69 | // `process.kill(process.pid)` still works.) |
| 70 | const realExit = process.exit.bind(process) |
| 71 | process.exit = ((code?: number) => { |
| 72 | throw new Error(`process.exit(${code ?? ''}) is not available to extensions`) |
| 73 | }) as typeof process.exit |
| 74 | process.abort = (() => { |
| 75 | throw new Error('process.abort() is not available to extensions') |
| 76 | }) as typeof process.abort |
| 77 | |
| 78 | // 3. One Cordis, one schemastery, one cosmokit for every plugin. |
| 79 | installResolveHooks({ |
| 80 | cordis: cordis as unknown as Record<string, unknown>, |
| 81 | schemastery: schemastery as unknown as Record<string, unknown>, |
| 82 | cosmokit: cosmokit as unknown as Record<string, unknown>, |
| 83 | 'dsh-util-values': dshUtilValues as unknown as Record<string, unknown>, |
| 84 | 'dsh-tools': dshToolsCompat as unknown as Record<string, unknown>, |
| 85 | 'dsh-composition': dshComposition as unknown as Record<string, unknown>, |
| 86 | 'dsh-loader': dshLoader as unknown as Record<string, unknown>, |
| 87 | 'dsh-include': dshInclude as unknown as Record<string, unknown>, |
| 88 | 'dsh-group': { default: dshLoader.Group }, |
| 89 | // `@deepseek-ai/dsh-commands/brand`: the brands are plain strings at runtime. |
| 90 | 'dsh-commands-brand': { CommandDefinitionId: (id: string) => id, CommandId: (id: string) => id }, |
| 91 | }) |
| 92 | |
| 93 | // Only the release compiler defines this constant. Runtime environments never |
| 94 | // supply it: the compiled image still identifies the canonical embedded source. |
| 95 | declare const CODEWHALE_COMPILED_BUNDLE_SHA256: string | undefined |
| 96 | |
| 97 | function bundleDigest(): string { |
| 98 | if (typeof CODEWHALE_COMPILED_BUNDLE_SHA256 === 'string') return CODEWHALE_COMPILED_BUNDLE_SHA256 |
| 99 | try { |
| 100 | return createHash('sha256').update(readFileSync(fileURLToPath(import.meta.url))).digest('hex') |
| 101 | } catch { |
| 102 | return 'unknown' |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // 3b. When the core goes away, take every process this host started with it. |
| 107 | // On Unix the core spawns the host as the leader of its own process group |
| 108 | // and says so; killing the group reaches plugin children (not ones that |
| 109 | // called `setsid`). On Windows the core's Job Object does this when the |
| 110 | // core's handle closes. |
| 111 | const OWN_GROUP = process.platform !== 'win32' && process.env.CODEWHALE_HOST_PROCESS_GROUP === '1' |
| 112 | |
| 113 | function killHostTree(): never { |
| 114 | if (OWN_GROUP) { |
| 115 | try { |
| 116 | process.kill(-process.pid, 'SIGKILL') |
| 117 | } catch { |
| 118 | // Not a group leader after all; exit alone. |
| 119 | } |
| 120 | } |
| 121 | return realExit(0) |
| 122 | } |
| 123 | |
| 124 | // A plugin that blocks the event loop would never see stdin EOF, so a |
| 125 | // watchdog on its own thread notices the parent going away (the host is |
| 126 | // re-parented, which PID reuse cannot fake) and kills the tree from there. |
| 127 | // `require`, not `import`: under Bun an ESM import would fix the module's |
| 128 | // namespace before `denyNativeCode` replaces `Worker` for plugins. |
| 129 | const { Worker } = createRequire(import.meta.url)('node:worker_threads') as typeof import('node:worker_threads') |
| 130 | const watchdog = new Worker( |
| 131 | `const { workerData } = require('node:worker_threads') |
| 132 | const parent = process.ppid |
| 133 | setInterval(() => { |
| 134 | if (process.ppid === parent) return |
| 135 | try { process.kill(workerData.group ? -workerData.pid : workerData.pid, 'SIGKILL') } catch {} |
| 136 | }, 500)`, |
| 137 | // A Worker isolate reserves its own executable code range. On Linux x64 |
| 138 | // with Node 24 that reservation is charged in full to the host's 1 GiB |
| 139 | // RLIMIT_DATA cap, so a default-sized range aborted the host at startup |
| 140 | // ("Failed to reserve virtual memory for CodeRange"). This loop needs |
| 141 | // almost no generated code. |
| 142 | { eval: true, workerData: { pid: process.pid, group: OWN_GROUP }, resourceLimits: { maxOldGenerationSizeMb: 8, codeRangeSizeMb: 16 } }, |
| 143 | ) |
| 144 | watchdog.unref() |
| 145 | |
| 146 | // 3c. No in-process native code for plugins (`runtime.ts`). After the |
| 147 | // watchdog, which is the host's only Worker. |
| 148 | await denyNativeCode() |
| 149 | |
| 150 | function shutdownNow(code: number) { |
| 151 | // Let queued frames flush before exiting. |
| 152 | channelWrite('', () => realExit(code)) |
| 153 | setTimeout(() => realExit(code), 200).unref() |
| 154 | } |
| 155 | |
| 156 | const rpc = new RpcPeer((message: Message) => { |
| 157 | channelWrite(encodeFrame(message)) |
| 158 | }, TIER) |
| 159 | const host = new HostRoot(rpc, TIER) |
| 160 | let initialized = false |
| 161 | |
| 162 | rpc.onRequest('host/initialize', (params: any) => { |
| 163 | if (params.protocol !== PROTOCOL_VERSION) { |
| 164 | stderrWrite(`codewhale-extension-host: core speaks protocol ${params.protocol}, host speaks ${PROTOCOL_VERSION}\n`) |
| 165 | setImmediate(() => realExit(78)) |
| 166 | throw new RpcError(ErrorCode.InvalidParams, `unsupported protocol ${params.protocol}`) |
| 167 | } |
| 168 | initialized = true |
| 169 | setImmediate(() => rpc.notify('host/ready', {})) |
| 170 | return {} |
| 171 | }) |
| 172 | |
| 173 | function requireInitialized() { |
| 174 | if (!initialized) throw new RpcError(ErrorCode.InvalidRequest, 'host is not initialized') |
| 175 | } |
| 176 | |
| 177 | rpc.onRequest('host/ping', () => { |
| 178 | requireInitialized() |
| 179 | return {} |
| 180 | }) |
| 181 | |
| 182 | rpc.onRequest('ext/activate', async (params: any) => { |
| 183 | requireInitialized() |
| 184 | return host.activate(params) |
| 185 | }) |
| 186 | |
| 187 | rpc.onRequest('ext/deactivate', async (params: any) => { |
| 188 | requireInitialized() |
| 189 | return host.deactivate(params.owner, params.entry) |
| 190 | }) |
| 191 | |
| 192 | rpc.onRequest('tool/call', async (params: any, cx) => { |
| 193 | requireInitialized() |
| 194 | return host.callTool(params.handle, params.input, params.call_id, cx.signal, params.workspace, params.ticket, params) |
| 195 | }) |
| 196 | |
| 197 | rpc.onRequest('command/run', async (params: any, cx) => { |
| 198 | requireInitialized() |
| 199 | return host.callCommand(params.handle, params.raw_input, params.command_id, cx.signal, params.workspace, params) |
| 200 | }) |
| 201 | |
| 202 | rpc.onRequest('hook/evaluate', async (params: any, cx) => { |
| 203 | requireInitialized() |
| 204 | return host.evaluateHook(params, cx.signal) |
| 205 | }) |
| 206 | |
| 207 | rpc.onRequest('harness/run', async (params, cx) => { requireInitialized(); return host.harnessRun(params as HarnessRunParams, cx.signal) }) |
| 208 | rpc.onRequest('mcp/open', async (params, cx) => { requireInitialized(); return host.mcpOpen(params as McpOpenParams, cx.signal) }) |
| 209 | rpc.onRequest('mcp/request', async (params, cx) => { requireInitialized(); return host.mcpRequest(params as McpRequestParams, cx.signal) }) |
| 210 | rpc.onRequest('mcp/close', async (params) => { requireInitialized(); return host.mcpClose(params as McpCloseParams) }) |
| 211 | |
| 212 | rpc.onRequest('host/shutdown', async () => { |
| 213 | await host.deactivateAll(2_000) |
| 214 | setImmediate(() => shutdownNow(0)) |
| 215 | return {} |
| 216 | }) |
| 217 | |
| 218 | // 4. Faults are attributed to the owning fiber, which is disposed; the host survives. |
| 219 | function onFault(error: unknown) { |
| 220 | const owner = ownerStorage.getStore() |
| 221 | const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error) |
| 222 | if (owner && owner.state !== 'disposed') { |
| 223 | rpc.notify('ext/faulted', { owner: owner.ref, error: text.slice(0, 4096) }) |
| 224 | void host.disposeOwner(owner).catch(() => undefined) |
| 225 | } else { |
| 226 | host.log('error', `unattributed extension fault: ${text}`) |
| 227 | } |
| 228 | } |
| 229 | process.on('uncaughtException', onFault) |
| 230 | process.on('unhandledRejection', onFault) |
| 231 | |
| 232 | // 5. The channel: stdin EOF means the core is gone, whatever the reason. |
| 233 | const decoder = new FrameDecoder() |
| 234 | process.stdin.on('data', (chunk: Buffer) => { |
| 235 | let messages: unknown[] |
| 236 | try { |
| 237 | messages = decoder.push(chunk) |
| 238 | } catch (error) { |
| 239 | stderrWrite(`codewhale-extension-host: ${(error as Error).message}\n`) |
| 240 | realExit(65) |
| 241 | return |
| 242 | } |
| 243 | for (const message of messages) { |
| 244 | try { |
| 245 | rpc.handle(message) |
| 246 | } catch (error) { |
| 247 | stderrWrite(`codewhale-extension-host: protocol error: ${(error as Error).message}\n`) |
| 248 | realExit(65) |
| 249 | return |
| 250 | } |
| 251 | } |
| 252 | }) |
| 253 | process.stdin.on('end', () => { |
| 254 | rpc.close('core closed the channel') |
| 255 | killHostTree() |
| 256 | }) |
| 257 | |
| 258 | rpc.notify('host/hello', { |
| 259 | protocol: { min: PROTOCOL_VERSION, max: PROTOCOL_VERSION }, |
| 260 | host_version: HOST_VERSION, |
| 261 | bundle_sha256: bundleDigest(), |
| 262 | runtime: RUNTIME, |
| 263 | tier: TIER, |
| 264 | builtin_modules: builtinModuleDigests(), |
| 265 | ...(MEMORY_LIMIT_MIB === undefined ? {} : { memory_limit_mib: MEMORY_LIMIT_MIB }), |
| 266 | }) |
| 267 |