Skip to content
Draft
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
1 change: 1 addition & 0 deletions .github/workflows/nightly-compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ jobs:
--version "$version" \
--target "$target" \
--binary "$cargo_target_dir/release/agenttab-host" \
--shim "$cargo_target_dir/release/agenttab-native" \
--out-dir "$release_dir"
python3 scripts/verify_release_archives.py \
--host-archive "$release_dir/agenttab-host-v$version-$target.tar.gz" \
Expand Down
51 changes: 34 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ jobs:
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_protocol_schemas.py
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_identity.py
PYTHONDONTWRITEBYTECODE=1 python3 tests/architecture/verify_forbidden_surface.py
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts.test_package_host_archive scripts.test_package_artifact_manifest scripts.test_verify_release_asset_set
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts.test_package_host_archive scripts.test_verify_release_archives scripts.test_package_artifact_manifest scripts.test_verify_release_asset_set
cargo test --workspace --locked --manifest-path host-rs/Cargo.toml

build-host:
Expand Down Expand Up @@ -161,13 +161,17 @@ jobs:
set -euo pipefail
cargo build --release --locked --manifest-path host-rs/Cargo.toml --package agenttab-host --target "$TARGET"
binary="$GITHUB_WORKSPACE/host-rs/target/$TARGET/release/agenttab-host"
if [[ "$RUNNER_OS" == "Windows" ]]; then binary="${binary}.exe"; fi
unsigned_copy="$RUNNER_TEMP/agenttab-host-${TARGET}-unsigned"
cp "$binary" "$unsigned_copy"
shim="$GITHUB_WORKSPACE/host-rs/target/$TARGET/release/agenttab-native"
if [[ "$RUNNER_OS" == "Windows" ]]; then binary="${binary}.exe"; shim="${shim}.exe"; fi
unsigned_host="$RUNNER_TEMP/agenttab-host-${TARGET}-unsigned"
unsigned_shim="$RUNNER_TEMP/agenttab-native-${TARGET}-unsigned"
cp "$binary" "$unsigned_host"
cp "$shim" "$unsigned_shim"
cargo clean --release --locked --manifest-path host-rs/Cargo.toml --package agenttab-host --target "$TARGET"
cargo build --release --locked --manifest-path host-rs/Cargo.toml --package agenttab-host --target "$TARGET"
cmp "$unsigned_copy" "$binary"
rm -f "$unsigned_copy"
cmp "$unsigned_host" "$binary"
cmp "$unsigned_shim" "$shim"
rm -f "$unsigned_host" "$unsigned_shim"
- name: Sign and notarize macOS host
if: ${{ runner.os == 'macOS' }}
env:
Expand All @@ -192,9 +196,11 @@ jobs:
keychain="$RUNNER_TEMP/agenttab-signing.keychain-db"
keychain_password="$(openssl rand -hex 32)"
notarization_zip="$RUNNER_TEMP/agenttab-notarization-${TARGET}.zip"
notarization_root="$RUNNER_TEMP/agenttab-notarization-${TARGET}"
cleanup() {
security delete-keychain "$keychain" >/dev/null 2>&1 || true
rm -f "$certificate" "$notary_key" "$notarization_zip"
rm -rf "$notarization_root"
}
trap cleanup EXIT
printf '%s' "$MACOS_CERTIFICATE_P12_BASE64" | /usr/bin/base64 -D > "$certificate"
Expand All @@ -206,9 +212,14 @@ jobs:
security import "$certificate" -k "$keychain" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain"
binary="$GITHUB_WORKSPACE/host-rs/target/$TARGET/release/agenttab-host"
/usr/bin/codesign --force --options runtime --timestamp --keychain "$keychain" --sign "$MACOS_SIGNING_IDENTITY" "$binary"
/usr/bin/codesign --verify --strict --verbose=2 "$binary"
/usr/bin/ditto -c -k --keepParent "$binary" "$notarization_zip"
shim="$GITHUB_WORKSPACE/host-rs/target/$TARGET/release/agenttab-native"
for executable in "$binary" "$shim"; do
/usr/bin/codesign --force --options runtime --timestamp --keychain "$keychain" --sign "$MACOS_SIGNING_IDENTITY" "$executable"
/usr/bin/codesign --verify --strict --verbose=2 "$executable"
done
mkdir -p "$notarization_root"
cp "$binary" "$shim" "$notarization_root/"
/usr/bin/ditto -c -k --keepParent "$notarization_root" "$notarization_zip"
xcrun notarytool submit "$notarization_zip" \
--key "$notary_key" \
--key-id "$APPLE_NOTARY_KEY_ID" \
Expand Down Expand Up @@ -246,12 +257,17 @@ jobs:
Sort-Object FullName -Descending |
Select-Object -First 1
if ($null -eq $signTool) { throw "signtool.exe was not found" }
$binary = Join-Path $env:GITHUB_WORKSPACE "host-rs\target\$env:TARGET\release\agenttab-host.exe"
& $signTool.FullName sign /sha1 $certificate.Thumbprint /fd SHA256 `
/tr http://timestamp.digicert.com /td SHA256 $binary
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed with exit code $LASTEXITCODE" }
& $signTool.FullName verify /pa /all $binary
if ($LASTEXITCODE -ne 0) { throw "signtool verify failed with exit code $LASTEXITCODE" }
$executables = @(
(Join-Path $env:GITHUB_WORKSPACE "host-rs\target\$env:TARGET\release\agenttab-host.exe"),
(Join-Path $env:GITHUB_WORKSPACE "host-rs\target\$env:TARGET\release\agenttab-native.exe")
)
foreach ($executable in $executables) {
& $signTool.FullName sign /sha1 $certificate.Thumbprint /fd SHA256 `
/tr http://timestamp.digicert.com /td SHA256 $executable
if ($LASTEXITCODE -ne 0) { throw "signtool sign failed with exit code $LASTEXITCODE" }
& $signTool.FullName verify /pa /all $executable
if ($LASTEXITCODE -ne 0) { throw "signtool verify failed with exit code $LASTEXITCODE" }
}
} finally {
if ($null -ne $certificate) {
Remove-Item "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force -ErrorAction SilentlyContinue
Expand All @@ -267,8 +283,9 @@ jobs:
run: |
set -euo pipefail
binary="$GITHUB_WORKSPACE/host-rs/target/$TARGET/release/agenttab-host"
if [[ "$RUNNER_OS" == "Windows" ]]; then binary="${binary}.exe"; fi
python scripts/package_host_archive.py --version "$RELEASE_VERSION" --target "$TARGET" --binary "$binary" --out-dir "$RELEASE_DIR"
shim="$GITHUB_WORKSPACE/host-rs/target/$TARGET/release/agenttab-native"
if [[ "$RUNNER_OS" == "Windows" ]]; then binary="${binary}.exe"; shim="${shim}.exe"; fi
python scripts/package_host_archive.py --version "$RELEASE_VERSION" --target "$TARGET" --binary "$binary" --shim "$shim" --out-dir "$RELEASE_DIR"
archive="$RELEASE_DIR/agenttab-host-v${RELEASE_VERSION}-${TARGET}.$([[ "$TARGET" == *windows-msvc ]] && printf zip || printf tar.gz)"
python scripts/verify_release_archives.py --host-archive "$archive" --target "$TARGET"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
Expand Down
72 changes: 72 additions & 0 deletions docs/adr/0002-persistent-core-daemon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# ADR 0002: Persistent Core daemon and Native Messaging relay

- Status: Proposed
- Date: 2026-08-31

## Context

Chrome owns the lifetime of a Native Messaging process. Manifest V3 may suspend the extension service worker, a native port may be replaced during extension reload, and Chrome exits the native process when the port closes. AgentTab currently combines the Native Messaging endpoint and Core IPC server in `agenttab-host`, so any of those browser events also tears down Core client connections, the SQLite runtime, and in-flight scheduling.

The desired product behavior is the opposite: browser reconnection should be routine transport churn, not a Core restart, and unattended agents should not require a user to notice or approve a prompt. The existing user-scoped Core socket/named pipe, journal, ownership rules, and wide automation surface remain unchanged.

## Decision

AgentTab will ship two Rust executables:

- `agenttab-host daemon` is one long-lived, per-user Core process. It opens the journal and existing Core IPC endpoint once, and separately accepts extension relay connections.
- `agenttab-native` is the executable registered with Chrome. It only connects byte streams: Chrome's framed stdin/stdout to the daemon's user-scoped relay. If the relay is unavailable, it starts the sibling daemon and retries for a bounded four seconds.

`agenttab-host` with no arguments retains the combined stdio plus Core IPC behavior for source builds, compatibility tests, and recovery. Installed Native Messaging manifests point to `agenttab-native`.

```mermaid
flowchart TD
Chrome["Chrome extension"] --> Shim["agenttab-native shim"]
Shim --> Relay["User relay"]
Relay --> Daemon["agenttab-host daemon"]
Client["SDK / MCP / OMP"] --> Core["Existing Core IPC"]
Core --> Daemon
Daemon --> Journal["SQLite journal"]
```

The daemon accepts one Native Messaging relay at a time. Every accepted connection receives a monotonically increasing in-process generation. Ready messages, event acknowledgements, commands, disconnect cleanup, and pending-response failure are scoped to that generation, so cleanup from an old Chrome port cannot detach or write into a newer port. A normal EOF moves the runtime to `reconciling` and leaves Core alive. A malformed or incompatible native protocol is terminal and exits the daemon so its user service can restart a clean process.

The relay uses the same trust boundary as Core IPC:

| Platform | Core IPC | Native relay | Persistent startup |
| --- | --- | --- | --- |
| macOS | private Unix socket | separate mode-`0600` Unix socket with same-UID peer check | per-user LaunchAgent with `RunAtLoad` and `KeepAlive` |
| Linux | private Unix socket | separate mode-`0600` Unix socket with same-UID peer check | `systemd --user` service with restart-on-failure |
| Windows | SID-scoped named pipe | separate SID-scoped named pipe with process-token SID verification | current-user, limited scheduled task at logon |

No administrator elevation, new consent dialog, or per-operation approval is introduced. The relay is transport only; it does not add an authorization boundary or narrow existing browser capabilities.

## Installation and upgrades

Release archives contain both signed executables. The installer validates that the archive contains exactly those two regular files, verifies both platform signatures where applicable, and installs them transactionally under the same version and target directory. A mode-`0600` `agenttab-runtime.json` beside the executables carries the absolute state directory so custom installs work even though Native Messaging manifests cannot declare environment variables.

For a stable install, the installer writes the user service definition and activates or restarts it after the file transaction. Service activation is deliberately best effort: if the user's service manager is unavailable, installation remains usable because the native shim starts the daemon on demand. Development installs use on-demand startup and do not modify the user's login services. Updating the platform service points it at the newly installed version before restarting it.

Release packaging signs and verifies `agenttab-host` and `agenttab-native` independently on macOS and Windows, then puts both into the deterministic host archive. Linux continues to authenticate the exact two-file archive through the signed artifact manifest.

## Consequences

- Chrome service-worker suspension, extension reload, and native-port replacement no longer close Core clients or reopen the journal.
- The first browser connection after a missing/crashed daemon may take up to four seconds to establish. Subsequent connections only pay a local IPC connect.
- Core remains in `reconciling` while no extension is attached. Status and recovery remain available, while browser operations retain the existing not-ready response.
- Stable installs gain a user-level background process. The existing on-demand behavior remains the recovery path and source compatibility mode.
- Windows Task Scheduler starts the daemon at logon, but the current task plan does not independently restart a crash while Chrome is closed. The next Chrome reconnect starts it on demand. A future installer can move to a Task Scheduler XML definition with explicit restart policy once that path has been exercised on supported Windows versions.
- Service activation and rollback cannot be one filesystem transaction. A failed activation is reported and falls back to the shim; it does not roll back a correctly verified install.
- Automated tests cover relay generation replacement, byte relay behavior, archive membership, custom state configuration, and exact service plans. Actual launchd, systemd, Task Scheduler, notarization, and Authenticode execution still require their platform release runners.

## Migration risks

An already-running legacy combined host continues until Chrome closes its old native port. It owns the Core singleton lock, so a newly installed daemon cannot take over concurrently. Stable service activation stops/restarts the managed process during upgrade; rollback restarts the restored service definition, and uninstall disables the managed service before removing owned files. If a lifecycle transaction fails after that stop, the installer attempts to reactivate the current service. An unmanaged or failed-service upgrade may still use the prior daemon until it exits, and the protocol handshake fails closed across incompatible versions.

The managed uninstall transaction removes exact owned service definitions, scheduled tasks, Native Messaging manifests, receipts, and versioned binaries while preserving resources that drifted after installation. Platform service-manager execution remains best effort so an unavailable login service never blocks recovery or removal.

## Alternatives considered

- Keeping the combined host and making every SDK reconnect preserves browser-owned process churn and loses in-flight Core sessions.
- Moving Core into the extension cannot provide local SDK/MCP/OMP IPC when the MV3 worker is suspended.
- A privileged system service adds elevation, administrative policy, and confirmation blockers without improving the single-user product model.
- Replacing Native Messaging with a localhost network listener expands discovery and firewall complexity. A tiny Native Messaging shim preserves Chrome's supported launch and framing contract.
Loading
Loading