返回 CodeWhale
main.ts
根目录 / crates / tui / extension-host / src / main.ts
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
267 lines TYPESCRIPT