From be460444cab8de2bfc681c33f2b486c0782dae99 Mon Sep 17 00:00:00 2001 From: wolfgang-aura <169568318+wolfgang-aura@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:53:50 +0800 Subject: [PATCH] fix(native-host): register a .cmd host that Smart App Control allows The installer compiled native-host/launcher.cs into an unsigned exe, and Smart App Control blocked it on every reconnect, so unattended capture could never reach the extension. Register a two-line ASCII .cmd that runs the signed node.exe instead, and remove the old exe on reinstall. The note that Chromium is unreliable with .cmd hosts dates from a .cmd registered through a BOM-prefixed manifest, which Chromium rejects on its own; the manifest is BOM-free now. The installer also refuses to report success when its shell runs inside a packaged app's container (the Claude desktop app), where the files and HKCU keys land in a private copy a normally started browser never sees. Adds a regression check that the host exits and frees the pipe when the browser's stdin ends, the orphan guard the launcher used to duplicate. Fixes #8 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 27 ++++-- CHANGELOG.md | 9 ++ README.md | 7 ++ native-host/launcher.cs | 155 -------------------------------- scripts/install-native-host.ps1 | 62 +++++++------ test/native-host.test.mjs | 21 +++++ 6 files changed, 89 insertions(+), 192 deletions(-) delete mode 100644 native-host/launcher.cs diff --git a/AGENTS.md b/AGENTS.md index 2e1fde0..d73f8d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,12 +175,20 @@ the finished AI readable link as JSON, with no clicks, prompts, or clipboard use `\\.\pipe\sourcecapsule-capture`, and the CLI connects to that pipe. One host owns the pipe, which is also what enforces one capture at a time. `SOURCECAPSULE_PIPE` overrides the name so tests never fight the host a running browser already owns. -- **The registered host must be a real `.exe`.** Chromium is unreliable about launching - `.bat`/`.cmd` native hosts. `scripts/install-native-host.ps1` compiles - `native-host/launcher.cs` with the .NET compiler already on Windows, installs everything to - `%LOCALAPPDATA%\SourceCapsule\native-host`, and registers it under HKCU only. Two traps it - handles: PowerShell 5.1 writes a UTF-8 BOM that Chromium rejects outright, and a running - host holds its own exe open so a reinstall must stop it first. +- **The registered host is a two-line `.cmd` that runs the signed `node.exe`.** An earlier + version compiled an unsigned launcher exe, and Smart App Control blocks unsigned binaries + with no per-file allowlist ([#8](https://github.com/wolfgang-aura/SourceCapsule/issues/8)). + The old claim that Chromium is unreliable with `.cmd` hosts was never verified: the `.cmd` + of that time was registered through a manifest with a UTF-8 BOM, which Chromium rejects + outright. `scripts/install-native-host.ps1` installs to + `%LOCALAPPDATA%\SourceCapsule\native-host`, registers under HKCU only, writes the manifest + without a BOM and the `.cmd` as ASCII. Do not reintroduce an unsigned exe. +- **Agent shells run inside the Claude desktop app's MSIX container.** Writes to + `%LOCALAPPDATA%` and HKCU from that shell land in the app's private copy + (`%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Local\...`). A browser the agent starts sees + that copy; a browser the owner starts from the desktop does not. The installer detects the + redirected copy and exits nonzero, so a real install has to run from an ordinary PowerShell + window. - **The extension ID is pinned** by a `key` in `extension-src/manifest.json`, because the host manifest has to name a fixed `chrome-extension://` origin. Do not regenerate it. - **Capture reuses `runExport('share')`.** `runAutomatedShareCapture` only waits for the page @@ -222,9 +230,10 @@ the finished AI readable link as JSON, with no clicks, prompts, or clipboard use arguments to the existing process and the flag is dropped. A one-time Load unpacked records `UNPACKED` instead and needs no launcher, but it is a manual UI step and cannot coexist with the command-line copy (same `key`, same ID). -- **The host must not outlive its browser.** `launcher.cs` closes the Node child's stdin once - the browser's stream ends, and the host exits on stdin end/close/error or a failed stdout - write. Without both, a killed browser left an orphan holding +- **The host must not outlive its browser.** Node inherits the browser's stdio through the + `.cmd`, and the host exits on stdin end/close/error or a failed stdout write + (`test/native-host.test.mjs` covers the stdin case). Without that, a killed browser left an + orphan holding `\\.\pipe\sourcecapsule-capture`, and every later CLI request was answered by a host whose extension was gone - a full-timeout hang that reads exactly like "the host is unreachable". diff --git a/CHANGELOG.md b/CHANGELOG.md index 957ebab..965e218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Fixed + +- Unattended capture works with Windows Smart App Control on. The native host installer used to + compile an unsigned launcher exe, which Smart App Control blocked every 30 seconds. It now + registers a two-line `.cmd` that runs the signed `node.exe`, and it removes the old exe. +- The native host installer exits with an error when it runs inside a packaged app's container. + There its files and registry keys go to a private copy that a normally started browser cannot + see, and it used to report success anyway. + ## [1.6.5] - 2026-10-05 ### Fixed diff --git a/README.md b/README.md index eb91489..ecba940 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,13 @@ npm.cmd run install:native-host The installer needs no administrator rights. It writes everything under `HKCU` and installs the host into `%LOCALAPPDATA%\SourceCapsule\native-host`, registering it for Chrome, Edge, and Brave. +The registered host is a two-line `.cmd` that runs your signed `node.exe`, so Windows Smart App +Control allows it. + +Run the installer from an ordinary PowerShell window. A shell opened inside a packaged app, such +as the Claude desktop app, redirects the files and registry keys into that app's private copy, +where a normally started browser cannot see them. The installer detects this and exits with an +error instead of reporting success. Then load `dist\sourcecapsule-extension` as an unpacked extension and confirm its ID reads `gaclgcfljpjojddiikddejenlnjaggie`. The ID is pinned by a `key` in the manifest, because the diff --git a/native-host/launcher.cs b/native-host/launcher.cs deleted file mode 100644 index 3fb690a..0000000 --- a/native-host/launcher.cs +++ /dev/null @@ -1,155 +0,0 @@ -// Native messaging launcher. -// -// Chromium on Windows is fussy about batch-file hosts, so the registered host is a real -// executable. All this does is start the Node host and shuttle the three standard -// streams through untouched. Native messaging framing is binary and length-prefixed, so -// the copies must stay raw: no text decoding, no newline translation, no buffering that -// outlives a message. -// -// Built by scripts/install-native-host.ps1 via Add-Type, which uses the .NET compiler -// already present on Windows. No toolchain to install. -using System; -using System.Diagnostics; -using System.IO; -using System.Reflection; -using System.Threading; - -public static class SourceCapsuleLauncher -{ - private static void Pump(Stream from, Stream to) - { - byte[] buffer = new byte[8192]; - try - { - int read; - while ((read = from.Read(buffer, 0, buffer.Length)) > 0) - { - to.Write(buffer, 0, read); - to.Flush(); - } - } - catch (Exception) - { - // A closed pipe just means the other side went away; let the process exit. - } - } - - private static void Trace(string message) - { - // Written before anything can fail, so an empty log means the browser never - // invoked the host at all, and a log with only a start line means Node failed. - try - { - string logPath = Path.Combine( - Path.GetTempPath(), "sourcecapsule-launcher.log"); - File.AppendAllText( - logPath, DateTime.UtcNow.ToString("o") + " " + message + Environment.NewLine); - } - catch (Exception) - { - } - } - - public static int Main(string[] args) - { - Trace("launcher invoked with " + args.Length + " arg(s): " + string.Join(" | ", args)); - string here = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - string script = Path.Combine(here, "sourcecapsule-host.mjs"); - string node = Environment.GetEnvironmentVariable("SOURCECAPSULE_NODE"); - if (string.IsNullOrEmpty(node)) - { - node = Path.Combine(here, "node-path.txt"); - node = File.Exists(node) ? File.ReadAllText(node).Trim() : "node.exe"; - } - - // Chrome appends the calling extension origin (and a window handle) as arguments. - // The host ignores them, but they are forwarded so it can log them if needed. - string arguments = "\"" + script + "\""; - foreach (string arg in args) - { - arguments += " \"" + arg + "\""; - } - - Trace("node=" + node + " script=" + script); - if (!File.Exists(node)) - { - Trace("FATAL: node executable not found"); - return 2; - } - ProcessStartInfo info = new ProcessStartInfo(node, arguments); - info.UseShellExecute = false; - info.CreateNoWindow = true; - info.RedirectStandardInput = true; - info.RedirectStandardOutput = true; - info.RedirectStandardError = true; - - Process child; - try - { - child = Process.Start(info); - } - catch (Exception error) - { - Trace("FATAL: could not start node: " + error.Message); - return 3; - } - using (child) - { - Thread toChild = new Thread(delegate () - { - Pump(Console.OpenStandardInput(), child.StandardInput.BaseStream); - }); - Thread fromChild = new Thread(delegate () - { - Pump(child.StandardOutput.BaseStream, Console.OpenStandardOutput()); - }); - Thread errFromChild = new Thread(delegate () - { - Pump(child.StandardError.BaseStream, Console.OpenStandardError()); - }); - toChild.IsBackground = true; - fromChild.IsBackground = true; - errFromChild.IsBackground = true; - toChild.Start(); - fromChild.Start(); - errFromChild.Start(); - - // When the browser goes away its stdin hits EOF and the to-child pump - // returns. Nothing else closes the child's stdin, so without this the Node - // host never sees EOF, never exits, and this launcher blocks forever on - // WaitForExit. The orphan keeps the capture named pipe, and every later CLI - // request is answered by a host whose browser is long dead - which looks - // exactly like "the host is unreachable". - Thread reaper = new Thread(delegate () - { - toChild.Join(); - Trace("browser stream closed; closing node stdin"); - try - { - child.StandardInput.Close(); - } - catch (Exception) - { - } - if (!child.WaitForExit(5000)) - { - Trace("node did not exit after stdin close; killing it"); - try - { - child.Kill(); - } - catch (Exception) - { - } - } - }); - reaper.IsBackground = true; - reaper.Start(); - - child.WaitForExit(); - fromChild.Join(2000); - Trace("node exited with " + child.ExitCode); - return child.ExitCode; - } - } -} diff --git a/scripts/install-native-host.ps1 b/scripts/install-native-host.ps1 index 62baadb..445d606 100644 --- a/scripts/install-native-host.ps1 +++ b/scripts/install-native-host.ps1 @@ -11,11 +11,11 @@ param( ) $ErrorActionPreference = 'Stop' +$startedAt = (Get-Date).AddSeconds(-2) $hostName = 'com.wolfgang_aura.sourcecapsule' $repoRoot = Split-Path -Parent $PSScriptRoot $sourceDir = Join-Path $repoRoot 'native-host' -$launcherSource = Join-Path $sourceDir 'launcher.cs' $sourceScript = Join-Path $sourceDir 'sourcecapsule-host.mjs' # The host is INSTALLED outside the repo, under LOCALAPPDATA. The browser resolves the @@ -69,41 +69,34 @@ if ($null -eq $node) { $nodeExe = $node.Source Write-Host "Node: $nodeExe" -# Chromium is unreliable about launching .bat/.cmd native hosts on Windows, so the -# registered host is a real executable built from native-host/launcher.cs with the .NET -# compiler that ships with Windows. No toolchain to install. The launcher does nothing -# but start Node and shuttle the raw stdio streams. -$nodePathFile = Join-Path $hostDir 'node-path.txt' -[System.IO.File]::WriteAllText($nodePathFile, $nodeExe, (New-Object System.Text.UTF8Encoding($false))) - -# A host spawned by a running browser holds this file open, so reinstalling over a live -# bridge fails on a locked file. Stop the launcher first; the browser reconnects on its -# own through the service worker's reconnect alarm. +# The registered host is a two-line .cmd that runs the signed node.exe. Smart App Control +# blocks unsigned executables and has no per-file allowlist, so the compiled launcher this +# script used to build (native-host/launcher.cs) never ran on a machine with it on (#8). +# The .cmd is plain ASCII on purpose. The host exits on its own when the browser's stdin +# ends, so nothing has to sit between Chromium and Node. +# +# A host started by the old installer is a running sourcecapsule-host.exe that holds its +# own file open. Stop it so the cleanup below cannot hit a locked file; the browser +# reconnects on its own through the service worker's reconnect alarm. Get-Process -Name 'sourcecapsule-host' -ErrorAction SilentlyContinue | ForEach-Object { - Write-Host "Stopping running host (pid $($_.Id))" + Write-Host "Stopping old launcher (pid $($_.Id))" $_ | Stop-Process -Force } -if (Test-Path $hostExe) { - Start-Sleep -Milliseconds 500 - Remove-Item $hostExe -Force -} -Add-Type -TypeDefinition (Get-Content $launcherSource -Raw) ` - -OutputAssembly $hostExe -OutputType ConsoleApplication -if (-not (Test-Path $hostExe)) { - throw "Failed to build $hostExe" -} -Write-Host "Built $hostExe" - -# The old batch wrapper would still be registered in stale manifests; remove it so there -# is exactly one host binary on disk. -if (Test-Path $hostCmd) { - Remove-Item $hostCmd -Force +foreach ($stale in @($hostExe, (Join-Path $hostDir 'node-path.txt'))) { + if (Test-Path $stale) { + Start-Sleep -Milliseconds 500 + Remove-Item $stale -Force + Write-Host "Removed $stale" + } } +$cmdText = "@echo off`r`n`"$nodeExe`" `"%~dp0sourcecapsule-host.mjs`" %*`r`n" +[System.IO.File]::WriteAllText($hostCmd, $cmdText, (New-Object System.Text.ASCIIEncoding)) +Write-Host "Wrote $hostCmd" $manifest = [ordered]@{ name = $hostName description = 'SourceCapsule local automation bridge' - path = $hostExe + path = $hostCmd type = 'stdio' allowed_origins = @("chrome-extension://$ExtensionId/") } @@ -113,6 +106,19 @@ $json = $manifest | ConvertTo-Json -Depth 4 [System.IO.File]::WriteAllText($manifestPath, $json, (New-Object System.Text.UTF8Encoding($false))) Write-Host "Wrote $manifestPath" +# A shell started from inside a packaged (MSIX) app, such as the Claude desktop app, +# silently redirects these writes, and the registry keys below, into that app's private +# copy. The install then looks fine from this shell, while a browser started from the +# desktop finds no host at all. Detect the redirected copy and refuse to report success. +$packageCopies = Join-Path $env:LOCALAPPDATA "Packages\*\LocalCache\Local\SourceCapsule\native-host\$hostName.json" +$redirected = @(Resolve-Path -Path $packageCopies -ErrorAction SilentlyContinue | + Get-Item | Where-Object { $_.LastWriteTime -ge $startedAt }) +if ($redirected.Count -gt 0) { + throw ("This PowerShell runs inside an app container, so the install landed in " + + "$($redirected[0].DirectoryName), which a normally started browser cannot see. " + + 'Re-run this script from a PowerShell window opened from the Start menu.') +} + foreach ($root in $registryRoots) { $key = Join-Path $root $hostName if (-not (Test-Path $key)) { diff --git a/test/native-host.test.mjs b/test/native-host.test.mjs index 00db5e6..c07b4b8 100644 --- a/test/native-host.test.mjs +++ b/test/native-host.test.mjs @@ -191,6 +191,27 @@ async function transportChecks() { const free = await sendOverPipe({ id: 'a7', action: 'ping', timeoutMs: 5000 }); assert.equal(free.ok, true, 'lock is released once the extension has replied'); console.log('ok lock survives a closed CLI socket and the late reply is logged'); + + // The registered host is a .cmd that runs node.exe with the browser's own stdio, so + // nothing between them closes stdin for the host. The host itself must exit when the + // browser's stream ends, or an orphan keeps the pipe and every later CLI call hangs. + const exited = new Promise((resolve) => host.on('exit', resolve)); + host.stdin.end(); + const outcome = await Promise.race([ + exited.then(() => 'exited'), + new Promise((resolve) => setTimeout(() => resolve('still running'), 5000)), + ]); + assert.equal(outcome, 'exited', 'host must exit when the browser closes its stdin'); + const released = await new Promise((resolve) => { + const probe = net.connect(PIPE); + probe.on('connect', () => { + probe.destroy(); + resolve(false); + }); + probe.on('error', () => resolve(true)); + }); + assert.ok(released, 'the pipe is released once the host exits'); + console.log('ok host exits and releases the pipe when the browser stream ends'); } finally { host.kill(); }