返回 CodeWhale
TS_EXTENSION_HOST.md
根目录 / docs / design / TS_EXTENSION_HOST.md
1 # Codewhale TypeScript extension host: design
2
3 > **Repository copy.** This is the design as reviewed on 2026-09-25, copied from
4 > the private release plan (`codewhale-ops/releases/0.10.1/plans-20260925/`) so
5 > the code and its design live together. The "As built" sections below
6 > record where the implementation (`crates/tui/extension-host/`,
7 > `crates/tui/src/extension_host/`, behind `[features] extension_host`) differs
8 > from the text that follows. Where they disagree, the newest "As built"
9 > section and the code are current; the rest is the plan for later phases.
10
11 ## As built: authored prompt sections, owner-local storage and invocation identity (2026-10-02)
12
13 `prompt` and `storage` are host-provided shim services whose replacement is
14 refused. `ctx.prompt.registerSection({id, text})` uses the existing owned
15 registration lifecycle and Rust's reviewed owner registry. Live snapshots are
16 scoped to one Engine attachment, validated against its desired plugin hashes
17 and current Native authority before and after collection, and sorted by
18 owner/id. Rust bounds raw text, section counts and the final attributed block;
19 the existing user-role runtime-message path delivers the complete bounded
20 snapshot at turn boundaries, including explicit withdrawal, without changing
21 the pinned system header or truncating it into workspace context deltas.
22 An author cannot replace the system prompt or choose another session. See
23 `docs/EXTENSIONS.md` for the exact byte/count limits and grammar.
24
25 `ctx.storage.get`, `set` and `delete` expose bounded plain JSON in the directory
26 Rust assigned to that owner. Atomic per-key replacement, corrupt-data refusal, symlink
27 refusal, serialized owner writes and live-owner checks preserve local state.
28 Storage has no session-history or credential access and introduces no protocol
29 method. It remains plugin-local state in the existing directory, rather than
30 another Codewhale session store.
31
32 `tool/call` and `command/run` add optional `session_id`, `agent_id` and
33 `origin_turn_id` wire strings. The host exposes only supplied fields as frozen
34 per-call `sessionId`, `agentId` and `originTurnId`; no identity is cached during
35 activation. A user slash command supplies its known session only. These are
36 labels, not handles or new authority. Older callers can omit every field.
37
38 ## As built: programmable pre-execute mods (2026-10-02)
39
40 `ctx.on('tools/pre-execute', (exec, next) => ...)` now registers an owned `hook` handle through Cordis's `internal/listener` extension point. `hook/evaluate` is core-to-host only, carries a Rust-composed frozen call projection with DSH's `name`, `arguments`, `callId`, and cancellation `signal` spelling, and accepts monotonic `abstain`, `deny`, `ask`, `annotate`, and `revise` proposals. DSH `allow` maps to abstention; unsupported answers fail closed. No runtime, session, agent, invocation ticket, or approval handle is given to a listener.
41
42 `HostAttachment::tool_before_hooks` scopes dispatch to this engine's desired reviewed owners, validates Native authority and owner generation before and after dispatch, and uses the existing owner cancellation and host supervision. Native hook results followed by TypeScript proposals enter `fold_tool_call_before_results`; the existing `reprepare_tool_call_after_hook` and all later policy/approval gates consume the revised arguments. Strict no-verdict semantics cover malformed replies, errors, the bounded five-second listener batch, and revocation. Multiple listeners share the existing deny/ask/last-input/context fold. The real-host acceptance drives native reads and a read-to-write revision through a real Engine, alongside owner withdrawal and workspace isolation.
43
44 This is the bounded pre-execute part of phase 4. It does not implement DSH's around-execution waterfalls or replace its agent/token runtime; `next()` is an abstention rather than tool execution. `prepend`/`global`, post-result transformations, and lifecycle events remain absent. Standalone ACP has no TypeScript attachment; its native hook contract is preserved.
45
46 ## As built: `core/call`, capability tickets and the gate for extension tools (2026-10-02)
47
48 Slice B of the tier plan, and the first thing the host can ask the core to do.
49 Still protocol v1 (a new method and an optional field); corpus `22`, `58`-`66`.
50 User-facing rules: `docs/EXTENSIONS.md`, "Asking the core to run a tool".
51 Nested shell and network calls force a fresh user prompt under the accepted
52 Slice B contract, including semantic action aliases. The separate routing hook
53 remains outstanding; programmable pre-execute proposals do not implement it.
54
55 - **Protocol.** `core/call` (host to core, request, both tiers)
56 `{owner, ticket, name, input}`, answered with the existing `ToolResultWire`;
57 `core_call::` below. New error codes `Refused` (-32002: policy, a limit, an
58 invalid ticket) and `Denied` (-32003: the user declined the card).
59 `tool/call` gains an optional `ticket`. `host_protocol_never_gains_core_authority`
60 has its reviewed row for `core/call` and was not weakened (the method name
61 mentions none of the core-only words); corpus `22` flipped from invalid to
62 valid and was renamed.
63 - **Tickets** (`extension_host/ticket.rs`). A Rust-minted opaque id (244 random
64 bits) indexing a Rust-side row `{kind, tier, host_generation, owner, method,
65 target, expires, uses_left}`. `TicketKind` has one variant, `Invocation`
66 (multi-use, budgeted); a process launch, fetch or MCP grant join with their
67 redeemers. `redeem` checks every field under one mutex (kind, tier, host
68 generation, the whole `OwnerRef`, method, a stated target as parsed JSON,
69 expiry, uses) and refuses an unknown, expired, replayed (exhausted), wrong-owner,
70 wrong-tier, wrong-generation or wrong-method ticket. Which field mismatched is
71 not told to the host. Eight invalid presentations within a minute from one
72 host process are a protocol violation (`HostRequestContext::violation` ends
73 the host through the reader's kill path); a valid ticket merely out of uses
74 does not count. Tickets are never persisted or logged (`Ticket`'s `Debug` is
75 redacted; nothing quotes one) and are revoked when the invocation ends (the
76 guard's `Drop`, so also when the tool call's future is dropped), when the
77 owner is revoked (reconcile, `ext/faulted`) and when the host exits.
78 Backstop expiry is 24 h: an invocation's life is bounded by its own deadline,
79 which pauses while a person decides, so a short ttl would be wrong.
80 - **Where one exists.** `HostToolSpec::execute` mints an `Invocation` ticket only
81 when the tool's context carries a `NestedCallGate` that the turn loop built
82 *for this tool* (`NestedCallGate::for_extension(caller, specs)`; the tool
83 checks the caller matches its own). The turn loop attaches one when a
84 registry spec says `extension_caller()` (a `ToolSpec` method, default
85 `None`), in `execute_tools_with_nested_gate`, the same function that serves an
86 `execute_tools` program's gate. A sub-agent, a test, and a tool nested in
87 `execute_tools` (whose invoker takes the gate from the nested context) have
88 none, nor does any command, timer or activation: the tool gets no `exec.core`
89 and a forged request finds no ticket.
90 - **One gate, one executor.** The turn loop serves the request with
91 `gate_nested_call`, now parameterised by who is asking
92 (`ToolCallSource::Extension`), so planning is `plan_tool_calls` unchanged
93 (budget, allow/deny lists, preparation, hooks, ask-rules, Auto-Review, repo
94 law, the authority envelope, the fleet guard). The caller side is the same
95 `CodemodeInvoker` code mode uses (`for_extension`; `invoke` is now `call` plus
96 a mapping): the concurrency cap, the exclusive/shared order lock, the
97 `PauseClock`, the receipts, the result bounding and spill. What this removed
98 rather than copied: nothing about the executor was duplicated; the only new
99 code in `codemode.rs` is the withdraw plumbing and the `NestedFailure` type
100 that lets a caller see the decision (code mode maps it back to
101 `DriverError`).
102 - **Refused outright** (`core_call::refusal`), before planning on the host's
103 name and again by the turn loop on the name planning resolved and the final
104 input (after a hook rewrite): everything `refusal_before_gate` refuses; any
105 extension tool (`ToolSpec::extension_caller`, found by case-insensitive name
106 and by canonical alias), the caller's own tool included; `mcp_*` and the MCP
107 resource tools, Computer Use included (founder's default: not in v1); tool
108 search and `retrieve_tool_result`; `remember`; and a name table for what
109 changes or schedules beyond the session (`request_plugin_install`, goals,
110 `automation*`, `send_later`, starting MCP servers). Names compare ASCII
111 case-insensitively and by canonical alias.
112 - **Approval** (`core_call::origin_approval`, applied at the end of
113 `plan_tool_calls` for `Extension` and only ever raising). `Required` unless the
114 tool is in `EXT_AUTO_ELIGIBLE` (`read`, `read_file`, `list_dir`, `file_search`,
115 `grep_files`; a test pins each as a registered, read-only, auto-approved tool;
116 the action-based `Git` tool is left out until its read-only actions can be
117 told apart by name) *and* planning found nothing
118 that asks. Shell and network (the registered tool's capabilities and concrete
119 execution-envelope classification, plus the authority categories and `web.run`,
120 `git_fetch`, `finance`, `run_tests`, `verify`, `run_verifiers`, `harness`)
121 set `approval_force_prompt`. The card text is composed in Rust ("Requested by
122 `extension:<plugin>` from inside its tool `<tool>` (core/call): ...") and the
123 audit events say `caller: extension` with the extension and tool. Keys are
124 origin-scoped (`extension_origin_approval_keys`: `extcall:<ext:plugin@hash>:`
125 plus the usual key), so a grant for the model's call never covers an
126 extension's and the reverse.
127 *How "forced" meets the existing postures* (a documented choice, tested in
128 `extension_calls_resolve_against_every_posture_as_documented`): the engine
129 always raises the card with `approval_force_prompt`; what answers it is
130 `resolve_approval_request_disposition`. Ask and Full Access: a modal every
131 time, no session grant consulted. The Engine-minted `extcall:ext:` approval
132 namespace identifies extension-origin calls; host labels cannot select this
133 policy. Explicit session denials still win. Other forced policy holds retain
134 their Full Access refusal. Auto-Review and Never refuse these extension calls
135 as for any hold. Every other call an
136 extension makes (Required, not forced) is, in Full Access, auto-approved as the
137 model's would be, and in Ask promptable and groupable under the extension's
138 own keys.
139 - **Two fixes the plan named.** (1) `await_tool_approval` takes an optional
140 withdraw token (`request_tool_approval_until`; the old name is a wrapper):
141 when it fires the wait ends with a `Cancelled` outcome in the approval log, a
142 status line and a cancelled error, and the call is never decided for the
143 person. The token fires on the host's `$/cancel` of the request, the owner's
144 revocation, the host's exit and the invocation's end (which includes the
145 tool call's deadline and the turn being cancelled); a request withdrawn
146 before the server starts it is dropped unplanned (`NestedCallRequest::is_stale`).
147 (2) `HostProcess::call_with_clock` measures the `tool/call` deadline on the
148 invocation's `PauseClock`, paused while any `core/call` waits on the gate
149 (`call` is the same with a clock nothing pauses), so a person taking a minute
150 on a card does not time the tool out; the deadline is otherwise as before.
151 - **Caps.** Per invocation 50 core calls in all (the ticket's uses), 4 at once
152 (code mode's `MAX_CONCURRENT_CALLS`; a fifth waits for a slot), and one
153 approval card at a time (the turn loop serves one gate request at a time; a
154 second call needing approval waits behind the first); per host 256 requests in
155 flight (A2's table). The call's result metadata gets `core_calls`: at most 50
156 receipts (decision, status, bytes, a note cut at 256 bytes).
157 - **Host.** `exec.core.call(name, input?, {signal?})` (`src/shims/core.ts`),
158 present on a tool's `exec` only when the call carried a ticket (so the frozen
159 `exec`'s keys are unchanged otherwise); it answers `{content, isError,
160 structured?}` or rejects with `CoreCallError` whose `code` is `refused`,
161 `denied`, `cancelled`, `unavailable` or `failed`. The host refuses locally
162 what it must not send (a bad name, input that is not plain JSON). The tool's
163 `exec.signal` and an optional per-call signal send `$/cancel` for the pending
164 `core/call`. The ticket is never exposed to plugin code. No DSH equivalent was
165 found in the vendored DSH surface (DSH reaches tools through its own
166 `ToolRuntime` service, which the host deliberately does not provide), so there
167 is only the Codewhale-native API.
168 - **Tests.** Rust: the ticket table (every field, expiry, exhaustion, burst
169 window, revocation, redaction); spoofing against `serve` (wrong owner, other
170 token, other tier, generation bump, unknown, after revoke/exit/end); the real
171 host through a stand-in gate (read plus receipts, every refusal in many
172 spellings before the gate is asked, no ticket without this tool's extension
173 gate, a command has no `core`, a held approval outliving a 1 s deadline,
174 withdrawal on drop, owner revoke and host kill, 50 total and 4 concurrent);
175 the real turn loop with a fake extension tool (forced prompt in Ask and Full
176 Access with Rust-composed card and extension-scoped keys, read unprompted and
177 write carded, refused calls raise no card, a withdrawn card recorded
178 cancelled, one card at a time) and `await_tool_approval`'s withdraw token on
179 its own; the refusal list and the approval table; the posture matrix; the key
180 scoping in both directions; the corpus and the lint. JS: `exec.core` against a
181 fake core (payload, absence without a ticket, every error code, local refusals,
182 cancel, concurrency).
183
184 Withdrawn calls emit the typed `ApprovalWithdrawn` identity after committing the
185 cancelled outcome. Terminal and runtime clients retire the matching card and
186 continue draining events; a queued allow cannot win over a ready cancellation.
187 Headless Full Access also refuses these forced holds.
188
189 Not done: no per-plugin process (a
190 ticket narrows a frame, it does not isolate plugins sharing the host, section
191 4.4); images and rich content from a core tool are dropped; the refusal table
192 names tools (a new mode/permission tool must be added); no `core/call` from a
193 command, a timer or activation (by design: they return a proposal); `mcp_*`
194 stays out until the MCP move.
195
196 ## As built: the tier in the handshake, method tiers and host requests (2026-10-02)
197
198 Slice A2 of the tier plan. It adds the three things A1 listed as not done that
199 need no new method. Still protocol v1; the TypeScript shapes are regenerated and
200 corpus fixtures `51`-`57` cover the new hello fields.
201
202 - **`host/hello` says what the host is.** It now carries `tier`
203 (`plugin`|`builtin`, the `--tier=` the host was started with) and
204 `builtin_modules`, one `{id, sha256}` row per built-in module source the host
205 *build* embeds, in id order. `build.mjs` builds the built-in modules first and
206 substitutes their digests into the host bundle, so the bundle states them
207 itself and stays deterministic. The core checks both the way it checks the
208 runtime name and version, in `supervisor::check_hello_identity`, and refuses
209 a mismatch before `host/initialize`: a tier other than the one in the launch
210 plan, or a module list that is not exactly the one the Rust table
211 (`tier::BUILTIN_MODULES`, carried on the launch as `builtin_modules`) pins.
212 Both fields are required (a host that omits one is a protocol violation, not
213 a legacy host: the bundle is embedded and always the same build). Today the
214 table and the list are empty. This is drift protection between the two halves
215 of one build, not authentication: a substituted host reports whatever it
216 likes, as `bundle_sha256` already allowed (§4.4, threat 1).
217 - **A tier allow-list per method.** `protocol::MethodSpec` has `tiers`, the
218 trust tiers whose host may send (host to core) or be sent (core to host) the
219 method; `row()` gives both and a reserved method is written
220 `MethodSpec { tiers: &[HostTier::Builtin], ..row(..) }`. It is enforced in both
221 directions on both sides: `admit_in` refuses a frame from a host of the wrong
222 tier like an unknown method (a protocol violation), `HostProcess::start_request`
223 refuses to send a method the host's tier may not receive (`MethodNotFound`,
224 nothing is written), and the host's `validateMessage(.., tier)` refuses both
225 what it would send and what it is sent. The generated `METHODS` table carries
226 `tiers`. No method is reserved yet (the first are the process broker, the
227 fetch proxy and the MCP client: `proc/*`, `net/*`, `mcp/*`; a test pins that
228 any method with those prefixes can never allow the plugin tier); the mechanism
229 is tested with test-only reserved rows in Rust and in the host's tests.
230 - **Host requests are tasks.** The reader no longer answers `registry/*`
231 inline. Each host request is admitted into an id table
232 (`supervisor::InboundRequests`, at most 256 in flight and no id reused in
233 flight, the same bound the host holds itself to; either is a protocol
234 violation that ends the host), runs as its own task through
235 `HostEvents::host_request` (an async trait method whose default answers the
236 registry requests exactly as before) and is answered when it finishes. It is
237 cancelled by the host's `$/cancel {id}` (the answer, if the handler still
238 produces one, is dropped; a cancel for an id not in flight is ignored), by
239 its owner's revocation (`revoke_calls_of`: the host is answered `Cancelled`)
240 and by the host's exit (nobody is answered). A handler gets a
241 `CancellationToken` it should wait on; one that ignores it is abandoned
242 500 ms (`CANCEL_GRACE`) after the cancel, so a cancelled request never holds
243 its slot. The host half: `RpcPeer.request(method, params, signal)` sends
244 `$/cancel` for its id and rejects as cancelled when the signal aborts.
245 - **Tests.** Rust: the hello identity rules (tier, missing, extra, changed and
246 repeated modules), a real host refused for a launched/reported tier mismatch
247 and for a pinned module its bundle lacks, the id table (cancel, late answer,
248 abandoned handler, revoke, exit, cap, id reuse, default registry answers), the
249 tier rule over a reserved test table, the corpus under both tiers, and the
250 generated-TypeScript drift test. JS: the corpus under both tiers, a reserved
251 test row refused to a plugin host in both directions, the hello fields, and
252 `RpcPeer` cancellation.
253
254 Not done: any reserved method (nothing needs the builtin-only tier before the
255 MCP move). The first host request that can be cancelled, `core/call`, is the
256 next section.
257
258 ## As built: trust tiers (2026-10-02)
259
260 Slice A1 of the tier plan (CURRENT_DECISIONS §26 D3, §1.5 above): the host is
261 now two processes' worth of machinery, one per trust tier, and nothing else
262 about it changed. No wire change, no new behaviour for a plugin author. What
263 exists:
264
265 - **Two tiers** (`extension_host/tier.rs`, `HostTier { Plugin, Builtin }`).
266 *Plugin* hosts reviewed third-party plugins; its owner ids are the plugin ids
267 discovery builds (`<scope>/<12 hex>/<name>`). *Builtin* (tier 0) is for
268 Codewhale's own host code and its owner ids are `host:<module>`. The id
269 spaces cannot meet: `OwnerRegistry::begin_owner` takes the tier and refuses a
270 `host:` id on the plugin tier and any other id on the builtin tier, and an
271 authority that does not fit (a plugin owner needs its reviewed plugin
272 authority, a module has none); every owner entry, tool and command
273 registration records its tier. Discovery cannot produce a `host:` id (a
274 manifest name cannot hold `:`, and an id starts with a scope name), a test
275 shows such a plugin failing validation, and `desired_owners` checks the id
276 once more before it reaches the host.
277 - **An empty production table.** `BUILTIN_MODULES` (`BuiltinModule { id,
278 source_sha256, tools: &[Tier0Tool { name, approval }] }`) is empty, so
279 nothing asks for the builtin tier and **it never spawns**; plugins behave
280 exactly as before. "Needed" currently means "has a row". A tier-0 tool's
281 approval comes only from this Rust table (`Auto` or `Required`); an unlisted
282 tool or module is `Required`, and the module's own say changes nothing. It is
283 still never read-only for plan mode. The manager holds the table
284 (`ManagerShared::builtin_modules`, the production const unless a test builds
285 the manager with `with_builtin_modules`), so a test exercises tier 0 without
286 a production row. A module's source is
287 expected at `<bundle dir>/builtin/<module>.mjs` and is activated only if its
288 SHA-256 is the pinned one (otherwise a failed owner with the reason, no host
289 started); nothing materializes such a file yet because there is no module.
290 - **Per-tier supervision.** `ManagerShared` holds two `TierRuntime`s (host
291 slot, generation, spawn count, crash and restart state), lazily spawned. The
292 owner registry, the runtime pin, the attachments and plugin settings stay
293 shared, so both hosts run on the same pinned runtime and one registry says
294 which tier an owner is in. A host's exit, restart, heartbeat and dirty
295 teardown touch only its own tier (`OwnerRegistry::host_exited(tier, ..)`).
296 A host answers `registry/*`, `log` and the like only for owners of its own
297 tier. Lock order, restated at `ManagerShared` and kept: `sync_lock`, then one
298 tier's host slot, then that tier's supervision state, then the registry; no
299 path holds both tiers' slots, and the other locks are leaves.
300 - **Supervisor.** `plan_launch` and the sandbox plan take a tier. The argv ends
301 `<bundle> --tier=plugin|builtin`. The plugin tier's data directory, working
302 directory and writable root are what they always were,
303 `extension-host/data`, so the per-plugin directories under it
304 (`data/plugins/<name>-<hex>`, unchanged and pinned by a test) keep their
305 data. The builtin tier's is **`extension-host/data-builtin`**, a sibling and
306 not `data/builtin`: a child would lie inside the plugin tier's writable root,
307 and the host sandbox has no per-subpath write deny, so plugin code could
308 then write tier-0 state. The plugin tier's read deny list names the builtin
309 directory explicitly (unit test on the path function). A built-in module's
310 own directory is `data-builtin/modules/<module>`. Under bubblewrap a builtin
311 directory that did not exist when the plugin host started cannot be masked;
312 Seatbelt denies it by name before it exists.
313 - **Host.** `--tier=` is parsed before anything else; an unknown value, a bare
314 `--tier` or a tier named twice exits with 64 before `host/hello` (no
315 `--tier` means `plugin`, the least-privileged). `ext/activate` for an owner
316 of the other tier (decided by the `host:` prefix) is refused with
317 `InvalidParams`, before anything is read or loaded. `dist/` was rebuilt.
318 - **Drift check.** `build.mjs` builds each `src/builtin/<id>.ts` to
319 `dist/builtin/<id>.mjs` and writes the SHA-256 of each to
320 `dist/builtin-modules.json` (`{"modules": {}}` today); a Rust test
321 (`tier::tests::table_matches_the_host_build`) fails when `BUILTIN_MODULES` and
322 that file disagree in either direction. A module that would bundle a
323 `node_modules` package fails the build until its licence notice is handled.
324 - **Activation policy** is untouched: v4 and its pinned digest test pass
325 without change, because tier-0 modules are not plugins and no installed
326 plugin is re-reviewed.
327 - **Tests.** Rust: owner/tier refusals and per-tier crash isolation in the
328 registry, a host answering only for its own tier, a `host:`-named manifest
329 failing validation, launch plans (argv, data directories, deny list, the
330 unchanged per-plugin path), the drift test, the table's approval lookup, a
331 tampered or missing module refused with no host started, production never
332 spawning the builtin tier, and (test table) a tier-0 host spawning apart from
333 the plugin host, activating `host:tier0-module`, its tools' approval
334 following the table, a call through it, and a plugin-host crash leaving it
335 alone. JS: refusal of bad `--tier` values, each tier refusing the other's
336 owners, `host:` activation on the builtin tier, and the dist digest file
337 matching `dist/builtin/`.
338
339 Not done, and not claimed: any real tier-0 module (the MCP move, §5, is the
340 first consumer); `host/hello` reporting a tier or module digests and a
341 per-method tier allow-list (done in slice A2, above); capability
342 tickets (landed with their first redeemer, `core/call`, in the section above); embedding or
343 materializing a module's source; a bundled tier-0 executable (D1); a demand
344 predicate that defers the builtin spawn until something needs it; the builtin
345 tier's sandbox denying the plugin tier's data directory (it can read it today);
346 `/plugin` listing the builtin host's modules and tools beyond one status line;
347 tier-0 commands or tools being offered to the model (an engine installs plugin
348 owners' tools only).
349
350 ## As built: plugin context, several entries, input validation, notices (2026-10-01)
351
352 A review of the commands slice found four defects and one gap in what an author
353 can do. This section is what changed; where it disagrees with the older "As
354 built" sections below, this one is current.
355
356 - **Licence notices.** The embedded bundle contains MIT code (cordis,
357 schemastery, cosmokit, dsh-util-values, and verbatim `dsh-tools` excerpts),
358 and its banner promised `LICENSES.txt` beside it, which nothing wrote.
359 `build.mjs` now generates `dist/LICENSES.txt` from the bundler's metafile
360 (every package that contributed an input file, each with its own LICENSE
361 file; a bundled package with none fails the build; the `dsh-tools` excerpt is
362 listed from a named table that the build checks against the source file). The
363 list was hand-kept before and named `@standard-schema/spec`, which is not
364 bundled. The existing `git diff --exit-code -- dist` check covers it. Rust
365 embeds it (`NOTICES`) and `materialize_bundle` writes it beside the bundle in
366 the same digest-named directory, by the same staging, `0400`, rename and
367 read-back as the bundle (so a symlink at its name is replaced, never written
368 through). `THIRD_PARTY_NOTICES.md` lists the packages. Tests: the directory
369 holds exactly the two files with the embedded bytes; and every
370 `node_modules/<pkg>` marker in the *embedded bundle* has a section in
371 `LICENSES.txt` and an entry in `THIRD_PARTY_NOTICES.md`, so the check does not
372 trust the generator.
373 - **Several `native` entries.** The manifest has always accepted
374 `native.paths` (up to 64), and the core sent one `ext/activate` per entry
375 under one owner token, which the host answered with "owner token already
376 active" for the second. No protocol change was needed: the host now accepts a
377 further `ext/activate` for a live owner token if the previous entry finished
378 activating, the plugin matches, and the path is new; each entry is a fiber of
379 the same owner, so deactivation, revocation and crash handling are unchanged.
380 Any failing entry fails the owner and the host disposes the earlier entries'
381 fibers (rolling back their registrations) before answering; the core already
382 drops the owner's registrations. The core merges the cumulative tool and
383 command names of the answers and skips a path the manifest lists twice. Tests
384 (JS and Rust, with `two-entries` and `two-entries-failing` fixtures): both
385 entries live under one owner, one tool callable from each, a repeated or
386 foreign entry refused, one `ext/deactivate` tearing both down, a throwing
387 second entry leaving nothing of the first.
388 - **Input is validated by the core.** `HostToolSpec` checked nothing against
389 the schema a plugin registered, so `additionalProperties: false` in the
390 example was advice (DSH's `defineTool` validates inside the plugin; a raw
391 registration did not). `registry::InputValidator` compiles the schema at
392 registration with the `jsonschema` crate (already linked for Workflow
393 `responseSchema`; it moved from `[dev-dependencies]` to `[dependencies]` of
394 `codewhale-tui`, no new crate and no lockfile change). No `$ref` resolver is
395 enabled, so a reference outside the schema fails to compile and is refused
396 at registration with the reason, as is any invalid schema. `prepare` (before
397 an approval card exists) and `execute` check each call and return
398 `ToolError::InvalidInput` naming the violations (at most five, bounded), so
399 the model can correct itself and the host sees nothing. The deferred-tool
400 first-call check (`deferred_first_call_matches_schema`) is a separate,
401 hand-written shape check of required and known field names; it leaves types
402 to the tool, so it was not the path to reuse. Known limit: the validator
403 runs on the channel's callback thread under the registry lock at
404 registration (a schema is at most 64 KiB); schema `format` assertions follow
405 the draft's own default.
406 - **Plugin context.** Core to host, three optional fields (protocol still v1;
407 an older host ignores them, the generated TypeScript was regenerated):
408 `ext/activate.data_dir`, `tool/call.workspace`, `command/run.workspace`
409 (corpus `46`-`50`). The workspace is `ToolContext::workspace` of the calling
410 session, or the workspace whose user registry loaded the command
411 (`ExtensionCommandRef::workspace`); a path that is not UTF-8 is omitted. A
412 plugin's `exec` is now frozen `{signal, callId, args, workspace?, dataDir?}`
413 and a command invocation gains the same two. `dataDir` is
414 `<home>/extension-host/data/plugins/<name>-<12 hex of sha256(plugin id)>`,
415 created by Rust (0700) before activation: inside the host's single writable
416 root, stable across generations, never deleted. It is not isolation between
417 plugins that share the process (§4.4). No home, credential or other
418 workspace path is passed, though `dataDir` and the entry path do reveal the
419 Codewhale home's location.
420 - **Plugin configuration.** There was no per-plugin config (`config` was always
421 `{}`). `[plugins."<name>".config]` in the *user's* `config.toml`
422 (`config::PluginSettings`, a new field on `Config`; project scope reads an
423 explicit key list that excludes it) becomes the second argument of `apply`.
424 Cordis already validates it against an exported `Config` schema (a
425 schemastery `Schema`) and applies defaults, so a mismatch fails activation
426 with the field named; no host code was needed for that. Rust bounds it
427 (`plugin_config.rs`: objects of plain TOML values, 16 KiB, 16 levels, no
428 date-times) and refuses an over-limit table by failing that plugin's
429 activation with the reason, before the host is asked. Each owner records the
430 digest of the config it was activated with; reconcile treats a changed
431 digest like a changed plugin (revoke, new generation), so settings re-read
432 at `/plugin reload` re-activate exactly the plugins whose table changed. A
433 refused config is hashed from its reason, so it is neither retried every turn
434 nor stuck once fixed. `/plugin show` lists the configured keys (never the
435 values) or the refusal. The example plugin reads one setting through its own
436 `Config` schema.
437 - **Tests.** Rust: settings parse, size/depth/date-time refusal and digest
438 behaviour; the real host with `plugin-context` (settings with the plugin's
439 default filled in, per-call workspace, frozen context, a write into `dataDir`
440 inside the sandbox, a command's workspace, no churn on unchanged settings, a
441 new generation on a change, a schema-refused config, an oversize config, and
442 recovery); `/plugin show` rendering and escaping. JS: the same against the
443 fake core, and the typed example with and without its setting.
444
445 Not done: a running session does not watch `config.toml` (only `/plugin reload`
446 re-reads it); `[plugins]` is not profile-scoped; the review screen shows the
447 config keys through `/plugin show`, not the trust review itself; the `Config`
448 schema is not exported into the plugin's registered tool schema; there is still
449 no storage service beyond the directory; the Linux and Windows runs of the new
450 Rust tests did not happen on this machine.
451
452 ## As built: extension commands (2026-10-01)
453
454 Phase 2's `command/run` landed in `crates/tui/src/extension_host/command.rs`,
455 the registry, the protocol and the host's `src/shims/commands.ts`, mirroring
456 how a tool flows: `registry/register` → owned entry (plugin id + generation,
457 never-reused handle) → adapter → call → teardown.
458
459 - **Protocol (still v1).** `registry/register` takes `kind: "command"`.
460 `RegisterSpecWire` (was `ToolSpecWire`) has `name`, `description`,
461 `input_schema?` and `argument_hint?`; the generator cannot express a
462 per-kind union, so `RegisterParams::check_spec` (a tool needs `input_schema`
463 and takes no hint; a command takes no schema) runs in `parse_host_message`
464 and is mirrored in the host's `validateMessage`, and the corpus holds both
465 (`20`, `37`–`40`). New core→host request `command/run`
466 `{handle, command_id, raw_input, deadline_ms}`; `raw_input` is the argument
467 text after the name, trimmed (the core's slash parser trims; the design's
468 `agent: AgentRef` was not built, the host has no agent or session). Its
469 answer, `CommandResultWire`, is `{kind: "success", text?}`,
470 `{kind: "error", text}` or `{kind: "submit", prompt, text?}` (corpus `41`–`45`).
471 `ext/activate`'s `ok` also reports `commands` (defaulted). The protocol lint
472 (`host_protocol_never_gains_core_authority`) passes unchanged by
473 construction: `command/run` names no event, store, approval, secret,
474 credential, token, auth, turn, loop, session or prompt authority, and has a
475 reviewed row saying why. `protocol.generated.ts` is regenerated by the
476 drift test.
477 - **What a command can return.** What the user-command machinery already does:
478 text shown as a `System` transcript cell (labelled with `/name` and
479 `extension:<plugin>`; escapes stripped, cut at 64 KiB) and/or a prompt sent
480 as the user's next message through the same `SendMessage` path a markdown
481 command's template takes (visible; refused over 128 KiB, never truncated;
482 empty refused). Nothing the host returns makes the core call a tool or the
483 model itself: after a `submit` the turn and tool approval are the ordinary
484 ones.
485 - **Rust.** `OwnerRegistry` holds commands beside tools with the same owner
486 rules; one never-reused handle counter; `remove_registrations_of`,
487 `mark_failed`, `revoke_owner`, `forget_owner`, `host_exited` and
488 `revoke_all` remove an owner's commands with its tools; a stale
489 `unregister` is a no-op. Admission (`register_command`): DSH's grammar
490 (`^[a-z][a-z0-9_-]*$`, ≤ 64), refused if a built-in command or alias (or the
491 fixed `jihua`/`zidong`) answers to the name, or another plugin holds it;
492 descriptions ≤ 1 KiB and hints ≤ 256 bytes, single-line; 64 per owner, 256
493 per host. Commands and tools are separate namespaces. The commands of the
494 owners an engine's workspace snapshot desires, bound to the reviewed hash,
495 are loaded by `UserCommandRegistry::load_extension_commands` into the
496 existing user registry, last, so a user, workspace or manifest markdown
497 command (or a built-in) always wins the spelling, with a load error; a
498 global `command::epoch()` bumped on every registry or attachment change
499 makes the registry reload. Entries carry the owner's reviewed authority, so
500 disabling or untrusting the plugin hides them at once, before reconcile.
501 Dispatch (`try_dispatch`) returns the new `AppAction::RunExtensionCommand`;
502 the UI loop awaits `extension_host::run_command` (like `/balance`).
503 `run` re-checks, immediately before sending, the policy flag, a running
504 host (so a dead host answers `extension host is down: <why>` at once), the
505 exact registration (handle and generation), the reviewed receipt and the
506 `Native` capability, through the same `live_host` the tool path now uses.
507 The call is bounded by `SupervisionOptions::command_run_deadline` (30 s),
508 sent as `deadline_ms`, then `$/cancel`, like every method. Capability: the
509 existing `Native`; no new one and no policy bump (§4.6).
510 - **Host.** `ctx.commands.register(definition)` returns an idempotent
511 disposer and is an effect of the calling fiber. It accepts the Codewhale
512 shape (`argumentHint`, handler may return a string, `{kind: 'submit'}`) and
513 DSH's (`input: {hint}`, `{kind: 'success' | 'error'}`, `rawInput` with its
514 leading separator; `@deepseek-ai/dsh-commands/brand`, the one value import
515 such plugins make, resolves to two identity functions in
516 `src/dsh/resolve-hooks.ts`, and the rest of that package still fails
517 loudly). `commands` moved from "not provided" to provided;
518 plugins still cannot provide it (the root provides its own shim once, frozen
519 like `tools`). `src/shims/owned.ts` holds the register/undo bookkeeping
520 both shims now share; the tool path was moved onto it. `dist/` was rebuilt.
521 - **Tests.** Host: register, each answer kind, cancel, refusal, dispose of
522 one handle versus deactivation, invalid definitions, no provide/rewrite.
523 Rust: registry rules (shadowing, caps, exact undo, revocation, crash),
524 wire shapes, end-to-end through the real host and `commands::execute`
525 (hints, per-workspace visibility, trimmed arguments, DSH `rawInput`,
526 escapes, disable), clash refusal (built-in, other plugin, markdown wins),
527 host-down, deadline cancel, and a killed host with a stale reference after
528 replay.
529
530 Not done: Esc or a keypress cancelling a running command before its deadline
531 (the UI loop awaits it); the Runtime API and GPUI cannot list or run
532 extension commands (`GET /v1/commands` omits them); a clash with a
533 markdown command is resolved at registry load, not refused at registration;
534 `/plugin show`'s localized owner line does not list commands (the `/plugin`
535 host section does); no `agent`/session handle, attachments, `list`/`find`
536 on the DSH surface or `command/run`/`command/done` session events; an
537 extension command does not reset the goal, todos or plan as a markdown command
538 does; a command's `submit` prompt is not marked as plugin-authored beyond the
539 transcript line printed before it; the Bun runtime and Windows/Linux were not
540 run for the Rust tests of this slice.
541
542 ## As built: script tools lose self-approval and shadowing (2026-09-30)
543
544 CURRENT_DECISIONS §26 D4 landed in Rust ahead of phase 4, so R1 below and
545 D9 describe the old behaviour. In `crates/tui/src/tools/plugin.rs` and
546 `ToolRegistry::apply_overrides`: a script's `# approval: auto` is ignored and
547 the tool gets the default a script with no `approval:` line gets (`Suggest`),
548 reported in the runtime log and `/plugin tools`; a `[tools.overrides]`
549 `script` / `command` entry keyed by a built-in is refused, named once in a
550 status line and in the runtime log, and the built-in stays active (`disabled` still works). D9(a) planned
551 `Required`, rememberable per tool: `Suggest` and `Required` resolve the same
552 way in `resolve_tool_permission`, and per-tool remembering was not built.
553
554 ## As built: Bun runtime (2026-09-30)
555
556 The host can run on Bun as an opt-in: `[extension_host] runtime = "node" |
557 "bun" | "auto"`, default `node`. It follows CURRENT_DECISIONS §26 (D1/D2 and
558 the 2026-09-29 update "go to bun asap") and the 2026-09-29 Bun-vs-Node spike.
559 Bun may become the default only after four gates are proven on every
560 platform, followed by an explicit, recorded cutover. The table below is the
561 record, measured on macOS 26.1 arm64 with Bun 1.4.0 and Node 22.20, 24.19 and
562 26.10; there is no Linux or Windows Bun proof, so Node stays the default and
563 the diagnosed fallback. The same embedded bundle runs on either runtime.
564
565 | Gate | How it is closed | Evidence |
566 | --- | --- | --- |
567 | 1. `--no-install` always | `supervisor::runtime_args` passes `--no-install --no-env-file --config=<null device> --no-addons` to Bun | host test against a local recording registry (a control run without the flag does contact it); Rust launch-plan test |
568 | 2. OS-enforced memory cap | Linux `RLIMIT_DATA`; Windows Job Object per-process limit; macOS + Bun: a fatal jetsam limit the host applies to itself | host test (Bun on macOS: `memory_limit_mib` reported, same pid, SIGKILL at 300 MiB); Rust `memory_cap_stops_a_{bun,node}_host` |
569 | 3. FFI policy in the loader | `src/runtime.ts` locks `bun:ffi`, `Bun.FFI`, SQLite, Workers and ShadowRealm; the host refuses to start if a lock does not hold | host test with 13 entry points; each one is reachable when the lockdown is removed |
570 | 4. `host/hello` reports the real runtime | `runtime: {name, version}` from `process.versions.bun` first; the handshake refuses a runtime or version mismatch | corpus 01/21/31–35; Rust handshake-mismatch test; host handshake test |
571
572 - **Selection** (`dependencies::resolve_extension_host_runtime`). Unset
573 means `node`, or `bun` when the table sets only a `bun` path
574 (`ExtensionHostConfig::effective_runtime`). A configured `node`/`bun` path is
575 the only candidate for its runtime and fails resolution with its reason
576 rather than falling through. Otherwise each `bun` on `PATH`, then
577 `$BUN_INSTALL/bin` or `~/.bun/bin` (or each `node` on `PATH`) is run, and
578 the first at or above the floor (Bun **1.4.0**, Node `^22.19 || >=24`) is
579 taken. A searched candidate inside a `node_modules` directory or the working
580 directory is skipped without being run (not applied when the working
581 directory contains the user's home). `auto` tries Bun first and records why
582 Bun was not used when it takes Node. `bun` and `node` try only that runtime
583 and never fall back. For Node the resolver also probes which of
584 `--no-experimental-sqlite` and `--no-experimental-ffi` the binary accepts
585 (`node <flag> --version`).
586 - **Pinned for the process.** The runtime is pinned once a host on it
587 completes the handshake. Every restart reuses it without resolving it
588 again, so a session cannot switch runtime. Under `auto`, a Bun host that
589 fails to launch or handshake before anything is pinned is reported once and
590 Node is resolved for the rest of the session. The handshake refuses a host
591 whose `host/hello` reports a different runtime, or a different version than
592 the pinned probe saw (the binary was replaced mid-session). `codewhale doctor`
593 prints the resolution (what, which version, where, the fallback reason and
594 how the memory cap is enforced). `/plugin` shows `running · pid … · bun
595 1.4.0 · …` and a `runtime:` line with the summary and memory posture.
596 - **Protocol.** `host/hello.node_version` is replaced by
597 `runtime: {name: "bun" | "node", version}`, read from `process.versions.bun`
598 first (Bun emulates `process.versions.node`), plus an optional
599 `memory_limit_mib` (below). The protocol integer stays 1: the bundle and the
600 core always ship together. Corpus: `01`, `21`, `31`–`35`.
601 - **Flags and environment** (`supervisor::runtime_args`, `runtime_env`). Node
602 keeps `--max-old-space-size=256 --disable-proto=throw --no-addons` and gets
603 whichever of `--no-experimental-sqlite --no-experimental-ffi` it accepts.
604 Bun ignores Node's heap and `__proto__` flags, so it gets `--no-install
605 --no-env-file --config=/dev/null --no-addons` and `BUN_JSC_useShadowRealm=0`.
606 Without them Bun fetches a missing package from npm while plugin code runs,
607 and loads `.env` and `bunfig.toml` (which can preload code) from the working
608 directory, which is the host's writable data dir.
609 - **Module resolution** (`src/dsh/resolve-hooks.ts`). Bun has no
610 `module.registerHooks`, and its runtime `onResolve` is not called for bare
611 package names. So the Bun branch uses `Bun.plugin` in three pieces:
612 `build.module` serves each singleton by exact name. `onResolve` classifies
613 the subpaths Bun does pass it. `onLoad` refuses any file under a
614 `node_modules` copy of a peer (a second Cordis, a shipped
615 `@deepseek-ai/dsh-*`). A peer that is not installed at all is mapped from
616 Bun's `Cannot find package` to the same ``requires `X` `` error
617 (`explainImportError`).
618 - **Native code** (`src/runtime.ts`, `denyNativeCode`). The checkpoint only
619 locked the `bun:ffi` export object. Probing found five ways around that:
620 `Bun.FFI` is a separate object with `dlopen`, `linkSymbols` and raw pointer
621 reads and writes; a Web Worker or `worker_threads` Worker is a new realm
622 with an untouched `bun:ffi`; `ShadowRealm#importValue('bun:ffi')` loads a
623 fresh copy, and a `node:vm` context hands out a working `ShadowRealm`
624 constructor even after the global is deleted; `bun:sqlite`
625 `setCustomSQLite` and SQLite extensions `dlopen` arbitrary libraries. Node
626 had gaps of its own: Node 26.10 ships `node:ffi` enabled, `--no-addons` does
627 not cover it or `node:sqlite` extension loading, and a Worker given its own
628 `execArgv` drops `--no-experimental-*`. Now, before any plugin loads:
629 `process.dlopen`, `process.execve` (which would drop the flags and the
630 macOS limit) and Worker threads are refused on both runtimes; under Bun,
631 the `bun:ffi`, `Bun.FFI`, `bun:sqlite` and `node:sqlite` exports are
632 throwing getters (for `node:sqlite`, whose ESM namespace Bun builds early,
633 every prototype method throws) and ShadowRealm is off engine-wide; under
634 Node, the two builtins are switched off by flags. The host then verifies
635 each Bun lock through a real `import()`, and checks ShadowRealm and the Node
636 builtins, and exits with an error rather than run with a lock that does not
637 hold. A process a plugin starts is outside this policy (as under Node);
638 Seatbelt stays the outer boundary on macOS. Known limit: this is a list of
639 the entry points found; one a newer runtime adds is not covered until it is
640 added.
641 - **Memory cap** (1 GiB, `supervisor::MemoryEnforcement`). Linux:
642 `RLIMIT_DATA` between fork and exec (clamped to a lower inherited hard
643 limit), so the kernel fails an allocation past it; measured once in a Linux
644 container (2026-09-29), Node 24 cannot create the watchdog Worker at
645 512 MiB and Bun 1.4 aborts at startup at 256 MiB, and both run at 1 GiB.
646 Windows: the Job Object's per-process limit (`JOB_OBJECT_LIMIT_PROCESS_MEMORY`),
647 set just after spawn when the host joins its job. Both apply to every process
648 a plugin starts as well. macOS: `setrlimit(RLIMIT_AS/RLIMIT_DATA)` below the
649 current mapping size returns `EINVAL`, and `memorystatus_control` returns
650 `EPERM`. A fatal jetsam limit set as a `posix_spawn` attribute
651 (`posix_spawnattr_setjetsam_ext`, libSystem SPI) works unprivileged, but any
652 later `exec` clears it, so the core cannot set it on `sandbox-exec`. The Bun
653 host therefore re-executes itself in place (`POSIX_SPAWN_SETEXEC`: same pid,
654 process group, stdio and Seatbelt sandbox) with the limit, before any plugin
655 loads, using `bun:ffi` before the lockdown takes it away, and reports
656 `memory_limit_mib` in `host/hello`. The core accepts only the value it asked
657 for (`CODEWHALE_HOST_MEMORY_LIMIT_MIB`). Past the limit the kernel SIGKILLs
658 the host; the exit reason reports the SIGKILL and the configured limit but
659 not a cause, since any SIGKILL looks the same. Processes the host starts are not
660 covered. A Bun host that cannot apply the requested kernel limit is refused
661 before initialization, with the reason retained in its stderr and `/plugin`
662 diagnostics. A Node host on macOS is checked at each heartbeat instead,
663 which lags by up to one interval. Node's 256 MB heap
664 flag still applies everywhere.
665 - **Tests.** The host JS suite runs under `node --test` and `bun test`
666 (`npm run test:bun`); each run spawns the host on the runtime running the
667 suite. CI adds a JS-suite Bun leg pinned to 1.4.0 on Linux and keeps the
668 Node leg. Rust covers the selection matrix and flag probe, the default and
669 config rule, the per-runtime launch flags and environment, the runtime and
670 version mismatch refusals, the `auto` fallback when Bun fails to start, a
671 Bun end-to-end run with a crash restart that stays on Bun, and the memory
672 cap: CI runs it with Node on Linux, macOS and Windows; the Bun case has run
673 on macOS only.
674
675 2026-10-02 source checkpoint: `compile-host.mjs` builds the same canonical host
676 with an explicitly supplied local Bun and no runtime download. An adjacent
677 compiled image is optional for Bun/Auto and must report this Engine's exact
678 source digest; Node remains the default. Six local compiled-image/fake-Core
679 cases pass, including both tiers, same-PID macOS jetsam, FFI/Worker refusal,
680 embedded no-install flags and zero registry traffic. This is separate from
681 Rust execution, code signing, release packaging and installed-binary proof.
682
683 Native launch now refuses an absent or failed verified OS wrapper. The pinned
684 Builtin exception remains diagnosed and ticket-bound; it does not close D9.
685 The CI source requires Node and Bun in the Rust three-OS matrix and adds
686 source/compiled-host suites, but these jobs have not run on this source. Linux
687 and Windows isolation/memory receipts, release assets and the four D2 gates
688 remain required before a Bun default cutover. The Linux `RLIMIT_DATA` path
689 clamps to a lower inherited hard limit and reports the configured cap.
690 Reviewed import closures diagnose Bun query/fragment and dynamic CommonJS
691 identity limitations explicitly rather than choosing a different module.
692
693 ## As built: phase 2a supervision (2026-09-29)
694
695 The experimental host now has bounded lifecycle supervision. It uses the same
696 Rust manager, owner registry, attachment snapshots, and approval gate:
697
698 - `host/ping` runs every 3 seconds. A ping unanswered for 3 seconds marks the
699 host **Unresponsive**; after 10 seconds the existing process-tree supervisor
700 kills it. A pong restores Ready only for that generation. Deadlines use a
701 monotonic clock; laptop suspend/resume behavior has not been qualified.
702 - Unexpected exits, protocol violations and hang kills share one crash budget.
703 Below 3 crashes in 5 minutes, the manager waits 250 ms then revalidates current
704 attachments and replays eligible owners with fresh generations and tokens.
705 The third crash stops recovery and retains the failure/stderr diagnostic.
706 Opening another engine and replaying a host never reset this budget.
707 - In-flight tool calls fail with the existing typed unavailable error; they
708 are **never replayed**. Failed/faulted receipts remain suppressed. A crash
709 during the sole activating owner's initialization is attributed to that
710 receipt, so other valid plugins can recover.
711 - Explicit plugin changes/reload clear the crash budget and retry failed
712 receipts through the existing `plugins_changed` path. Start failures also
713 permit a new engine attachment to retry once the one-minute cooldown has
714 elapsed. There is no automatic handshake/start-failure loop.
715 - Old-generation callbacks and recovery tickets cannot mutate a newer host.
716 Planned shutdown is not a crash. Native-entry, staged-byte, persisted-state,
717 approval-grant and platform sandbox rules remain unchanged.
718 - Two incomplete, leaking, malformed or failed teardowns in ten minutes request
719 one planned restart. The existing monitor waits until reconciliation and
720 all non-heartbeat requests are idle, then atomically closes request admission
721 before retiring the process tree. Current valid owners replay with fresh
722 tokens; calls are never replayed. This maintenance neither consumes nor
723 resets the unexpected-crash budget. Late old-process outcomes cannot dirty
724 the replacement.
725
726 The authoring follow-up adds an escaped `/plugin show` owner section with state,
727 live tools and up to 20 recent attributed messages from the bounded 64-entry
728 shared diagnostic ring. Regular `.mts` entries use the same reviewed-byte and
729 discovery rules as `.mjs`/`.js`; Node strips erasable types. The executable
730 [hello extension](../examples/plugins/hello-extension/hello.mts) and
731 [author guide](../EXTENSIONS.md) describe the actual services and trust loop.
732
733 `exec.cwd` remains deferred: the current execution context does not expose the
734 caller's workspace path. Hooks, MCP, `core/call`, and sandbox parity
735 also remain subsequent work (commands landed: see the 2026-10-01 section). The following phase-1 section is its historical
736 receipt, including the earlier lack of heartbeat/restart and `.mts` support.
737
738 ## As built: phase 1 (2026-09-25)
739
740 **Scope matches §8:** tools only, one host per engine process, protocol v1,
741 no `core/call`, no commands, hooks, MCP, heartbeat or auto-restart. The
742 differences from the text below:
743
744 - **An OS sandbox arrived early (§4.5 said phase 5).** On macOS the host runs
745 under Seatbelt with a workspace-write profile rooted at
746 `$CODEWHALE_HOME/extension-host/data`: no direct network, writes only there
747 and in the temp dirs, and no reads of
748 - every entry of the Codewhale homes (runtime home, `~/.codewhale`, legacy
749 `~/.deepseek`) except `extension-host/`, `plugins/` and `builtin-plugins/`.
750 Existing entries are denied by enumeration at launch, and the known stores
751 (`secrets`, `tokens`, `state`, `sessions`, `config.toml.bak`, …) are denied
752 by name even before they exist;
753 - the Codex home (`$CODEX_HOME` / `~/.codex`, holding `auth.json`) and the DSH
754 home (`$DSH_HOME` / `~/.dsh`, holding `.credentials.yaml`);
755 - the default credential-store deny-list (`sandbox::read_guard`).
756
757 **What it does not do:** other user-readable files, including project `.env`
758 files, stay readable (the `.env` filename rule has no Seatbelt subpath form),
759 and Mach services and `exec` are not restricted, so this is defense in depth,
760 not the §4.5 containment. On Linux the same policy runs under bubblewrap
761 when a launch-time probe shows bwrap works; each Codewhale home is masked
762 whole and its readable entries bound again, since bwrap cannot deny a path
763 that does not exist yet (`extension_host::supervisor` lists what that does
764 not cover). Current Native admission refuses missing or failed verified
765 wrappers, including Windows while filesystem/network isolation is absent.
766 Only the pinned Builtin exception can run unsandboxed with its diagnostic;
767 §4.5's full containment and mandatory-host cutover remain open.
768 - **Extension tool names that the approval path keys by name are refused.**
769 Approval keys (`approval_cache`), approval-card summaries and the approval /
770 auto-review category are derived from the tool name. A plugin tool named
771 `web_fetch` would otherwise share `fetch_url`'s `net:<host>` session grant.
772 `registry::core_special_case` probes those classifiers and refuses any name
773 they special-case (`web_fetch`, `exec_wait`, `task_shell_start`, `read_*`,
774 `get_*`, `list_*`, …), in addition to the §3.1 native and prefix rules.
775 - **Teardown of the whole process tree.** The host leads its own process group
776 on Unix (a Job Object on Windows). It kills that group itself at stdin EOF,
777 and a watchdog thread kills it when the host's parent process changes, even
778 while a plugin blocks the event loop. A plugin child that calls `setsid`
779 escapes the group. There is no Rust-side `host/shutdown` in production: the
780 host is shared by every engine in the process and ends with it.
781 - **Revocation on plugin changes is immediate.** `/plugin disable`, `revoke`,
782 `enable` and the runtime API's plugin actions call
783 `extension_host::plugins_changed`, which reconciles the host at once instead
784 of waiting for the next turn.
785 - **Shared-process disclosure (§4.4, threat 3).** When other plugins share the
786 host, the approval card and `/plugin` say so. The tools shim's prototype is
787 frozen, which stops one plugin rewriting `register` for the others; shared
788 globals remain, so the disclosure is the control.
789 - **Core-service refusal does not depend on the caller.** Providing a refused
790 service name through `ctx.root` fails like providing it through `ctx`.
791 - **Node resolution** skips relative `PATH` entries and probes each candidate
792 with an empty environment.
793 - **The host re-hashes only the `native` entry file** before importing it, not
794 the whole package closure (§4.4, threat 2). The rest of the staged snapshot is
795 covered by Rust's per-call receipt check.
796 - **Measured** (this machine, Node 22.20, debug build): spawn + handshake +
797 activation of the DSH fixture 78–95 ms; host RSS 61–63 MB with the watchdog
798 thread (53 MB without it). `hyperfine` was not run.
799 - **DSH references** are pinned to `refs/dsh` commit `00102833`
800 (`0.1.7-alpha.2`); the local checkout's HEAD has since moved to `0d1f50007f`.
801
802 **Phase-1 fixes (2026-09-28).**
803
804 - **Engines attach; they do not own the host.** The host and its owner
805 registry are process-wide, but each engine holds a `HostAttachment` with
806 its own workspace plugin snapshot. Reconcile activates the union of what
807 every attached snapshot desires, re-verifying each snapshot against
808 persisted plugin state (so a disable or revoke through any registry
809 revokes everywhere), and revokes only owners no attachment desires. An
810 engine installs only the tools of owners its own snapshot desires. Engines
811 without a snapshot of their own (isolated chats, the empty fallback) do not
812 attach, and dropping an attachment detaches without revoking. Before this,
813 every engine's `sync` revoked whatever *its* registry did not desire, so
814 two workspaces, or one isolated chat, cancelled each other's in-flight
815 calls. The native-name set is now additive across engines.
816 - **Session grants are bound to the reviewed build (§4.3).** Extension tools
817 key both the exact and the session-grant approval key as
818 `ext:<plugin_id>@<content_hash>:<name>:<hash(input)>`, through the
819 `ToolSpec::approval_scope` hook, so an updated plugin, or another plugin
820 that later takes the same tool name, is asked again. This only narrows
821 grants; widening them is an open decision.
822 - **The native-entry rule is checked at review time.** "One `.mjs` or `.js`
823 file" is one function (`plugins::runtime::native_entry_problem`). With the
824 flag on, discovery reports a violating entry as an error diagnostic, so
825 `/plugin validate` and the review screen fail it, and activation refuses it.
826 - **Code mode suspends extension tools for approval.** Lane 6562 landed
827 (#6583) before phase 1 (#6600), so acceptance 2's code-mode assertion is
828 "suspends for approval": an `execute_tools` call of an extension tool in a
829 main-session turn raises `<call>.<seq>` approval attributed to
830 `extension:<plugin>`, sends no `tool/call` before approval, returns the
831 result on allow and fails only that call on deny. Direct
832 `execute_tools_tool` with no gate still refuses it before any host call.
833 - **The handshake timeout is 30 s**, not the 2 s §1.4 and §8 state
834 (`supervisor::HANDSHAKE_DEADLINE`). 5 s failed on loaded Windows CI with a
835 silent host (a cold `node` start plus an antivirus scan of the freshly
836 materialized bundle), and a miss fails the host for the whole session. The
837 handshake is off the first-prompt path, so 30 s (the MCP stdio handshake
838 budget) costs nothing when the host is healthy.
839 - **A failed host is retried by a new engine, not by `/plugin enable`
840 itself.** In the TUI every plugin change respawns the engine, and
841 `Engine::new` calls `begin_session()`, which resets a failed host. On the
842 runtime-API path a plugin action retries a failed host only when it makes
843 a plugin the process has not yet seen desired; otherwise the host stays
844 failed until a new thread engine starts. Explicit retry is phase-2
845 supervision work.
846 - **`hyperfine` was never run** for acceptance 7; the flag-off guarantees
847 rest on the never-spawned test and the pinned v3 policy digest.
848
849
850 Status: **proposal**, 2026-09-25. Written for the founder direction of that date: move plugins, hooks, commands, custom tools, agent presets and MCP to TypeScript, using the same model as the DSH (DeepSeek Harness) plugin system, and make only that part of Codewhale extensible.
851
852 This stage was read-only. I changed no source, branch, tracker or checkout. The only file written is this one. **Revised the same day after an adversarial review.** §0.1 lists the 13 findings (R1–R13), and the exact phase-1 scope is in §8.
853
854 ## 0. Evidence base and corrections
855
856 | Source | What I actually read |
857 |---|---|
858 | Engine | `/private/tmp/cw-wt-6446`. **HEAD is `8a835d7c4`, not `58b1dd3dd`** as the Rust inventory states. It is two commits past `58b1dd3dd` (#6571, #6483), and neither touches plugins or MCP. Line numbers below were re-checked against `8a835d7c4`. |
859 | Code-mode lane | `feat/code-mode-mcp-6562` (`fda77b64a`, `18241e3e8`, `ebda21f02`), read with `git show` |
860 | DSH | `/Volumes/VIXinSSD/CW/refs/dsh` at `00102833` (`0.1.7-alpha.2`), read with `git show` |
861 | Research inputs | The DSH, RUST and REFS memos embedded in the task. I re-verified the load-bearing claims listed below. |
862
863 **Checks I ran myself** (greps and `git show` only; nothing was built):
864
865 - **Lane code:**
866 - `NestedCallGate { mcp_pool, … }` is at `codemode.rs:200`. `refusal_before_gate` is at :250 and `ungated_admission` at :502. The MCP branch in `execute` is at :533–556.
867 - `gate_nested_call` plans through `plan_tool_calls(…, ToolCallSource::CodeMode)` with `<parent>.<seq>` ids (lane `turn_loop.rs`, around line 4706).
868 - **Feature flags:** `Feature` and `Stage::{Experimental, Beta, Stable}` live in `crates/tui/src/features.rs`, with a `[features]` table in `config.example.toml:1228`.
869 - **Activation policy:** `ACTIVATION_POLICY_VERSION = 3` (`plugins/activation.rs:22`). Its capability set already has a **`Native` capability that is inventoried but inactive** (`activation.rs:93`), and a manifest field `native` with the alias `native_extension` (`manifest.rs:51`).
870 - **Built-in bundles:** `plugins/builtin.rs` embeds `crates/tui/plugins/computer-use` with `include_bytes!` and materializes it under `$CODEWHALE_HOME/builtin-plugins` as a digest-named snapshot. **This is the shipping path the host bundle will reuse.**
871 - **ToolCallSource:** `crates/tools/src/lib.rs:386` has `ToolCallSource { Direct, JsRepl }`. The lane adds a second, private `ToolCallSource { Model, CodeMode }` at `turn_loop.rs:39`.
872 - **MCP boot:** it is already lazy. `tool_selection_covers_server` drives the lazy boot and `tools_always_load` (`mcp.rs:2852`, `config.rs:4863`), `McpServerConfig.required` is at `mcp.rs:571`, and `McpPool::new` hashes the config.
873 - **DSH plugins:**
874 - They import runtime helpers by package name. My grep counted 109 `from '@deepseek-ai/dsh-tools'` imports in `packages/**/src`.
875 - `@deepseek-ai/dsh-mcp-client` spawns stdio servers **itself**, through `StdioClientTransport` from `@modelcontextprotocol/client` 2.0.0 (`transport.ts:10,34`).
876 - The DSH root requires Node `^22.19.0 || >=24.0.0`.
877 - **Local Node:** the first `node` on `PATH` on this machine (`/opt/homebrew/bin/node`, 25.8.0) **fails to start** because `libllhttp.9.3.dylib` is missing. `/usr/local/bin/node` is v22.20.0. Spawning `node -e ''` six times took **96, 23, 22, 27, 31 and 22 ms**, measured with Python `perf_counter` around `subprocess.run`. That is the only measurement in this document.
878
879 **Two facts the plan depends on:**
880 - **Node 20 reached end-of-life on 2026-04-30.** This is from my knowledge, not checked in this session. Adding a *new* runtime dependency with a floor of 20 would ship on an unsupported runtime.
881 - **The local Homebrew breakage is a real failure mode.** The host must resolve Node by *running* it, not by finding it on `PATH`. **Correction (review round 2):** `resolve_node()` (`dependencies.rs:278`) is *not* reusable as is. It runs `node --version` once, only for the first `node` on `PATH`, caches the answer in a process-wide `OnceLock`, and never parses the version. On this machine it would return `None` (Homebrew's broken node comes first), and the host would be unavailable although a working 22.20 is installed. Phase 1 adds a candidate ladder with a version floor (§8).
882
883 ### 0.1 Adversarial review, round 2 (2026-09-25)
884
885 Checked against `/private/tmp/cw-wt-6446` @ `8a835d7c4`, lane `feat/code-mode-mcp-6562` @ `ebda21f02`, and `refs/dsh` @ `00102833` (the checkout's own HEAD has moved on to `0d1f50007f`, so every DSH reference here is pinned to the commit, not to HEAD). Greps and `git show` only; nothing was built or run.
886
887 **Facts that changed the design:**
888
889 | # | Finding | Evidence | Consequence |
890 |---|---|---|---|
891 | R1 | **A second custom-tool system already ships, outside plugin trust.** Scripts in `~/.codewhale/tools/` become model-visible tools on every turn. Each declares its own approval in frontmatter (`# approval: auto`). `ToolRegistry::register` *overwrites* a built-in of the same name with only a `warn!`. `[tools.overrides]` `Script` / `Command` entries replace built-ins on purpose. | `tools/plugin.rs:1-20,111`; `tools/registry.rs:53-63,375-414`; `core/engine.rs:4910,7360-7385` | Today, a script already approves itself and shadows built-ins. The founder's "only the TS host is extensible" requires moving this, so it is added to the deletion plan (§7) and decision D9. The host must not be weaker than this path, and must not copy it either. |
892 | R2 | **Registry tools are the existing seam for extension tools; `ExternalToolDispatch` is not needed in phase 1.** The registry is rebuilt every turn (`build_turn_tool_registry_and_catalog`). Tools outside `DEFAULT_ACTIVE_NATIVE_TOOLS` are deferred by default (`tool_catalog.rs:136-149`). On main, code mode already sees registry tools and refuses the ones that need approval (`codemode.rs:233-238`). The lane gates "native, plugin, or MCP" nested calls with approval suspension (lane `codemode.rs:1-25`). | as cited | Phase-1 extension tools are `ToolSpec` adapters registered next to `configure_plugin_tools`. They get plan mode, the authority envelope, deferral, approval and code-mode gating from code that already exists. **Phase 1 does not depend on the unmerged lane.** `ExternalToolDispatch` is left as an MCP-only interface that the lane may adopt (§5.2). |
893 | R3 | **The lane is not merged.** `origin/main` has code-mode Phase 1 (`e23ce514c`, which runs Auto-only nested calls and refuses MCP). The lane is 3 commits ahead. | `git log origin/main..feat/code-mode-mcp-6562` | Phase-1 acceptance cannot require "gated identically from `execute_tools` with `<parent>.<seq>` ids". On main, the assertion is "refused as needs-approval". Once the lane lands, it is "suspends for approval". **Resolved:** the lane landed first (#6583, then #6600), and the phase-1 fixes assert "suspends for approval" (`execute_tools_gates_an_extension_tool_before_any_host_call`). |
894 | R4 | **The self-declared read-only hint is an auto-approve.** `approval_hint_for` → `TrustedReadOnly` → `ApprovalRequirement::Auto` (`mcp.rs:1265-1274`, `tool_preparation.rs:46-48`, test at `:537-540`). | as cited | If extension tools honoured `presentCall` / `kind: 'read'` (old §4.3), a plugin would switch off approval for its own tools, and those tools run arbitrary Node. **Removed.** Extension tools are always `Required` (§4.3). |
895 | R5 | **Secrets are on disk, readable by any same-user process.** The default secret backend is `~/.codewhale/secrets/` (`crates/secrets/src/lib.rs:64-69`). MCP OAuth tokens are re-read "from the on-disk credential" (`mcp/oauth.rs:749-752`). | as cited | "Tokens never enter Node" and "one gate *even if the host is compromised*" (old §4.4, §5.1) are false until the phase-5 sandbox denies those paths. They are restated as protocol properties, not containment (§4.1, §4.4). |
896 | R6 | **The protocol names a mechanism that does not exist.** No `change:tool_surface` exists anywhere in `crates/tui/src`. | grep | Removed. Per-turn rebuild plus a liveness check at dispatch time is enough (§3.3). |
897 | R7 | **Computer Use is an MCP stdio server (`mcp/server.mjs`), not host code.** `agent.mjs` is the one-shot or `--serve` *remote SSH agent* that runs on another computer. | `crates/tui/plugins/computer-use/{package.json:12, agent.mjs:1-4}` | "Computer Use runs in host #0" (old §1.5) and the phase-2 `agent.mjs` item were wrong, and both are removed. CU stays a separate process. In phase 3 it is spawned by the broker like any stdio server. |
898 | R8 | **The manifest is closed.** `CodewhalePluginExtension` is `deny_unknown_fields` (`agent_plugin.rs:627-653`, test `:1646-1652`). A new `extensions."net.codewhale".host` key makes every *older* build reject the whole plugin. The existing `native` field and `Native` capability are exactly "executable extension, hashed, inventoried, inactive" (`manifest.rs:51,254`, `activation.rs:93`). | as cited | Phase 1 **activates `native`** as the host entry instead of adding `host` and later retiring `Native` (§4.6). Older builds keep loading the plugin's other components and report native as inactive. Only one executable-extension concept ever exists. |
899 | R9 | **One host per "session runtime" multiplies memory.** Sub-agents share the parent's `McpPool` (`tools/subagent/mod.rs:2758,3204`), and the runtime API runs many threads per process. | as cited | With a fan-out of 8, a host per session would cost 8 × ≤80 MB. Changed to **one host per engine process per trust tier**, with registrations scoped per session (§1.5). |
900 | R10 | **CI does not run root `npm test`, and its JS jobs pin Node 20 or 22.** The web job runs `npm test` inside `web/`. The CU and bridge suites use explicit `cd … && npm test` steps (`ci.yml:280-307`). | as cited | "The gate runs it automatically" is true only for the local gate. Phase 1 adds an explicit CI step on Node 22 (§8). Without it, the real-bundle Rust test would skip on CI and nobody would notice. |
901 | R11 | **The phase-1 DSH plugin has no `dsh.bundle.patch`.** Its `schemastery` is a regular *dependency*, not a peer, and the repository has only `src/index.ts`. The installable artefact is the published `lib/index.js`. | `git show 00102833:packages/skill/tool-workspace-dependencies/package.json` | Phase 1 cannot rely on recognising `dsh.bundle.patch`. The fixture is a Codewhale bundle whose `native` entry is a Cordis plugin that loads the vendored DSH `lib/index.js` with literal config. The resolve hook maps `schemastery` whether it is declared as a peer or as a dependency (§6.2). |
902 | R12 | **The phase-3 grant check was a denylist.** It inspected only `tools/call`, `resources/read` and `prompts/get`, so newer MCP methods (tasks, subscriptions, completion) would pass ungated. | old §4.4 | Changed to a default-deny method allowlist, with `arguments` compared as parsed JSON values, not by hashing re-serialized bytes (§4.4). |
903 | R13 | **Phase 1 was too large for one slice.** It combined commands, heartbeat, auto-restart with replay, `dsh.bundle.patch` recognition, a 3-capability policy split, and a dependency on the lane. | old §8 | Cut to tools only. §8 lists the exact scope. |
904
905 **Claims re-verified and left unchanged:** `ACTIVATION_POLICY_VERSION = 3`, and the whole policy (supported plus inactive lists) is hashed into every receipt (`activation.rs:107-122`), so any policy change re-reviews every plugin. `builtin.rs` digest-named materialisation. `ReviewedStdioLaunch` (`mcp.rs:929`; note that on macOS a Node entry is "hash-checked here, then reopened by path"). `HookProcessTree` / `WindowsHookJob` (`hooks/executor.rs:908-990`, private to that module). `McpCatalogBudget` (`mcp.rs:1546`). `PendingAuthorityWatch` (`mcp.rs:1661`). `ToolCallDecision::Allow` no-op (`turn_loop.rs:6364`). `updated_input` re-plan (`turn_loop.rs:3238`). `execute_mcp_tool_with_pool` (`tool_execution.rs:232`). Lane `NestedCallGate` at `:200`, `refusal_before_gate` at `:250`, `ungated_admission` at `:502`, `execute` at `:530`. Lane `ToolCallSource { Model, CodeMode }` at `turn_loop.rs:39`. `crates/tools` `ToolCallSource { Direct, JsRepl }`: its only users are the tests in `crates/tools/tests/parity_tools.rs`. DSH `mcp-client` spawns through `StdioClientTransport` (`transport.ts:10,34`). DSH `engines` is `^22.19.0 || >=24.0.0`. `vendor/cordis` is `4.0.4` with `publishConfig.access: public` (publication itself is still unverified offline).
906
907 ---
908
909 ## 1. Architecture
910
911 ### 1.1 Shape
912
913 ```
914 ┌──────────────────────────── Rust core (closed, authoritative) ─────────────────────────────┐
915 model ─────▶│ turn loop ─ plan_tool_calls ─ hooks fold ─ approval ─ sandbox ─ store ─ prompt ─ credentials │
916 │ ▲ ▲ │
917 │ │ ToolRegistry (HostToolSpec, ph.1) · ExternalToolDispatch (MCP, ph.3) │
918 │ HostSupervisor ── OwnerRegistry(owner, generation, handle) ── ProcessBroker ── FetchProxy │
919 └──────┬──────────────────────────────────────────────────────────────────────────▲──────────┘
920 │ one framed channel (stdin/stdout of the child), typed, versioned │ spawn/kill,
921 ▼ │ egress
922 ┌──────────────── codewhale-extension-host (Node ≥22.19, one process) ───────────────┐ │
923 │ Cordis root ── shim services (tools, commands, skills, systemPrompt*, mcp, logger) │──┘
924 │ ├── plugin fiber A (DSH package) ├── plugin fiber B (Codewhale TS plugin) │
925 │ └── builtin:mcp (official MCP SDK clients, one fiber per server) │
926 └────────────────────────────────────────────────────────────────────────────────────┘
927 ```
928
929 **The host has no turn loop, event store, prompt authority or approval.** Everything it does is one of three things:
930 - *registers* something, which Rust admits or refuses;
931 - *answers* a Rust request (`tool/call`, `command/run`, `hook/evaluate`, `mcp/*`);
932 - *asks* Rust to act (`core/call`, `proc/*`, `net/fetch`, `prompt/propose`), and Rust gates the action exactly as it would gate a model call.
933
934 ### 1.2 Where it lives in the repo
935
936 The package goes at **`crates/tui/extension-host/`**, named `@codewhale/extension-host` with `private: true`. **It is not a root workspace member** (correction): npm ignores a member's own lockfile, and the host needs its own pinned lockfile, as `computer-use` has.
937
938 **Why not `npm/extension-host`?** The bundle has to be `include_bytes!`-embedded by `codewhale-tui`. `builtin.rs` already puts Computer Use under `crates/tui/plugins/` so that the embed path stays inside the crate, which is what `cargo package` / `cargo install` need. The host follows the same precedent.
939
940 **Root `package.json` change:** extend the `test` script the way `web` already is: `npm test --workspaces --if-present && npm --prefix crates/tui/extension-host test && npm --prefix web test`. The local gate then covers the host. **CI does not run root `npm test`** (R10). Phase 1 therefore adds an explicit step to the existing Node-22 JS job in `ci.yml`, next to the Computer Use suites: `(cd crates/tui/extension-host && npm ci && npm test && npm run build && git diff --exit-code dist)`.
941
942 **The tests run against the committed `dist/` bundle and `node:test` only, so `npm test` needs no install.** This keeps the same property the CU and bridge suites have ("npm test works without npm ci", `ci.yml:284-286`). Only the build and drift check need `npm ci`. The package keeps its own `package-lock.json` (as `computer-use` does), so the root lockfile, which carries `wrangler`, is not churned.
943
944 **Layout:**
945
946 ```
947 crates/tui/extension-host/
948 package.json engines.node "^22.19.0 || >=24.0.0"; build + test scripts
949 src/main.ts boot: framing, console rebinding, parent watchdog, crash attribution
950 src/protocol.generated.ts GENERATED from the Rust protocol types (constants, method table, params shapes, types)
951 src/protocol.ts frame codec + envelope checks over the generated shapes
952 src/root.ts Cordis root, shim services, refusal list
953 src/shims/{tools,commands,skills,system-prompt,mcp-resources,logger}.ts
954 src/dsh/{resolve-hooks,profile,dsh-tools-compat}.ts
955 src/mcp/{service,broker-stdio-transport,proxied-fetch}.ts (phase 3)
956 dist/codewhale-extension-host.mjs committed esbuild output, single file, + LICENSES.txt
957 test/*.test.mjs node --test, importing dist/ only (no type stripping, no install)
958 ```
959
960 - **Committed build.** `dist/` is committed so that `cargo build` never needs Node or npm. CI runs `npm run build -w @codewhale/extension-host && git diff --exit-code crates/tui/extension-host/dist`. This is the same guarantee Computer Use gets today by shipping plain `.mjs`.
961 - **Dependencies are bundled** into the one file:
962 - `@deepseek-ai/cordis` 4.0.x
963 - `@deepseek-ai/schemastery`
964 - `cosmokit`
965 - the loader/include pieces we use
966 - later, `@modelcontextprotocol/client` 2.0.0
967
968 They are pinned by exact version plus lockfile integrity. MIT notices go into `dist/LICENSES.txt` and `THIRD_PARTY_NOTICES.md`.
969 - **Unverified: whether `@deepseek-ai/cordis` is published to npm.** DSH depends on it as `workspace:~`, and I had no network access. If it is not published, we vendor `refs/dsh/vendor/{cordis,loader,include}` at `00102833` under `extension-host/vendor/`, keeping the MIT notices and DSH's `vendor/README.md` modification list. Phase 1 has to settle this first.
970
971 ### 1.3 How it ships in every distribution channel
972
973 The host bundle ships the same way as Computer Use. It rides **inside the Rust binary**, so the npm, tarball, `cargo install`, brew, AUR, winget, FreeBSD, nix and Docker channels need no changes.
974
975 At first use, `extension_host::materialize()`:
976 - writes the bundle to `$CODEWHALE_HOME/extension-host/<sha256>/codewhale-extension-host.mjs` (read-only, digest-named, and never overwritten across builds, as `builtin.rs` already does);
977 - launches it with the reviewed-launch discipline from `ReviewedStdioLaunch`: the digest is re-checked on the opened file before exec.
978
979 **Node is the only new external requirement**, and only for users who enable a host plugin or, after phase 3, any MCP server. Per channel:
980
981 | Channel | Node situation | What we do |
982 |---|---|---|
983 | npm (`npm/codewhale`) | Node is present, but its `engines` floor is `>=18` | The host checks its own floor; the npm package floor is unchanged |
984 | brew / tarball / cargo / AUR / winget / FreeBSD | Node is often absent | `codewhale doctor` and `/plugin` report "extension host needs Node ≥22.19 (found: none / 20.x / broken)". Setting `[extension_host] node = "/path/to/node"` overrides discovery |
985 | nix | `nix/package.nix` | Phases 1–2: nothing, because the host is opt-in. Phase 3: add `nodejs_22` to the wrapper's `PATH`, since MCP then needs it. Do not add a runtime closure dependency for an experimental flag |
986 | Docker (`packaging/docker`, `Dockerfile`) | — | Phase 3: install Node 22 in the image |
987 | `cargo install --git` / `--path` | Builds from a checkout | `include_bytes!` reads the committed `dist/`, so no Node is needed to *build*. `codewhale-tui` is not published to crates.io (no `cargo publish` in the release workflows), so there is no crate-size limit to plan around |
988 | Termux / Android | Node 22 is available as a Termux package; unverified on our targets | Doctor diagnostic only |
989
990 **The founder must decide (§9, D1):** whether MCP-only users get a bundled Node runtime or a diagnostic. My recommendation is a diagnostic in phases 1–3, then revisit with install telemetry before MCP is moved by default. That is phase 3's exit gate.
991
992 ### 1.4 Process lifecycle and supervision
993
994 **Lazy spawn.** The host process starts only when one of these first happens:
995 - an enabled, reviewed plugin declares a `native` (host code) entry and the flag is on;
996 - (phase 3+) an MCP connect is needed.
997
998 This follows VS Code's `lazyCreateExtensionHostManager`. The host never blocks the first prompt:
999 - **Phase 1:** the host is spawned at session start, in the background, when an enabled plugin has a `native` entry. Tools it registers join the registry at the next turn's rebuild, deferred, so they are reachable through `tool_search`. The first prompt never waits, and nothing is cached.
1000 - **Phase 3:** MCP tools are advertised from the Rust-side catalog cache (§5.4).
1001 - Either way, tools that arrive late enter through the deferred catalog / `tool_search` or `execute_tools`, so the pinned prefix is never rebuilt.
1002
1003 **Node resolution** (new in phase 1, `dependencies.rs`). `resolve_node_at_least(22, 19)` tries candidates in order:
1004 1. `[extension_host] node`;
1005 2. every `node` / `node.exe` found by scanning `PATH` in order, not only the first;
1006 3. stop at the first candidate whose `--version` both runs and parses at or above the floor.
1007
1008 The result is cached per process, together with the rejected candidates and why each was rejected, for `/plugin` and `codewhale doctor`. The existing `resolve_node()` keeps its contract for `js_execution`. Converging the two is a later cleanup, not phase 1.
1009
1010 **Spawn.** Rust spawns `node --max-old-space-size=256 --disable-proto=throw --no-addons <bundle>`. `--disable-proto=throw` stays only if the phase-1 fixtures run under it: DSH writes `__proto__` keys through `defineProperty` (`dsh-tools` `schema.ts:242`), which it allows, but other packages are unverified.
1011 - `--no-addons` follows decision D6, which recommends refusing native modules in v1. If D6 goes the other way, the flag is dropped for the trust tier that allows them.
1012 - The process is placed in its own process group (Unix) or job object (Windows), reusing the hooks' `HookProcessTree` / `WindowsHookJob`.
1013 - The environment is scrubbed like MCP's `child_env`. **No credentials enter it.**
1014 - `CODEWHALE_HOST_PARENT_PID` carries the parent PID.
1015
1016 **Handshake:** `host/hello` (host → core), then `host/initialize` (core → host), then `host/ready`, all within 2 s. A version mismatch makes the host exit with code 78 (EX_CONFIG) and puts a diagnostic in `/plugin`.
1017
1018 **Watchdogs:**
1019
1020 - **In the host:**
1021 - It exits on **EOF of stdin**, which is the protocol channel. The pipe closes when the core dies for any reason, so this is exact and immune to PID reuse. (Corrected: an earlier draft polled the parent PID every 1 s.) `CODEWHALE_HOST_PARENT_PID` is kept only for diagnostics.
1022 - Global `uncaughtException` / `unhandledRejection` handlers attribute the error to the owning fiber, using Cordis fiber context or an async-context tag set around each invocation. They report `ext/faulted {owner}` and dispose that fiber. The process survives.
1023 - `process.exit` and `process.abort` are replaced with a thrower. This guards against plugin *bugs* that would kill the host. It is not a security control, since `process.kill(process.pid)` still works.
1024 - **In Rust (phase 2; phase 1 relies on per-call deadlines and process exit only):**
1025 - Heartbeat `host/ping` every 3 s. A missed 3 s window marks the host **unresponsive**, which the status line shows. After 10 s the host is killed and restarted.
1026 - Crash tracker: 3 crashes in 5 min stops restarts. The host is then shown as failed with its last stderr. Nothing restarts silently after that.
1027 - **Phase 1 has no auto-restart.** When the host exits, the generation bump below runs, the host is marked *failed* with its stderr tail in `/plugin`, and it respawns only on the next session or on an explicit `/plugin enable`. That is the whole crash story for phase 1, and it cannot loop.
1028
1029 **Restart = generation bump.** When the host dies, Rust does the following, synchronously:
1030 1. Drops every registration the host owned (§3).
1031 2. Fails in-flight `tool/call` / `command/run` / `hook/evaluate` / `mcp/*` with a typed `ToolError::not_available("extension host restarted")`.
1032 3. Kills brokered children.
1033 4. Emits a core status event. The host itself never emits session events.
1034
1035 Rust then respawns the host and replays activations from its own record of which plugins are enabled. Replaying registrations is the plugin's job: its `apply` runs again. Rust keeps no replay log of the host's calls.
1036
1037 **Shutdown.** `host/shutdown` has a 2 s budget; this matches omp's `session_shutdown` cap, so Ctrl-C is never held hostage. After that comes SIGTERM, then SIGKILL of the process group at 3 s.
1038
1039 **Stdout discipline.** The protocol runs on the child's stdin/stdout.
1040 - Before any plugin loads, `main.ts` rebinds `console.*` and replaces `process.stdout.write` with a writer to a log channel on stderr, which Rust tails into its tracing with owner attribution.
1041 - A plugin that writes raw bytes to fd 1 corrupts framing. Rust detects this through bad magic or length, kills the host, and restarts it (counted as a crash).
1042 - I rejected fd 3: Rust's `std::process` cannot pass extra handles on Windows, and the task prefers one transport everywhere.
1043
1044 ### 1.5 Isolation units
1045
1046 - **One process means one trust domain.** Plugins in the same host share a V8 isolate and can monkey-patch each other's globals (§4.4).
1047 - **Default: one host per engine *process* per trust tier**, shared by every session, thread and sub-agent in that process. Registrations carry `RegScope::{Global, Session(id), Agent(id)}`. (Corrected from "one host per session runtime": sub-agents already share the parent's `McpPool` (R9), and a host per session would multiply ≤80 MB by the fan-out.)
1048 - host #0, builtin: only `builtin:mcp` (phase 3) and other first-party host code. **Computer Use is not host code** (R7). It remains an MCP stdio server process that the broker spawns;
1049 - host #1: reviewed third-party plugins.
1050 - Phase 1 runs host #1 only, because no builtin host code exists yet.
1051 - **Phase-3 entry gate:** `builtin:mcp` never shares a process with third-party code. The tier split has to exist before MCP moves.
1052 - **A later option is per-plugin `isolation: "process"`**, the DSH `isolate` analogue for processes, for plugins whose declared capabilities differ sharply from the rest. The supervisor already supports N hosts, because a host is keyed by `HostId`.
1053 - **Cost:** about +40 MB RSS per extra host (estimated, not measured).
1054
1055 ---
1056
1057 ## 2. Protocol
1058
1059 ### 2.1 Transport and framing
1060
1061 - **Framing.** Each frame is a 4-byte magic `CWX1`, then a u32 LE length, then UTF-8 JSON.
1062 - `MAX_FRAME = 32 MiB`, enough for Computer Use screenshots after base64. Anything larger is refused with a typed error, never truncated.
1063 - A length prefix is used rather than NDJSON. Framing errors are detectable, which matters because plugins share the process with the channel (§1.4). It also follows the precedent in Codex `code-mode-protocol/src/host/codec.rs`.
1064 - **Envelope.** JSON-RPC 2.0 (`id`, `method`, `params` / `result` / `error`). It is familiar, and in phase 3 relayed MCP frames sit inside `params` as opaque strings.
1065 - **Limits:**
1066 - At most 256 requests in flight per direction.
1067 - Outgoing queue bounded at 128. When it is full, the host awaits; Rust never blocks the turn loop on the host.
1068 - Request IDs are deduplicated over a window of 4096.
1069
1070 These copy the Codex code-mode-host limits.
1071
1072 ### 2.2 Types and versioning
1073
1074 - **Rust serde types are the source of truth.** They go in `crates/tui/src/extension_host/protocol.rs`, not `crates/protocol`, because the host protocol is private to the engine process and the app-server has no reason to see it.
1075 - Host → core types use `#[serde(deny_unknown_fields, tag = "kind")]`, because host output is untrusted input.
1076 - Core → host types are tolerant.
1077 - **Generated TypeScript.** `schemars` (already a `crates/tui` dependency, derived on the wire types under `cfg(test)`) reads each type's serde shape, and a Rust test (`crates/tui/src/extension_host/protocol/tests.rs`) renders `src/protocol.generated.ts` from it and from the Rust method table (`protocol::METHODS`): constants, error codes, the method table, every params shape the host validates, and the wire types. The file is committed and the test fails on drift (re-record with `CODEWHALE_CONFORMANCE_UPDATE=1`, then rebuild `dist/`); no npm generator is involved. Both sides still parse and round-trip the shared JSON fixture corpus (`crates/tui/tests/fixtures/extension_host/protocol/*.json`).
1078 - **Handshake:**
1079
1080 ```
1081 host → core host/hello {protocol: {min: 1, max: 1}, host_version, bundle_sha256, runtime: {name, version},
1082 tier, builtin_modules: [{id, sha256}], (memory_limit_mib)}
1083 core → host host/initialize {protocol: 1, session_runtime_id, workspace_roots, caps_granted: [...],
1084 limits: {max_frame, max_inflight, hook_deadline_ms, dispose_deadline_ms}}
1085 host → core host/ready {}
1086 ```
1087
1088 - **The bundle digest must equal the one Rust materialized.** On a mismatch the host is refused. This catches a stale or corrupted bundle. It is **not** anti-substitution: a substituted host would simply report the expected digest. The real control is that Rust chooses what to exec (§4.4, threat 1).
1089 - **Capability versioning.** Adding a method means adding an optional capability, so the protocol integer does not change. Changing semantics means `protocol + 1`. The core supports `[n-1, n]` for one release.
1090
1091 ### 2.3 Message catalogue
1092
1093 **Core → host (requests unless noted)**
1094
1095 | Method | Params → Result | Notes |
1096 |---|---|---|
1097 | `host/initialize` | above | |
1098 | `ext/activate` | `{owner: OwnerRef, entry: StagedEntry, config, scope}` → `{ok} \| {failed, diagnostic}` | `OwnerRef = {plugin_id, generation, owner_token}`. `StagedEntry` is the reviewed, hash-bound path. The host re-hashes it before `import()` |
1099 | `ext/deactivate` | `{owner}` → `{disposed, leaked: [..]}` | Returns only after the fiber is quiescent (Cordis `quiesceFiber`) or the deadline passes |
1100 | `tool/call` | `{handle, call_id, parent_call_id, input, deadline_ms, origin}` → `ToolResultWire` | Only for handles Rust registered. The call has **already passed the gate** |
1101 | `command/run` | `{handle, command_id, raw_input, agent: AgentRef}` → `CommandResultWire` | |
1102 | `hook/evaluate` (phase 4) | `{handle, event, payload, deadline_ms}` → `abstain \| deny{reason} \| ask{reason} \| annotate{text} \| revise{input}` | **No `allow` in the type** |
1103 | `mcp/connect`, `mcp/disconnect`, `mcp/list`, `mcp/call`, `mcp/readResource`, `mcp/getPrompt` (phase 3) | §5 | `mcp/call` carries a `grant` |
1104 | `proc/data`, `proc/exit` (notifications) | `{proc, stream, bytes_b64 \| code}` | Broker → host bytes for relayed stdio |
1105 | `$/cancel` (notification) | `{id}` | Cancels any request; the host fires the invocation's `AbortSignal` |
1106 | `host/ping`, `host/shutdown` | | |
1107
1108 **Host → core**
1109
1110 | Method | Params → Result | Notes |
1111 |---|---|---|
1112 | `registry/register` | `{owner, kind, spec}` → `{handle} \| {refused, reason}` | `kind` ∈ `tool, command, skillRoot, promptSection, hook, preset, mcpServer`. Rust checks the owner's reviewed capabilities, name collisions and schema caps |
1113 | `registry/unregister` | `{owner, handle}` → `{}` | Idempotent. A stale handle is a no-op (§3.2) |
1114 | `core/call` | `{owner, invocation_id?, name, input}` → `ToolResultWire \| refused` | Runs a tool **through `plan_tool_calls` + approval**, with `ToolCallSource::Extension` (§4.2) |
1115 | `proc/spawn`, `proc/write`, `proc/closeStdin`, `proc/kill` | | Process broker (phase 3). A spawn needs a Rust-issued `launch_ticket`; the host cannot name arbitrary argv |
1116 | `net/fetch` | `{ticket, method, url, headers, body_b64}` → streamed response | Fetch proxy (phase 3). Rust applies network policy and injects auth |
1117 | `prompt/propose` | `{owner, section_id, text}` → `{accepted \| deferred \| refused}` | Rust decides inclusion and timing: new sessions only, or through a recorded transition |
1118 | `storage/get`, `storage/set` | owner-scoped key/value in the core store | Phase 2+ |
1119 | `ext/faulted` (notification) | `{owner, error}` | |
1120 | `log` (notification) | `{owner?, level, msg}` | |
1121 | `$/progress` (notification) | `{call_id, progress, total?, message?}` | Streaming progress for `tool/call` and `mcp/call`. It resets the Rust-side idle deadline, like opencode's `resetTimeoutOnProgress` |
1122
1123 ### 2.4 Streaming and cancellation
1124
1125 **Streaming.**
1126 - Long results use `$/progress` notifications. The final result is a single frame.
1127 - Streamed `net/fetch` responses (SSE and Streamable HTTP) arrive as `net/chunk {stream, bytes_b64}` notifications, then `net/end`.
1128 - The host applies backpressure with `net/credit {stream, n}`, so Rust never buffers without limit.
1129
1130 **Cancellation is bidirectional and always a notification.**
1131 - **Rust cancels a host call** (turn interrupt, deadline, or revocation within `PendingAuthorityWatch`'s ~50 ms) by sending `$/cancel {id}`. The host aborts the `AbortSignal` passed as `exec.signal` (DSH `ToolExecution.signal`) or `invocation.signal`.
1132 - **Grace period.** Rust waits 500 ms for a response. After that it **resolves the call as cancelled on its own side regardless**, so the turn is never held by the host. A late response is dropped and logged.
1133 - **The host cancels its own `core/call`** with `$/cancel`, and Rust cancels the planned call the way it cancels an interrupted tool.
1134
1135 ---
1136
1137 ## 3. Owned registrations and coordinated async teardown
1138
1139 ### 3.1 Ownership model (Rust side is the authority)
1140
1141 ```rust
1142 struct OwnerKey { plugin_id: PluginId, generation: u64 } // generation bumps on every (re)activation and host restart
1143 struct Registration { handle: RegHandle /* u64, never reused */, owner: OwnerKey, kind: RegKind,
1144 public_name: String, scope: RegScope /* Global | Session(id) | Agent(id) */ }
1145 struct OwnerRegistry { by_handle: HashMap<RegHandle, Registration>, by_owner: HashMap<OwnerKey, BTreeSet<RegHandle>>,
1146 by_name: HashMap<(RegKind, String, RegScope), RegHandle> }
1147 ```
1148
1149 - **Admission.** Each `registry/register` is checked against the owner's **reviewed capability receipt** (`verify_plugin_authority`, which is re-hashed on every dispatch per #6209).
1150 - Phase 1: any registration needs the owner's `Native` capability to be reviewed and supported, via `verify_plugin_component_authority(…, Native)`, and only `kind = "tool"` exists. Later phases add finer capabilities only as they start enforcing them (§4.6). A `mcpServer` registration always needs `McpStdio` / `McpRemote`.
1151 - An unknown capability is refused with a diagnostic, and the row FAILS in the host.
1152 - **Names.**
1153 - A name that collides with a native tool, an MCP tool, another owner's tool or a reserved prefix (`mcp_`, `ext_`, `execute_tools`, `tool_search`, …) is **refused**, never shadowed. omp lets an extension replace built-ins; we do not.
1154 - **This is enforced twice.** Once at `registry/register`, against the static native set. Again at turn build, where the `HostToolSpec` adapters are added *after* natives and `~/.codewhale/tools` scripts, and any name already in the registry is skipped with a diagnostic. The second check is needed because `ToolRegistry::register` silently overwrites a same-name tool (R1).
1155 - Within one owner, re-registering the same name returns a new handle and retires the old one. This matches DSH's `NamedEntries` semantics.
1156 - **Schema caps.** Tool input schemas must be ≤ 64 KiB and description text ≤ 4 KiB. An owner may hold at most 128 tools, and a host at most 1,024. These reuse `McpCatalogBudget` numbers where they exist.
1157
1158 ### 3.2 Exact-entry undo (from DSH `NamedEntries` / `AnonymousEntries`)
1159
1160 - `unregister(handle)` removes exactly one entry. If a newer registration has taken the name, the old handle no longer matches `by_name`. Removing it only deletes its own `by_handle` row and never touches the newer entry.
1161 - In the host, each shim returns a disposer `() => void` that is idempotent and closes over its handle. Cordis runs these disposers in reverse order, either when the fiber unloads or when the plugin calls the disposer early.
1162
1163 ### 3.3 Teardown protocol (Rust revokes first, then asks the host to drain)
1164
1165 When a plugin is disabled, a trust receipt changes, the plugin is uninstalled or updated, or the session ends for session scope:
1166
1167 1. **Rust revokes synchronously.**
1168 - Remove every registration of `OwnerKey` from the catalog.
1169 - Future dispatches fail with `not_available`. `HostToolSpec::execute` re-checks that its handle and generation are still admitted before sending `tool/call`, which covers a revocation in the middle of a turn.
1170 - In-flight calls get `$/cancel` and resolve as cancelled after 500 ms.
1171 - The next turn's registry rebuild simply leaves the tool out. The tool was deferred, so the pinned prefix does not move. (Corrected: the earlier `change:tool_surface` event does not exist (R6), and none is needed.)
1172
1173 **Revocation never waits for the host.**
1174 2. **Rust sends `ext/deactivate {owner}`.** The host runs the fiber's memoised `dispose()`. Racing callers await the same promise; this is DSH `createScope().dispose` with `disposing ??= quiesceFiber(fiber)`. Disposers run in reverse order. Async disposers are awaited, e.g. `tool-workspace-dependencies` awaits its in-flight preparation (`index.ts` ~l.243).
1175 3. **Bounded wait.** The deadline is 2 s, or 5 s for MCP servers so that a transport close can finish.
1176 4. **The host acks `{disposed, leaked}`.** Leak detection counts the fiber's remaining effects, open managed timers and brokered processes after quiescence.
1177 - A leak, or a timeout, marks the host **dirty**.
1178 - Rust has already removed everything from its own view, so correctness does not depend on the ack.
1179 - Two dirty events in 10 minutes schedule a host restart at the next idle point (no turn in flight). The restart replays the remaining activations.
1180 5. **Rust kills brokered processes** still owned by `OwnerKey`, as a whole process group.
1181
1182 **Activation is all-or-nothing.** If `apply` throws, or an async `apply` rejects, or activation exceeds 5 s, the host disposes the partial fiber, which rolls back every registration made so far. It then reports `{failed}`. Rust also sweeps `by_owner` for that generation, so nothing half-registered survives on either side. **MCP tool lists get the same rule:** a refresh replaces the whole generation or keeps the previous one (DSH `mcp-client` all-or-nothing sync).
1183
1184 **Timers and child processes.**
1185 - The host exposes Cordis `ctx.setTimeout` / `ctx.setInterval`. They are `unref`'d, owned by the fiber and cleared at dispose (the omp `managed-timers` lesson).
1186 - Raw `setInterval` still works, since this is Node, but it is detected as a leak at dispose.
1187 - Child processes are expected to go through `ctx.subprocess`, which is the broker. A direct `child_process` call is outside our control until the host is OS-sandboxed (§4.5).
1188
1189 ---
1190
1191 ## 4. Trust and approvals
1192
1193 ### 4.1 The invariant, stated narrowly
1194
1195 **The claim:** every action the host asks **the core** to perform (a tool call, shell, file write through the core, process spawn, network fetch, prompt section, store write) goes through the same Rust gate as a model-originated action, and the host has no protocol method that expresses approval.
1196
1197 **What the claim does not cover:** ambient Node code (`node:fs`, `node:net`, `child_process`). The RUST memo §1.1 says the same. Until §4.5 lands, a **trusted host plugin runs with the user's privileges**. The review UI must say exactly that. This is no weaker than today's MCP stdio servers (`mcp.rs:753-756`: "not an OS sandbox") or today's `~/.codewhale/tools` scripts (R1).
1198
1199 **Concretely, "user privileges" includes the secret store** (R5). The default backend is `~/.codewhale/secrets/`, which also holds MCP OAuth tokens, and provider keys may sit in `config.toml`. The host process gets a scrubbed environment and never receives a credential over the protocol. But an unsandboxed plugin can `readFile` those paths. So everywhere this document says that secrets or tokens "never enter Node", it means **never through the protocol**, which rules out accidental leaks, logs and crash dumps. It does **not** mean a malicious plugin is contained. Containment arrives with the §4.5 sandbox, whose profile must explicitly deny `$CODEWHALE_HOME/{secrets,config.toml,auth*}` and keychain access.
1200
1201 ### 4.2 Extension-originated tool calls reuse code mode's gate
1202
1203 **Phase 1 needs none of this section.** A phase-1 extension tool is a `ToolSpec` adapter (`HostToolSpec`) in the per-turn registry (R2). When the model calls it, the call goes through `plan_tool_calls`, then hooks, then approval, then execution, exactly as a script tool's call does. When `execute_tools` calls it, main refuses it as needs-approval and the lane suspends it for approval. The host *cannot* ask the core to do anything in phase 1: `core/call` is not in the phase-1 protocol.
1204
1205 The rest of this section is phase 2, when `core/call` arrives. Lane 6562 already built what it needs. `gate_nested_call` plans one call through `plan_tool_calls(…, ToolCallSource::CodeMode)`, raises `ApprovalRequired` with a `<parent>.<seq>` id, suspends the program while it waits, and records receipts. The proposal:
1206
1207 - **Generalize `NestedCallGate` into a `CallGate`.** The turn loop serves it for any executor that runs *inside a tool call*: `execute_tools` programs and extension tool executions.
1208 - While `tool/call` for extension tool `T` is in flight, the host's `core/call` requests carrying that `invocation_id` go into `T`'s `CallGate`.
1209 - They are planned with `ToolCallSource::Extension { owner }` and receive ids `<T.call_id>.<seq>`.
1210 - Approval cards say "requested by extension `<plugin>` (inside `<T>`)". **Rust composes the card text.** The host cannot supply approval text.
1211 - **Attribution spoofing.** In a shared host, plugin B can read A's in-flight `invocation_id` and send `core/call` under it. The card would then blame A. Rust rejects a `core/call` whose `owner_token` differs from the invocation's owner, which catches bugs. Because the token is readable inside the process (§4.4, threat 3), the card for a multi-plugin host also shows "host with N plugins". **The gate itself still holds:** B's request still needs the user's approval, and a spoofed id buys a misleading label, not an approval.
1212 - **Out-of-turn actions have no gate.** Command handlers, event listeners, timers and activation code get `ungated_admission` semantics, the same as code mode without a turn: only read-only, `ApprovalRequirement::Auto` tools pass `enforce_tool_authority`, and everything else is refused with "no session permission gate; return a proposal instead".
1213 - Commands return `{kind: 'submit', prompt}`. Rust submits that as a visible user turn, so anything the model then does is gated normally.
1214 - **`ToolCallSource` hygiene.** Merge or rename before adding the third variant. `crates/tools` `ToolCallSource { Direct, JsRepl }` and the lane's private `turn_loop.rs` `ToolCallSource { Model, CodeMode }` are unrelated enums with the same name. Rename the tools-crate one to `ToolInvocationChannel`, then add `Extension { owner: OwnerKey }` to the engine one.
1215
1216 ### 4.3 What an extension can and cannot express
1217
1218 | DSH surface | Codewhale host | Why |
1219 |---|---|---|
1220 | `ctx.approval.request`, `approval/request` answerers | **Refused.** The row FAILS with a diagnostic | No self-approval |
1221 | `tools/pre-execute` returning `{kind: 'allow'}` | **Not expressible.** The shim maps allow to `abstain` and logs it once per owner | Matches DSH's own `hooks-claude-code` rule (a `PreToolUse` `allow` does not pre-approve) and Codewhale's `ToolCallDecision::Allow` no-op (`turn_loop.rs:6364`) |
1222 | `tools/pre-execute` deny / ask / revise | `hook/evaluate` verdict, folded in Rust as `deny > ask > abstain` | A `revise{input}` is **re-planned through the gate**, as `updated_input` is today (`turn_loop.rs:3238`). A hook error or timeout counts as **deny** (fail closed, as in omp `wrapper.ts:240`) |
1223 | `tools/execute` around-wrappers | **Refused** in v1 | They could rewrite results or arguments after the gate |
1224 | Providing a core service name (`agents`, `sessions`, `approval`, `llm`, `sandboxPolicy`, `credentials`, `fs`, `subprocess`, `systemPrompt`, `tools`, `commands`, `skills`) | **Refused** at `ctx.reflect.provide` time. The row FAILS | One authority per concern |
1225 | `presentCall` / annotations such as `kind: 'read'` | **Display only; never changes approval.** Extension tools are always `ApprovalRequirement::Required`, and the user can remember the approval per tool (bound to the plugin's receipt hash, so an update re-asks). They are never read-only for plan mode | (Corrected, R4.) Under `approval_hint_for`, a read-only hint becomes `Auto`, which would let a plugin turn off approval for its own tool, and that tool body runs arbitrary Node. That is self-approval. MCP servers keep today's rule unchanged; extension tools do not inherit it |
1226
1227 ### 4.4 Anti-spoofing
1228
1229 The threats, from the outside in:
1230
1231 1. **Host substitution.** A different JS file is launched as the host. Rust launches only the digest-named bundle it materialized, and re-checks the digest on an open descriptor before exec (the `ReviewedStdioLaunch` pattern). **Scope:** on macOS, `ReviewedStdioLaunch` itself hash-checks a Node entry and then *reopens it by path* (`mcp.rs:937-939`). A same-user process could still swap the file in that window, and the bundle lives in the user's own home directory. This control defeats corruption and stale builds. It does not stop an attacker who already runs as the user, who would own the machine anyway. `host/hello.bundle_sha256` is self-reported and serves only as a consistency check.
1232 2. **Plugin substitution after review.** `ext/activate.entry` is the staged, hash-bound copy from `plugins/install/stage.rs`. The host re-hashes the **whole package closure** before `import()`: every file under the package root, including any `node_modules` shipped inside the tarball. A mismatch fails activation and Rust marks the receipt `ContentChanged`.
1233 3. **Stale or cross-owner calls (confused deputy).**
1234 - Every host→core frame carries `owner_token`. This is 128 random bits minted by Rust per `(plugin_id, generation)` and handed over only in `ext/activate`.
1235 - Rust rejects a frame whose token does not match its handle's owner or whose generation is stale.
1236 - This stops bugs and stale fibers. **It does not stop a malicious plugin inside the same process**, which can monkey-patch the shared transport and read other owners' tokens.
1237 - The honest statement: **the effective authority of a host process is the union of its plugins' reviewed capabilities.** That is why hosts are split by trust tier (§1.5), and why the review UI for a plugin in the shared third-party host says "runs alongside N other plugins".
1238 4. **Impersonation toward the user or the model.**
1239 - Extension tool names cannot collide with or shadow existing names (§3.1).
1240 - `tool_search` output and approval cards always show the origin, `extension:<plugin>`.
1241 - Extension prompt sections are wrapped in an attributed, bounded envelope (`prompt/propose`), the same treatment MCP server instructions get. The host cannot write raw system-prompt text.
1242 5. **Bypassing the gate to reach MCP servers directly** (phase 3). A plugin could grab the host's SDK `Client` objects and call `tools/call` without Rust.
1243 - Stdio bytes flow through the Rust broker, and HTTP flows through the Rust fetch proxy. So Rust **parses the top-level `method` of every outbound JSON-RPC message** and applies a **default-deny** method policy (corrected from a three-method denylist, R12):
1244 - **Ungated allowlist:** `initialize`, `notifications/initialized`, `ping`, `notifications/cancelled`, `notifications/progress`, `*/list` (with pagination), `logging/setLevel`, and responses to server-initiated requests. Responses are admitted only if Rust forwarded the matching server request.
1245 - **Needs a grant:** `tools/call`, `resources/read`, `prompts/get`.
1246 - **Everything else is dropped**, including `resources/subscribe`, `completion/complete`, `tasks/*` and any method added in a future spec revision, until someone classifies it on purpose.
1247 - A grant is single-use, minted by the gate when it plans the call, and bound to `(server, generation, params.name or uri, call_id)`. It also requires `params.arguments` to **equal the planned input as a parsed JSON value** (`serde_json::Value` equality). The SDK re-serializes arguments, so a hash over bytes would reject honest calls.
1248 - A request with no grant is dropped: the broker answers the server-side request with a JSON-RPC error. The event is logged as `extension_host.gate_bypass_attempt`.
1249 - **What this buys** (corrected, R5): a host bug, or a plugin that reaches the host's SDK `Client` objects, cannot call an MCP tool without the gate, *over the brokered channel*. It does **not** survive a malicious unsandboxed plugin, which can read the on-disk OAuth token and open its own connection, or `spawn` the stdio server itself. Until §4.5, "one gate" holds for every path Codewhale provides, not against same-user code. That is the same boundary as today's MCP stdio servers.
1250
1251 ### 4.5 Sandboxing the host (phase 5, required before third-party host plugins leave experimental)
1252
1253 - Launch the host under `crates/tui/src/sandbox/` (seatbelt, bwrap or seccomp), with a profile that:
1254 - denies network except the loopback control socket;
1255 - denies writes outside `$CODEWHALE_HOME/extension-host/data/<plugin>/` and the OS temp dir;
1256 - allows reading the workspace and plugin roots;
1257 - **explicitly denies reading `$CODEWHALE_HOME/secrets/`, `config.toml`, auth and session stores, and keychain services**, even where the workspace allow rule would cover them (R5);
1258 - denies `exec` except `node` itself.
1259 - Once MCP egress goes through `net/fetch` and stdio spawns go through `proc/spawn`, legitimate plugin paths need no ambient network or exec. The sandbox then turns §4.1's narrow claim into the broad one.
1260 - DSH plugins that use `node:child_process` or `node:net` directly fail under the sandbox and are reported "needs unsandboxed host". That is decision D5.
1261
1262 ### 4.6 Plugin trust and review
1263
1264 - **Phase 1 activates the existing `Native` capability; it does not add a new one** (corrected, R8). `native` (alias `native_extension`) is already a hashed, inventoried and *inactive* "executable extension" component, in both `plugin.toml` and `extensions."net.codewhale"`. Under the flag it moves from `inactive` to `supported`, and each `native` path is one host entry module (an ESM Cordis plugin).
1265 - **Why not a new `host` key?** `CodewhalePluginExtension` is `deny_unknown_fields`, so every older build would reject the whole plugin. With `native`, an older build loads the plugin's skills and MCP servers and reports native as inactive.
1266 - **Why not new capabilities?** A second executable concept would need retiring later.
1267 - **Display:** `native` is shown as "host code (JavaScript)".
1268 - **Finer capabilities come later, and each is a policy bump.** Candidates are `HostCommands`, `HostHooks`, `HostNetwork` and `HostSubprocess`, the last two once the sandbox exists. Every policy change re-reviews every plugin, because the whole policy is hashed into each receipt (`activation.rs:107-122`). So add a capability only in the phase that first enforces it.
1269 - **To avoid invalidating every user's receipts for an experiment**, the policy is selected by the flag:
1270 - With `extension_host` off, it returns v3 byte-for-byte. Existing receipts, including Computer Use's, stay valid.
1271 - With it on, it returns v4 (`Native` supported). Receipts fail closed as `CapabilitiesChanged`, which triggers re-review; that is intended. **Toggling the flag in either direction re-reviews everything.** Say so in the flag's description.
1272 - **Mechanics:** `current()` is a `const fn` called at 6 production sites with no `Features` in reach (`types.rs:239`, `registry.rs:2218`, `manifest.rs:268,284,292,1676`). It becomes a read of a process-wide `OnceLock<PluginActivationPolicy>`, set once at boot from config. A config reload never flips it mid-process, and it defaults to v3 when unset, which also covers tests.
1273 - When the flag graduates, v4 becomes the only policy and everyone re-reviews once. Put that in the release notes.
1274 - **Review screen for a host plugin:**
1275 - package name and version, the full-closure content hash and file count;
1276 - declared capabilities (the `native` entries in `plugin.json`, or, from phase 5, inferred for DSH from `inject` plus the patch rows by the static Rust reviewer, §6.3);
1277 - whether it has install scripts (always refused in v1);
1278 - "Runs JavaScript on your computer with your user permissions".
1279
1280 The last line is removed only when the sandbox is on.
1281 - **Installation stays in Rust:** `plugins/install/{stage, place, tarball}` with exact-hash review. In v1, installs are from a path or a tarball only: **no npm registry fetch and no install scripts**. Dependencies must be bundled in the tarball or be host-provided peers (§6.2). A pnpm-based install with approval of build scripts is a Tier C item.
1282
1283 ---
1284
1285 ## 5. MCP on the host
1286
1287 ### 5.1 Recommended split (founder's option B, hardened)
1288
1289 | Concern | Owner | Mechanism |
1290 |---|---|---|
1291 | Which servers exist, merge and scope (global, project, plugin), project-config trust, `/mcp add`, `required` / `enabled` / `enabled_tools` / `disabled_tools`, `execute_timeout` | **Rust** (unchanged: `mcp.rs` config half, 1–1300 and 5245–6468) | Sent to the host as `mcp/connect {server, generation, transport: {stdio: {launch_ticket}} \| {http: {fetch_ticket, url}}, timeouts}` |
1292 | stdio **spawn / kill** | **Rust ProcessBroker** | Merges `ReviewedStdioLaunch` (fd-bound exec of the hashed entry), the `mcp/stdio.rs` spawn, `child_env` scrubbing plus credential injection, and hook `HookProcessTree` / `WindowsHookJob`. Bytes move over `proc/*`. Each whole process group is killed on disconnect |
1293 | HTTP / SSE / Streamable HTTP egress | **Rust FetchProxy** | The SDK transports are given `fetch: proxiedFetch`. Rust applies `NetworkPolicyDecider`, `reviewed_redirect_matches_origin`, `configured_mcp_proxy` and **injects the OAuth bearer**. Tokens never cross the protocol into Node. They are still readable on disk by unsandboxed code (§4.1) |
1294 | OAuth sign-in, token store, needs-auth state, synthetic `mcp_<s>_authenticate` | **Rust** (`mcp/oauth.rs` unchanged) | A 401 seen by FetchProxy sets needs-auth, which yields the existing `mcp_catalog_changed` metadata contract |
1295 | JSON-RPC session: initialize and version negotiation (2026-07-28 with legacy fallback), pagination, `list_changed`, reconnect with backoff, stale-session retry, resources, prompts, progress, cancellation | **Host** (`builtin:mcp` fiber per server, official SDK `@modelcontextprotocol/client` 2.0.0) | One SDK `Client` per server generation. The code is forked or adapted from DSH `mcp-client` (MIT, ~1,209 lines, effect-scoped teardown, all-or-nothing tool generations) |
1296 | Catalog admission | **Rust** | Host-reported lists are **untrusted**. They pass `McpCatalogBudget` page, item and byte caps plus name and schema validation |
1297 | Model-facing names | **Rust** | `mcp_<server>_<tool>` is unchanged; it lives in session history and the cached prefix. The host reports `(server, raw_name, annotations)` |
1298 | Approval hints, disallowed tools, ask rules | **Rust** | `McpToolApprovalHint` and `authorize_call` are unchanged. Annotations are trusted only for reviewed plugin servers |
1299 | Status for TUI, GPUI and runtime API | **Rust** | A `HostMcpClient` produces the existing `McpManagerSnapshot`, `McpRecoveryKind` and `needs_auth_generation`, so `tui/views/extensions.rs`, `runtime_api.rs:1429-1458` and the GPUI app do not change |
1300 | Server-initiated sampling and elicitation | **Rust** | The host forwards `mcp/serverRequest` to Rust, whose UI answers or refuses. Default: refuse, the same as today. **Never auto-resolved in the host** |
1301
1302 **Why not the VS Code split (option A, Rust keeps the JSON-RPC client)?** It is lower risk, but it keeps the hand-rolled Rust session code (~3,100 lines) that the founder wants gone, and it forgoes the SDK's protocol tracking (SHA-6628). Option B with brokered transports keeps every *authority* concern in Rust and moves only the protocol state machine.
1303
1304 **The cost of option B is IPC hops.** A stdio tool call crosses the channel four times: `mcp/call`, then `proc/write`, then `proc/data`, then the `mcp/call` result. Today it crosses twice. JSON results are also serialized twice.
1305 - For Computer Use screenshots (hundreds of KB to MBs of base64) this is the risk to measure. Phase 3's gate: p95 added latency ≤ 5 ms for a 1 MB result on the CU `screenshot` tool.
1306 - **Fallback if the gate fails:** a `direct_stdio` launch mode for **builtin, reviewed** servers only. The host spawns the process itself from a Rust-issued ticket that holds the exact argv and scrubbed env. That places secrets in host memory, so it is allowed only in the builtin host #0.
1307 - **Computer Use in particular.** Its `screenshot` results are the largest payloads. Today they are Rust↔CU. In phase 3 they would be CU → broker → host → Rust, parsed twice. Code mode drops image blocks from nested results anyway (lane `codemode.rs` "Known limitations"), so the extra cost falls only on direct calls. Measure it before choosing.
1308
1309 ### 5.2 Code mode reaches MCP through the one gate: the seam for lane 6562
1310
1311 **Scope (corrected, R2/R3).** This seam is for **MCP only**. Extension tools do not need it: they are registry `ToolSpec`s, which the lane already gates as "plugin" nested calls. The lane is unmerged and in flight, so this is an **offer** to that lane, not a phase-1 dependency:
1312 - If the lane adopts it before merging, `codemode.rs` does not change again in phase 3.
1313 - If it does not, phase 3 makes exactly the edits listed under "Lane changes" below. That is about 5 call sites in `codemode.rs` plus `tool_execution.rs:232`.
1314
1315 It merges the DSH and RUST memo proposals.
1316
1317 ```rust
1318 /// Tools served outside the native registry: MCP servers (Rust pool today, host in phase 3).
1319 /// Extension tools are NOT served here; they are registry ToolSpecs (HostToolSpec).
1320 /// Planning (plan_tool_calls → hooks fold → approval) happens BEFORE `call`; implementors never gate.
1321 #[async_trait]
1322 pub(crate) trait ExternalToolDispatch: Send + Sync {
1323 /// Catalog lookup by model-facing name. None = not an external tool.
1324 fn describe(&self, model_name: &str) -> Option<ExternalToolDescriptor>;
1325 async fn call(&self, model_name: &str, input: serde_json::Value, cx: ExternalCallCx)
1326 -> Result<RichToolResult, ToolError>;
1327 }
1328
1329 pub(crate) struct ExternalToolDescriptor {
1330 pub origin: ExternalOrigin, // Mcp { server, plugin: Option<PluginId> }
1331 pub generation: u64, // catalog generation; call_tool rejects stale
1332 pub starts_sign_in: bool, // replaces the `_authenticate` suffix test
1333 pub read_only_hint: bool, pub destructive_hint: bool, // already filtered by approval_hint_for
1334 }
1335
1336 pub(crate) struct ExternalCallCx {
1337 pub call_id: String, pub parent_call_id: Option<String>,
1338 pub source: ToolCallSource, // Model | CodeMode | Extension{owner}
1339 pub tx_event: mpsc::Sender<Event>,
1340 pub disallowed_tools: Arc<[String]>,
1341 pub cancel: CancellationToken, // turn interrupt + PendingAuthorityWatch revocation
1342 pub deadline: Option<Instant>,
1343 pub grant: GateGrant, // minted by the gate; opaque to McpPool today, enforced by the broker in phase 3
1344 }
1345 ```
1346
1347 **Lane changes (small; the lane's to take or leave, otherwise phase 3's):**
1348 - `NestedCallGate.mcp_pool: Option<Arc<AsyncMutex<McpPool>>>` becomes `external: Option<Arc<dyn ExternalToolDispatch>>`.
1349 - `CodemodeInvoker::execute` becomes `if let Some(d) = external.describe(name) { external.call(..) }`.
1350 - `refusal_before_gate` becomes `describe(name).is_some_and(|d| d.starts_sign_in)`.
1351 - `ungated_admission` becomes `describe(name).is_some()`, which yields "external tool; needs a session gate".
1352 - The direct path's `execute_mcp_tool_with_pool` call sites (`tool_execution.rs:232` and its callers) go through the same trait, so direct and nested calls share one dispatcher.
1353 - `McpPool::is_mcp_tool` (about 30 static-prefix call sites) becomes `external_tool_kind(name)` where the question is "is this external?", and stays name-based where it really is about the `mcp_` naming scheme.
1354
1355 **Implementations:**
1356 1. `McpPoolDispatch`, whenever the lane or phase 3 wants it: a thin wrapper that locks the pool and calls `authorize_call` then `call_tool`.
1357 2. `HostMcpDispatch`, phase 3: `mcp/call` with the grant.
1358
1359 There is no `CompositeDispatch` and no `HostExtensionDispatch` (removed). Only one implementation is live at a time, and it is swapped when the pool is deleted. The parity harness (§9.3) is the one place both exist.
1360
1361 ### 5.3 Migrating pool, deferral, always_load and auth
1362
1363 | Today (`mcp.rs`) | After phase 3 |
1364 |---|---|
1365 | `McpPool::new(config)` + `hash_mcp_config` | `HostMcpClient::new(config)`. The same hash keys the Rust catalog cache |
1366 | Lazy boot pass, `tool_selection_covers_server`, `tools_always_load`, per-turn explicit-connect wait (#6033) | **Unchanged Rust logic.** It now decides *when to send* `mcp/connect` rather than when to connect a Rust `McpConnection` |
1367 | `required: true` servers block boot / report failure | Unchanged semantics. A required server forces host spawn at boot, in parallel with provider warmup |
1368 | Deferred MCP tools via `tool_search` / `defer_loading` | Unchanged. The tool surface policy is Rust's |
1369 | `McpConnection` / transport trait / `stdio.rs`, `sse.rs`, `streamable_http.rs`, `http.rs`, `wire.rs` | **Deleted.** The SDK plus brokered transports replace them |
1370 | Reconnect / backoff (`mcp.rs:~2845`), stale-session retry | Host, per server fiber. Rust observes state via `mcp/state` notifications |
1371 | OAuth (`mcp/oauth.rs`), needs-auth transition, `_authenticate` tool | Unchanged Rust. The trigger moves from the transport to FetchProxy's 401 |
1372 | MCP prompts and resources | Host SDK, exposed through the existing Rust tools (`mcp_read_resource`, …) |
1373 | `crates/mcp` `McpManager` / `ChildProcessMcpClient` | **Deleted independently in phase 0** (dead in production per the RUST memo §1.3) |
1374 | `mcp_server.rs` (Codewhale *as* an MCP server) | Unchanged. It is a Rust server over the core gate |
1375 | `runtime_mcp.rs`, `mcp_registry.rs` (model-started servers) | Unchanged authority (`ApprovalRequirement::Required`). They now end in `mcp/connect` |
1376
1377 ### 5.4 Startup: advertise from cache, connect on demand
1378
1379 - **A persistent catalog cache in Rust.** Keyed by `(server, config_hash, server_version)`, it lives in the core store with a 30-day TTL. This follows VS Code's nonce cache, omp's SHA-256 tool cache and Codex's `LazyWhenCached`.
1380 - **Advertising.** A server with a cached catalog is advertised without starting the host or the server. The first call starts both.
1381 - **Uncached eager servers.** A server that must start eagerly and has no cache races a 250 ms window (omp's `STARTUP_TIMEOUT_MS`). Slower servers join through the deferred catalog.
1382 - **When the host is late or has died,** the cache keeps the tool array, and therefore the KV-cache prefix, stable.
1383
1384 ---
1385
1386 ## 6. DSH compatibility level
1387
1388 ### 6.1 Matrix
1389
1390 | DSH artefact | Status | Mechanism |
1391 |---|---|---|
1392 | `apply(ctx, config)` plugins and `Service` subclasses using `tools`, `commands`, `logger`, `timer`, `schemastery` `Config` | **Runs natively** (`tools`, `logger`, `timer`, `Config` in phase 1; `commands` in phase 2) | Real Cordis fibers under the host root. Shims provide `tools` (phase 1) and `commands` (phase 2). In phase 1, a plugin that injects `commands` FAILS activation with "requires `commands`, not yet provided" |
1393 | `ctx.effect`, `ctx.on` teardown, async `apply`, `internal/plugin` unload during startup | **Native** | Real Cordis semantics, with the DSH-hardened fiber (vendor mod #6) |
1394 | `skills` (roots or providers) | **Native** (phase 2) | `register.skillRoot`. Rust discovers and audits. Skills stay Markdown plus files |
1395 | `systemPrompt` sections | **Shimmed** (phase 2) | `prompt/propose`. Rust decides inclusion and timing |
1396 | `mcpResources` | **Shimmed** (phase 3) | Onto the host MCP service |
1397 | `dsh-mcp-client` rows | **Native config, Codewhale runtime** (phase 3) | Rows are translated into Rust MCP server configs (Rust stays the authority for which servers exist) and run by `builtin:mcp`. The DSH `mcp-client` *code* does not run as-is, because it spawns `StdioClientTransport` itself (`transport.ts:34`), which would bypass the broker. Names map `mcp__s__t` ↔ `mcp_s_t` for imported permission rules |
1398 | `dsh-skill-filesystem` rows | **Native config** | `register.skillRoot` |
1399 | Bundle composition: `dsh.bundle.patch` (string or list), `insert`, override-by-id, groups, disabled ancestry | **Native** (phase 2 literal rows, phase 5 full) | DSH's own `applyEntryPatches` (`vendor/include`), so it cannot drift. **Phase 1 has no patch recognition** (R11). A DSH plugin is loaded by a Codewhale bundle whose `native` entry is a Cordis plugin calling `ctx.plugin(dshPlugin, literalConfig)`. That is the same semantics as a single literal patch row, with no new parser |
1400 | `!!js` in config and `disabled` | **Native for trusted, enabled plugins** (phase 5) | Evaluated in the host against the row's ctx, as in DSH. For credential-shaped expressions (`process.env.X` in MCP `env` or `headers`), the static reviewer rewrites them to a Rust `CredentialRef(X)` so the secret never passes through Node |
1401 | `inject`, `intercept`, `isolate` | **Native** within the host root. `isolate` gives a private realm per service | |
1402 | `tools/pre-execute`, `tools/post-execute`, `agent/pre-step`, `agent/turn-stopping` listeners | **Shimmed, monotonic** (phase 4) | `hook/evaluate`. No allow. Revisions are re-gated |
1403 | `hooks-claude-code` / `hooks-codex` rows | **Mapped** (phase 4) | Onto the one hook runtime, which in phase 4 is the host's shell-hook executor, folded in Rust |
1404 | `agents`, `sessions`, `sessionProjections` | **Read-only projections** (phase 5) | Snapshots plus subscriptions to Rust core events |
1405 | `fs`, `subprocess`, `sandboxPolicy` consumers | **Gated shim** (phase 5) | `core/call` or `proc/spawn` through the gate or the broker |
1406 | Agent-preset rows (`dsh-agent-preset`) | **Translated** (phase 5) | Codewhale agent profiles at the roster's Plugin layer, capped to reviewed capabilities. The preset's `plugins` become per-agent host scopes with pinned revisions |
1407 | `llm` consumers | **Skipped** in v1 | Provider routing and billing are core. Revisit with a budgeted `core/llm` |
1408 | `approval` answerers, `allow` from pre-execute, `tools/execute` wrappers | **Refused by design** | §4.3 |
1409 | Rows providing `agent-loop`, `session-*`, `system-prompt`, persistence, `tools` / `commands` services | **Refused by design** | One loop, one store, one prompt |
1410 | `dsh.client` web modules, `slots` / `locale` / `ui*` / `remote` | **Skipped and reported** | The GPUI app is the product client |
1411 | pnpm install with build-script approval | **Tier C** | v1 accepts path and tarball only, with no scripts |
1412 | Rebuilding a retired preset revision after restart | Out (DSH does not do it either) | |
1413
1414 **Sizing** (from the DSH memo's approximate grep): about 13–21 of the ~90 parsed `inject`-declaring packages are headless and within reach by phase 5. About 69 need DSH UI or internal services and stay skipped. This is a planning estimate. Accepted Native source now reviews closed compositions and exact preset entries; unsupported service or prompt-replacement rows remain visibly broken. Complete stock-bundle acceptance must be established from actual installed Engine fixtures before claiming that every config bundle runs. UI and loop-replacing plugins remain outside the shared Engine boundary.
1415
1416 ### 6.2 Module resolution: what "natively" requires
1417
1418 DSH packages declare `@deepseek-ai/cordis` and `@deepseek-ai/dsh-tools` as **peers**. `@deepseek-ai/schemastery` is sometimes a plain **dependency**: it is one in the phase-1 plugin (R11). There must be **one** Cordis instance and one schemastery instance, or `instanceof`, symbols and services break. So the resolve hook maps these specifiers **however the package declares them**, and ignores a bundled copy under the package's own `node_modules`. So:
1419
1420 - The host installs Node **synchronous module hooks** (`module.registerHooks`, available on Node ≥ 22.15 and within the 22.19 floor). They resolve these specifiers to host-provided singletons:
1421 - `@deepseek-ai/cordis`, `@deepseek-ai/schemastery`, `cosmokit` resolve to the bundled real packages.
1422 - `@deepseek-ai/dsh-tools` resolves to a **compat module**. It exports `defineTool` plus the error classes and constants that DSH plugins use at runtime. Its types match DSH `0.1.7-alpha.2`. **It does not export `ToolRuntime` as a provider.**
1423 - Any other `@deepseek-ai/dsh-*` peer fails the row with "requires `<pkg>`, which the Codewhale host does not provide". The failure is never silent.
1424 - **Measure in phase 1:** whether bundling the real `@deepseek-ai/dsh-tools` helper subset is cheaper than maintaining the compat module. It has 109 import sites across DSH, so the compat surface should be generated from actual usage.
1425
1426 ### 6.3 What `install/dsh.rs` becomes
1427
1428 It becomes the **static reviewer**: parse `package.json`, compose the patch rows without executing anything, infer declared capabilities from `inject` and rows, hash the whole closure, and produce the review screen. It no longer *converts* anything.
1429
1430 Its module doc ("arbitrary DSH TypeScript execution is outside this compatibility scope") and `DSH-PLUGIN-ADOPTION-20260922.md` §"Compatibility boundary" **must be updated in the phase that lands native loading.** They currently disagree with the founder's direction, and under AGENTS.md that disagreement is itself a defect.
1431
1432 ---
1433
1434 ## 7. What Rust gets deleted, in order
1435
1436 This follows "migrate the last consumer or do not start". Every phase's exit criterion includes its deletion, and a phase does not count as done while both paths exist outside the feature flag. The estimates are the RUST memo's reading-based numbers, not measured diffs.
1437
1438 | Order | Deleted | ≈ prod lines | Consumers that must migrate first |
1439 |---|---|---|---|
1440 | **0** | `crates/mcp` client pool, `InMemoryMcpClient`, `ChildProcessMcpClient` and legacy CLI aggregation proxy | Removed in the 0.10.1 completion source | The earlier "no production caller" premise was false: the CLI proxy spawned registered child clients. Under the recorded founder D5 decision, that proxy is removed and `mcp-server` delegates to existing native `serve --mcp`. The unused Core/App-server pool and `/mcp/startup` route/docs were removed together. The small shared bounded instruction sanitizer remains; saved legacy definitions are preserved without a new reader/writer. Hosted and package acceptance are separate pending receipts. |
1441 | **1** (no deletion) | Nothing. Phase 1 adds the host behind the flag and deletes nothing, because no consumer has moved yet. The `ExternalToolDispatch` seam is the lane's option or phase 3's work (§5.2) | 0 | — |
1442 | **3** (MCP move) | `McpConnection`, transport trait, discovery, pool connect / supervise / backoff / reconnect / stale retry / route (`mcp.rs` ~1466–2660 and most of 2709–5240); `mcp/{sse, streamable_http, http, http_client, wire, headers}.rs`; stdio framing (spawn moves to the broker); about half of `mcp/tests.rs` | ~4,700 | `core/engine.rs`, `turn_loop.rs`, `tool_execution.rs`, `tool_preparation.rs`, `dispatch.rs`, `runtime_api.rs`, `hooks/executor.rs`, `tools/subagent/mod.rs`, `tools/runtime_mcp.rs`, `tools/registry.rs`, `codemode.rs`, `lib.rs`, `tui/views/extensions.rs`, `tui/command_palette.rs`, `tui/setup/tools_mcp.rs`. Current adoption supersedes this estimate: callers retain `McpPool`/`McpConnection` as the sole Rust authority and use the selected SDK transport. Only native protocol/client orchestration is deleted after the one-release default window; shared ProcessBroker, guarded HTTP/OAuth, framing bounds and catalogue/session policy remain |
1443 | **0** | Legacy `run_stdio_server` aggregating proxy | Removed with its last CLI consumer | Founder D5 drops the proxy. The CLI spelling remains an alias of the native server and introduces no SDK proxy or second client pool. |
1444 | **4** (hooks) | `hooks/executor.rs` orchestration: matching, env building, sync and background runs, observers, message-submit transform; part of `hooks/config.rs` validation | ~2,450 | Turn-loop fire points (kept), `tui/ui/observer_hooks.rs`, `exec_agent`. **Kept in Rust:** the verdict fold, `authority.rs` project-hook receipts, output sanitizers, and the process tree (moved into the broker) |
1445 | **4** (script tools, new row, R1) | `tools/plugin.rs` (`ScriptPluginTool`, `CommandPluginTool`, frontmatter parser; 893 lines incl. tests), `ToolRegistry::load_plugins`, the non-`Disabled` arms of `apply_overrides`, `configure_plugin_tools` (`core/engine.rs:7360-7400`) | ~700 | Users' `~/.codewhale/tools/*` scripts and `[tools.overrides]` `Script` / `Command` entries. They become one `builtin:script-tools` host plugin: each script is registered as an extension tool, spawned through the broker (the same executor as shell hooks, which is why this lands with phase 4). **Two user-visible changes are decision D9:** `# approval: auto` is no longer honoured (Required, rememberable), and a script can no longer replace a built-in. `[tools.overrides] X = "disabled"` stays in Rust: it is configuration, not extensibility |
1446 | **5** (DSH native) | The conversion half of `install/dsh.rs` and `commands/groups/plugins/dsh_import.rs`, `dsh_tests.rs`; the DSH dialects of `scripts/convert-plugin.py` (the OpenCode dialect stays) | ~2,010 + ~200 py | `/plugin import dsh` and `POST /v1/apps/plugins/import/dsh/preview` become install-and-review of a host package |
1447 | — | (Removed row.) `Native` is no longer retired. Phase 1 makes it the host capability (§4.6, R8) | 0 | — |
1448 | optional | `integrations/dsh/*` external DSH launcher | ~2,850 | **Founder call** once DSH plugins run natively |
1449
1450 **Committed net: about −9,850 prod lines removed (including the script-tools row), +2,800 Rust added**
1451 - The +2,800: supervisor and client (~600), protocol (~500), OwnerRegistry (~500), tool adapter and MCP dispatch (~300), broker glue (~300), FetchProxy (~400), catalog admission and cache (~200).
1452 - That leaves **about −7,050 net**, plus about 5–8k lines of TypeScript. These remain reading-based estimates, not measured diffs.
1453
1454 **Stop rule.** The accepted anchor is the Phase 1 exit gate, not the September 26 host merge. If Phase 3 has not landed within six weeks of that gate, delete the host rather than retain another extension runtime behind a flag. Phase 1 exit is not yet achieved, so that clock has not started. The October 1 plan proposes a later anchor; no founder adoption of that change is recorded. `CURRENT_DECISIONS.md` §26 is the accepted decision.
1455
1456 ---
1457
1458 ## 8. Phase plan
1459
1460 ### Phase 0: independent cleanups (can run in parallel with phase 1)
1461
1462 - Delete the dead `crates/mcp` client stack (§7, row 0), **together with its live readers** (`/mcp/startup`, the `crates/core` mapping and `docs/RUNTIME_API.md`).
1463 - Re-scope **SHA-6628** (rmcp MCP 2026-07-28 client in Rust). It plans the Rust client this design removes. Its spec-convergence acceptance moves to phase 3, and the rmcp implementation is dropped. Done in Linear, not here.
1464 - *Offer* the `ExternalToolDispatch` seam to lane 6562 (§5.2). This is optional, and phase 1 does not wait for it.
1465
1466 ### Phase 1: host + protocol + extension tools + one real DSH plugin, behind a flag
1467
1468 **One PR, tools only** (cut from the earlier draft, R13). Commands, heartbeat, auto-restart, patch recognition and `core/call` all move to phase 2. What remains is the smallest slice that proves every load-bearing claim:
1469 - a TS host starts lazily;
1470 - a real DSH plugin runs unmodified;
1471 - its tool goes through the one Rust gate;
1472 - teardown is coordinated;
1473 - flag-off is byte-identical.
1474
1475 **Flag.** `Feature::ExtensionHost`, `Stage::Experimental`, `default_enabled: false`. Config key `[features] extension_host = true`. With the flag off, the host is never spawned, the activation policy is v3 byte-for-byte, and no registry, catalog or turn-loop path changes.
1476
1477 **Why tools, not hooks or commands, first?**
1478 - **Tools** land as `ToolSpec` adapters in the *existing* per-turn registry, beside `~/.codewhale/tools` scripts (R2). Every existing gate applies to them unchanged: plan mode, authority envelope, deferral, hooks, approval, and code mode. Nothing new is built on the core side of the gate.
1479 - **Hooks** would give Codewhale two hook executors (Rust shell, host JS) until the whole executor moves. So hooks wait for phase 4, where they move in one piece.
1480 - **Commands** need a new executable entry kind in `UserCommandRegistry` plus the `submit` round trip. Useful, but not load-bearing for any claim, so they are phase 2.
1481
1482 **Exact phase-1 scope: Rust** (`crates/tui/src/extension_host/`, ~900 lines estimated):
1483
1484 | File | Contents |
1485 |---|---|
1486 | `protocol.rs` | Frame codec (`CWX1` + u32 LE + JSON, 32 MiB max, typed refusal over that). Host→core types use `deny_unknown_fields`. Method subset: `host/{hello, initialize, ready, shutdown}`, `ext/{activate, deactivate, faulted}`, `registry/{register, unregister}` with `kind = "tool"` only, `tool/call`, `$/cancel`, `log`. Nothing else is accepted. |
1487 | `supervisor.rs` | Lazy spawn when an enabled, reviewed plugin has a `native` entry and the flag is on. `resolve_node_at_least(22,19)` candidate ladder (§1.4) with the `[extension_host] node` override. Digest-named materialisation reusing `builtin.rs` helpers. Scrubbed env via `child_env`. Process group / job object: move `HookProcessTree` / `WindowsHookJob` out of `hooks/executor.rs` into a shared module; it is a move, not a copy. 2 s handshake. Bounded shutdown: 2 s, then SIGTERM, then SIGKILL at 3 s. **On exit:** revoke everything, fail in-flight calls with a typed error, mark the host failed with its stderr tail. **No auto-restart.** One host per engine process. |
1488 | `registry.rs` | `OwnerRegistry` (§3.1–3.3): owner tokens, exact-handle undo, synchronous revocation, name refusal against the native set and reserved prefixes, schema caps (64 KiB schema, 4 KiB description, 128 tools per owner). |
1489 | `tool.rs` | `HostToolSpec: ToolSpec`: always `ApprovalRequirement::Required`; `capabilities = [ExecutesCode, RequiresApproval]` (as script tools); not read-only; `registration_origin = "extension:<plugin>"`; deferred by default (not in `DEFAULT_ACTIVE_NATIVE_TOOLS`). `execute` re-checks liveness, sends `tool/call`, and on drop sends `$/cancel`. The result is resolved as cancelled 500 ms after cancel whatever the host does. |
1490 | `core/engine.rs` | About 10 lines at `configure_plugin_tools`: after scripts and overrides, add the admitted `HostToolSpec`s, skipping any name already present (with a diagnostic). |
1491 | `core/engine/dispatch.rs` | Approval-card description: "Extension tool `<name>` from plugin `<plugin>` (runs JavaScript with your permissions)". |
1492 | `plugins/activation.rs` | Policy from a boot-time `OnceLock`. The v4 policy moves `Native` to `supported`, only with the flag on. |
1493 | `plugins/*` display strings | `native` is shown as "host code (JavaScript)"; the review screen shows the closure hash and file count. No new manifest key (R8). |
1494 | `features.rs`, `config.example.toml` | The flag, and the `[extension_host] node` key. |
1495 | `dependencies.rs` | `resolve_node_at_least`. `resolve_node()` is left untouched. |
1496
1497 **Exact phase-1 scope: TypeScript** (`crates/tui/extension-host/`, ~600 lines estimated plus vendored deps):
1498
1499 | File | Contents |
1500 |---|---|
1501 | `src/main.ts` | Framing; console and `process.stdout.write` rebinding to stderr before any plugin loads; stdin-EOF exit; `process.exit` guard; per-owner fault attribution via `AsyncLocalStorage`. |
1502 | `src/root.ts` | Cordis root; refusal list for core service names (`approval`, `agents`, `sessions`, `llm`, `sandboxPolicy`, `credentials`, `fs`, `subprocess`, `systemPrompt`, `tools`, `commands`, `skills`); a `tools` shim (`register` → `registry/register`, `tool/call` → `execute` with an `AbortSignal`, `output.render` applied host-side); a `logger` shim. A plugin injecting any service the root does not provide FAILS with the name of that service. |
1503 | `src/dsh/resolve-hooks.ts` | `module.registerHooks` singletons for `@deepseek-ai/{cordis, schemastery}` and `cosmokit`, whether declared as peers or as dependencies. A `@deepseek-ai/dsh-tools` compat module exporting only what the fixture uses (`defineTool` and its error types). Any other `@deepseek-ai/dsh-*` fails loudly. |
1504 | Vendored deps | `@deepseek-ai/{cordis, schemastery}` and `cosmokit`, from npm if published, otherwise vendored from `refs/dsh@00102833/vendor` with MIT notices. **This is the first task of the PR**, because everything else builds on it. |
1505 | `dist/codewhale-extension-host.mjs` | Committed, plus `LICENSES.txt`. |
1506 | `test/*.test.mjs` | Framing, the refusal list, the resolve hooks, owner teardown (reverse-order disposers, awaited async disposer). Tests run against `dist/`. |
1507
1508 **Fixtures:**
1509 - `crates/tui/tests/fixtures/extension_host/dsh-workspace-deps/`: a `plugin.json` whose `extensions."net.codewhale".native` names `index.mjs`, a Cordis plugin that does `ctx.plugin(await import('./vendor/dsh-tool-workspace-dependencies/lib/index.js'), {source: './payload/runtime.json'})`. Alongside it, the published `lib/index.js` of `@deepseek-ai/dsh-tool-workspace-dependencies@0.1.7-alpha.2` (MIT) and a small payload. The plugin injects only `tools`, registers `load_workspace_dependencies`, and has an **async `ctx.effect` disposer** that awaits in-flight work (`src/index.ts:245-248`). With no `root` config it is read-only.
1510 - `crates/tui/tests/fixtures/extension_host/refuses-approval/`: `ctx.plugin` providing `approval`. It must FAIL activation.
1511 - The protocol conformance corpus (`*.json`), which both sides parse and round-trip.
1512
1513 **CI** (`.github/workflows/ci.yml`):
1514 - In the existing Node-22 JS job: `cd crates/tui/extension-host && npm test`, then `npm ci && npm run build && git diff --exit-code dist`.
1515 - In the Rust test job that runs the new integration test: `actions/setup-node` pinned to 22, so the real-bundle test **runs** on CI. It skips, with a printed reason, only when `CODEWHALE_EXT_HOST_TESTS` is unset *and* no suitable Node is found, which is the local case.
1516
1517 **Acceptance (all run, with pass/fail counts in the commit message):**
1518 1. `npm test` passes (it now includes `npm --prefix crates/tui/extension-host test`), and `npm run check:web` passes.
1519 2. A Rust integration test spawns the real bundle under Node ≥22.19, installs the DSH fixture through the existing reviewed installer (`plugins/install`), reviews it, enables it, and drives one model-path call of `load_workspace_dependencies`. It asserts:
1520 - the tool is **deferred** and reachable through `tool_search`;
1521 - the approval request is raised (`Required`), and its text names `extension:dsh-workspace-deps`;
1522 - after approval, the result JSON comes from the fixture payload;
1523 - the same call from `execute_tools` in a main-session turn suspends for approval with a `<parent>.<seq>` id attributed to `extension:<plugin>`, and no host `tool/call` is sent before approval; allow returns the result to the program and deny fails only that nested call. (Lane 6562 landed first, as #6583; the assertion was updated in the phase-1 fixes. Without a gate, `execute_tools` still refuses it as needs-approval.)
1524 3. Disabling the plugin mid-call: Rust's registry drops the handle at once; the in-flight call resolves as cancelled within 500 ms; the host acks `disposed` only after the async disposer settles, and `leaked` is empty.
1525 4. `kill -9` on the host: the in-flight call fails with the typed `not_available("extension host exited")`; `/plugin` shows *failed* with the stderr tail; nothing respawns until the next session or `/plugin enable`.
1526 5. The `refuses-approval` fixture FAILS activation with a diagnostic, and no registration survives on either side.
1527 6. An extension tool registered with a native name (`read_file`) is refused at `registry/register`. A second one named like an existing `~/.codewhale/tools` script is skipped at turn build with a diagnostic, and the script tool is unaffected.
1528 7. With the flag off: the host is never spawned (asserted by test); `PluginActivationPolicy` hashes equal v3 (a unit test pins the digest); and `hyperfine 'codewhale --version'` shows no change. With the flag on and no `native` plugin enabled, the host is never spawned.
1529 8. The protocol conformance corpus round-trips on both sides.
1530
1531 **Explicitly not in phase 1:** commands, `core/call` and the generalised `CallGate`, heartbeat and auto-restart, `dsh.bundle.patch` recognition, `!!js`, prompt sections, skills, MCP, hooks, script-tool migration, sandbox, registry installs, and multiple hosts or trust tiers. **Trust note for phase 1:** a single third-party host is acceptable only because the flag is Experimental and nothing builtin shares it.
1532
1533 ### Phase 2: commands, restart, `core/call`, patch rows, skills, prompt sections
1534
1535 - `command/run`, with owner-bound entries in `UserCommandRegistry`. `{kind: 'submit'}` becomes a visible user turn. This needs a policy bump only if commands get their own capability (§4.6).
1536 - Heartbeat, crash tracker, and auto-restart with activation replay (§1.4).
1537 - `core/call` plus the generalised `CallGate` (§4.2), with `ToolCallSource::Extension`. This requires lane 6562 merged, because the suspension machinery is the lane's.
1538 - `dsh.bundle.patch` with literal rows (§6.1).
1539 - Generated protocol (`schemars` → TS, with a drift check).
1540 - `register.skillRoot`, `prompt/propose` and `storage/*`.
1541 - The trust-tier split: host #0 builtin, host #1 third-party. This is **required before phase 3**.
1542 - (Removed: running Computer Use's `agent.mjs` as a host plugin. It is a remote SSH agent, not host code, R7.)
1543
1544 ### Phase 3: MCP protocol orchestration moves to the host
1545
1546 **Entry gates:**
1547 - the phase-2 tier split exists, so `builtin:mcp` runs in host #0 only;
1548 - decision D1 is ratified in Ops CURRENT_DECISIONS §26: system Node with a doctor diagnostic;
1549 - the `ExternalToolDispatch` seam exists, landed by the lane or at the start of this phase.
1550
1551 **Work:**
1552 - ProcessBroker (merged spawn path) and FetchProxy with auth injection.
1553 - `builtin:mcp`, the SDK client adapted from DSH `mcp-client`.
1554 - The actual pinned SDK transport under the existing `McpConnection`/`McpPool`
1555 Rust authority facade: catalogue/session/permission/provenance and credential
1556 state stay in Rust. `mcp_backend = "host"` demand starts the Builtin tier
1557 independently of optional Native activation; harness/third-party activation
1558 continues to require the actual Native policy, including after restart.
1559 - Grant enforcement at the broker and proxy with the default-deny method policy (§4.4, threat 5).
1560
1561 **Exit gates:**
1562 - Every existing `mcp/tests.rs` behaviour test, not the transport internals, passes against the host through a Rust↔host conformance harness that reuses the MCP fixture servers.
1563 - The CU screenshot latency gate (§5.1).
1564 - Code mode's nested MCP calls pass the lane's MCP tests unchanged through `HostMcpDispatch`.
1565 - The unchanged recorded corpus and real HTTP/OAuth/Computer Use/broker tests
1566 pass; platform isolation and the measured Phase 3 gates pass before a mandatory
1567 default. D9 forbids making tier 0 mandatory before Linux/Windows host sandboxes.
1568 - Ops CURRENT_DECISIONS §26 orders the default flip for one release, then
1569 deletion of the native protocol/client adapters after their last selected
1570 consumers migrate. The Rust pool's authority/cache stays. Optional Native
1571 activation is not made mandatory by MCP. An MCP user without supported Node
1572 gets the existing doctor diagnostic, per ratified D1; no Rust fallback.
1573
1574 ### Phase 4: hooks and script tools move to the host
1575
1576 - A shell-script executor is added as `builtin:hooks` in the host, spawning through the broker.
1577 - DSH `tools/pre-execute` and related listeners, plus `hooks-claude-code` and `hooks-codex` rows.
1578 - `builtin:script-tools` registers `~/.codewhale/tools` scripts and `[tools.overrides]` `Script` / `Command` entries as extension tools (R1, D9).
1579 - The fold, sanitizers and authority stay in Rust.
1580 - Deletion rows 4 (hooks) and 4 (script tools) land.
1581
1582 ### Phase 5: full native DSH loading and host sandbox
1583
1584 - Complete patch composition, `!!js` for trusted plugins, `inject` / `intercept` / `isolate`, read-only `agents` and `sessions` projections, the gated `fs` and `subprocess` shims, and agent-preset translation.
1585 - The OS sandbox for the host, including the secret-path denials (§4.5).
1586 - `install/dsh.rs` shrinks to the reviewer.
1587 - Deletion row 5 lands. The docs listed in §6.3 are reconciled.
1588 - Decide `integrations/dsh`.
1589
1590 ---
1591
1592 ## 9. Risks, budgets, tests, open decisions
1593
1594 ### 9.1 Risks
1595
1596 | Risk | Likelihood / impact | Mitigation |
1597 |---|---|---|
1598 | Node becomes mandatory for MCP (brew, cargo, Termux users) | High / high | D1. A doctor diagnostic with an exact fix. The catalog cache still advertises tools, which fail with a clear "needs Node" error. Phase 3 does not graduate without the decision |
1599 | Broken or old Node on `PATH` (observed on this machine) | Medium / medium | `resolve_node_at_least`: try `[extension_host] node`, then every `node` on `PATH` in order, keep the first that runs and meets the version floor, and report the rejected candidates. (Not `resolve_node()`, which probes only the first `node` on `PATH`) |
1600 | Same-process plugins can borrow each other's authority | Certain / medium | Trust-tier hosts (§1.5), union-of-capabilities disclosure, broker-level grants for MCP, and the sandbox in phase 5 |
1601 | IPC cost for large MCP results | Medium / medium | Phase 3 latency gate; `direct_stdio` for builtins as a fallback |
1602 | Cordis not published on npm; vendoring churn from DSH | Medium / low | Vendor at a recorded commit with a modification list, as DSH itself does |
1603 | DSH peer-API drift (`dsh-tools` compat) | High / low | Generate the compat surface from DSH usage. Unknown exports fail loudly per row |
1604 | Two hook executors or two MCP stacks living too long | Medium / high | Phase exit criteria include the deletions; stop rule (§7) |
1605 | Trust-receipt churn when v4 becomes default | Certain / low | Flag-scoped policy until graduation; release note |
1606 | A plugin writes to stdout and corrupts framing | Medium / low | Console rebinding. Framing detection leads to host exit (phase 1: marked failed) or a restart counted as a crash (phase 2) |
1607 | Existing script tools self-approve (`# approval: auto`) and shadow built-ins (R1) | Certain (shipping today) / medium | Out of the host's scope until phase 4. The host never copies the behaviour (always `Required`; refuse or skip on collision). D9 decides the migration |
1608 | Unsandboxed host plugin reads on-disk secrets (R5) | Possible / high | Honest review text; the flag stays Experimental; third-party host plugins do not leave Experimental before the §4.5 sandbox, whose profile denies the secret paths |
1609 | Phase 1 couples to the unmerged code-mode lane | Was certain in the old draft / medium | Removed: phase 1 uses registry `ToolSpec`s, which work on main and under the lane (R2, R3) |
1610 | Memory multiplies with sub-agents and runtime-API threads | Was likely in the old draft / medium | One host per engine process per trust tier (R9) |
1611 | Out-of-date sources (SHA-6628; DSH-ADOPTION doc; `dsh.rs` module doc) | Certain / low | Reconcile in the phases named in §6.3 and §8, phase 0 |
1612
1613 ### 9.2 Budgets
1614
1615 | Metric | Budget | Basis |
1616 |---|---|---|
1617 | Added startup with no host plugins or MCP | **0 ms**: the host is not spawned | Lazy spawn |
1618 | Added startup with host plugins (phase 1) | **0 ms on the first-prompt path.** The spawn and handshake run in the background, and tools join at the next turn | Design rule; verify with the acceptance test |
1619 | Added startup for MCP users (phase 3) | 0 ms with a cached catalog. With an uncached `required` server, the host spawn (≤400 ms cold) overlaps provider warmup | Target |
1620 | Node process start | 22–31 ms warm, 96 ms first cold | Measured here: Node 22.20, `node -e ''`, 6 runs |
1621 | `hello → ready` with a bundle ≤ 2 MB | ≤ 150 ms p50 warm, ≤ 400 ms cold | Target; measure in phase 1 with `hyperfine` |
1622 | Per-plugin activation | ≤ 50 ms p50. The hard timeout is 5 s, after which the plugin FAILS | Target |
1623 | First prompt | Never waits on the host. Required MCP servers are the only eager case, and they start in parallel with provider warmup | Design rule |
1624 | Host RSS, idle, no MCP | ≤ 80 MB **per engine process per trust tier** (not per session or sub-agent). V8 heap capped with `--max-old-space-size=256` | Target; not measured |
1625 | Per MCP server in the host | ≤ 5 MB beyond the server process itself | Target |
1626 | Bundle size | ≤ 2 MB phase 1, ≤ 4 MB with the SDK | Embedded in the binary |
1627 | Hook deadline (phase 4) | 2 s default, 30 s max. The clock pauses while waiting on the user | omp `runner.ts` precedent |
1628 | Shutdown | 2 s drain, then SIGTERM, SIGKILL at 3 s | omp precedent |
1629
1630 ### 9.3 Test strategy
1631
1632 This follows the evidence rules in AGENTS.md: match the evidence to the surface.
1633 - **TS unit tests** (`node --test` against `dist/`, run by root `npm test` through `npm --prefix`, and by an explicit Node-22 CI step): framing, shims, refusal list, the `dsh-tools` compat module, fiber teardown and leak detection.
1634 - **Protocol conformance corpus.** JSON fixtures that Rust `serde` and TS both parse and round-trip. Generated types plus the drift check arrive in phase 2.
1635 - **Rust integration tests** that spawn the **real bundle** under a real Node. **CI installs Node 22 for this job, so they run there.** Locally they skip with a visible reason when no Node ≥22.19 is found. They cover admission, gating (direct, and code mode as refused on main or suspended once the lane lands), revocation, crash, and anti-spoofing: a stale token, a cross-owner handle, a name collision against natives and against scripts, and a refused `provide`. Restart and replay join in phase 2.
1636 - **MCP parity harness** (phase 3): the existing MCP fixture servers are driven through both `McpPoolDispatch` and `HostMcpDispatch` with the same assertions, until the native protocol adapters retire after the one-release default window. The Rust pool's authority/cache remains. Transport-internal Rust tests are deleted with the code they test; security guards and behaviour tests are re-targeted to the actual SDK/broker consumers.
1637 - **The DSH corpus** reuses the 41 `test_convert_plugin.py` cases as *install and review* cases (phase 5), plus the pinned real packages:
1638 - `tool-workspace-dependencies` in phase 1;
1639 - `dsh-mcp-client` rows in phase 3;
1640 - `hooks-claude-code` rows in phase 4.
1641 - **Performance:** `hyperfine` on startup with the flag on and off, and the CU screenshot latency gate in phase 3.
1642 - **Not claimed by any of the above:** hosted CI, a real provider call, or a customer run. Each is a separate level of evidence.
1643
1644 ### 9.4 Decisions and remaining choices
1645
1646 Ops CURRENT_DECISIONS §26 is the current decision authority; older alternatives
1647 below are historical proposals unless that table leaves the choice open.
1648
1649 - **D1. Node for MCP users — ratified.** System Node ≥22.19 with the existing
1650 doctor diagnostic for the initial 0.10.1 host. The September 29 founder
1651 follow-up makes Bun the target runtime and bundled executable after the four
1652 measured D2 gates; Node remains a diagnosed fallback while that work lands.
1653 Selected Host failures never silently fall back to the Rust adapter.
1654 - **D2. Node floor.** `^22.19 || >=24` for the host, matching DSH. I recommend it: Node 20 is end-of-life, and `module.registerHooks` needs ≥22.15. Computer Use is a separate MCP server process (R7) and keeps its own `>=20` floor. Several CI jobs still pin Node 20 (`ci.yml` version-drift and conversion jobs, release workflows), and none of them run host code.
1655 - **D3. Stdio secrets.** Relayed broker, which I recommend; or a `direct_stdio` ticket for builtin servers only if the phase-3 latency gate fails.
1656 - **D4. Legacy stdio proxy:** resolved by Ops CURRENT_DECISIONS §26 D5: drop it. The native `serve --mcp` server remains.
1657 - **D5. Third-party host plugins that need ambient network or exec** under the phase-5 sandbox: refuse them, or offer an explicit "unsandboxed host" tier with its own warning.
1658 - **D6. Native addons (`.node`) in host packages.** Refuse them in v1, which I recommend: they defeat the closure hash and the sandbox story.
1659 - **D7. `integrations/dsh` external launcher:** keep it or delete it once native loading lands.
1660 - **D9. Script tools** (`~/.codewhale/tools`, `[tools.overrides]` `Script` / `Command`; R1). Moving them into the host in phase 4 is required by "only the host is extensible", and it changes two behaviours:
1661 - (a) `# approval: auto` is ignored and scripts become `Required`, rememberable per tool;
1662 - (b) a script can no longer replace a built-in.
1663
1664 **Recommended: accept both, and ship them with a release note.** Today's behaviour is exactly the self-approval and shadowing the host refuses. The alternative, keeping script tools in Rust as a second extension path, contradicts the founder direction.
1665 - **D8. Tracker.** Link SHA-6521, SHA-6628 and #6562 to the host epic. Filing goes to the Codewhale team, which is public. This design contains no inference or business details, so it is safe to file there.
1666
1667 ## 10. File index
1668
1669 **Engine** (`/private/tmp/cw-wt-6446` @ `8a835d7c4`):
1670 - `crates/tui/src/plugins/{activation.rs:22,26-37,80-100, manifest.rs:35-59,254, agent_plugin.rs:649, builtin.rs:1-30, install/{dsh.rs,stage.rs}}`
1671 - `crates/tui/src/features.rs`
1672 - `config.example.toml:1228`
1673 - `crates/tui/src/mcp.rs:536-575,753-756,2852-2865`
1674 - `crates/tui/src/dependencies.rs:54-85,278-297` (`probe_executable`, single-probe `resolve_node`)
1675 - `crates/tui/src/tools/{plugin.rs:1-20,57-172, registry.rs:53-63,360-440, spec.rs:1436-1550, codemode.rs:193-238}` (main)
1676 - `crates/tui/src/core/engine.rs:4672,4895-4915,7360-7400`; `core/engine/{tool_catalog.rs:136-155, tool_preparation.rs:40-60, dispatch.rs:870-886}`
1677 - `crates/tui/src/mcp.rs:929-1000` (`ReviewedStdioLaunch`), `:1265-1274` (`approval_hint_for`); `mcp/oauth.rs:700-760`
1678 - `crates/tui/src/plugins/{agent_plugin.rs:627-672,1646-1652, registry.rs:2205-2225, types.rs:230-240, manifest.rs:160-180,245-262,1676-1690}`
1679 - `crates/secrets/src/lib.rs:60-70`
1680 - `crates/core/src/lib.rs:24,902,914,1278-1285,2460`; `crates/app-server/src/lib.rs:376,394`; `docs/RUNTIME_API.md:55`
1681 - `crates/workflow-js/{Cargo.toml, src/lib.rs}` (code mode runs in-process QuickJS, which is untrusted model code and a separate runtime from the host by design)
1682 - `.github/workflows/{ci.yml:186-307, web.yml:13-40}`; `crates/tui/plugins/computer-use/{package.json, agent.mjs}`; `crates/tui/src/tools/subagent/mod.rs:2758,3204`
1683 - `crates/tui/src/hooks/{config.rs:27, executor.rs}`
1684 - `crates/tui/src/commands/{user_commands.rs, user_registry.rs:97}`
1685 - `crates/tools/src/lib.rs:386`
1686 - `crates/tui/plugins/computer-use/{package.json, plugin.json}`
1687 - `package.json` (workspaces)
1688 - `npm/{codewhale, runtime-sdk}/package.json`
1689
1690 **Lane** (`feat/code-mode-mcp-6562`):
1691 - `crates/tui/src/tools/codemode.rs:200-243,250-282,502-556,1175`
1692 - `crates/tui/src/core/engine/turn_loop.rs:39,~4706-4790`
1693
1694 **DSH** (`refs/dsh` @ `00102833`):
1695 - `packages/skill/tool-workspace-dependencies/{package.json, src/index.ts:237-277}`
1696 - `packages/mcp/mcp-client/{package.json, src/transport.ts, src/index.ts}`
1697 - `packages/core/tools/{package.json, src/index.ts}`
1698 - `docs/subsystems/commands.md`
1699 - `packages/feedback/command-feedback/src/index.ts`
1700 - `packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md`
1701 - `package.json` (engines)
1702
1702 lines MARKDOWN