fix: reliability, Windows launch and IPC findings from the code audit - #93
BIackFIame wants to merge 22 commits into
Conversation
BrowserStore chained every save onto writeQueue with no catch. After one failed write (full disk, permissions, antivirus lock on the temp file) the queue stayed rejected, so every later save rejected with the same error until restart. BrowserService awaits persistRuntime() before updating the view in close/newTab/selectTab/closeTab/navigate, so those actions failed half-way; did-navigate and popup adoption left unhandled rejections, and applyBrowserSettings called setRestoreTabs() without awaiting it. - BrowserStore.persist() continues after an earlier failure (same pattern as PluginMediaService.persist) and removes its temp file when the write or rename fails. Each save still reports its own error. - BrowserService.persistRuntime() and a new clearSavedTabs() log a failed save and keep the in-memory state, so the tabs on screen are unchanged; only the copy restored at the next start is stale. - index.ts catches the setRestoreTabs() promise. No other Browser card behaviour changes. Test: tests/browser-policy-store.test.mjs "BrowserStore recovers after one failed write ..." (read-only data folder, then writable again) and a source check that BrowserService callers cannot see the rejection.
…m the main renderer About half of the handlers in registerIpc.ts checked the sender with assertMainRenderer(); the rest trusted any sender. Among them were settings.update, plugin preview/install/set-modules/enable/uninstall, plugin and provider secrets, plugins.openExternal, terminal create/restart/input/dispose and media.read. Today only the main window loads the preload that exposes them, so this is defence in depth, but a second renderer that gains ipcRenderer (a new window, a preload change) would reach terminal input and secrets without any check. - Those handlers now call assertMainRenderer(). terminal.input is a fire-and-forget send, so a foreign sender is dropped (isMainRenderer) instead of throwing inside the IPC layer. - Home media: settings keep mediaPath only when it is an absolute path of at most 4096 characters, without NUL, with an image extension Home can show; anything else keeps the previous value. media.read (new homeMedia.ts) follows a symbolic link only while its target stays in the folder the file was chosen from, requires the target to be a supported image, and reads size and content from one open handle. The picker saves the resolved path of the chosen file. Tests: tests/browser-ipc-security.test.mjs (every listed channel checks its sender), tests/home-media.test.mjs (link inside the folder is read, links to another folder or to a non-image are refused; settings reject a relative path, a non-image path and a crafted settings file).
…md.exe pass A .cmd/.bat provider (the npm shims for claude, codex, ...) is started as `cmd /d /s /c "<exe> <args>"`. cmd.exe removes one level of carets when it reads that line, and the shim then runs `node cli.js %*`, which cmd.exe parses again. Arguments carried only one level of carets and used \" for inner quotes, which cmd.exe does not treat as an escape. In the second pass the quote state flipped at every \", so && and | inside an argument were outside quotes: the Claude --settings JSON with its hook command (`set "ELECTRON_RUN_AS_NODE=1" && "...CanvasTTY.exe" ...`) was cut at the first && and the rest ran as a separate command. - Arguments are caret-escaped twice (as cross-spawn does for npm shims); every quote is escaped at both levels, so cmd.exe never enters a quoted region and every operator stays escaped. The executable is unchanged. - The MSVC quoting step doubled only one of two or more backslashes before a quote (lookahead regex), so `a\\"b` lost its quote. Backslash runs are now doubled whole. Test: tests/provider-cli-registry.test.mjs "Windows batch arguments survive cmd.exe and the shim's %* re-parse unchanged (cmd.exe model)": a model of %VAR% expansion, caret/quote handling (both passes), operators outside quotes and MSVC argv splitting; the settings JSON, operators, %PATH%, ^, !, quotes, trailing and doubled backslashes round-trip exactly, and no operator is outside quotes in either pass. This is a model; verification on real Windows is still pending.
…llers AgentControlGateway kept a receipt for every mutating request (create, send, interrupt, choose, dismiss) so a retry with the same request id replays the first answer. Receipts were never removed: after 4096 such requests, including failed ones, every mutating request answered LIMIT_REACHED until the app restarted. A refusal that wrote nothing (BUSY, NOT_READY) was cached too, so retrying that request id replayed BUSY forever. - At the cap the oldest finished receipts are dropped; LIMIT_REACHED remains only when every kept receipt is still running. - BUSY, NOT_READY, LIMIT_REACHED, LIFECYCLE_DISABLED and CLOSED are refusals before any write; their receipt is removed so the same id is performed again. Other results, successful or not, still replay. - maxReceipts option (default 4096) so the cap can be tested. Test: tests/agent-control.test.mjs "request receipts are bounded without locking the gateway, and a refused request can be retried": with a cap of 3, five failed requests do not block a create, a recent receipt still replays, and a BUSY send is performed on retry with the same id.
…ain process WindowsPipeHostTransport had no 'error' listener on the host's stdin, stdout or stderr. A relay write or the shutdown end() after the host process died raises EPIPE on stdin; with no listener Node throws it as an uncaught exception in the main process. stdin and stdout errors now fail the transport the same way a FATAL frame does (virtual sockets closed with the error, 'fatal' emitted, child killed); stderr errors are ignored because stderr only feeds diagnostics. Errors from a previous host process are ignored after a restart. Test: tests/windows-pipe-host-transport.test.mjs "... host pipe errors into a transport failure instead of an uncaught exception" (fake host; EPIPE on each stream). Runs on any platform with platform: "win32" injected.
… failed start RuntimeGateway and AgentControlGateway did not listen for the pipe host transport's 'fatal' event (AgentGateway does). After the host died, RuntimeGateway kept its endpoint and dead transport, so every later agent launch failed with "must be started" until restart; agent control kept publishing an endpoint nobody served. A rejected RuntimeGateway start() also left the dead transport in place. AgentControlGateway.start() did not clean up when a step after listen() failed (for example writing the token file): the socket kept listening and a second start() answered "already started". - RuntimeGateway: on 'fatal' the transport and its sockets are dropped and the host is started again with the same bounded backoff as AgentGateway (3 attempts, 500 ms doubling); a failed start closes and forgets its transport; close() cancels a pending restart. - AgentControlGateway: start() is split into openEndpoint / writeDiscovery / closeEndpoint. A failure closes the server or transport and removes the temporary socket folder, so start() can be called again. On 'fatal' the pipe host is restarted the same way and connection.json is rewritten with the new endpoint (the token file is written once). close() also removes the socket folder. connection.json is written to a temp file and renamed, so a controller reading it during that republish never sees it empty or half written; onTransportRestarted() reports the republished record. Tests: tests/agent-runtime-gateway.test.mjs (fatal -> restart, launches refused only while down, no restart after close; failed start drops the transport) and tests/agent-control.test.mjs (failed start then a successful start on the same gateway; Windows host restart republishes the endpoint, awaited through onTransportRestarted; 20 isolated runs pass). Windows cases use injected platform and a fake transport.
TerminalManager handed environments.wrap() `planned.env.PATH`. planned.env is a plain-object copy of process.env; on Windows process.env is case-insensitive but the copy keeps the key as Windows spells it, usually "Path", so the value was undefined. EnvironmentRegistry.resolveCommand() then found no bare program name on PATH and refused every wrapper that answered with one (for example `wsl` or `docker`). launchSearchPath(env, platform) returns PATH, and on Windows falls back to the first key that matches PATH case-insensitively. POSIX is unchanged. Test: tests/session-environments.test.mjs "the environment wrapper gets the launch's search path even when Windows spells it Path" (injected platform, plus the call site).
…call OrchestrationGateway created an AbortController per request and aborted it on `cancel` or disconnect, but never passed its signal to the handler. The call ran to completion: a canceled spawn_agent still created and prompted the subagent, a plugin tool kept the request open, and when the call finished the orchestrator got the successful result instead of CANCELED. - OrchestrationCommandHandler.execute() takes the signal. The gateway answers CANCELED as soon as the signal aborts and drops a late result. - ScopedOrchestrationHandler refuses a call that is already canceled; a spawn_agent canceled while its agent was starting closes that agent, since nobody will receive its id. Plugin tool calls have no cancel in the service protocol, so they are no longer waited for. Tests: tests/orchestration-gateway.test.mjs "cancel reaches the running command and the answer is CANCELED, not the late result" and "a spawn_agent canceled while it was starting closes the agent it created".
… hash PluginServiceSupervisor hashed the entry with one read and then started `node <entry>`, which read the file again; an await (mkdir of the data folder) sat between the two. A file replaced in that window ran as trusted native code. The service process now starts with `--import` of a small data: URL boot that registers module load hooks (module.register). For the entry URL the hook reads the file itself, checks its SHA-256 against the trusted hash and returns exactly those bytes as the module source; a mismatch stops the load and the process exits. The host's own check stays, so a changed file still fails at once with "changed after it was trusted" and is not retried. The entry keeps its path: argv[1], __filename, import.meta.url and the format (ESM or CommonJS) are as before. Checked by hand with the bundled Electron 43 in ELECTRON_RUN_AS_NODE mode (.cjs, .js and .mjs entries run with the right hash and stop with a wrong one); packaged-build fuses do not affect --import. Tests: tests/plugin-services.test.mjs "an entry swapped after the host checked it is not run ..." (the started "node" swaps the entry before the real node reads it; the swapped code must not run) and "a verified entry still runs from its own location with the guard in place" (ESM and CommonJS, __filename and argv[1] unchanged).
RuntimeGateway accepts up to 64 sockets and only started a timer after the first message. A connection that sent nothing stayed open for good, so a process of the same user could open 64 idle connections and every lifecycle and decision hook was refused until restart. A connection now has 5 s (firstMessageTimeoutMs) to send its one message; hook helpers write it right after connecting. The timer is cleared once the message arrives, so a decision request still waits for its answer as before. OrchestrationGateway was listed with the same problem, but it already closes an unauthenticated connection on its heartbeat sweep (15 s, heartbeats need authentication); unchanged. AgentControlGateway has a 10 s per-connection timer; unchanged. Test: tests/agent-runtime-gateway.test.mjs "RuntimeGateway closes a connection that sends no message ...".
ProviderRuntimeLaunch.atomicWrite() and TerminalSessionStore.persist() write a temp file and rename it over the target. When the rename failed (EPERM from a locked file or antivirus on Windows, a full disk) the temp file stayed. atomicWrite() names it with a random UUID, so every failed launch left one more *.tmp next to the provider's settings for good. TerminalSessionStore also created its folder without a mode. Both now delete the temp file when the write or rename fails and rethrow; TerminalSessionStore creates its folder with mode 0700 (an existing folder is left as it is). Test: tests/atomic-write-cleanup.test.mjs (a non-empty folder in place of the target makes the rename fail; no *.tmp remains; a new store folder is private).
The browser audit log is a hash chain that is verified when the store
opens; any line that does not parse sets integrityError, and every agent
browser mutation then fails with AUDIT_UNAVAILABLE ("retryable") until
someone deletes the log by hand. A crash or a full disk during an append
leaves exactly such a line: half a record with no newline. A failed
append in a running app also left its partial bytes, so the next record
was written onto the same line.
- On open, an active file that does not end with a newline was cut
during a write: a whole last record only gets its newline back, a
partial one is removed (with a warning). The chain before it is
untouched, and anything else that does not verify still fails closed,
as the tamper test requires.
- A failed append truncates the file back to its size before the write.
Test: tests/browser-audit-store.test.mjs "BrowserAuditStore repairs a
line torn by a crash instead of refusing every later action" (partial
record, then a whole record missing only its newline).
The audit chain hashes a canonical JSON of each record whose keys were sorted with localeCompare. That order follows the ICU locale of the process: with LC_ALL=sv_SE "ä" sorts after "z", with en_US before it, and upper case sorts differently from code-unit order. A log written under one locale could fail verification under another, which blocks every agent browser mutation (AUDIT_UNAVAILABLE). The other canonical JSON implementations in the repo sort by code unit. - New records sort keys by UTF-16 code unit. - Verification accepts a record whose hash matches either the code-unit or the previous localeCompare order, so existing logs keep verifying exactly as before and new records chain onto them. (An old record written under a different locale still depends on that locale, as it did before; new records no longer do.) - Rotated audit files are ordered by code unit too (their names are ASCII, so the order is unchanged). Tests: tests/browser-audit-store.test.mjs "the audit hash does not depend on the system locale" (append under sv_SE, verify and append under en_US, verify under sv_SE again, in child processes) and "records hashed with the earlier locale-ordered keys still verify and extend the chain".
…oviders Two lookups of cmd.exe disagreed. providerCliRegistry (batch provider launches) takes ComSpec, then %SystemRoot%\System32\cmd.exe. terminalLaunch (the plain terminal when no PowerShell exists) searched PATH before SystemRoot, so a cmd.exe in a project folder or any earlier PATH entry started as the terminal shell. Both now use windowsCommandPromptPath(): ComSpec, then %SystemRoot%\System32\cmd.exe, never PATH. pwsh is still found on PATH, where its installer puts it; that is unchanged. Test: tests/terminal-launch.test.mjs "the Windows terminal falls back to the system cmd.exe, never one found on PATH, like provider launches" (injected platform, environment and file checks).
pollDeviceCode() awaited fetch without a catch. One dropped connection or the 15 s request timeout (an AbortError from the per-request controller) left the loop; startDeviceFlow() swallowed the error, so the UI kept showing the code until it expired and an approval on GitHub was never picked up. - A network error, a request timeout, a non-OK response or an unreadable body now backs off (interval doubled, at most 60 s, RFC 8628 3.5) and polls again until the code's lifetime ends. Cancelling the flow (new flow, sign-out) still stops it at once. - slow_down adds 5 s for this and later polls and honours a longer `interval` GitHub sends with it. access_denied, expired_token and the flow lifetime end the poll as before. Test: tests/github-auth.test.mjs "device polling survives network errors and timeouts, backs off, and honours slow_down" (fake fetch and clock: fetch failure, timeout, 503, slow_down, pending, then approval).
setAvailableProviders() filtered a snapshot of this.value when it was called and queued a write of that snapshot. update() sets this.value only after its own write, so a CLI recheck (index.ts recheckProviderClis) during a settings update took the value without the patch and wrote it after the update: the file lost the change while memory kept it, until the next write or restart. The filter now runs inside the write queue, on the value left by the writes before it, and memory is updated after its own write, like update(). Test: tests/settings-normalizer.test.mjs "a provider recheck during a settings update does not write the settings without the update".
…posed KimiWebUsageClient.startChild() awaits reserveLoopbackPort() and then spawns `kimi web`. dispose() (app quit, or providerClisRefreshed after a CLI recheck) during that await found no child to stop, and the spawn went ahead: an orphaned local Kimi web server with its token in the URL, outside any cleanup. startChild() now checks `disposed` after the await and refuses to spawn. Test: tests/limits-dispose.test.mjs (a stand-in kimi records whether it ran; dispose() right after get()).
writePlaylist() checked that the library's Playlists folder is a real
folder inside the library, then wrote `<name>.tmp` there with writeFile,
which follows a symbolic link. A link already sitting at that name (the
library is a user folder; anything with access to it can plant one) made
the write land outside the library, with the plugin's content.
The temp file now has a random name and is created with O_EXCL ("wx"),
which fails on any existing entry, link or not; it is removed if the
write or rename fails.
Test: tests/plugin-media-service.test.mjs "a playlist write does not
follow a link planted at its temporary name".
A card shows at most 4 plugin badges. When a plugin loses trust its
badges are hidden from decorations() but stay in the card's map, and
setBadge() counted the map. Four hidden badges kept every trusted plugin
off that card ("already shows the most plugin badges") while the card
showed none.
setBadge() now drops the badges of plugins that are no longer trusted
before it counts. Hiding on revoke is unchanged.
Test: tests/plugin-tools-events-cards.test.mjs "badges of plugins whose
trust was revoked do not use up a card's badge slots".
storageSet() read the plugin's storage file, set one key and wrote the
whole object back. readStorage() returned {} on any failure: a read
error (permissions, a file locked by another process on Windows), a
broken JSON file or one over the 64 KB quota. The next write then
replaced every other key with just the new one.
Writes now use readStorageForWrite():
- a missing file starts empty, as before;
- a read error refuses the write ("could not be read; nothing was
written"), the file stays as it is;
- a file that is not valid storage (bad JSON, not an object, over the
quota) is renamed to `<id>.json.unreadable-<time>` and a fresh file is
started, with a warning. Uninstall removes those copies too.
storageGet() is unchanged (unreadable storage reads as empty).
Test: tests/plugin-manager.test.mjs "plugin storage that cannot be read
is not replaced by the next write".
…ommonJS shim electron-vite places its CommonJS shim (`__dirname`, `require`) after the last static `import ... from` it finds anywhere in the main bundle, string literals included. The entry guard hooks are ESM source kept in a string, and their two static imports pulled the shim into that string: the built main process had no `__dirname` and could not open its window. The hooks now load their modules with `await import(...)`. A test decodes the hooks from the `--import` arguments and checks them against the shim's own import pattern.
|
Added 1c58f2a: the entry-guard hooks from 232a9fd were ESM source kept in a string with two static imports, and electron-vite's CommonJS shim ( |
|
I reviewed the updated head In PluginServiceSupervisor.ts:344–364, the expected entry URL is computed from If the entry is replaced with a symlink to another module after the parent checks it but before the child resolves its main module, the resolved URL can change. That appears to route the actual entry through The new swap test replaces file contents with This requires the ability to modify the plugin files during startup; I am not describing a remote exploit. It is a gap in the specific TOCTOU guarantee this guard is intended to provide. |
…the host resolved The entry guard hashed the module whose URL equalled realpath(spec.entryPath) as the host computed it before spawning, while the child was started with spec.entryPath and node resolves the main module itself. An entry, or a folder above it, replaced by a symlink between the two resolutions sent the main module to another URL, which the load hook passed to nextLoad unchecked: the untrusted module ran. The hooks now also resolve: the one module resolved without a parent is node's main entry, and its URL is checked with the same hash as the host's URL, so whatever file node resolves the entry to must hold the trusted bytes, and those bytes are what runs (CommonJS entries included; node takes the source the hook returns for them too). Tests start the service through a "node" that changes the plugin files first and assert the untrusted module's marker is never written: the entry replaced by a symlink (ES module and CommonJS), the entry's folder replaced by a symlink, and a CommonJS entry replaced by content.
|
Thanks for the review. The symlink substitution reproduced. It is fixed in one new commit on top of Final head: Commit: Root cause. The guard compared each loaded URL with Fix. The guard hooks now also export I checked that node runs the source the hook returns for a CommonJS main as well: a hook-supplied source ran with I did not add a rule that the two URLs must be equal. On Windows, Tests (
The existing content-swap test, the "no static import" test for the bundled CommonJS shim, and the "runs from its own location" test pass unchanged. I also ran Also checked. I looked for other places where the verified path is not the path that runs:
Gates on |
…e base for this change
There were three: the browser catalog's (strict, but it rebuilt objects, so integer-like keys came out in numeric order), the orchestration catalog's (no checks at all; an undefined property came out as the bare word `undefined`, which is not JSON), and the browser audit's `stableJson`. `canonicalStringify` in tool-catalog.mjs is now the only one; the orchestration catalog re-exports it. Keys are sorted by UTF-16 code unit, strictly as strings. Strict by default, as the browser bridge already was: cycles, non-finite numbers, non-plain objects and undefined array entries throw, undefined properties are left out. The browser audit uses `lenient`, which answers those as JSON.stringify would, and verifies records written before howdeploy#93 with `compareKeys: localeCompare`, so its hash chain is unchanged. A test replays 500 random JSON values through the previous audit serializer in both key orders and requires the same text.
Goal
Fix the verified findings of a code audit of
31f287d: failures that leave a feature broken until restart, Windows-only launch bugs, IPC handlers without a sender check, and a few plugin storage and file-write defects. One commit per finding (two pairs), each with a test that fails before the fix.Two areas from the same audit are not in this PR: base-protection parser bypasses (
safety/commandFacts.ts) and the permission gate failing open are handled in a separate PR.Findings, fixes and tests
Line numbers are at
31f287d.writeQueue: every later save rejected; close/new tab/select/close tab/navigate failed half-way; unhandled rejections fromdid-navigate, popup adoption andsetRestoreTabs(not awaited inapplyBrowserSettings)browser/BrowserStore.ts:67,BrowserService.ts:684,787,794,998,index.ts:679PluginMediaService.persist), temp file removed;persistRuntime/clearSavedTabslog and keep in-memory state; the settings call is caught. Bug fix only: no other Browser card behaviour changes.browser-policy-store.test.mjs: read-only folder, then writes succeed again; callers cannot see the rejectionregisterIpc.tshandlers had no sender check, among themsettings.update, plugin install/set-modules/enable/uninstall, plugin and provider secrets,plugins.openExternal, terminal create/restart/input/dispose,media.read.mediaPathaccepted any string, andmedia.readfollowed links anywhereipc/registerIpc.ts:144,357,380-397,675-679,SettingsStore.ts:439assertMainRendereron those handlers (terminal.inputdrops a foreign sender).mediaPathmust be an absolute image path; reading follows a link only inside the chosen folder, from one open handle; the picker saves the resolved pathbrowser-ipc-security.test.mjs,home-media.test.mjs.cmd/.batproviders: one caret level and\"quotes. The shim's%*is parsed again by cmd.exe, so&&inside the Claude--settingshook JSON ended the argument and the rest ran as a command. Separately,a\\"blost its quote (lookahead regex doubled only one backslash)providerCliRegistry.ts:278-292provider-cli-registry.test.mjs: round-trip through a cmd.exe parsing model (%VAR%, carets, quotes, both passes, operators, MSVC argv). Real-Windows verification is pending.AgentControlGatewayreceipts never cleared: after 4096 mutating requests every create/send answeredLIMIT_REACHED; a cachedBUSY/NOT_READYreplayed forever on retryagent-control/AgentControlGateway.ts:85,239-241agent-control.test.mjserrorlisteners on the Windows pipe host's stdin/stdout/stderr: EPIPE after the host died was an uncaught exception in mainagent-browser/WindowsPipeHostTransport.ts:138-195windows-pipe-host-transport.test.mjs(injectedwin32, fake host)RuntimeGatewayandAgentControlGatewayignored the transport'sfatal: after the pipe host died every launch failed "must be started"; a failedstart()kept the dead transport.AgentControlGateway.start()left the socket listening when a later step failedagent-runtime/RuntimeGateway.ts:155-163,AgentControlGateway.ts:94-119fatalwith AgentGateway's bounded backoff; control republishesconnection.json; failed start closes everything (and removes its socket folder) sostart()can run again;connection.jsonis written atomically, so a reader never sees it half written during a republishagent-runtime-gateway.test.mjs,agent-control.test.mjsplanned.env.PATHisundefinedon Windows (Path), so environment wrappers could not use a bare program nameTerminalManager.ts:1460launchSearchPath(): case-insensitive on Windowssession-environments.test.mjs(injected platform)spawn_agentstill spawned and the orchestrator got the result instead ofCANCELEDagent-browser/OrchestrationGateway.ts:330-340execute;CANCELEDsent at once, late result dropped; an agent spawned after cancel is closedorchestration-gateway.test.mjsnode <entry>read it again after anawaitPluginServiceSupervisor.ts:302-320--importdata: boot whose module hook reads the entry, checks the trusted SHA-256 and returns exactly those bytes; path,argv[1],__filename, ESM/CJS unchanged. Checked by hand with Electron 43 in run-as-node modeplugin-services.test.mjs: a wrapper swaps the entry right before node reads it; swapped code does not runagent-runtime/RuntimeGateway.ts:265-309agent-runtime-gateway.test.mjsatomicWrite, so they pile up); session store folder created without a modeagent-runtime/ProviderRuntimeLaunch.ts:1149-1155,TerminalSessionStore.ts:132-1370700atomic-write-cleanup.test.mjsAUDIT_UNAVAILABLEfor every agent browser mutation until the log is deleted by hand; a failed append also left partial bytes for the next recordbrowser/BrowserAuditStore.ts:83,114,167-181browser-audit-store.test.mjslocaleComparefor key order, so it depended on the ICU localebrowser/BrowserAuditStore.ts(stableJson)browser-audit-store.test.mjs: append undersv_SE, verify/append underen_US(child processes); legacy record chains onterminalLaunch.ts:206,providerCliRegistry.ts:398windowsCommandPromptPath(): ComSpec, then%SystemRoot%\System32\cmd.exe, never PATHterminal-launch.test.mjs(injected platform)GithubAuthService.ts:229slow_down+5 s or GitHub's longer interval; cancel still stopsgithub-auth.test.mjssetAvailableProviderswrote a snapshot taken during an in-flightupdate(), dropping the update from the fileSettingsStore.ts:235-276settings-normalizer.test.mjsdispose()while Kimi limits reserved a port left an orphanedkimi webLimitsService.ts:572-588disposedchecked after the awaitlimits-dispose.test.mjs<name>.tmpPluginMediaService.ts:185-187wx; removed on failureplugin-media-service.test.mjsPluginCards.ts:82,107plugin-tools-events-cards.test.mjs{}and the nextstorageSetwiped all other keysPluginManager.ts:1169-1180<id>.json.unreadable-<time>; uninstall removes thoseplugin-manager.test.mjsNot changed, and why
OrchestrationGatewayalready closes an unauthenticated connection on its heartbeat sweep (15 s; heartbeats need authentication).AgentControlGatewayhas a 10 s per-connection timer.CANCELEDand the late result is dropped.pwshis still found on PATH (where its installer puts it); only the cmd.exe lookup changed.Verification
perf/claude-http-hooks); the merged tree passes typecheck and the full suite (1056/1056).npm run typecheck, full suite (1043/1043),electron-vite build,npm run audit:secrets,npm run test:even(47/47): all pass. Every test run used a throw-awayHOME/XDG_*/provider homes..cmdprovider (Claude with lifecycle hooks) before release.ELECTRON_RUN_AS_NODE=1) for.cjs,.jsand.mjsentries.