Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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".

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 0 additions & 155 deletions native-host/launcher.cs

This file was deleted.

62 changes: 34 additions & 28 deletions scripts/install-native-host.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/")
}
Expand All @@ -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)) {
Expand Down
21 changes: 21 additions & 0 deletions test/native-host.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading