diff --git a/.github/workflows/nightly-compatibility.yml b/.github/workflows/nightly-compatibility.yml index a276439..5d0805b 100644 --- a/.github/workflows/nightly-compatibility.yml +++ b/.github/workflows/nightly-compatibility.yml @@ -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" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5da33d..89bc2ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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: @@ -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" @@ -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" \ @@ -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 @@ -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 diff --git a/docs/adr/0002-persistent-core-daemon.md b/docs/adr/0002-persistent-core-daemon.md new file mode 100644 index 0000000..afa2f7c --- /dev/null +++ b/docs/adr/0002-persistent-core-daemon.md @@ -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. diff --git a/docs/commands.md b/docs/commands.md index c9f73c0..b0bee20 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -10,13 +10,17 @@ Commands intended to be copied contain no user-controlled paths, shell interpola agenttab --help agenttab --version agenttab install [--version X.Y.Z] [--verify-readiness] [--development --manifest-url URL --signature-url URL] +agenttab update --version X.Y.Z [--verify-readiness] +agenttab rollback [--dry-run] +agenttab uninstall [--dry-run] +agenttab prune [--keep N] [--dry-run] agenttab status -agenttab doctor [--layer ipc|extension] +agenttab doctor [--layer installation|ipc|protocol|host|extension|all] agenttab mcp agenttab proxy --token-file PATH [--port 9224] ``` -There is no `agenttab uninstall`, no Standard-mode `--port`, no Standard-mode token command, no Python-host command, and no legacy task or lease command. +There is no Standard-mode `--port`, no Standard-mode token command, no Python-host command, and no legacy task or lease command. The CLI rejects unknown commands, unknown or duplicate options, missing option values, and positional arguments after a command. Boolean options do not accept values: use `--dry-run` to enable a dry run and omit it otherwise. In particular, `--dry-run false` and `--dry-run=false` are errors rather than real-install requests. @@ -28,7 +32,7 @@ The intended post-launch install command is: npx agenttab install ``` -It is not usable until the package and signed release artifacts are public. The installer always resolves an exact version rather than `latest`, verifies the signed artifact manifest, requires the matching immutable `vX.Y.Z` asset, verifies the asset hash and platform signature, installs transactionally, writes an install receipt, registers `dev.agenttab.host`, and plans supported local client configuration updates. See [Setup](setup.md) for current source, prerelease, and future stable state. +It is not usable until the package and signed release artifacts are public. The installer always resolves an exact version rather than `latest`, verifies the signed artifact manifest, requires the matching immutable `vX.Y.Z` asset, verifies the archive plus both host executables, installs transactionally, writes an install receipt, registers the `agenttab-native` relay as `dev.agenttab.host`, and plans supported local client configuration updates. Stable installs also attempt to start the persistent Core with the platform's per-user service manager; on-demand shim startup remains the no-elevation fallback. See [Setup](setup.md) for current source, prerelease, and future stable state. ### Install options @@ -42,10 +46,20 @@ It is not usable until the package and signed release artifacts are public. The | `--state-dir PATH` | Overrides the installer staging, version, receipt, extension, and wrapper directory. This is distinct from the Rust host default on Windows. | | `--home PATH` | Overrides the home root used to locate per-user browser registration and supported client configuration files. | | `--dry-run` | Renders the proposed transaction and returns a planned extension state without applying the file transaction. | -| `--verify-readiness` | After installation, connects through the local IPC path, creates a disposable background task tab, captures an accessibility snapshot, and closes the tab. | +| `--verify-readiness` | Before committing activation, verifies the receipt, IPC endpoint, exact RPC version, exact running host version, and extension routing with one disposable background tab. A failure rolls files, client configuration, and Windows default registry values back together. | | `--no-open-browser` | With readiness verification, prevents the installer from launching Chrome before it waits for IPC. | -The CLI prints a semantic diff before changing files, skips malformed supported client configuration instead of mutating it, backs up prior changed files, and rolls back touched files if the multi-file transaction fails. It does not silently enable a browser extension. When an extension must be loaded manually, the result includes its directory and instructions. +The CLI prints a semantic diff before changing files, skips malformed supported client configuration instead of mutating it, backs up prior changed files, and rolls back touched files if the multi-file transaction or readiness gate fails. The schema-v2 receipt records exact hashes and modes for installed files, the previous file values, semantic client-configuration values, Windows registry default values, and the prior active receipt. Receipts are mode `0600`. It does not silently enable a browser extension. When an extension must be loaded manually, the result includes its directory and instructions. + +## Update, rollback, uninstall, and prune + +`agenttab update --version X.Y.Z` requires an exact version newer than the active receipt. The executing installer bundle must itself be that exact version, because its CLI, OMP adapter, and extension bytes are part of the activation; an older installed CLI tells the user to run the exact newer installer package instead of mixing versions. It verifies the same immutable signed inputs as install, stages the new version beside the old one, then activates the wrapper, native-host registration, supported client entries, receipt, and optional readiness check as one transaction. It never resolves `latest`; a downgrade uses `agenttab rollback`. + +`agenttab rollback` restores the immediately previous activation recorded by the active receipt. It preflights every owned activation file, client entry, and Windows registry default and aborts the whole rollback before changing anything if any one of them drifted. Version artifacts remain available until prune or uninstall. `agenttab uninstall` walks the authenticated active receipt chain, restores values that still exactly match AgentTab's installed values, and removes exact version artifacts. `agenttab prune --keep N` replays inactive artifact ownership newest to oldest, restoring any pre-existing file recorded by the receipt rather than blindly deleting it; it retains receipts as an audit trail. + +All lifecycle commands are conservative. A file with a changed hash or mode, an edited `mcpServers.agenttab` value, an edited OMP sequence item, or a changed Windows registry default value is reported as preserved; rollback treats any such preservation as a reason to abort without flipping the active receipt. JSON/YAML cleanup changes only the owned property or sequence item and preserves unrelated edits. Windows cleanup uses default-value deletion (`/ve`) and never recursively deletes a browser/vendor registry key. No command recursively deletes a home, state, version, config, or registry tree. Use `--dry-run` to inspect the complete changed-resource list without applying it. + +Install, update, rollback, uninstall, and prune take one cross-process lock for the selected state directory before reading authoritative state. Before their first mutation they record exact before/installed file states and Windows default values, and they preflight the same-directory hard links required on every target filesystem. Unsupported filesystems such as configurations of exFAT or SMB fail before a target is changed. If a process stops before its commit marker is durable, the next mutating command recovers that intent while holding the same lock. Recovery restores only resources that still equal either side of the recorded transaction; a concurrent edit is preserved and blocks further mutation with the exact conflicting resource named. Unix transaction namespace boundaries request filesystem durability with directory barriers. Node does not expose the equivalent Windows directory barrier, so Windows recovery is process-crash atomic but does not claim sudden power-loss atomicity; `agenttab doctor --layer installation` reports that limitation. The current source contains the stable Ed25519 verification public key, but no matching signed release artifacts or public package have been verified or published. Therefore the default command cannot complete a live installation yet. @@ -55,7 +69,7 @@ The current source contains the stable Ed25519 verification public key, but no m agenttab status ``` -Connects to local AgentTab IPC and prints the Core `agenttab.status` result as JSON. The status response reports the host lifecycle state, protocol version, whether a handoff is active, and the current connection's task identifier when one exists. +Connects to local AgentTab IPC and prints the Core `agenttab.status` result as JSON. The status response reports the host lifecycle state, exact host version, protocol version, whether a handoff is active, and the current connection's task identifier when one exists. Use this only after the extension and native host are installed. It does not start a browser, create a task, use a port, or authenticate with a token. @@ -69,12 +83,15 @@ agenttab doctor --layer ipc agenttab doctor --layer extension ``` -`--layer` accepts only `ipc` or `extension`; it defaults to `ipc`. The command runs the same status request and prints JSON. On failure, it returns a layer-specific recovery message: +`--layer` accepts `installation`, `ipc`, `protocol`, `host`, `extension`, or `all`; it defaults to `all`. These are distinct checks: -- `ipc`: open Chrome with AgentTab enabled, then rerun `agenttab doctor --layer ipc`. -- `extension`: reload AgentTab in `chrome://extensions`, then rerun `agenttab doctor --layer extension`. +- `installation` verifies the active receipt and its exact files, semantic client entries, and Windows default values. Its evidence also reports the platform's transaction-recovery scope, including the Windows power-loss limitation. +- `ipc` proves a connection to the per-user host endpoint. +- `protocol` compares the SDK, connection acknowledgment, and host-reported RPC versions. +- `host` requires the ready lifecycle and an exact running-host version match with the active receipt. +- `extension` compares the connected extension's native hello version with the bundled extension version in the active receipt, then creates one disposable background `about:blank` task tab and validates its tab/revision response. The initial task capability is intentionally not confirmed; closing the diagnostic connection invokes the host's exact-task cleanup. -The extension layer is a diagnostic label around the status check. It does not reload Chrome for you. +The command prints independent evidence and recovery for every selected layer. It does not relabel a single status RPC as an extension check and does not reload Chrome for you. ## `agenttab mcp` and `agenttab-mcp` diff --git a/docs/rust-host.md b/docs/rust-host.md index a5beae5..18a8b49 100644 --- a/docs/rust-host.md +++ b/docs/rust-host.md @@ -1,6 +1,8 @@ # AgentTab Rust host -`agenttab-host` is the production AgentTab host. It is a Rust workspace with `agenttab-protocol` and `agenttab-host` crates. There is no Python production host or Python fallback in the v2 runtime. +`agenttab-host` is the production AgentTab Core. It is a Rust workspace with `agenttab-protocol` and `agenttab-host` crates. Release archives also contain the thin Rust `agenttab-native` Native Messaging relay. There is no Python production host or Python fallback in the v2 runtime. + +The proposed persistent layout runs `agenttab-host daemon` independently of Chrome's native-port lifetime and registers `agenttab-native` with Chrome. A normal extension disconnect leaves Core IPC alive in `reconciling`; a replacement native port reconciles against the same journal and returns it to ready. Running `agenttab-host` without arguments preserves the combined stdio/Core process for source compatibility. See [ADR 0002](adr/0002-persistent-core-daemon.md). ## Architecture @@ -17,7 +19,7 @@ Core RPC and the native bridge are separate protocols. Both reject unsupported v ## Native bridge -Chrome launches the native host named `dev.agenttab.host`; the extension maintains the Native Messaging connection. Native frames are an unsigned 32-bit little-endian length followed by UTF-8 JSON. Host-to-extension messages are capped at 1 MiB and extension-to-host messages at 64 MiB. The extension sends `hello` inventory, paused state, handoff state, and staged Commit state. The host becomes ready only after compatible hello and reconciliation, then returns `ready` with `ready` or `paused` state. +Chrome connects to the native host named `dev.agenttab.host`; installed manifests launch `agenttab-native`, while the source-compatibility path can still launch the combined host directly. Native frames are an unsigned 32-bit little-endian length followed by UTF-8 JSON. Host-to-extension messages are capped at 1 MiB and extension-to-host messages at 64 MiB. The extension sends `hello` inventory, paused state, handoff state, and staged Commit state. Core becomes ready only after compatible hello and reconciliation, then returns `ready` with `ready` or `paused` state. A native disconnect returns the host to reconciliation. A protocol mismatch is terminal rather than a compatibility fallback. @@ -33,7 +35,7 @@ Standard mode has no TCP listener or bearer token. The advanced `agenttab proxy ## Lifecycle and admission -The implemented lifecycle states are `starting`, `reconciling`, `ready`, `paused`, and terminal. Browser work is admitted only in `ready`. In `starting` or `reconciling` it returns `runtime_not_ready`; in `paused` it returns `automation_paused`; in terminal state it returns a protocol-recovery error. +The implemented lifecycle states are `starting`, `reconciling`, `ready`, `paused`, and terminal. Browser work is admitted only in `ready`. In `starting` or `reconciling` it returns `runtime_not_ready`; in `paused` it returns `automation_paused`; in terminal state it returns a protocol-recovery error. `agenttab.status` also reports the compiled host package version, RPC protocol version, and connected extension hello version so installer diagnostics can compare the running components with the exact active receipt. Pause admission is also enforced by the extension scheduler. It closes new admission, waits for in-flight work, persists pause state, and rejects queued work before dispatch. Handoff is a global write barrier and causes a host-side blackout check both before and after request admission. diff --git a/docs/setup.md b/docs/setup.md index 57c8080..1738dee 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -67,7 +67,7 @@ The manifest keeps `nativeMessaging`, `debugger`, `tabs`, `tabGroups`, `storage` The frozen native host identity is `dev.agenttab.host`. The extension build derives its stable development identity and the native-host allowed origins from [config/identity.json](../config/identity.json). Do not edit a generated native-host JSON, substitute an extension ID, or add an origin by hand. -A successful installer registers the same native-host manifest for supported browser locations: +A successful installer registers the same native-host manifest for supported browser locations. The manifest launches the small `agenttab-native` relay; the per-user `agenttab-host daemon` keeps Core IPC and the journal alive across Chrome service-worker or native-port churn. Stable installs attempt to activate a user-level launchd, systemd, or Windows scheduled-task entry without elevation. If that service manager is unavailable, the relay starts the daemon on demand instead: | Platform | Native Messaging registration | |---|---| @@ -97,13 +97,15 @@ AgentTab v2 is side-by-side and recoverable: The installer detects the old native-host registration and known legacy state artifacts, reports them, and leaves them untouched. It never silently removes the v1 extension, registration, files, policies, or logs. -## Rollback and uninstall status +## Update, rollback, and uninstall -The installer stages changed files, creates backups for replaced files, and rolls back touched files if a multi-file installation transaction fails. A second successful installation of the same verified version leaves matching files unchanged. +The installer serializes each install, update, rollback, uninstall, or prune for a state directory with a cross-process lock. It records a recovery intent, stages changed files, creates backups for replaced or deleted files, and preflights required same-directory hard links on every target filesystem before the first target mutation. Unsupported exFAT/SMB configurations therefore fail without partially activating AgentTab. It rolls back touched files and exact Windows registry defaults if a multi-file transaction or requested readiness gate fails. A later mutating command recovers an interrupted, uncommitted intent before planning new work; it preserves and reports any resource that no longer matches either recorded transaction side. Unix uses directory durability barriers. Node exposes no equivalent Windows directory barrier, so Windows guarantees process-crash recovery but not sudden power-loss namespace atomicity; the installation doctor check reports this explicitly. A second successful installation of the same verified version leaves matching files unchanged. An update is explicit and version-aware: `agenttab update --version X.Y.Z` accepts only an exact version newer than the active managed receipt. The prior version stays staged for `agenttab rollback`. To return a test profile to the available legacy path, disable AgentTab manually in `chrome://extensions` and continue using the preserved Chrome Bridge v1.0.1 setup. Do not delete Chrome Bridge files as part of that rollback. -There is **no `agenttab uninstall` command in the current CLI source**, and the current installer does not implement an automated v2 removal procedure. Keep the install receipt, native-host manifest, and any listed backups until a supported uninstall path is released. This is intentionally not replaced with an unsafe manual-deletion recipe. +For a managed v2 test installation, `agenttab rollback`, `agenttab uninstall`, and `agenttab prune --keep N` use schema-v2 receipts. Cleanup restores or removes a file only while its hash and mode still match the receipt. Rollback aborts before any mutation if an activation file, owned client entry, or Windows registry default drifted. Prune restores a receipt's prior file when an inactive artifact displaced one, and deletes only when the prior snapshot records absence. Client and registry cleanup restores exact owned values while preserving later user edits and unrelated configuration. Registry cleanup deletes only the owned default value, never a browser/vendor key recursively. File cleanup names every exact receipt-owned file; it does not recursively delete the state directory or a version tree. Use `--dry-run` before cleanup when inspecting a test machine. + +Uninstall affects only AgentTab v2 receipts and values. It never removes the preserved Chrome Bridge v1 extension, registration, state, policy, token, or logs. Receipt-less or older schema-v1 test installs are intentionally not guessed at; reinstall them through the managed v2 flow before using automated lifecycle commands. ## Supported-platform state diff --git a/docs/verification.md b/docs/verification.md index 325afbc..88cb0fc 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -50,7 +50,7 @@ cargo test --workspace --locked --manifest-path host-rs/Cargo.toml On Windows, additionally prove the current-user SID pipe name, DACL, remote-client rejection, and client SID verification. On Unix, prove the private runtime directory, socket mode, same-user peer check, stale-socket handling, and second-host lock. Source-level success on one platform is not portability evidence for another. -Installer tests use temporary user/config homes and cover transactional configuration changes, malformed configuration preservation, rollback, proxy authentication, and repeat-install behavior. A successful test fixture is not a clean-machine install of a signed release. +Installer tests use temporary user/config homes and cover transactional configuration changes, malformed configuration preservation, exact-version update activation, rollback, uninstall, prune, later-edit preservation, proxy authentication, and repeat-install behavior. Fault injection verifies rollback of file deletion, active-state activation, readiness failure, and Windows default-value changes. Windows registry fixtures reject any recursive key deletion; Unix fixtures verify hash/mode ownership and semantic JSON/YAML cleanup. Layered doctor fixtures independently exercise installation, IPC, protocol, exact host-version, and extension-route evidence. A successful test fixture is not a clean-machine install of a signed release. ## Packaged artifact evidence diff --git a/host-rs/crates/agenttab-host/Cargo.toml b/host-rs/crates/agenttab-host/Cargo.toml index ca0f6e8..ea21199 100644 --- a/host-rs/crates/agenttab-host/Cargo.toml +++ b/host-rs/crates/agenttab-host/Cargo.toml @@ -9,6 +9,10 @@ publish = false name = "agenttab-host" path = "src/main.rs" +[[bin]] +name = "agenttab-native" +path = "src/bin/agenttab-native.rs" + [dependencies] agenttab-protocol = { path = "../agenttab-protocol" } base64.workspace = true diff --git a/host-rs/crates/agenttab-host/src/bin/agenttab-native.rs b/host-rs/crates/agenttab-host/src/bin/agenttab-native.rs new file mode 100644 index 0000000..fb0f952 --- /dev/null +++ b/host-rs/crates/agenttab-host/src/bin/agenttab-native.rs @@ -0,0 +1,9 @@ +use agenttab_host::{native_relay, AgentTabPaths}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let shim = std::env::current_exe()?; + let paths = AgentTabPaths::discover_for_shim(&shim)?; + native_relay::run_shim(paths, shim).await?; + Ok(()) +} diff --git a/host-rs/crates/agenttab-host/src/lib.rs b/host-rs/crates/agenttab-host/src/lib.rs index dfda362..6d90bc0 100644 --- a/host-rs/crates/agenttab-host/src/lib.rs +++ b/host-rs/crates/agenttab-host/src/lib.rs @@ -4,6 +4,7 @@ pub mod handoff; pub mod journal; pub mod lifecycle; pub mod native; +pub mod native_relay; pub mod paths; pub mod runtime; pub mod server; diff --git a/host-rs/crates/agenttab-host/src/main.rs b/host-rs/crates/agenttab-host/src/main.rs index 6a4c432..13c865b 100644 --- a/host-rs/crates/agenttab-host/src/main.rs +++ b/host-rs/crates/agenttab-host/src/main.rs @@ -1,10 +1,17 @@ -use agenttab_host::{AgentTabPaths, HandoffState, Lifecycle, Runtime, StdioNative}; +use agenttab_host::{native_relay, AgentTabPaths, HandoffState, Lifecycle, Runtime, StdioNative}; use std::io; use std::sync::Arc; -#[cfg(unix)] #[tokio::main] async fn main() -> Result<(), Box> { + match std::env::args().nth(1).as_deref() { + None => run_legacy().await, + Some("daemon") => run_daemon().await, + Some(argument) => Err(format!("unknown agenttab-host mode: {argument}").into()), + } +} + +async fn run_legacy() -> Result<(), Box> { let paths = AgentTabPaths::discover()?; let lifecycle = Arc::new(Lifecycle::default()); let handoff = Arc::new(HandoffState::default()); @@ -21,39 +28,41 @@ async fn main() -> Result<(), Box> { let _ = native_done_sender.send(result); })?; + #[cfg(unix)] tokio::select! { result = agenttab_host::server::serve_unix(runtime, paths.socket_file) => result?, result = tokio::signal::ctrl_c() => result?, result = native_done => result??, } + #[cfg(windows)] + tokio::select! { + result = agenttab_host::server::serve_windows(runtime) => result?, + result = tokio::signal::ctrl_c() => result?, + result = native_done => result??, + } lifecycle.terminal("host shutdown"); Ok(()) } -#[cfg(windows)] -#[tokio::main] -async fn main() -> Result<(), Box> { - let paths = AgentTabPaths::discover()?; +async fn run_daemon() -> Result<(), Box> { + let paths = AgentTabPaths::discover_for_shim(&std::env::current_exe()?)?; let lifecycle = Arc::new(Lifecycle::default()); let handoff = Arc::new(HandoffState::default()); - let native = StdioNative::new(io::stdout(), lifecycle.clone(), handoff.clone()); + let native = StdioNative::reconnectable(lifecycle.clone(), handoff.clone()); let runtime = Runtime::open(&paths, lifecycle.clone(), native.clone(), handoff)?; - let (native_done_sender, native_done) = tokio::sync::oneshot::channel(); - let reader_native = native.clone(); - std::thread::Builder::new() - .name("agenttab-native-reader".into()) - .spawn(move || { - let stdin = io::stdin(); - let result = reader_native.reader_loop(stdin.lock()); - let _ = native_done_sender.send(result); - })?; - + #[cfg(unix)] + tokio::select! { + result = agenttab_host::server::serve_unix(runtime, paths.socket_file.clone()) => result?, + result = native_relay::serve(native, &paths) => result?, + result = tokio::signal::ctrl_c() => result?, + } + #[cfg(windows)] tokio::select! { result = agenttab_host::server::serve_windows(runtime) => result?, + result = native_relay::serve(native, &paths) => result?, result = tokio::signal::ctrl_c() => result?, - result = native_done => result??, } - lifecycle.terminal("host shutdown"); + lifecycle.terminal("daemon shutdown"); Ok(()) } diff --git a/host-rs/crates/agenttab-host/src/native.rs b/host-rs/crates/agenttab-host/src/native.rs index 878f519..03992db 100644 --- a/host-rs/crates/agenttab-host/src/native.rs +++ b/host-rs/crates/agenttab-host/src/native.rs @@ -11,7 +11,7 @@ use parking_lot::{Mutex, RwLock}; use serde_json::Value; use std::collections::HashMap; use std::io::{Read, Write}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, SyncSender}; use std::sync::Arc; use std::time::Duration; @@ -32,6 +32,11 @@ pub enum NativeError { Transport(String), } +struct SessionWriter { + generation: u64, + writer: Box, +} + #[derive(Debug, Clone)] pub struct NativeEventResult { pub outcome: Outcome, @@ -85,15 +90,20 @@ pub trait NativeTransport: Send + Sync { } fn cancel_connection(&self, _connection_id: Uuid) {} fn set_event_sink(&self, _sink: Arc) {} + fn extension_version(&self) -> Option { + None + } } pub struct StdioNative { - writer: Mutex>, + writer: Mutex>, pending: Mutex>, lifecycle: Arc, handoff: Arc, event_sink: RwLock>>, + extension_version: RwLock>, disconnected: AtomicBool, + generation: AtomicU64, } impl std::fmt::Debug for StdioNative { @@ -113,45 +123,135 @@ impl StdioNative { handoff: Arc, ) -> Arc { Arc::new(Self { - writer: Mutex::new(Box::new(writer)), + writer: Mutex::new(Some(SessionWriter { + generation: 1, + writer: Box::new(writer), + })), + pending: Mutex::new(HashMap::new()), + lifecycle, + handoff, + event_sink: RwLock::new(None), + extension_version: RwLock::new(None), + disconnected: AtomicBool::new(true), + generation: AtomicU64::new(1), + }) + } + + pub fn reconnectable(lifecycle: Arc, handoff: Arc) -> Arc { + Arc::new(Self { + writer: Mutex::new(None), pending: Mutex::new(HashMap::new()), lifecycle, handoff, event_sink: RwLock::new(None), + extension_version: RwLock::new(None), disconnected: AtomicBool::new(true), + generation: AtomicU64::new(0), }) } + pub(crate) fn attach_writer( + &self, + writer: W, + ) -> Result { + let mut active = self.writer.lock(); + if active.is_some() { + return Err(NativeError::Transport( + "an extension relay is already connected".into(), + )); + } + let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + *active = Some(SessionWriter { + generation, + writer: Box::new(writer), + }); + self.disconnected.store(true, Ordering::Release); + drop(active); + self.lifecycle.begin_reconciliation(); + self.handoff.restore(true); + self.fail_all(NativeError::Disconnected); + Ok(generation) + } + pub fn reader_loop(self: &Arc, mut reader: R) -> Result<(), ProtocolError> { + let generation = self + .writer + .lock() + .as_ref() + .map(|writer| writer.generation) + .ok_or_else(|| { + ProtocolError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "extension relay is not connected", + )) + })?; loop { let value = match read_frame(&mut reader, EXTENSION_TO_HOST_MAX_BYTES) { Ok(Some(value)) => value, Ok(None) => { - self.reconcile_extension_disconnect("native messaging stream closed"); - self.lifecycle.extension_disconnected(); - self.handoff.restore(true); - self.fail_all(NativeError::Disconnected); + self.disconnect_generation(generation, "native messaging stream closed", None); return Ok(()); } Err(error) => { - self.reconcile_extension_disconnect("native messaging stream failed"); - self.lifecycle.terminal(error.to_string()); - self.handoff.restore(true); - self.fail_all(NativeError::Protocol(error.to_string())); + self.disconnect_generation( + generation, + "native messaging stream failed", + Some(error.to_string()), + ); return Err(error); } }; - if let Err(error) = self.handle_inbound(value) { - self.reconcile_extension_disconnect("native protocol failed"); - self.lifecycle.terminal(error.to_string()); - self.handoff.restore(true); - self.fail_all(NativeError::Protocol(error.to_string())); + if let Err(error) = self.handle_inbound_generation(generation, value) { + self.disconnect_generation( + generation, + "native protocol failed", + Some(error.to_string()), + ); return Err(error); } } } + pub(crate) fn receive_generation( + self: &Arc, + generation: u64, + value: Value, + ) -> Result<(), ProtocolError> { + self.handle_inbound_generation(generation, value) + } + + pub(crate) fn disconnect_generation( + &self, + generation: u64, + reason: &str, + terminal_error: Option, + ) { + let removed = { + let mut writer = self.writer.lock(); + if writer.as_ref().map(|writer| writer.generation) != Some(generation) { + false + } else { + writer.take(); + true + } + }; + if !removed { + return; + } + self.reconcile_extension_disconnect(reason); + self.disconnected.store(true, Ordering::Release); + if let Some(error) = terminal_error { + self.lifecycle.terminal(error.clone()); + self.fail_all(NativeError::Protocol(error)); + } else { + self.lifecycle.extension_disconnected(); + self.fail_all(NativeError::Disconnected); + } + self.handoff.restore(true); + } + fn reconcile_extension_disconnect(&self, reason: &str) { + *self.extension_version.write() = None; if let Some(sink) = self.event_sink.read().clone() { let _ = sink.handle( &NativeEventPayload::ExtensionDisconnected(NativeDisconnectEvent { @@ -161,7 +261,33 @@ impl StdioNative { ); } } + #[cfg(test)] fn handle_inbound(self: &Arc, value: Value) -> Result<(), ProtocolError> { + let generation = self + .writer + .lock() + .as_ref() + .map(|writer| writer.generation) + .ok_or_else(|| { + ProtocolError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "extension relay is not connected", + )) + })?; + self.handle_inbound_generation(generation, value) + } + + fn handle_inbound_generation( + self: &Arc, + generation: u64, + value: Value, + ) -> Result<(), ProtocolError> { + if self.writer.lock().as_ref().map(|writer| writer.generation) != Some(generation) { + return Err(ProtocolError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "extension relay generation is no longer active", + ))); + } let protocol = value .get("protocol") .and_then(Value::as_str) @@ -179,6 +305,7 @@ impl StdioNative { match value.get("kind").and_then(Value::as_str) { Some("hello") => { let hello = NativeHello::parse(value)?; + *self.extension_version.write() = Some(hello.extension_version.clone()); self.lifecycle.begin_reconciliation(); if let Some(sink) = self.event_sink.read().clone() { sink.reconcile(&hello.inventory, &hello.staged_commits, &hello.handoff) @@ -192,7 +319,7 @@ impl StdioNative { } else { RuntimeState::Ready }; - self.write_value(&native_ready(state))?; + self.write_value_generation(generation, &native_ready(state))?; } Some("response") => { let response = NativeResponse::parse(value)?; @@ -213,7 +340,9 @@ impl StdioNative { | NativeEventPayload::PopupCommitAbandoned(_) ) { let native = Arc::clone(self); - std::thread::spawn(move || native.handle_popup_commit_event(event, payload)); + std::thread::spawn(move || { + native.handle_popup_commit_event(generation, event, payload) + }); return Ok(()); } let clear_handoff = matches!( @@ -252,7 +381,7 @@ impl StdioNative { .into(), )); } - self.write_value(&native_event_ack( + self.write_value_generation(generation, &native_event_ack( NativeEventName::HandoffChanged, event.event_id.as_deref().expect( "validated inactive handoff event must carry an event_id", @@ -302,7 +431,12 @@ impl StdioNative { Ok(()) } - fn handle_popup_commit_event(&self, event: NativeEvent, payload: NativeEventPayload) { + fn handle_popup_commit_event( + &self, + generation: u64, + event: NativeEvent, + payload: NativeEventPayload, + ) { let event_id = event .event_id .as_deref() @@ -328,18 +462,42 @@ impl StdioNative { result.result, result.error, ); - if self.write_value(&acknowledgement).is_err() { - self.reconcile_extension_disconnect("native event acknowledgement failed"); - self.disconnected.store(true, Ordering::Release); - self.lifecycle.extension_disconnected(); - self.handoff.restore(true); - self.fail_all(NativeError::Disconnected); + if self + .write_value_generation(generation, &acknowledgement) + .is_err() + { + self.disconnect_generation(generation, "native event acknowledgement failed", None); } } - fn write_value(&self, value: &Value) -> Result<(), ProtocolError> { - let mut writer = self.writer.lock(); - write_frame(&mut *writer, value, HOST_TO_EXTENSION_MAX_BYTES) + fn active_generation(&self) -> Result { + self.writer + .lock() + .as_ref() + .map(|writer| writer.generation) + .ok_or_else(|| { + ProtocolError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "extension relay is not connected", + )) + }) + } + + fn write_value_generation(&self, generation: u64, value: &Value) -> Result<(), ProtocolError> { + let mut active = self.writer.lock(); + let writer = active.as_mut().ok_or_else(|| { + ProtocolError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "extension relay is not connected", + )) + })?; + if writer.generation != generation { + return Err(ProtocolError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "extension relay generation changed", + ))); + } + write_frame(&mut writer.writer, value, HOST_TO_EXTENSION_MAX_BYTES) } fn fail_all(&self, error: NativeError) { @@ -363,19 +521,25 @@ impl NativeTransport for StdioNative { if self.disconnected.load(Ordering::Acquire) { return Err(NativeError::Disconnected); } + let generation = self + .active_generation() + .map_err(|_| NativeError::Disconnected)?; let request_id = Uuid::new_v4(); let (sender, receiver) = mpsc::sync_channel(1); self.pending .lock() .insert(request_id, (connection_id, task_id, sender)); - if let Err(error) = self.write_value(&native_command( - request_id, - connection_id, - task_id, - method, - params, - origin_policy.as_ref(), - )) { + if let Err(error) = self.write_value_generation( + generation, + &native_command( + request_id, + connection_id, + task_id, + method, + params, + origin_policy.as_ref(), + ), + ) { self.pending.lock().remove(&request_id); return Err(NativeError::Transport(error.to_string())); } @@ -393,12 +557,17 @@ impl NativeTransport for StdioNative { if self.disconnected.load(Ordering::Acquire) { return Err(NativeError::Disconnected); } + let generation = self + .active_generation() + .map_err(|_| NativeError::Disconnected)?; let request_id = Uuid::new_v4(); let (sender, receiver) = mpsc::sync_channel(1); self.pending .lock() .insert(request_id, (Uuid::nil(), task_id, sender)); - if let Err(error) = self.write_value(&native_close_task(request_id, task_id)) { + if let Err(error) = + self.write_value_generation(generation, &native_close_task(request_id, task_id)) + { self.pending.lock().remove(&request_id); return Err(NativeError::Transport(error.to_string())); } @@ -453,6 +622,10 @@ impl NativeTransport for StdioNative { fn set_event_sink(&self, sink: Arc) { *self.event_sink.write() = Some(sink); } + + fn extension_version(&self) -> Option { + self.extension_version.read().clone() + } } #[cfg(test)] @@ -535,24 +708,27 @@ mod tests { } } - #[test] - fn hello_reconciles_before_ready_frame_is_emitted() { - let lifecycle = Arc::new(Lifecycle::default()); - let handoff = Arc::new(HandoffState::default()); - let output = SharedWriter::default(); - let native = StdioNative::new(output.clone(), lifecycle.clone(), handoff.clone()); - let hello = json!({ + fn hello(paused: bool) -> Value { + json!({ "protocol": NATIVE_PROTOCOL, "version": PROTOCOL_VERSION, "kind": "hello", "extension_version": "0.2.0", "inventory": [], - "paused": false, + "paused": paused, "handoff": {"active": false}, "staged_commits": [] - }); + }) + } + + #[test] + fn hello_reconciles_before_ready_frame_is_emitted() { + let lifecycle = Arc::new(Lifecycle::default()); + let handoff = Arc::new(HandoffState::default()); + let output = SharedWriter::default(); + let native = StdioNative::new(output.clone(), lifecycle.clone(), handoff.clone()); let mut input = Vec::new(); - write_frame(&mut input, &hello, EXTENSION_TO_HOST_MAX_BYTES).unwrap(); + write_frame(&mut input, &hello(false), EXTENSION_TO_HOST_MAX_BYTES).unwrap(); native.reader_loop(Cursor::new(input)).unwrap(); assert_eq!(lifecycle.state(), RuntimeState::Reconciling); let bytes = output.bytes.lock().clone(); @@ -561,6 +737,67 @@ mod tests { .unwrap(); assert_eq!(ready["kind"], "ready"); } + + #[test] + fn extension_version_tracks_the_connected_native_hello() { + let lifecycle = Arc::new(Lifecycle::default()); + let native = StdioNative::new( + SharedWriter::default(), + lifecycle, + Arc::new(HandoffState::default()), + ); + native + .handle_inbound(json!({ + "protocol": NATIVE_PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "hello", + "extension_version": "2.0.0", + "inventory": [], + "paused": false, + "handoff": {"active": false}, + "staged_commits": [] + })) + .unwrap(); + assert_eq!(native.extension_version().as_deref(), Some("2.0.0")); + native.reconcile_extension_disconnect("test disconnect"); + assert!(native.extension_version().is_none()); + } + + #[test] + fn reconnectable_transport_keeps_new_generation_after_stale_disconnect() { + let lifecycle = Arc::new(Lifecycle::default()); + let handoff = Arc::new(HandoffState::default()); + let native = StdioNative::reconnectable(lifecycle.clone(), handoff); + + let first_output = SharedWriter::default(); + let first = native.attach_writer(first_output.clone()).unwrap(); + native.receive_generation(first, hello(false)).unwrap(); + assert_eq!(lifecycle.state(), RuntimeState::Ready); + assert_eq!( + read_frame( + &mut first_output.bytes.lock().as_slice(), + HOST_TO_EXTENSION_MAX_BYTES + ) + .unwrap() + .unwrap()["kind"], + "ready" + ); + + native.disconnect_generation(first, "first relay closed", None); + assert_eq!(lifecycle.state(), RuntimeState::Reconciling); + + let second_output = SharedWriter::default(); + let second = native.attach_writer(second_output.clone()).unwrap(); + assert!(second > first); + native.receive_generation(second, hello(true)).unwrap(); + assert_eq!(lifecycle.state(), RuntimeState::Paused); + + native.disconnect_generation(first, "stale cleanup", None); + assert_eq!(lifecycle.state(), RuntimeState::Paused); + assert!(native.receive_generation(first, hello(false)).is_err()); + assert!(native.attach_writer(SharedWriter::default()).is_err()); + assert_eq!(native.active_generation().unwrap(), second); + } #[test] fn handoff_clear_is_acknowledged_only_after_sink_applies_it() { let lifecycle = Arc::new(Lifecycle::default()); diff --git a/host-rs/crates/agenttab-host/src/native_relay.rs b/host-rs/crates/agenttab-host/src/native_relay.rs new file mode 100644 index 0000000..2cc7e4c --- /dev/null +++ b/host-rs/crates/agenttab-host/src/native_relay.rs @@ -0,0 +1,407 @@ +use crate::native::StdioNative; +use crate::paths::AgentTabPaths; +use agenttab_protocol::EXTENSION_TO_HOST_MAX_BYTES; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; +use tokio::sync::mpsc; + +const DAEMON_CONNECT_TIMEOUT: Duration = Duration::from_secs(4); +const DAEMON_CONNECT_RETRY: Duration = Duration::from_millis(50); + +#[derive(Clone)] +struct RelayWriter { + sender: mpsc::UnboundedSender>, +} + +impl Write for RelayWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.sender.send(buffer.to_vec()).map_err(|_| { + io::Error::new(io::ErrorKind::BrokenPipe, "native relay connection closed") + })?; + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +async fn serve_connection(native: Arc, stream: S) -> io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let (mut reader, mut writer) = tokio::io::split(stream); + let (sender, mut outbound) = mpsc::unbounded_channel::>(); + let generation = native + .attach_writer(RelayWriter { sender }) + .map_err(|error| io::Error::new(io::ErrorKind::AlreadyExists, error))?; + + let reader_native = native.clone(); + let reader_task = async move { + loop { + let value = + match crate::server::read_frame_async(&mut reader, EXTENSION_TO_HOST_MAX_BYTES) + .await + { + Ok(Some(value)) => value, + Ok(None) => return Ok::<(), String>(()), + Err(error) => return Err(error.to_string()), + }; + reader_native + .receive_generation(generation, value) + .map_err(|error| error.to_string())?; + } + }; + let writer_task = async move { + while let Some(bytes) = outbound.recv().await { + writer.write_all(&bytes).await?; + writer.flush().await?; + } + Ok::<(), io::Error>(()) + }; + tokio::pin!(reader_task); + tokio::pin!(writer_task); + + let result = tokio::select! { + result = &mut reader_task => match result { + Ok(()) => Ok(()), + Err(error) => { + native.disconnect_generation(generation, "native relay protocol failed", Some(error.clone())); + Err(io::Error::new(io::ErrorKind::InvalidData, error)) + } + }, + result = &mut writer_task => result, + }; + native.disconnect_generation(generation, "native relay connection closed", None); + result +} + +fn spawn_connection( + native: Arc, + stream: S, + fatal_sender: mpsc::UnboundedSender, +) where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + tokio::spawn(async move { + if let Err(error) = serve_connection(native, stream).await { + if error.kind() == io::ErrorKind::InvalidData { + let _ = fatal_sender.send(error); + } + } + }); +} + +#[cfg(unix)] +#[derive(Debug)] +struct RelaySocketGuard { + path: PathBuf, +} + +#[cfg(unix)] +impl Drop for RelaySocketGuard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +#[cfg(unix)] +fn bind_relay_socket(path: &Path) -> io::Result<(RelaySocketGuard, tokio::net::UnixListener)> { + use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; + + let parent = path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "relay path has no parent"))?; + let parent_metadata = std::fs::symlink_metadata(parent)?; + if !parent_metadata.is_dir() + || parent_metadata.file_type().is_symlink() + || parent_metadata.uid() != unsafe { libc::geteuid() } + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "native relay directory must be owned by the current user", + )); + } + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + if path.exists() { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.file_type().is_socket() || metadata.uid() != unsafe { libc::geteuid() } { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "refusing to replace an unsafe native relay path", + )); + } + if std::os::unix::net::UnixStream::connect(path).is_ok() { + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + "another AgentTab daemon owns the native relay", + )); + } + std::fs::remove_file(path)?; + } + let listener = tokio::net::UnixListener::bind(path)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(( + RelaySocketGuard { + path: path.to_path_buf(), + }, + listener, + )) +} + +#[cfg(unix)] +pub async fn serve(native: Arc, paths: &AgentTabPaths) -> io::Result<()> { + let (_guard, listener) = bind_relay_socket(&paths.native_socket_file)?; + let (fatal_sender, mut fatal_receiver) = mpsc::unbounded_channel(); + loop { + let (stream, _) = tokio::select! { + error = fatal_receiver.recv() => return Err(error.expect("relay worker channel remains open")), + accepted = listener.accept() => accepted?, + }; + if !crate::server::peer_is_current_user(&stream) { + continue; + } + // One Chrome Native Messaging port owns the relay at a time. A queued + // reconnect is rejected quickly rather than hanging behind the active port. + spawn_connection(native.clone(), stream, fatal_sender.clone()); + } +} + +#[cfg(windows)] +pub async fn serve(native: Arc, _paths: &AgentTabPaths) -> io::Result<()> { + let sid = crate::server::current_user_sid()?; + let pipe_name = windows_native_pipe_name(&sid)?; + let mut first = true; + let (fatal_sender, mut fatal_receiver) = mpsc::unbounded_channel(); + loop { + let pipe = crate::server::create_windows_pipe(&pipe_name, &sid, first)?; + first = false; + tokio::select! { + error = fatal_receiver.recv() => return Err(error.expect("relay worker channel remains open")), + connected = pipe.connect() => connected?, + } + if crate::server::verify_windows_pipe_client(&pipe, &sid).is_err() { + continue; + } + spawn_connection(native.clone(), pipe, fatal_sender.clone()); + } +} + +pub fn windows_native_pipe_name(sid: &str) -> io::Result { + let components = sid.trim().strip_prefix("S-1-"); + if components.is_none_or(|components| { + components.is_empty() + || components.split('-').any(|component| { + component.is_empty() || !component.bytes().all(|byte| byte.is_ascii_digit()) + }) + }) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "current-user SID must use the canonical S-1-... form", + )); + } + Ok(format!(r"\\.\pipe\agenttab-native-{}", sid.trim())) +} + +fn daemon_executable(shim: &Path) -> PathBuf { + shim.with_file_name(if cfg!(windows) { + "agenttab-host.exe" + } else { + "agenttab-host" + }) +} + +fn spawn_daemon(host: &Path, state_root: &Path) -> io::Result<()> { + std::process::Command::new(host) + .arg("daemon") + .env("AGENTTAB_STATE_DIR", state_root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) +} + +async fn relay_stdio(stream: S) -> io::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let (mut relay_reader, mut relay_writer) = tokio::io::split(stream); + let (stdin_sender, mut stdin_receiver) = mpsc::unbounded_channel::>>(); + std::thread::Builder::new() + .name("agenttab-native-stdin".into()) + .spawn(move || { + let stdin = io::stdin(); + let mut input = stdin.lock(); + let mut buffer = vec![0_u8; 16 * 1024]; + loop { + match input.read(&mut buffer) { + Ok(0) => return, + Ok(count) => { + if stdin_sender.send(Ok(buffer[..count].to_vec())).is_err() { + return; + } + } + Err(error) => { + let _ = stdin_sender.send(Err(error)); + return; + } + } + } + })?; + let mut stdout = tokio::io::stdout(); + let stdin_to_relay = async move { + while let Some(chunk) = stdin_receiver.recv().await { + relay_writer.write_all(&chunk?).await?; + } + relay_writer.shutdown().await + }; + tokio::select! { + result = stdin_to_relay => result, + result = tokio::io::copy(&mut relay_reader, &mut stdout) => { + result?; + stdout.flush().await + }, + } +} + +#[cfg(unix)] +async fn connect(paths: &AgentTabPaths) -> io::Result { + tokio::net::UnixStream::connect(&paths.native_socket_file).await +} + +#[cfg(windows)] +async fn connect( + _paths: &AgentTabPaths, +) -> io::Result { + use tokio::net::windows::named_pipe::ClientOptions; + + let sid = crate::server::current_user_sid()?; + ClientOptions::new().open(windows_native_pipe_name(&sid)?) +} + +pub async fn run_shim(paths: AgentTabPaths, shim: PathBuf) -> io::Result<()> { + let host = daemon_executable(&shim); + let started_at = tokio::time::Instant::now(); + let mut started_daemon = false; + loop { + match connect(&paths).await { + Ok(stream) => return relay_stdio(stream).await, + Err(error) if started_at.elapsed() < DAEMON_CONNECT_TIMEOUT => { + if !started_daemon { + spawn_daemon(&host, &paths.root).map_err(|spawn_error| { + io::Error::new( + spawn_error.kind(), + format!( + "native relay unavailable ({error}); failed to start {}: {spawn_error}", + host.display() + ), + ) + })?; + started_daemon = true; + } + tokio::time::sleep(DAEMON_CONNECT_RETRY).await; + } + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!( + "AgentTab daemon did not expose its native relay within {} ms: {error}", + DAEMON_CONNECT_TIMEOUT.as_millis() + ), + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{HandoffState, Lifecycle}; + use agenttab_protocol::{ + write_frame, RuntimeState, HOST_TO_EXTENSION_MAX_BYTES, NATIVE_PROTOCOL, PROTOCOL_VERSION, + }; + use serde_json::json; + + #[test] + fn windows_relay_name_is_user_scoped_and_rejects_injection() { + assert_eq!( + windows_native_pipe_name("S-1-5-21-1000").unwrap(), + r"\\.\pipe\agenttab-native-S-1-5-21-1000" + ); + for invalid in ["", "S-1-", "S-1-5\\evil", "Global\\S-1-5"] { + assert!(windows_native_pipe_name(invalid).is_err()); + } + } + + #[cfg(unix)] + async fn handshake(stream: &mut S, paused: bool) + where + S: AsyncRead + AsyncWrite + Unpin, + { + let hello = json!({ + "protocol": NATIVE_PROTOCOL, + "version": PROTOCOL_VERSION, + "kind": "hello", + "extension_version": "0.2.0", + "inventory": [], + "paused": paused, + "handoff": {"active": false}, + "staged_commits": [] + }); + let mut bytes = Vec::new(); + write_frame(&mut bytes, &hello, EXTENSION_TO_HOST_MAX_BYTES).unwrap(); + stream.write_all(&bytes).await.unwrap(); + let ready = crate::server::read_frame_async(stream, HOST_TO_EXTENSION_MAX_BYTES) + .await + .unwrap() + .unwrap(); + assert_eq!(ready["kind"], "ready"); + } + + #[cfg(unix)] + #[tokio::test] + async fn unix_relay_reconnects_without_restarting_the_daemon() { + let temp = tempfile::tempdir().unwrap(); + let paths = AgentTabPaths::from_root(temp.path().join("state")); + paths.prepare().unwrap(); + let lifecycle = Arc::new(Lifecycle::default()); + let native = + StdioNative::reconnectable(lifecycle.clone(), Arc::new(HandoffState::default())); + let (mut first, first_server) = tokio::io::duplex(16 * 1024); + let first_native = native.clone(); + let first_relay = + tokio::spawn(async move { serve_connection(first_native, first_server).await }); + handshake(&mut first, false).await; + assert_eq!(lifecycle.state(), RuntimeState::Ready); + + let (_second_while_active, competing_server) = tokio::io::duplex(1024); + let competing_native = native.clone(); + let competing = + tokio::spawn(async move { serve_connection(competing_native, competing_server).await }); + assert_eq!( + competing.await.unwrap().unwrap_err().kind(), + io::ErrorKind::AlreadyExists + ); + + drop(first); + first_relay.await.unwrap().unwrap(); + + assert_eq!(lifecycle.state(), RuntimeState::Reconciling); + + let (mut second, second_server) = tokio::io::duplex(16 * 1024); + let second_native = native.clone(); + let second_relay = + tokio::spawn(async move { serve_connection(second_native, second_server).await }); + handshake(&mut second, true).await; + assert_eq!(lifecycle.state(), RuntimeState::Paused); + assert!(!second_relay.is_finished()); + drop(second); + second_relay.await.unwrap().unwrap(); + } +} diff --git a/host-rs/crates/agenttab-host/src/paths.rs b/host-rs/crates/agenttab-host/src/paths.rs index 57553a9..249bf50 100644 --- a/host-rs/crates/agenttab-host/src/paths.rs +++ b/host-rs/crates/agenttab-host/src/paths.rs @@ -3,6 +3,13 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct InstalledRuntimeConfig { + schema_version: u8, + state_dir: PathBuf, +} + #[derive(Debug, Clone)] pub struct AgentTabPaths { pub root: PathBuf, @@ -14,6 +21,8 @@ pub struct AgentTabPaths { pub lock_file: PathBuf, #[cfg(unix)] pub socket_file: PathBuf, + #[cfg(unix)] + pub native_socket_file: PathBuf, } impl AgentTabPaths { @@ -53,6 +62,35 @@ impl AgentTabPaths { Self::from_root_and_run(root, run_dir) } + pub fn discover_for_shim(shim: &Path) -> io::Result { + let config_path = shim + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "shim has no parent"))? + .join("agenttab-runtime.json"); + let bytes = match fs::read(&config_path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Self::discover(), + Err(error) => return Err(error), + }; + let config: InstalledRuntimeConfig = serde_json::from_slice(&bytes).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid {}: {error}", config_path.display()), + ) + })?; + if config.schema_version != 1 || !config.state_dir.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "installed runtime config must use schemaVersion 1 and an absolute stateDir", + )); + } + #[cfg(unix)] + let run_dir = runtime_directory(&config.state_dir)?; + #[cfg(windows)] + let run_dir = config.state_dir.join("run"); + Ok(Self::from_root_and_run(config.state_dir, run_dir)) + } + fn from_root_and_run(root: PathBuf, run_dir: PathBuf) -> Self { Self { state_db: root.join("state.sqlite3"), @@ -62,6 +100,8 @@ impl AgentTabPaths { lock_file: run_dir.join("host.lock"), #[cfg(unix)] socket_file: run_dir.join("agenttab.sock"), + #[cfg(unix)] + native_socket_file: run_dir.join("agenttab-native.sock"), run_dir, root, } @@ -128,6 +168,11 @@ mod tests { assert_eq!(paths.upload_staging_dir, paths.root.join("upload-staging")); assert_eq!(paths.lock_file, paths.run_dir.join("host.lock")); #[cfg(unix)] + assert_eq!( + paths.native_socket_file, + paths.run_dir.join("agenttab-native.sock") + ); + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!( @@ -144,4 +189,23 @@ mod tests { ); } } + + #[test] + fn installed_shim_config_selects_the_installer_state_root() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("versions/v2/target"); + fs::create_dir_all(&target).unwrap(); + let state = temp.path().join("custom-state"); + fs::write( + target.join("agenttab-runtime.json"), + serde_json::to_vec(&serde_json::json!({ + "schemaVersion": 1, + "stateDir": state, + })) + .unwrap(), + ) + .unwrap(); + let paths = AgentTabPaths::discover_for_shim(&target.join("agenttab-native")).unwrap(); + assert_eq!(paths.root, temp.path().join("custom-state")); + } } diff --git a/host-rs/crates/agenttab-host/src/runtime.rs b/host-rs/crates/agenttab-host/src/runtime.rs index 3bf2017..266d77b 100644 --- a/host-rs/crates/agenttab-host/src/runtime.rs +++ b/host-rs/crates/agenttab-host/src/runtime.rs @@ -939,6 +939,8 @@ impl Runtime { json!({ "state": self.lifecycle.state(), "protocol_version": PROTOCOL_VERSION, + "host_version": env!("CARGO_PKG_VERSION"), + "extension_version": self.native.extension_version(), "handoff_active": self.handoff.is_active(), "task_id": task_id, }), @@ -2251,6 +2253,12 @@ mod tests { }), ); assert_eq!(response["result"]["state"], "starting"); + assert_eq!( + response["result"]["host_version"], + env!("CARGO_PKG_VERSION") + ); + assert_eq!(response["result"]["protocol_version"], PROTOCOL_VERSION); + assert!(response["result"]["extension_version"].is_null()); assert!(response["result"]["task_id"].is_null()); let rejected = runtime.handle( &connection, diff --git a/host-rs/crates/agenttab-host/src/server.rs b/host-rs/crates/agenttab-host/src/server.rs index 31292b1..e4ae526 100644 --- a/host-rs/crates/agenttab-host/src/server.rs +++ b/host-rs/crates/agenttab-host/src/server.rs @@ -283,7 +283,7 @@ pub fn windows_pipe_name(current_user_sid: &str) -> io::Result { } #[cfg(windows)] -fn create_windows_pipe( +pub(crate) fn create_windows_pipe( pipe_name: &str, current_user_sid: &str, first: bool, @@ -343,13 +343,13 @@ fn create_windows_pipe( } #[cfg(windows)] -fn current_user_sid() -> io::Result { +pub(crate) fn current_user_sid() -> io::Result { use windows_sys::Win32::System::Threading::GetCurrentProcess; sid_for_windows_process(unsafe { GetCurrentProcess() }) } #[cfg(windows)] -fn verify_windows_pipe_client( +pub(crate) fn verify_windows_pipe_client( pipe: &tokio::net::windows::named_pipe::NamedPipeServer, expected_sid: &str, ) -> io::Result<()> { @@ -762,7 +762,7 @@ fn response_carries_new_capability(response: &Value) -> bool { .is_some() } -async fn read_frame_async( +pub(crate) async fn read_frame_async( reader: &mut R, max_bytes: usize, ) -> io::Result> { @@ -851,7 +851,7 @@ fn socket_owned_by_current_user(metadata: &fs::Metadata) -> bool { } #[cfg(unix)] -fn peer_is_current_user(stream: &UnixStream) -> bool { +pub(crate) fn peer_is_current_user(stream: &UnixStream) -> bool { peer_uid(stream).is_some_and(|uid| uid == unsafe { libc::geteuid() }) } diff --git a/packages/installer/src/cli.ts b/packages/installer/src/cli.ts old mode 100644 new mode 100755 index 0d40b7b..1670e3b --- a/packages/installer/src/cli.ts +++ b/packages/installer/src/cli.ts @@ -2,7 +2,8 @@ import { readFile } from "node:fs/promises"; import { AgentTabClient } from "../../sdk-typescript/src/index"; import { main as mcpMain } from "../../mcp/src/server"; import packageJson from "../package.json" with { type: "json" }; -import { install, InstallError } from "./install"; +import { install, InstallError, update, type InstallOptions } from "./install"; +import { doctor, prune, rollback, uninstall, type DoctorLayer, type LifecycleOptions } from "./lifecycle"; import { runProxy } from "./proxy"; interface ParsedArgs { @@ -11,21 +12,33 @@ interface ParsedArgs { type FlagKind = "boolean" | "string"; +const ARTIFACT_FLAGS: Readonly> = { + development: "boolean", + "dry-run": "boolean", + home: "string", + "manifest-url": "string", + "no-open-browser": "boolean", + "public-key": "string", + "signature-url": "string", + "state-dir": "string", + "verify-readiness": "boolean", + version: "string", +}; + +const LIFECYCLE_FLAGS: Readonly> = { + "dry-run": "boolean", + home: "string", + "state-dir": "string", +}; + const COMMAND_FLAGS: Record>> = { - install: { - development: "boolean", - "dry-run": "boolean", - home: "string", - "manifest-url": "string", - "no-open-browser": "boolean", - "public-key": "string", - "signature-url": "string", - "state-dir": "string", - "verify-readiness": "boolean", - version: "string", - }, + install: ARTIFACT_FLAGS, + update: ARTIFACT_FLAGS, + rollback: LIFECYCLE_FLAGS, + uninstall: LIFECYCLE_FLAGS, + prune: { ...LIFECYCLE_FLAGS, keep: "string" }, status: {}, - doctor: { layer: "string" }, + doctor: { home: "string", layer: "string", "state-dir": "string" }, mcp: {}, proxy: { port: "string", "token-file": "string" }, }; @@ -75,8 +88,12 @@ const USAGE = [ " agenttab --help", " agenttab --version", " agenttab install [--version X.Y.Z] [--verify-readiness] [--development --manifest-url URL --signature-url URL]", + " agenttab update --version X.Y.Z [--verify-readiness]", + " agenttab rollback [--dry-run]", + " agenttab uninstall [--dry-run]", + " agenttab prune [--keep N] [--dry-run]", " agenttab status", - " agenttab doctor [--layer ipc|extension]", + " agenttab doctor [--layer installation|ipc|protocol|host|extension|all]", " agenttab mcp", " agenttab proxy --token-file PATH [--port 9224]", ].join("\n"); @@ -96,6 +113,34 @@ async function status(): Promise { } } +function lifecycleOptions(parsed: ParsedArgs): LifecycleOptions { + return { + stateDir: stringFlag(parsed, "state-dir"), + home: stringFlag(parsed, "home"), + dryRun: boolFlag(parsed, "dry-run"), + }; +} + +async function artifactOptions(parsed: ParsedArgs, requireVersion: boolean): Promise { + const development = boolFlag(parsed, "development"); + const publicKeyPath = stringFlag(parsed, "public-key"); + if (publicKeyPath && !development) throw new Error("--public-key is allowed only with --development"); + const version = stringFlag(parsed, "version"); + if (requireVersion && !version) throw new Error("agenttab update requires an exact --version X.Y.Z"); + return { + version: version ?? packageJson.version, + development, + manifestUrl: stringFlag(parsed, "manifest-url"), + signatureUrl: stringFlag(parsed, "signature-url"), + stateDir: stringFlag(parsed, "state-dir"), + home: stringFlag(parsed, "home"), + publicKeyPem: publicKeyPath ? await readFile(publicKeyPath, "utf8") : undefined, + dryRun: boolFlag(parsed, "dry-run"), + verifyReadiness: boolFlag(parsed, "verify-readiness"), + openBrowser: !boolFlag(parsed, "no-open-browser"), + }; +} + async function run(): Promise { const argv = process.argv.slice(2); if (argv.length === 1 && argv[0] === "--help") usage(0); @@ -119,22 +164,17 @@ async function run(): Promise { return; } if (command === "doctor") { - const layer = stringFlag(parsed, "layer") ?? "ipc"; - if (layer !== "ipc" && layer !== "extension") throw new Error("--layer must be ipc or extension"); - try { - const result = await status(); - console.log(JSON.stringify({ success: true, layer, result }, null, 2)); - } catch (error) { - console.log(JSON.stringify({ - success: false, - layer, - error: error instanceof Error ? error.message : String(error), - recovery: layer === "ipc" - ? "Open Chrome with the AgentTab extension enabled, then rerun agenttab doctor --layer ipc." - : "Reload AgentTab in chrome://extensions, then rerun agenttab doctor --layer extension.", - }, null, 2)); - process.exitCode = 1; + const layer = stringFlag(parsed, "layer") ?? "all"; + if (!["installation", "ipc", "protocol", "host", "extension", "all"].includes(layer)) { + throw new Error("--layer must be installation, ipc, protocol, host, extension, or all"); } + const result = await doctor({ + stateDir: stringFlag(parsed, "state-dir"), + home: stringFlag(parsed, "home"), + layer: layer as DoctorLayer | "all", + }); + console.log(JSON.stringify(result, null, 2)); + if (!result.success) process.exitCode = 1; return; } if (command === "proxy") { @@ -150,23 +190,23 @@ async function run(): Promise { }); return; } - if (command === "install") { - const development = boolFlag(parsed, "development"); - const publicKeyPath = stringFlag(parsed, "public-key"); - if (publicKeyPath && !development) throw new Error("--public-key is allowed only with --development"); - const result = await install({ - version: stringFlag(parsed, "version") ?? packageJson.version, - development, - manifestUrl: stringFlag(parsed, "manifest-url"), - signatureUrl: stringFlag(parsed, "signature-url"), - stateDir: stringFlag(parsed, "state-dir"), - home: stringFlag(parsed, "home"), - publicKeyPem: publicKeyPath ? await readFile(publicKeyPath, "utf8") : undefined, - dryRun: boolFlag(parsed, "dry-run"), - verifyReadiness: boolFlag(parsed, "verify-readiness"), - openBrowser: !boolFlag(parsed, "no-open-browser"), - }); - console.log(JSON.stringify(result, null, 2)); + if (command === "install" || command === "update") { + const options = await artifactOptions(parsed, command === "update"); + console.log(JSON.stringify(command === "install" ? await install(options) : await update(options), null, 2)); + return; + } + if (command === "rollback") { + console.log(JSON.stringify(await rollback(lifecycleOptions(parsed)), null, 2)); + return; + } + if (command === "uninstall") { + console.log(JSON.stringify(await uninstall(lifecycleOptions(parsed)), null, 2)); + return; + } + if (command === "prune") { + const keepValue = stringFlag(parsed, "keep"); + if (keepValue !== undefined && !/^\d+$/.test(keepValue)) throw new Error("--keep must be a non-negative integer"); + console.log(JSON.stringify(await prune({ ...lifecycleOptions(parsed), ...(keepValue ? { keep: Number(keepValue) } : {}) }), null, 2)); return; } usage(); diff --git a/packages/installer/src/configs.ts b/packages/installer/src/configs.ts index 9fe720d..2c97b1c 100644 --- a/packages/installer/src/configs.ts +++ b/packages/installer/src/configs.ts @@ -1,14 +1,38 @@ import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { readFile, stat } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { parseDocument, isSeq } from "yaml"; -import type { PlannedFile } from "./transaction"; +import { expectationFor, type PlannedFile } from "./transaction"; export interface ConfigPlan { files: PlannedFile[]; + ownership: ConfigOwnership[]; skipped: Array<{ client: string; path: string; reason: string }>; } +export interface JsonConfigOwnership { + kind: "json_property"; + client: string; + path: string; + property: ["mcpServers", "agenttab"]; + installedValue: unknown; + previous: { exists: false } | { exists: true; value: unknown }; + owned: boolean; +} + +export interface YamlConfigOwnership { + kind: "yaml_sequence_item"; + client: "OMP"; + path: string; + property: "extensions"; + value: string; + installedPresent: boolean; + previousPresent: boolean; + owned: boolean; +} + +export type ConfigOwnership = JsonConfigOwnership | YamlConfigOwnership; + interface JsonClient { client: string; path: string; @@ -27,6 +51,12 @@ async function readText(path: string): Promise { } } +async function textExpectation(path: string, source: string | null) { + if (source === null) return expectationFor(null); + const mode = process.platform === "win32" ? undefined : (await stat(path)).mode & 0o777; + return expectationFor(Buffer.from(source, "utf8"), mode); +} + function jsonClients(home: string, currentPlatform: NodeJS.Platform): JsonClient[] { const claude = currentPlatform === "darwin" ? join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json") @@ -53,7 +83,8 @@ function parseJsonConfig(source: string | null): Record { async function planJsonClient( spec: JsonClient, cliPath: string, -): Promise { + previousOwnership: ConfigOwnership[], +): Promise<{ file: PlannedFile | null; ownership: JsonConfigOwnership } | { reason: string; ownership?: JsonConfigOwnership } | null> { if (!configExistsOrClientInstalled(spec.path)) return null; const source = await readText(spec.path); let config: Record; @@ -67,25 +98,56 @@ async function planJsonClient( return { reason: "mcpServers must be a JSON object" }; } const servers = (current ?? {}) as Record; - servers.agenttab = { command: cliPath, args: ["mcp"] }; + const previous = Object.prototype.hasOwnProperty.call(servers, "agenttab") + ? { exists: true as const, value: structuredClone(servers.agenttab) } + : { exists: false as const }; + const previousValue = previous.exists ? previous.value : undefined; + const installedValue = { command: cliPath, args: ["mcp"] }; + const prior = previousOwnership.find((entry): entry is JsonConfigOwnership => + entry.kind === "json_property" && entry.path === spec.path + ); + if (prior && JSON.stringify(previousValue) !== JSON.stringify(prior.installedValue)) { + return { + reason: "mcpServers.agenttab changed after the previous AgentTab activation", + ownership: { ...prior, previous, owned: false }, + }; + } + const owned = JSON.stringify(previousValue) !== JSON.stringify(installedValue); + servers.agenttab = installedValue; config.mcpServers = servers; return { - path: spec.path, - content: `${JSON.stringify(config, null, 2)}\n`, - mode: 0o600, - label: `${spec.client} config`, - semanticDiff: [ - `--- ${spec.client}: mcpServers.agenttab`, - `+++ ${spec.client}: mcpServers.agenttab`, - `+command: ${cliPath}`, - "+args: [mcp]", - ].join("\n"), + file: owned ? { + path: spec.path, + content: `${JSON.stringify(config, null, 2)}\n`, + mode: 0o600, + label: `${spec.client} config`, + expectedBefore: await textExpectation(spec.path, source), + semanticDiff: [ + `--- ${spec.client}: mcpServers.agenttab`, + `+++ ${spec.client}: mcpServers.agenttab`, + `+command: ${cliPath}`, + "+args: [mcp]", + ].join("\n"), + } : null, + ownership: { + kind: "json_property", + client: spec.client, + path: spec.path, + property: ["mcpServers", "agenttab"], + installedValue, + previous, + owned, + }, }; } -async function planOmp(home: string, adapterPath: string): Promise { +async function planOmp( + home: string, + adapterPath: string, + previousOwnership: ConfigOwnership[], +): Promise<{ file: PlannedFile | null; ownership: YamlConfigOwnership[] } | { path: string; reason: string } | null> { const path = process.env.OMP_AGENT_HOME - ? join(process.env.OMP_AGENT_HOME, "config.yml") + ? resolve(process.env.OMP_AGENT_HOME, "config.yml") : join(home, ".omp", "agent", "config.yml"); if (!configExistsOrClientInstalled(path)) return null; const source = await readText(path); @@ -98,18 +160,58 @@ async function planOmp(home: string, adapterPath: string): Promise String(item)) : []; - if (!values.includes(adapterPath)) values.push(adapterPath); + const originalValues = [...values]; + const ownership: YamlConfigOwnership[] = []; + for (const prior of previousOwnership) { + if ( + prior.kind !== "yaml_sequence_item" || + prior.path !== path || + !prior.owned || + !prior.installedPresent || + prior.value === adapterPath + ) continue; + const matching = values.filter((value) => value === prior.value).length; + if (matching !== 1) continue; + const index = values.indexOf(prior.value); + values.splice(index, 1); + ownership.push({ + kind: "yaml_sequence_item", + client: "OMP", + path, + property: "extensions", + value: prior.value, + installedPresent: false, + previousPresent: true, + owned: true, + }); + } + const previousPresent = values.includes(adapterPath); + if (!previousPresent) values.push(adapterPath); + ownership.push({ + kind: "yaml_sequence_item", + client: "OMP", + path, + property: "extensions", + value: adapterPath, + installedPresent: true, + previousPresent, + owned: !previousPresent, + }); document.set("extensions", values); return { - path, - content: document.toString(), - mode: 0o600, - label: "OMP config", - semanticDiff: [ - "--- OMP: extensions", - "+++ OMP: extensions", - `+${adapterPath}`, - ].join("\n"), + file: JSON.stringify(values) === JSON.stringify(originalValues) ? null : { + path, + content: document.toString(), + mode: 0o600, + label: "OMP config", + expectedBefore: await textExpectation(path, source), + semanticDiff: [ + "--- OMP: extensions", + "+++ OMP: extensions", + ...ownership.filter((entry) => entry.owned).map((entry) => `${entry.installedPresent ? "+" : "-"}${entry.value}`), + ].join("\n"), + }, + ownership, }; } @@ -118,22 +220,30 @@ export async function planClientConfigs(options: { cliPath: string; ompAdapterPath: string; platform?: NodeJS.Platform; + previousOwnership?: ConfigOwnership[]; }): Promise { const files: PlannedFile[] = []; + const ownership: ConfigOwnership[] = []; const skipped: ConfigPlan["skipped"] = []; + const previousOwnership = options.previousOwnership ?? []; for (const spec of jsonClients(options.home, options.platform ?? process.platform)) { - const planned = await planJsonClient(spec, options.cliPath); + const planned = await planJsonClient(spec, options.cliPath, previousOwnership); if (!planned) continue; if ("reason" in planned) { skipped.push({ client: spec.client, path: spec.path, reason: planned.reason }); + if (planned.ownership) ownership.push(planned.ownership); } else { - files.push(planned); + if (planned.file) files.push(planned.file); + ownership.push(planned.ownership); } } - const omp = await planOmp(options.home, options.ompAdapterPath); + const omp = await planOmp(options.home, options.ompAdapterPath, previousOwnership); if (omp) { if ("reason" in omp) skipped.push({ client: "OMP", path: omp.path, reason: omp.reason }); - else files.push(omp); + else { + if (omp.file) files.push(omp.file); + ownership.push(...omp.ownership); + } } - return { files, skipped }; + return { files, ownership, skipped }; } diff --git a/packages/installer/src/install.ts b/packages/installer/src/install.ts index 00fa66a..a31f614 100644 --- a/packages/installer/src/install.ts +++ b/packages/installer/src/install.ts @@ -2,6 +2,7 @@ import { execFile, execFileSync } from "node:child_process"; import { createHash, verify as verifySignature } from "node:crypto"; import { chmod, + lstat, mkdtemp, mkdir, readFile, @@ -14,17 +15,42 @@ import { existsSync } from "node:fs"; import { arch as currentArch, homedir, platform as currentPlatform, tmpdir } from "node:os"; import { basename, dirname, join, relative, resolve } from "node:path"; import { inflateRawSync } from "node:zlib"; -import { setTimeout as delay } from "node:timers/promises"; -import { AgentTabClient, AgentTabError, type ConnectionAck } from "../../sdk-typescript/src/index"; import identityJson from "../../../config/identity.json" with { type: "json" }; import trustJson from "../../../config/release-trust.json" with { type: "json" }; import migrationJson from "../../../config/migration-v1.json" with { type: "json" }; import { planClientConfigs } from "./configs"; -import { applyTransaction, type PlannedFile, type TransactionResult } from "./transaction"; +import { + applyRegistryChanges, + activeReceiptDrift, + queryRegistryValue, + registryChangesForJournal, + verifyRuntimeReadiness, + withInstallerStateMutation, + windowsRegistryKeys, +} from "./lifecycle"; +import { + activeStatePath, + canonicalJson, + expectationFromSnapshot, + loadActiveReceipt, + newReceiptPath, + ownershipForFile, + readOptionalBytes, + referenceForReceipt, + type InstallReceiptV2, + type RegistryOwnership, +} from "./receipt"; +import { + activateDaemonService, + planDaemonService, + type DaemonServiceManager, +} from "./service"; +import { applyTransaction, type FileExpectation, type PlannedFile, type TransactionResult } from "./transaction"; import { fileURLToPath } from "node:url"; const identity = identityJson as { product: string; + version: string; nativeHost: string; developmentExtension: { id: string; publicKey: string }; webStoreExtensionId: string | null; @@ -59,19 +85,9 @@ interface ArtifactManifest { assets: ArtifactEntry[]; } -interface InstallReceipt { - schemaVersion: 1; - version: string; - target: string; - manifestSha256: string; - assetSha256: string; - hostSha256: string; - cliSha256: string; - ompSha256: string; - extensionSha256: string; -} - export interface RuntimeAssets { + /** Exact version represented by these bundled CLI/OMP/extension bytes. */ + version?: string; cliBundlePath: string; ompBundlePath: string; extensionDir: string; @@ -93,6 +109,11 @@ export interface InstallOptions { openBrowser?: boolean; print?: (line: string) => void; transactionFailAfter?: number; + transactionCrashAfter?: number; + transactionCrashAfterExternal?: boolean; + registryFailAfter?: number; + /** Fault-injection hook for verifying plan-to-transaction compare-and-swap behavior. */ + beforeTransaction?: () => Promise; } export interface LegacyReport { @@ -120,6 +141,11 @@ export interface InstallResult { skipped: boolean; reason?: "dry_run" | "manual_extension_load"; }; + service: { + manager: DaemonServiceManager; + status: "active" | "planned" | "shim_fallback"; + reason?: string; + }; } export class InstallError extends Error { @@ -348,7 +374,18 @@ function zipEndOfCentralDirectory(archive: Buffer): number { throw zipError("is missing its end-of-central-directory record"); } -function extractWindowsZipHost(archive: Buffer, binaryName: string): Buffer { +interface ZipExecutable { + name: string; + flags: number; + method: number; + expectedCrc: number; + compressedSize: number; + uncompressedSize: number; + nameBytes: Buffer; + localOffset: number; +} + +function extractWindowsZipExecutables(archive: Buffer, expectedNames: string[]): Map { const endOffset = zipEndOfCentralDirectory(archive); const disk = zipUInt16(archive, endOffset + 4); const centralDirectoryDisk = zipUInt16(archive, endOffset + 6); @@ -359,105 +396,117 @@ function extractWindowsZipHost(archive: Buffer, binaryName: string): Buffer { if ( disk !== 0 || centralDirectoryDisk !== 0 || - entriesOnDisk !== 1 || - entries !== 1 + entriesOnDisk !== expectedNames.length || + entries !== expectedNames.length ) { - throw zipError(`must contain exactly ${binaryName}`); + throw zipError(`must contain exactly ${expectedNames.join(" and ")}`); } if (centralDirectoryOffset + centralDirectorySize !== endOffset) { throw zipError("has an invalid central directory"); } - const centralOffset = centralDirectoryOffset; - if (zipUInt32(archive, centralOffset) !== ZIP_CENTRAL_DIRECTORY_FILE) { - throw zipError("has an invalid central-directory member"); - } - - const flags = zipUInt16(archive, centralOffset + 8); - const method = zipUInt16(archive, centralOffset + 10); - const expectedCrc = zipUInt32(archive, centralOffset + 16); - const compressedSize = zipUInt32(archive, centralOffset + 20); - const uncompressedSize = zipUInt32(archive, centralOffset + 24); - const nameLength = zipUInt16(archive, centralOffset + 28); - const extraLength = zipUInt16(archive, centralOffset + 30); - const memberCommentLength = zipUInt16(archive, centralOffset + 32); - const memberDisk = zipUInt16(archive, centralOffset + 34); - const creator = zipUInt16(archive, centralOffset + 4) >>> 8; - const attributes = zipUInt32(archive, centralOffset + 38); - const localOffset = zipUInt32(archive, centralOffset + 42); - const centralEnd = centralOffset + 46 + nameLength + extraLength + memberCommentLength; - if (centralEnd !== endOffset || memberDisk !== 0) throw zipError("has an invalid central-directory member"); - if ((flags & ~ZIP_UTF8_FLAG) !== 0 || (method !== 0 && method !== 8)) { - throw zipError("uses unsupported encryption, streaming, or compression"); - } - if (uncompressedSize === 0 || uncompressedSize > MAX_HOST_EXECUTABLE_BYTES) { - throw zipError("has an invalid executable size"); - } - - const nameBytes = archive.subarray(centralOffset + 46, centralOffset + 46 + nameLength); - const name = zipMemberName(nameBytes); - if (name !== binaryName) throw zipError(`must contain exactly ${binaryName}`); - assertZipRegularFile(creator, attributes); - if (localOffset !== 0 || zipUInt32(archive, localOffset) !== ZIP_LOCAL_FILE) { - throw zipError("has an invalid local member"); - } - - const localFlags = zipUInt16(archive, localOffset + 6); - const localMethod = zipUInt16(archive, localOffset + 8); - const localCrc = zipUInt32(archive, localOffset + 14); - const localCompressedSize = zipUInt32(archive, localOffset + 18); - const localUncompressedSize = zipUInt32(archive, localOffset + 22); - const localNameLength = zipUInt16(archive, localOffset + 26); - const localExtraLength = zipUInt16(archive, localOffset + 28); - const localName = archive.subarray(localOffset + 30, localOffset + 30 + localNameLength); - const dataStart = localOffset + 30 + localNameLength + localExtraLength; - const dataEnd = dataStart + compressedSize; - if ( - localFlags !== flags || - localMethod !== method || - localCrc !== expectedCrc || - localCompressedSize !== compressedSize || - localUncompressedSize !== uncompressedSize || - !localName.equals(nameBytes) || - dataEnd !== centralOffset - ) { - throw zipError("has inconsistent local-member metadata"); - } - - let executable: Buffer; - try { - const data = archive.subarray(dataStart, dataEnd); - executable = method === 0 - ? Buffer.from(data) - : inflateRawSync(data, { maxOutputLength: uncompressedSize }); - } catch { - throw zipError("contains invalid compressed executable data"); + const expected = new Set(expectedNames); + const members: ZipExecutable[] = []; + let centralOffset = centralDirectoryOffset; + for (let index = 0; index < entries; index += 1) { + if (zipUInt32(archive, centralOffset) !== ZIP_CENTRAL_DIRECTORY_FILE) { + throw zipError("has an invalid central-directory member"); + } + const flags = zipUInt16(archive, centralOffset + 8); + const method = zipUInt16(archive, centralOffset + 10); + const expectedCrc = zipUInt32(archive, centralOffset + 16); + const compressedSize = zipUInt32(archive, centralOffset + 20); + const uncompressedSize = zipUInt32(archive, centralOffset + 24); + const nameLength = zipUInt16(archive, centralOffset + 28); + const extraLength = zipUInt16(archive, centralOffset + 30); + const memberCommentLength = zipUInt16(archive, centralOffset + 32); + const memberDisk = zipUInt16(archive, centralOffset + 34); + const creator = zipUInt16(archive, centralOffset + 4) >>> 8; + const attributes = zipUInt32(archive, centralOffset + 38); + const localOffset = zipUInt32(archive, centralOffset + 42); + const centralEnd = centralOffset + 46 + nameLength + extraLength + memberCommentLength; + if (centralEnd > endOffset || memberDisk !== 0) throw zipError("has an invalid central-directory member"); + if ((flags & ~ZIP_UTF8_FLAG) !== 0 || (method !== 0 && method !== 8)) { + throw zipError("uses unsupported encryption, streaming, or compression"); + } + if (uncompressedSize === 0 || uncompressedSize > MAX_HOST_EXECUTABLE_BYTES) { + throw zipError("has an invalid executable size"); + } + const nameBytes = archive.subarray(centralOffset + 46, centralOffset + 46 + nameLength); + const name = zipMemberName(nameBytes); + if (!expected.delete(name)) throw zipError(`must contain exactly ${expectedNames.join(" and ")}`); + assertZipRegularFile(creator, attributes); + members.push({ name, flags, method, expectedCrc, compressedSize, uncompressedSize, nameBytes, localOffset }); + centralOffset = centralEnd; } - if (executable.byteLength !== uncompressedSize || crc32(executable) !== expectedCrc) { - throw zipError("contains an invalid executable payload"); + if (centralOffset !== endOffset || expected.size !== 0) { + throw zipError(`must contain exactly ${expectedNames.join(" and ")}`); } - return executable; -} -async function readJsonOptional(path: string): Promise { - try { - return JSON.parse(await readFile(path, "utf8")) as T; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - return null; + const executables = new Map(); + const localMembers = [...members].sort((left, right) => left.localOffset - right.localOffset); + let expectedLocalOffset = 0; + for (const member of localMembers) { + const { localOffset } = member; + if (localOffset !== expectedLocalOffset || zipUInt32(archive, localOffset) !== ZIP_LOCAL_FILE) { + throw zipError("has an invalid local member"); + } + const localFlags = zipUInt16(archive, localOffset + 6); + const localMethod = zipUInt16(archive, localOffset + 8); + const localCrc = zipUInt32(archive, localOffset + 14); + const localCompressedSize = zipUInt32(archive, localOffset + 18); + const localUncompressedSize = zipUInt32(archive, localOffset + 22); + const localNameLength = zipUInt16(archive, localOffset + 26); + const localExtraLength = zipUInt16(archive, localOffset + 28); + const localName = archive.subarray(localOffset + 30, localOffset + 30 + localNameLength); + const dataStart = localOffset + 30 + localNameLength + localExtraLength; + const dataEnd = dataStart + member.compressedSize; + if ( + localFlags !== member.flags || + localMethod !== member.method || + localCrc !== member.expectedCrc || + localCompressedSize !== member.compressedSize || + localUncompressedSize !== member.uncompressedSize || + !localName.equals(member.nameBytes) || + dataEnd > centralDirectoryOffset + ) { + throw zipError("has inconsistent local-member metadata"); + } + let executable: Buffer; + try { + const data = archive.subarray(dataStart, dataEnd); + executable = member.method === 0 + ? Buffer.from(data) + : inflateRawSync(data, { maxOutputLength: member.uncompressedSize }); + } catch { + throw zipError("contains invalid compressed executable data"); + } + if (executable.byteLength !== member.uncompressedSize || crc32(executable) !== member.expectedCrc) { + throw zipError("contains an invalid executable payload"); + } + executables.set(member.name, executable); + expectedLocalOffset = dataEnd; } + if (expectedLocalOffset !== centralDirectoryOffset) throw zipError("has unindexed local data"); + return executables; } -async function extractHost(archive: Buffer, stateDir: string, platform: NodeJS.Platform): Promise<{ bytes: Buffer; tempDir: string }> { +async function extractHost(archive: Buffer, stateDir: string, platform: NodeJS.Platform): Promise<{ hostBytes: Buffer; shimBytes: Buffer; tempDir: string }> { await mkdir(stateDir, { recursive: true, mode: 0o700 }); const tempDir = await mkdtemp(join(stateDir, ".install-")); const binaryName = platform === "win32" ? "agenttab-host.exe" : "agenttab-host"; + const shimName = platform === "win32" ? "agenttab-native.exe" : "agenttab-native"; if (platform === "win32") { try { const hostPath = join(tempDir, binaryName); - const bytes = extractWindowsZipHost(archive, binaryName); - await writeFile(hostPath, bytes, { mode: 0o700 }); + const shimPath = join(tempDir, shimName); + const executables = extractWindowsZipExecutables(archive, [binaryName, shimName]); + const hostBytes = executables.get(binaryName)!; + const shimBytes = executables.get(shimName)!; + await writeFile(hostPath, hostBytes, { mode: 0o700 }); + await writeFile(shimPath, shimBytes, { mode: 0o700 }); await chmod(hostPath, 0o755); - return { bytes, tempDir }; + await chmod(shimPath, 0o755); + return { hostBytes, shimBytes, tempDir }; } catch (error) { await rm(tempDir, { recursive: true, force: true }); throw error; @@ -470,18 +519,21 @@ async function extractHost(archive: Buffer, stateDir: string, platform: NodeJS.P .split(/\r?\n/) .filter(Boolean) .map((name) => name.replace(/^\.\//, "")); - if (listing.length !== 1 || listing[0] !== binaryName) { - throw new Error(`host archive must contain exactly ${binaryName}`); + if (listing.length !== 2 || new Set(listing).size !== 2 || !listing.includes(binaryName) || !listing.includes(shimName)) { + throw new Error(`host archive must contain exactly ${binaryName} and ${shimName}`); } if (listing.some((name) => name.startsWith("/") || name.split("/").includes(".."))) { throw new Error("host archive contains an unsafe path"); } execFileSync("tar", ["-xzf", archivePath, "-C", tempDir]); const hostPath = join(tempDir, binaryName); - const metadata = await stat(hostPath); - if (!metadata.isFile()) throw new Error("host archive did not extract a regular file"); + const shimPath = join(tempDir, shimName); + const metadata = await lstat(hostPath); + const shimMetadata = await lstat(shimPath); + if (!metadata.isFile() || !shimMetadata.isFile()) throw new Error("host archive did not extract regular executables"); await chmod(hostPath, 0o755); - return { bytes: await readFile(hostPath), tempDir }; + await chmod(shimPath, 0o755); + return { hostBytes: await readFile(hostPath), shimBytes: await readFile(shimPath), tempDir }; } function quotePowerShell(value: string): string { @@ -543,18 +595,33 @@ function extensionDigest(plans: PlannedFile[]): string { return hash.digest("hex"); } +function extensionVersion(plans: PlannedFile[], extensionRoot: string): string { + const manifest = plans.find((plan) => plan.path === join(extensionRoot, "manifest.json")); + if (!manifest) throw new Error("AgentTab extension bundle is missing manifest.json"); + const parsed: unknown = JSON.parse( + (Buffer.isBuffer(manifest.content) ? manifest.content : Buffer.from(manifest.content)).toString("utf8"), + ); + if ( + typeof parsed !== "object" + || parsed === null + || Array.isArray(parsed) + || typeof (parsed as Record).version !== "string" + ) throw new Error("AgentTab extension manifest has no version"); + return (parsed as Record).version as string; +} + function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } -function nativeManifest(hostPath: string): string { +function nativeManifest(shimPath: string): string { const extensionIds = [identity.developmentExtension.id, identity.webStoreExtensionId].filter( (value): value is string => typeof value === "string" && value.length > 0, ); return `${JSON.stringify({ name: identity.nativeHost, description: "AgentTab local browser runtime", - path: hostPath, + path: shimPath, type: "stdio", allowed_origins: extensionIds.map((id) => `chrome-extension://${id}/`), }, null, 2)}\n`; @@ -578,51 +645,6 @@ function nativeManifestPaths(home: string, stateDir: string, platform: NodeJS.Pl return [join(stateDir, "native-messaging", `${identity.nativeHost}.json`)]; } -interface RegistryValue { - existed: boolean; - value: string | null; -} - -function windowsRegistryKeys(): string[] { - return [ - `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${identity.nativeHost}`, - `HKCU\\Software\\Chromium\\NativeMessagingHosts\\${identity.nativeHost}`, - `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${identity.nativeHost}`, - ]; -} - -function queryRegistryValue(key: string): RegistryValue { - try { - const output = execFileSync("reg.exe", ["query", key, "/ve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); - const match = output.match(/REG_SZ\s+([^\r\n]*)/); - return { existed: true, value: match?.[1]?.trim() || null }; - } catch { - return { existed: false, value: null }; - } -} - -async function registerWindows(manifestPath: string): Promise { - const snapshots = new Map(); - const changed: string[] = []; - try { - for (const key of windowsRegistryKeys()) { - const current = queryRegistryValue(key); - snapshots.set(key, current); - if (current.value === manifestPath) continue; - execFileSync("reg.exe", ["add", key, "/ve", "/t", "REG_SZ", "/d", manifestPath, "/f"], { stdio: "pipe" }); - changed.push(key); - } - } catch (error) { - for (const key of changed.reverse()) { - const previous = snapshots.get(key)!; - if (!previous.existed) execFileSync("reg.exe", ["delete", key, "/f"], { stdio: "pipe" }); - else if (previous.value === null) execFileSync("reg.exe", ["delete", key, "/ve", "/f"], { stdio: "pipe" }); - else execFileSync("reg.exe", ["add", key, "/ve", "/t", "REG_SZ", "/d", previous.value, "/f"], { stdio: "pipe" }); - } - throw error; - } -} - function legacyManifestPaths(home: string, platform: NodeJS.Platform): string[] { if (platform === "darwin") { return [ @@ -664,98 +686,43 @@ function startBrowser(platform: NodeJS.Platform): void { } } -function isRetryableReadinessError(error: unknown): error is AgentTabError { - return error instanceof AgentTabError - && error.code === "runtime_not_ready" - && error.outcome === "not_started"; -} - -async function runReadiness(openBrowser: boolean, platform: NodeJS.Platform): Promise { - if (openBrowser) startBrowser(platform); - const deadline = Date.now() + 20_000; - let client: AgentTabClient | undefined; - let connectError: unknown; - while (Date.now() < deadline) { - try { - client = await AgentTabClient.connect({ connectTimeoutMs: 500, requestTimeoutMs: 10_000 }); - break; - } catch (error) { - connectError = error; - await delay(250); - } - } - if (!client) { - throw new InstallError("ipc", connectError instanceof Error ? connectError.message : "AgentTab host did not become ready", "agenttab doctor --layer ipc"); - } - let lifecycleState: ConnectionAck["state"] = client.connection.state; - while (lifecycleState !== "ready" && Date.now() < deadline) { - try { - const status = await client.call<"agenttab.status", { state?: unknown }>("agenttab.status", {}); - lifecycleState = typeof status.state === "string" - ? status.state as ConnectionAck["state"] - : undefined; - connectError = new Error(`AgentTab host is ${lifecycleState ?? "in an unknown lifecycle state"}`); - } catch (error) { - connectError = error; - } - if (lifecycleState !== "ready") await delay(250); - } - if (lifecycleState !== "ready") { - client.close(); - throw new InstallError("ipc", connectError instanceof Error ? connectError.message : "AgentTab host did not become ready", "agenttab doctor --layer ipc"); - } - let tabId: number | undefined; - let revision: number | undefined; - try { - let opened: Record | undefined; - while (opened === undefined && Date.now() < deadline) { - try { - opened = await client.call<"browser_open", Record>( - "browser_open", - { mode: "create", url: "about:blank", background: true }, - ); - } catch (error) { - if (!isRetryableReadinessError(error)) throw error; - const remaining = deadline - Date.now(); - if (remaining > 0) await delay(Math.min(250, remaining)); - } - } - if (opened === undefined) { - throw new Error("AgentTab extension did not become ready before the readiness deadline"); - } - tabId = Number(opened.tab_id); - revision = Number(opened.page_revision); - if (!Number.isInteger(tabId) || !Number.isInteger(revision)) throw new Error("browser_open did not return tab_id and page_revision"); - const snapshot = await client.call<"browser_snapshot", Record>( - "browser_snapshot", - { tab_id: tabId, mode: "accessibility", max_nodes: 10 }, - ); - const latestRevision = Number(snapshot.page_revision); - if (Number.isInteger(latestRevision)) revision = latestRevision; - } catch (error) { - throw new InstallError("extension", error instanceof Error ? error.message : String(error), "agenttab doctor --layer extension"); - } finally { - if (tabId !== undefined && revision !== undefined) { - await client.call("browser_act", { - tab_id: tabId, - expected_page_revision: revision, - actions: [{ kind: "close" }], - }).catch(() => undefined); - } - client.close(); - } -} - function runtimeAssetsFromBundle(): RuntimeAssets { const root = dirname(fileURLToPath(import.meta.url)); return { + version: identity.version, cliBundlePath: join(root, "cli.mjs"), ompBundlePath: join(root, "omp.mjs"), extensionDir: join(root, "extension"), }; } -export async function install(options: InstallOptions): Promise { +function compareVersions(left: string, right: string): number { + const parse = (value: string): { core: number[]; pre: string[] | null } => { + const [core, prerelease] = value.split("-", 2); + return { core: core.split(".").map(Number), pre: prerelease === undefined ? null : prerelease.split(".") }; + }; + const a = parse(left); + const b = parse(right); + for (let index = 0; index < 3; index += 1) { + if (a.core[index] !== b.core[index]) return a.core[index] < b.core[index] ? -1 : 1; + } + if (a.pre === null || b.pre === null) return a.pre === b.pre ? 0 : a.pre === null ? 1 : -1; + const length = Math.max(a.pre.length, b.pre.length); + for (let index = 0; index < length; index += 1) { + const av = a.pre[index]; + const bv = b.pre[index]; + if (av === bv) continue; + if (av === undefined || bv === undefined) return av === undefined ? -1 : 1; + const an = /^[0-9]+$/.test(av) ? Number(av) : null; + const bn = /^[0-9]+$/.test(bv) ? Number(bv) : null; + if (an !== null && bn !== null) return an < bn ? -1 : 1; + if (an !== null || bn !== null) return an !== null ? -1 : 1; + return av < bv ? -1 : 1; + } + return 0; +} + +async function installInternal(options: InstallOptions & { activation: "install" | "update" }): Promise { const version = validVersion(options.version); const development = options.development === true; const platform = options.platform ?? currentPlatform(); @@ -763,6 +730,21 @@ export async function install(options: InstallOptions): Promise { const home = resolve(options.home ?? homedir()); const stateDir = resolve(options.stateDir ?? join(home, ".agenttab")); const print = options.print ?? ((line: string) => console.log(line)); + const active = await loadActiveReceipt(stateDir); + if (options.activation === "update") { + if (!active) throw new Error("agenttab update requires an existing managed AgentTab installation"); + if (compareVersions(version, active.receipt.version) <= 0) { + throw new Error(`agenttab update requires a version newer than ${active.receipt.version}; use agenttab rollback for a downgrade`); + } + } else if (active && active.receipt.version !== version) { + throw new Error(`AgentTab ${active.receipt.version} is active; use agenttab update --version ${version} to activate a newer version`); + } + const runtime = options.runtimeAssets ?? runtimeAssetsFromBundle(); + if (runtime.version !== undefined && runtime.version !== version) { + throw new Error( + `installer runtime ${runtime.version} cannot activate AgentTab ${version}; run the exact AgentTab ${version} installer package`, + ); + } const manifestUrl = options.manifestUrl ?? defaultManifestUrl(version); if (manifestUrl.includes("/latest")) throw new Error("installer must never resolve a latest release URL"); const signatureUrl = options.signatureUrl ?? `${manifestUrl}.sig`; @@ -782,35 +764,39 @@ export async function install(options: InstallOptions): Promise { const versionRoot = join(stateDir, "versions", `v${version}`); const hostPath = join(versionRoot, target, platform === "win32" ? "agenttab-host.exe" : "agenttab-host"); - const receiptPath = join(versionRoot, "install-receipt.json"); + const shimPath = join(versionRoot, target, platform === "win32" ? "agenttab-native.exe" : "agenttab-native"); + const runtimeConfigPath = join(versionRoot, target, "agenttab-runtime.json"); const manifestSha = sha256(manifestBytes); - const priorReceipt = await readJsonOptional(receiptPath); let hostBytes: Buffer; + let shimBytes: Buffer; if ( - priorReceipt?.schemaVersion === 1 && - priorReceipt.version === version && - priorReceipt.target === target && - priorReceipt.manifestSha256 === manifestSha && - priorReceipt.assetSha256 === asset.sha256 && - existsSync(hostPath) + active?.receipt.version === version && + active.receipt.target === target && + active.receipt.manifestSha256 === manifestSha && + active.receipt.assetSha256 === asset.sha256 && + existsSync(hostPath) && + existsSync(shimPath) ) { - hostBytes = await readFile(hostPath); - if (sha256(hostBytes) !== priorReceipt.hostSha256) throw new Error("installed AgentTab host does not match its receipt"); + [hostBytes, shimBytes] = await Promise.all([readFile(hostPath), readFile(shimPath)]); + if (sha256(hostBytes) !== active.receipt.hostSha256) throw new Error("installed AgentTab host does not match its receipt"); + if (sha256(shimBytes) !== active.receipt.shimSha256) throw new Error("installed AgentTab native shim does not match its receipt"); await verifyPlatformSignature(hostPath, asset, platform); + await verifyPlatformSignature(shimPath, asset, platform); } else { const archive = await fetchBytes(asset.url, development); if (archive.byteLength !== asset.bytes) throw new Error(`host asset byte count mismatch: expected ${asset.bytes}, got ${archive.byteLength}`); if (sha256(archive) !== asset.sha256) throw new Error("host asset SHA-256 mismatch"); const extracted = await extractHost(archive, options.dryRun ? tmpdir() : stateDir, platform); try { - hostBytes = extracted.bytes; + hostBytes = extracted.hostBytes; + shimBytes = extracted.shimBytes; await verifyPlatformSignature(join(extracted.tempDir, basename(hostPath)), asset, platform); + await verifyPlatformSignature(join(extracted.tempDir, basename(shimPath)), asset, platform); } finally { await rm(extracted.tempDir, { recursive: true, force: true }); } } - const runtime = options.runtimeAssets ?? runtimeAssetsFromBundle(); const cliBytes = await readFile(runtime.cliBundlePath); const ompBytes = await readFile(runtime.ompBundlePath); const cliPath = join(versionRoot, "agenttab-cli.mjs"); @@ -821,26 +807,27 @@ export async function install(options: InstallOptions): Promise { const wrapper = platform === "win32" ? `@\"${process.execPath}\" \"${cliPath}\" %*\r\n` : `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(cliPath)} \"$@\"\n`; - const receipt: InstallReceipt = { - schemaVersion: 1, - version, - target, - manifestSha256: manifestSha, - assetSha256: asset.sha256, - hostSha256: sha256(hostBytes), - cliSha256: sha256(cliBytes), - ompSha256: sha256(ompBytes), - extensionSha256: extensionDigest(extensionPlans), - }; - const manifestContent = nativeManifest(hostPath); + const manifestContent = nativeManifest(shimPath); + const runtimeConfig = `${JSON.stringify({ schemaVersion: 1, stateDir }, null, 2)}\n`; const manifestPaths = nativeManifestPaths(home, stateDir, platform); - const configPlan = await planClientConfigs({ home, cliPath: cliCommand, ompAdapterPath: ompPath, platform }); - const files: PlannedFile[] = [ + const configPlan = await planClientConfigs({ + home, + cliPath: cliCommand, + ompAdapterPath: ompPath, + platform, + previousOwnership: active?.receipt.configs, + }); + const servicePlan = planDaemonService({ platform, home, hostPath, stateDir }); + const artifactFiles: PlannedFile[] = [ { path: hostPath, content: hostBytes, mode: 0o755, label: "AgentTab host" }, + { path: shimPath, content: shimBytes, mode: 0o755, label: "AgentTab native shim" }, + { path: runtimeConfigPath, content: runtimeConfig, mode: 0o600, label: "AgentTab runtime configuration" }, { path: cliPath, content: cliBytes, mode: 0o755, label: "AgentTab CLI" }, { path: ompPath, content: ompBytes, mode: 0o600, label: "AgentTab OMP adapter" }, - { path: cliCommand, content: wrapper, mode: platform === "win32" ? 0o600 : 0o755, label: "AgentTab command" }, ...extensionPlans, + ]; + const activationFiles: PlannedFile[] = [ + { path: cliCommand, content: wrapper, mode: platform === "win32" ? 0o600 : 0o755, label: "AgentTab command" }, ...manifestPaths.map((path) => ({ path, content: manifestContent, @@ -849,21 +836,218 @@ export async function install(options: InstallOptions): Promise { semanticDiff: [ `--- native host ${identity.nativeHost}`, `+++ native host ${identity.nativeHost}`, - `+path: ${hostPath}`, + `+path: ${shimPath}`, `+allowed_origins: ${[identity.developmentExtension.id, identity.webStoreExtensionId].filter(Boolean).join(", ")}`, ].join("\n"), })), + ...(development ? [] : servicePlan.files), + ]; + const cliSha = sha256(cliBytes); + const ompSha = sha256(ompBytes); + const extensionSha = extensionDigest(extensionPlans); + const installedExtensionVersion = extensionVersion(extensionPlans, extensionRoot); + const isRepeat = active?.receipt.version === version + && active.receipt.target === target + && active.receipt.manifestSha256 === manifestSha + && active.receipt.assetSha256 === asset.sha256 + && active.receipt.hostSha256 === sha256(hostBytes) + && active.receipt.shimSha256 === sha256(shimBytes) + && active.receipt.cliSha256 === cliSha + && active.receipt.ompSha256 === ompSha + && active.receipt.extensionSha256 === extensionSha + && active.receipt.extensionVersion === installedExtensionVersion + && active.receipt.daemonService.manager === servicePlan.manager + && active.receipt.daemonService.managed === !development; + if (active?.receipt.version === version && !isRepeat) { + throw new Error(`AgentTab ${version} runtime assets do not match its active receipt; use a new signed version`); + } + if (isRepeat) { + const drift = await activeReceiptDrift(active!.receipt, platform); + if (drift.length > 0) { + throw new Error( + `AgentTab ${version} active resources changed after installation: ${drift.join(", ")}`, + ); + } + const plannedFiles = [ + ...artifactFiles.map((file) => ({ file, role: "artifact" as const })), + ...activationFiles.map((file) => ({ file, role: "activation" as const })), + ]; + if ( + active!.receipt.files.length !== plannedFiles.length + || active!.receipt.files.some((owned) => + !plannedFiles.some((planned) => planned.file.path === owned.path && planned.role === owned.role) + ) + ) throw new Error(`AgentTab ${version} active receipt file set does not match this installer runtime`); + for (const { file: planned, role } of plannedFiles) { + const prior = active!.receipt.files.find((file) => file.role === role && file.path === planned.path); + if (!prior) throw new Error(`AgentTab ${version} active receipt does not own expected file: ${planned.path}`); + planned.expectedBefore = { + exists: true, + sha256: prior.installedSha256, + ...(prior.installedMode === undefined ? {} : { mode: prior.installedMode }), + } satisfies FileExpectation; + } + } + + if (active && active.receipt.version !== version) { + for (const planned of activationFiles) { + const prior = active.receipt.files.find((file) => file.role === "activation" && file.path === planned.path); + if (!prior) continue; + planned.expectedBefore = { + exists: true, + sha256: prior.installedSha256, + ...(prior.installedMode === undefined ? {} : { mode: prior.installedMode }), + }; + const current = await readOptionalBytes(planned.path); + const currentMode = current && process.platform !== "win32" ? (await stat(planned.path)).mode & 0o777 : undefined; + if ( + current === null + || sha256(current) !== prior.installedSha256 + || (prior.installedMode !== undefined && currentMode !== undefined && prior.installedMode !== currentMode) + ) { + throw new Error(`AgentTab activation file changed after ${active.receipt.version}: ${planned.path}`); + } + } + } + + const registry: RegistryOwnership[] = []; + const registryChanges: Parameters[0] = []; + if (platform === "win32") { + for (const key of windowsRegistryKeys()) { + const current = queryRegistryValue(key); + const prior = active?.receipt.registry.find((entry) => entry.key === key); + if (active && prior && (current.existed !== true || current.value !== prior.installedValue)) { + throw new Error(`AgentTab registry value changed after ${active.receipt.version}: ${key}`); + } + const installedValue = manifestPaths[0]; + const owned = current.existed !== true || current.value !== installedValue; + registry.push({ key, installedValue, previous: current, owned }); + if (!isRepeat && owned) registryChanges.push({ key, expected: current, target: { existed: true, value: installedValue } }); + } + } + + let receiptPath: string; + let receiptBytes: Buffer; + let activeBytes: Buffer; + let receipt: InstallReceiptV2; + if (isRepeat) { + receiptPath = active.state.receiptPath; + receiptBytes = active.bytes; + activeBytes = canonicalJson(active.state); + receipt = active.receipt; + } else { + receiptPath = newReceiptPath(stateDir, version); + const previousActive = active?.stateSnapshot ?? { exists: false as const }; + const ownedFiles = await Promise.all([ + ...artifactFiles.map((file) => ownershipForFile(file, "artifact")), + ...activationFiles.map((file) => ownershipForFile(file, "activation")), + ]); + receipt = { + schemaVersion: 2, + activationId: basename(receiptPath, ".json"), + activatedAt: new Date().toISOString(), + version, + target, + platform, + stateDir, + home, + manifestSha256: manifestSha, + assetSha256: asset.sha256, + hostSha256: sha256(hostBytes), + shimSha256: sha256(shimBytes), + cliSha256: cliSha, + ompSha256: ompSha, + extensionSha256: extensionSha, + extensionVersion: installedExtensionVersion, + daemonService: { manager: servicePlan.manager, managed: !development }, + previousActive, + previousReceipt: active ? { + version: active.state.version, + receiptPath: active.state.receiptPath, + receiptSha256: active.state.receiptSha256, + } : null, + files: ownedFiles, + configs: configPlan.ownership, + registry, + }; + receiptBytes = canonicalJson(receipt); + activeBytes = canonicalJson({ + schemaVersion: 1, + ...referenceForReceipt(version, receiptPath, receiptBytes), + }); + } + + const files: PlannedFile[] = [ + ...artifactFiles, + ...activationFiles, ...configPlan.files, - { path: receiptPath, content: `${JSON.stringify(receipt, null, 2)}\n`, mode: 0o600, label: "AgentTab install receipt" }, + { + path: receiptPath, + content: receiptBytes, + mode: 0o600, + label: "AgentTab activation receipt", + expectedBefore: isRepeat + ? expectationFromSnapshot(active!.receiptSnapshot) + : { exists: false }, + }, + { + path: activeStatePath(stateDir), + content: activeBytes, + mode: 0o600, + label: "AgentTab active activation", + expectedBefore: expectationFromSnapshot(active?.stateSnapshot ?? { exists: false }), + statePointer: true, + }, ]; + await options.beforeTransaction?.(); + let service: InstallResult["service"] = { + manager: servicePlan.manager, + status: development ? "shim_fallback" : options.dryRun ? "planned" : "shim_fallback", + ...(development ? { reason: "development installs use on-demand daemon startup" } : {}), + }; const transaction = await applyTransaction(files, { dryRun: options.dryRun, failAfter: options.transactionFailAfter, + crashAfter: options.transactionCrashAfter, + crashAfterExternal: options.transactionCrashAfterExternal, printDiff: (diff) => print(diff), - afterApply: platform === "win32" ? () => registerWindows(manifestPaths[0]) : undefined, + journal: { + stateDir, + operation: options.activation, + external: registryChangesForJournal(registryChanges), + }, + applyExternal: registryChanges.length === 0 + ? undefined + : () => applyRegistryChanges(registryChanges, options.registryFailAfter), + afterApply: options.dryRun ? undefined : async () => { + try { + if (options.verifyReadiness === true) { + if (options.openBrowser !== false) startBrowser(platform); + await verifyRuntimeReadiness({ stateDir, home, platform, version }); + } + } catch (error) { + if (error instanceof InstallError) throw error; + const enriched = error as Error & { layer?: string; recovery?: string }; + if (enriched.layer) { + throw new InstallError(enriched.layer, enriched.message, enriched.recovery ?? `agenttab doctor --layer ${enriched.layer}`); + } + throw error; + } + }, }); + if (!development && !options.dryRun) { + try { + await activateDaemonService(servicePlan); + service = { manager: servicePlan.manager, status: "active" }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + service = { manager: servicePlan.manager, status: "shim_fallback", reason }; + print(`Could not activate the ${servicePlan.manager} user service; Chrome will start the daemon on demand: ${reason}`); + } + } + for (const skipped of configPlan.skipped) { print(`Skipped ${skipped.client} config at ${skipped.path}: ${skipped.reason}`); } @@ -884,7 +1068,6 @@ export async function install(options: InstallOptions): Promise { readiness = { passed: false, skipped: true, reason: "dry_run" }; extensionStatus = "planned"; } else if (options.verifyReadiness === true) { - await runReadiness(options.openBrowser !== false, platform); readiness = { passed: true, skipped: false }; extensionStatus = "verified"; } else { @@ -902,5 +1085,28 @@ export async function install(options: InstallOptions): Promise { legacy, extension: { path: extensionRoot, status: extensionStatus, instructions }, readiness, + service, }; } + +export async function install(options: InstallOptions): Promise { + const home = resolve(options.home ?? homedir()); + const stateDir = resolve(options.stateDir ?? join(home, ".agenttab")); + return withInstallerStateMutation( + stateDir, + "install", + options.dryRun, + () => installInternal({ ...options, activation: "install" }), + ); +} + +export async function update(options: InstallOptions): Promise { + const home = resolve(options.home ?? homedir()); + const stateDir = resolve(options.stateDir ?? join(home, ".agenttab")); + return withInstallerStateMutation( + stateDir, + "update", + options.dryRun, + () => installInternal({ ...options, activation: "update" }), + ); +} diff --git a/packages/installer/src/lifecycle.ts b/packages/installer/src/lifecycle.ts new file mode 100644 index 0000000..972dd21 --- /dev/null +++ b/packages/installer/src/lifecycle.ts @@ -0,0 +1,1231 @@ +import { execFileSync } from "node:child_process"; +import { lstat, readdir, readFile, stat } from "node:fs/promises"; +import { homedir, platform as currentPlatform } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { parseDocument, isSeq } from "yaml"; +import { + AgentTabClient, + AgentTabError, + RPC_PROTOCOL, + RPC_VERSION, + resolveEndpoint, + type ConnectionAck, +} from "../../sdk-typescript/src/index"; +import identityJson from "../../../config/identity.json" with { type: "json" }; +import type { ConfigOwnership, JsonConfigOwnership, YamlConfigOwnership } from "./configs"; +import { + activeStatePath, + expectationFromSnapshot, + loadActiveReceipt, + readOptionalBytes, + readReceipt, + receiptDirectory, + sha256, + snapshotBytes, + type ActiveInstallState, + type ActiveReceiptReference, + type FileOwnership, + type FileSnapshot, + type InstallReceiptV2, + type RegistryOwnership, + type RegistrySnapshot, +} from "./receipt"; +import { withStateDirectoryLock } from "./state-lock"; +import { + activateDaemonService, + deactivateDaemonService, + planDaemonService, + type DaemonServicePlan, +} from "./service"; +import { + applyTransaction, + expectationFor, + pendingTransactionExists, + recoverPendingTransaction, + TransactionConflictError, + type DurableExternalChange, + type ExternalRecoveryHandler, + type FileExpectation, + type PlannedChange, + type PlannedFile, + type TransactionResult, +} from "./transaction"; + +const identity = identityJson as { nativeHost: string }; + +export interface LifecycleOptions { + stateDir?: string; + home?: string; + platform?: NodeJS.Platform; + dryRun?: boolean; + print?: (line: string) => void; + transactionFailAfter?: number; + transactionCrashAfter?: number; + transactionCrashAfterExternal?: boolean; + registryFailAfter?: number; +} + +export interface LifecycleResult { + operation: "rollback" | "uninstall" | "prune"; + changed: string[]; + unchanged: string[]; + preserved: Array<{ resource: string; reason: string }>; + activeVersion: string | null; + transaction: TransactionResult; +} + +export type RegistryValue = RegistrySnapshot; + +export interface RegistryChange { + key: string; + expected: RegistryValue; + target: RegistryValue; +} + +export function windowsRegistryKeys(): string[] { + return [ + `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${identity.nativeHost}`, + `HKCU\\Software\\Chromium\\NativeMessagingHosts\\${identity.nativeHost}`, + `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${identity.nativeHost}`, + ]; +} + +function queryRegistryValueWithPowerShell(key: string): RegistryValue { + const literal = key.replaceAll("'", "''"); + const script = [ + "$ErrorActionPreference = 'Stop'", + `$full = '${literal}'`, + "if (-not $full.StartsWith('HKCU\\')) { throw 'Only HKCU registry values are supported' }", + "$path = $full.Substring(5)", + "$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($path, $false)", + "if ($null -eq $key) { [ordered]@{ existed = $false } | ConvertTo-Json -Compress; exit 0 }", + "try {", + " if (-not ($key.GetValueNames() -contains '')) { [ordered]@{ existed = $false } | ConvertTo-Json -Compress; exit 0 }", + " $kind = $key.GetValueKind('').ToString()", + " $value = $key.GetValue('', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)", + " [ordered]@{ existed = $true; kind = $kind; value = [string]$value } | ConvertTo-Json -Compress", + "} finally { $key.Dispose() }", + ].join("; "); + let output: string; + try { + output = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + windowsHide: true, + }); + } catch (error) { + throw new Error(`AgentTab could not query registry default ${key} with the Windows registry API`, { cause: error }); + } + let value: unknown; + try { + value = JSON.parse(output.trim()); + } catch (error) { + throw new Error(`AgentTab Windows registry query returned malformed structured output for ${key}`, { cause: error }); + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`AgentTab Windows registry query returned invalid structured output for ${key}`); + } + const entry = value as Record; + if (entry.existed === false) return { existed: false, value: null }; + if (entry.existed !== true || typeof entry.kind !== "string" || typeof entry.value !== "string") { + throw new Error(`AgentTab Windows registry query returned invalid structured output for ${key}`); + } + if (entry.kind !== "String") throw new Error(`AgentTab registry default ${key} has unsupported type ${entry.kind}`); + return { existed: true, value: entry.value }; +} + +export function queryRegistryValue(key: string): RegistryValue { + if (process.platform === "win32" && process.env.AGENTTAB_REG_EXE === undefined) { + return queryRegistryValueWithPowerShell(key); + } + let output: string; + try { + output = execFileSync(process.env.AGENTTAB_REG_EXE ?? "reg.exe", ["query", key, "/ve"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + } catch (error) { + const failure = error as Error & { status?: number; stdout?: string | Buffer; stderr?: string | Buffer }; + const diagnostic = `${failure.stdout ?? ""}\n${failure.stderr ?? ""}`; + if ( + (process.env.AGENTTAB_REG_EXE !== undefined && failure.status === 3) + || (failure.status === 1 && /unable to find the specified registry key or value/i.test(diagnostic)) + ) { + return { existed: false, value: null }; + } + throw new Error(`AgentTab could not query registry default ${key}: ${diagnostic.trim() || failure.message}`, { cause: error }); + } + const rows = output.split(/\r?\n/).flatMap((line) => { + const match = /\b(REG_[A-Z0-9_]+)\b/.exec(line); + return match && match.index !== undefined ? [{ line, type: match[1], end: match.index + match[1].length }] : []; + }); + if (rows.length !== 1) throw new Error(`AgentTab registry query returned malformed output for ${key}`); + const row = rows[0]; + if (row.type !== "REG_SZ") throw new Error(`AgentTab registry default ${key} has unsupported type ${row.type}`); + const remainder = row.line.slice(row.end); + const value = remainder.startsWith(" ") + ? remainder.slice(4) + : remainder.startsWith("\t") + ? remainder.slice(1) + : null; + if (value === null) throw new Error(`AgentTab registry query returned an ambiguous REG_SZ value for ${key}`); + return { existed: true, value }; +} + +function sameRegistryValue(left: RegistryValue, right: RegistryValue): boolean { + return left.existed === right.existed + && (!left.existed || (right.existed && left.value === right.value)); +} + +function setRegistryValue(key: string, value: RegistryValue): void { + if (!value.existed) { + // Delete only the default value. Never recursively delete a browser/vendor key. + try { + execFileSync(process.env.AGENTTAB_REG_EXE ?? "reg.exe", ["delete", key, "/ve", "/f"], { + stdio: "pipe", + env: { ...process.env }, + }); + } catch (error) { + const current = queryRegistryValue(key); + if (!current.existed) return; + throw new Error(`AgentTab could not delete registry default ${key}`, { cause: error }); + } + return; + } + execFileSync(process.env.AGENTTAB_REG_EXE ?? "reg.exe", ["add", key, "/ve", "/t", "REG_SZ", "/d", value.value, "/f"], { + stdio: "pipe", + env: { ...process.env }, + }); +} + +function registryRecoveryConflict(context: string, key: string, error: unknown): TransactionConflictError { + if (error instanceof TransactionConflictError && error.recoveryIncomplete) return error; + return new TransactionConflictError( + `${context} (${error instanceof Error ? error.message : String(error)})`, + [key], + true, + ); +} + +export async function applyRegistryChanges( + changes: RegistryChange[], + failAfter?: number, +): Promise<() => Promise> { + const applied: Array<{ key: string; before: RegistryValue }> = []; + const safelyRestore = async (): Promise => { + const restore: Array<{ key: string; before: RegistryValue }> = []; + const problems: string[] = []; + for (const entry of applied) { + const change = changes.find((candidate) => candidate.key === entry.key)!; + try { + const current = queryRegistryValue(entry.key); + if (sameRegistryValue(current, entry.before)) continue; + if (!sameRegistryValue(current, change.target)) { + problems.push(entry.key); + continue; + } + restore.push(entry); + } catch (error) { + problems.push(...registryRecoveryConflict("AgentTab could not preflight registry rollback", entry.key, error).resources); + } + } + for (const entry of [...restore].reverse()) { + const change = changes.find((candidate) => candidate.key === entry.key)!; + try { + const current = queryRegistryValue(entry.key); + if (sameRegistryValue(current, entry.before)) continue; + if (!sameRegistryValue(current, change.target)) { + problems.push(entry.key); + continue; + } + setRegistryValue(entry.key, entry.before); + if (!sameRegistryValue(queryRegistryValue(entry.key), entry.before)) { + throw new Error("registry value did not reach its rollback state"); + } + } catch (error) { + problems.push(...registryRecoveryConflict("AgentTab could not restore a registry value during transaction rollback", entry.key, error).resources); + } + } + if (problems.length > 0) { + throw new TransactionConflictError( + "AgentTab preserved registry values changed during transaction rollback", + [...new Set(problems)], + true, + ); + } + }; + try { + for (const change of changes) { + const current = queryRegistryValue(change.key); + if (!sameRegistryValue(current, change.expected)) { + throw new Error(`registry value changed before activation: ${change.key}`); + } + if (sameRegistryValue(current, change.target)) continue; + applied.push({ key: change.key, before: current }); + setRegistryValue(change.key, change.target); + if (!sameRegistryValue(queryRegistryValue(change.key), change.target)) { + throw new Error(`registry value did not reach its planned state: ${change.key}`); + } + if (failAfter !== undefined && applied.length === failAfter) { + throw new Error(`Injected registry failure after ${applied.length} values`); + } + } + } catch (error) { + try { + await safelyRestore(); + } catch (rollbackError) { + if (rollbackError instanceof TransactionConflictError) { + throw new TransactionConflictError( + `AgentTab registry transaction failed and preserved concurrent changes (${error instanceof Error ? error.message : String(error)})`, + rollbackError.resources, + true, + ); + } + throw registryRecoveryConflict( + `AgentTab registry transaction failed and rollback is incomplete (${error instanceof Error ? error.message : String(error)})`, + applied.at(-1)?.key ?? "Windows registry", + rollbackError, + ); + } + throw error; + } + return safelyRestore; +} + +export function registryChangesForJournal(changes: RegistryChange[]): DurableExternalChange[] { + return changes.map((change) => ({ + kind: "windows_registry_default", + resource: change.key, + before: change.expected, + after: change.target, + })); +} + +function registryValueFromJournal(value: unknown): RegistryValue | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const entry = value as Record; + if (entry.existed === false && entry.value === null) return { existed: false, value: null }; + if (entry.existed === true && typeof entry.value === "string") return { existed: true, value: entry.value }; + return null; +} + +const registryRecoveryHandler: ExternalRecoveryHandler = { + async inspect(change) { + if (!windowsRegistryKeys().includes(change.resource)) return "conflict"; + const before = registryValueFromJournal(change.before); + const after = registryValueFromJournal(change.after); + if (!before || !after) return "conflict"; + try { + const current = queryRegistryValue(change.resource); + if (sameRegistryValue(current, before)) return "before"; + if (sameRegistryValue(current, after)) return "after"; + return "conflict"; + } catch (error) { + throw registryRecoveryConflict("AgentTab could not inspect a registry value during crash recovery", change.resource, error); + } + }, + async restore(change) { + const before = registryValueFromJournal(change.before); + const after = registryValueFromJournal(change.after); + if (!windowsRegistryKeys().includes(change.resource) || !before || !after) { + throw new TransactionConflictError("AgentTab preserved a registry value changed during crash recovery", [change.resource], true); + } + try { + const current = queryRegistryValue(change.resource); + if (sameRegistryValue(current, before)) return; + if (!sameRegistryValue(current, after)) { + throw new TransactionConflictError("AgentTab preserved a registry value changed during crash recovery", [change.resource], true); + } + setRegistryValue(change.resource, before); + if (!sameRegistryValue(queryRegistryValue(change.resource), before)) { + throw new Error("registry value did not reach its recovery state"); + } + } catch (error) { + throw registryRecoveryConflict("AgentTab could not restore a registry value during crash recovery", change.resource, error); + } + }, +}; + +export async function withInstallerStateMutation( + stateDir: string, + operation: string, + dryRun: boolean | undefined, + callback: () => Promise, +): Promise { + return withStateDirectoryLock(stateDir, operation, async () => { + if (dryRun) { + if (await pendingTransactionExists(stateDir)) { + throw new Error(`AgentTab cannot dry-run while an interrupted transaction requires recovery: ${stateDir}`); + } + } else { + await recoverPendingTransaction(stateDir, { windows_registry_default: registryRecoveryHandler }); + } + return callback(); + }); +} + +function currentPaths(options: LifecycleOptions): { stateDir: string; home: string; platform: NodeJS.Platform } { + const home = resolve(options.home ?? homedir()); + return { + home, + stateDir: resolve(options.stateDir ?? join(home, ".agenttab")), + platform: options.platform ?? currentPlatform(), + }; +} + +function daemonPlanForReceipt(receipt: InstallReceiptV2): DaemonServicePlan { + const hostPath = join( + receipt.stateDir, + "versions", + `v${receipt.version}`, + receipt.target, + receipt.platform === "win32" ? "agenttab-host.exe" : "agenttab-host", + ); + return planDaemonService({ + platform: receipt.platform, + home: receipt.home, + hostPath, + stateDir: receipt.stateDir, + }); +} + +async function reconcileDaemonService( + action: "activate" | "deactivate", + receipt: InstallReceiptV2, + preserved: LifecycleResult["preserved"], + print?: (line: string) => void, +): Promise { + if (!receipt.daemonService.managed) return; + const plan = daemonPlanForReceipt(receipt); + try { + if (action === "activate") await activateDaemonService(plan); + else await deactivateDaemonService(plan); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + preserved.push({ + resource: `daemon service (${plan.manager})`, + reason: `${action} failed; the native relay remains available as an on-demand fallback: ${reason}`, + }); + print?.(`Could not ${action} the ${plan.manager} daemon service; on-demand relay fallback remains available: ${reason}`); + } +} + +function sameJson(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function snapshotMatches(snapshot: FileSnapshot, bytes: Buffer | null, mode?: number): boolean { + if (!snapshot.exists) return bytes === null; + return bytes !== null + && sha256(bytes) === snapshot.sha256 + && (snapshot.mode === undefined || mode === undefined || snapshot.mode === mode); +} + +async function modeOptional(path: string, bytes: Buffer | null): Promise { + return bytes !== null && process.platform !== "win32" ? (await stat(path)).mode & 0o777 : undefined; +} + +function planFromSnapshot( + path: string, + target: FileSnapshot, + label: string, + expectedBefore?: FileExpectation, + statePointer = false, +): PlannedChange { + if (!target.exists) return { operation: "delete", path, label, expectedBefore, statePointer }; + const bytes = snapshotBytes(target)!; + if (sha256(bytes) !== target.sha256) throw new Error(`receipt snapshot hash mismatch for ${path}`); + return { + path, + content: bytes, + ...(target.mode === undefined ? {} : { mode: target.mode }), + label, + expectedBefore, + statePointer, + }; +} + +async function planFileReversal( + receipts: InstallReceiptV2[], + roles: Set, + preserved: LifecycleResult["preserved"], + excludedPaths: ReadonlySet = new Set(), +): Promise { + const byPath = new Map(); + for (const receipt of receipts) { + for (const file of receipt.files) { + if (!file.owned || !roles.has(file.role) || excludedPaths.has(file.path)) continue; + const entries = byPath.get(file.path) ?? []; + entries.push(file); + byPath.set(file.path, entries); + } + } + const plans: PlannedChange[] = []; + for (const [path, entries] of byPath) { + const current = await readOptionalBytes(path); + const currentMode = await modeOptional(path, current); + const matchesInstalled = (entry: FileOwnership, bytes: Buffer | null, mode?: number): boolean => + bytes !== null + && sha256(bytes) === entry.installedSha256 + && (entry.installedMode === undefined || mode === undefined || entry.installedMode === mode); + const start = entries.findIndex((entry) => matchesInstalled(entry, current, currentMode)); + if (start === -1) { + const fullyReversed = entries.at(-1)!.previous; + if (snapshotMatches(fullyReversed, current, currentMode)) continue; + preserved.push({ resource: path, reason: "value or mode changed after AgentTab activation" }); + continue; + } + let virtualBytes = current; + let virtualMode = currentMode; + let blocked = false; + for (const entry of entries.slice(start)) { + if (blocked) continue; + if (!matchesInstalled(entry, virtualBytes, virtualMode)) { + preserved.push({ resource: path, reason: "value or mode changed after AgentTab activation" }); + blocked = true; + continue; + } + virtualBytes = snapshotBytes(entry.previous); + virtualMode = entry.previous.exists ? entry.previous.mode : undefined; + } + const target: FileSnapshot = virtualBytes === null + ? { exists: false } + : { + exists: true, + sha256: sha256(virtualBytes), + contentBase64: virtualBytes.toString("base64"), + ...(virtualMode === undefined ? {} : { mode: virtualMode }), + }; + if (!snapshotMatches(target, current, currentMode)) { + plans.push(planFromSnapshot(path, target, `restore ${path}`, expectationFor(current, currentMode))); + } + } + return plans; +} + +interface ConfigDocumentState { + source: string; + changed: boolean; + blocked: Set; +} + +async function planConfigReversal( + receipts: InstallReceiptV2[], + preserved: LifecycleResult["preserved"], +): Promise { + const operations = new Map(); + for (const receipt of receipts) { + for (const config of receipt.configs) { + if (!config.owned) continue; + const entries = operations.get(config.path) ?? []; + entries.push(config); + operations.set(config.path, entries); + } + } + const plans: PlannedFile[] = []; + for (const [path, entries] of operations) { + const source = await readFile(path, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (source === null) { + preserved.push({ resource: path, reason: "configuration file was removed after AgentTab activation" }); + continue; + } + const sourceBytes = Buffer.from(source, "utf8"); + const sourceMode = await modeOptional(path, sourceBytes); + const state: ConfigDocumentState = { source, changed: false, blocked: new Set() }; + const jsonEntries = entries.filter((entry): entry is JsonConfigOwnership => entry.kind === "json_property"); + if (jsonEntries.length > 0) { + let value: Record; + try { + const parsed: unknown = JSON.parse(source); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("top level is not an object"); + value = parsed as Record; + } catch (error) { + preserved.push({ resource: path, reason: `configuration is no longer valid JSON: ${error instanceof Error ? error.message : String(error)}` }); + continue; + } + for (const entry of jsonEntries) { + const key = "mcpServers.agenttab"; + if (state.blocked.has(key)) continue; + const servers = value.mcpServers; + const record = typeof servers === "object" && servers !== null && !Array.isArray(servers) + ? servers as Record + : null; + const exists = record !== null && Object.prototype.hasOwnProperty.call(record, "agenttab"); + const currentValue = exists ? record!.agenttab : undefined; + if (!exists || !sameJson(currentValue, entry.installedValue)) { + preserved.push({ resource: `${path}#${key}`, reason: "value changed after AgentTab activation" }); + state.blocked.add(key); + continue; + } + if (entry.previous.exists) { + record!.agenttab = structuredClone(entry.previous.value); + } else { + delete record!.agenttab; + } + state.changed = true; + } + if (state.changed) state.source = `${JSON.stringify(value, null, 2)}\n`; + } + + const yamlEntries = entries.filter((entry): entry is YamlConfigOwnership => entry.kind === "yaml_sequence_item"); + if (yamlEntries.length > 0) { + const document = parseDocument(state.source); + if (document.errors.length > 0) { + preserved.push({ resource: path, reason: `configuration is no longer valid YAML: ${document.errors[0].message}` }); + continue; + } + const extensions = document.get("extensions", true); + if (extensions !== undefined && !isSeq(extensions)) { + preserved.push({ resource: `${path}#extensions`, reason: "extensions is no longer a YAML sequence" }); + continue; + } + const values = isSeq(extensions) ? extensions.items.map((item) => String(item)) : []; + let yamlChanged = false; + for (const entry of yamlEntries) { + const key = `extensions:${entry.value}`; + if (state.blocked.has(key)) continue; + const present = values.includes(entry.value); + if (present !== entry.installedPresent) { + preserved.push({ resource: `${path}#${key}`, reason: "sequence item changed after AgentTab activation" }); + state.blocked.add(key); + continue; + } + if (entry.previousPresent && !present) values.push(entry.value); + if (!entry.previousPresent && present) values.splice(values.indexOf(entry.value), 1); + yamlChanged = yamlChanged || present !== entry.previousPresent; + } + if (yamlChanged) { + document.set("extensions", values); + state.source = document.toString(); + state.changed = true; + } + } + if (state.changed) { + plans.push({ + path, + content: state.source, + label: `restore AgentTab entries in ${path}`, + expectedBefore: expectationFor(sourceBytes, sourceMode), + }); + } + } + return plans; +} + +function planRegistryReversal( + receipts: InstallReceiptV2[], + preserved: LifecycleResult["preserved"], +): RegistryChange[] { + const byKey = new Map(); + for (const receipt of receipts) { + for (const entry of receipt.registry) { + if (!entry.owned) continue; + const entries = byKey.get(entry.key) ?? []; + entries.push(entry); + byKey.set(entry.key, entries); + } + } + const changes: RegistryChange[] = []; + for (const [key, entries] of byKey) { + const current = queryRegistryValue(key); + let virtual = current; + let blocked = false; + for (const entry of entries) { + if (blocked) continue; + const expected: RegistryValue = { existed: true, value: entry.installedValue }; + if (!sameRegistryValue(virtual, expected)) { + preserved.push({ resource: key, reason: "registry default value changed after AgentTab activation" }); + blocked = true; + continue; + } + virtual = entry.previous; + } + if (!sameRegistryValue(current, virtual)) changes.push({ key, expected: current, target: virtual }); + } + return changes; +} + +async function loadReceiptChain( + stateDir: string, +): Promise<{ + active: ActiveInstallState; + activeSnapshot: FileSnapshot; + receipts: InstallReceiptV2[]; + references: ActiveReceiptReference[]; +}> { + const loaded = await loadActiveReceipt(stateDir); + if (!loaded) throw new Error("AgentTab is not installed in this state directory"); + const receipts: InstallReceiptV2[] = []; + const references: ActiveReceiptReference[] = []; + const seen = new Set(); + const seenActivations = new Set(); + let reference: ActiveReceiptReference | null = loaded.state; + while (reference) { + if (seen.has(reference.receiptPath)) throw new Error("AgentTab receipt history contains a cycle"); + seen.add(reference.receiptPath); + const entry = await readReceipt(reference, stateDir); + if (seenActivations.has(entry.receipt.activationId)) { + throw new Error("AgentTab receipt history contains a duplicate activation"); + } + seenActivations.add(entry.receipt.activationId); + receipts.push(entry.receipt); + references.push(reference); + reference = entry.receipt.previousReceipt; + } + return { active: loaded.state, activeSnapshot: loaded.stateSnapshot, receipts, references }; +} + +async function applyLifecycleTransaction(options: { + operation: LifecycleResult["operation"]; + stateDir: string; + plans: PlannedChange[]; + registry: RegistryChange[]; + preserved: LifecycleResult["preserved"]; + activeVersion: string | null; + dryRun?: boolean; + print?: (line: string) => void; + transactionFailAfter?: number; + transactionCrashAfter?: number; + transactionCrashAfterExternal?: boolean; + registryFailAfter?: number; +}): Promise { + const transaction = await applyTransaction(options.plans, { + dryRun: options.dryRun, + failAfter: options.transactionFailAfter, + crashAfter: options.transactionCrashAfter, + crashAfterExternal: options.transactionCrashAfterExternal, + printDiff: options.print, + journal: { + stateDir: options.stateDir, + operation: options.operation, + external: registryChangesForJournal(options.registry), + }, + applyExternal: options.registry.length === 0 + ? undefined + : () => applyRegistryChanges(options.registry, options.registryFailAfter), + }); + return { + operation: options.operation, + changed: transaction.changed, + unchanged: transaction.unchanged, + preserved: options.preserved, + activeVersion: options.activeVersion, + transaction, + }; +} + +async function rollbackUnlocked(options: LifecycleOptions): Promise { + const { stateDir, platform } = currentPaths(options); + const chain = await loadReceiptChain(stateDir); + const current = chain.receipts[0]; + if (!current.previousReceipt) throw new Error("AgentTab has no previous activation to roll back to"); + const previous = chain.receipts[1]; + if (!previous) throw new Error("AgentTab previous activation receipt is unavailable"); + for (const file of previous.files) { + if (file.role !== "artifact") continue; + const bytes = await readOptionalBytes(file.path); + const mode = await modeOptional(file.path, bytes); + if ( + bytes === null + || sha256(bytes) !== file.installedSha256 + || (file.installedMode !== undefined && mode !== undefined && file.installedMode !== mode) + ) { + throw new Error(`AgentTab cannot roll back because a previous-version artifact is unavailable or changed: ${file.path}`); + } + } + if (!current.previousActive.exists) throw new Error("AgentTab rollback receipt is missing its previous active state"); + const previousActiveBytes = snapshotBytes(current.previousActive)!; + const parsedPrevious = JSON.parse(previousActiveBytes.toString("utf8")) as ActiveInstallState; + if ( + parsedPrevious.receiptPath !== current.previousReceipt.receiptPath + || parsedPrevious.receiptSha256 !== current.previousReceipt.receiptSha256 + || parsedPrevious.version !== current.previousReceipt.version + ) throw new Error("AgentTab rollback receipt does not match its previous active state"); + + const preserved: LifecycleResult["preserved"] = []; + const activationPlans = await planFileReversal([current], new Set(["activation"]), preserved); + const configPlans = await planConfigReversal([current], preserved); + const registry = platform === "win32" ? planRegistryReversal([current], preserved) : []; + if (preserved.length > 0) { + throw new Error( + `AgentTab rollback aborted because active resources drifted: ${preserved.map((entry) => entry.resource).join(", ")}`, + ); + } + const plans: PlannedChange[] = [ + ...activationPlans, + ...configPlans, + planFromSnapshot( + activeStatePath(stateDir), + current.previousActive, + "restore previous AgentTab activation", + expectationFromSnapshot(chain.activeSnapshot), + true, + ), + ]; + const result = await applyLifecycleTransaction({ + operation: "rollback", + stateDir, + plans, + registry, + preserved, + activeVersion: current.previousReceipt.version, + ...options, + }); + if (!options.dryRun) { + await reconcileDaemonService("activate", previous, preserved, options.print); + } + return result; +} + +export async function rollback(options: LifecycleOptions = {}): Promise { + const { stateDir } = currentPaths(options); + return withInstallerStateMutation(stateDir, "rollback", options.dryRun, () => rollbackUnlocked(options)); +} + +async function uninstallUnlocked(options: LifecycleOptions): Promise { + const { stateDir, platform } = currentPaths(options); + if (!await loadActiveReceipt(stateDir)) { + return { + operation: "uninstall", + changed: [], + unchanged: [], + preserved: [], + activeVersion: null, + transaction: { changed: [], unchanged: [], backups: [] }, + }; + } + const chain = await loadReceiptChain(stateDir); + const allReceipts = await receiptFiles(stateDir); + const chainPaths = new Set(chain.references.map((reference) => reference.receiptPath)); + const inactiveReceipts = allReceipts.filter((entry) => !chainPaths.has(entry.path)); + const preserved: LifecycleResult["preserved"] = []; + const oldest = chain.receipts.at(-1)!; + const artifactHistory = newestReceiptHistory([ + ...chain.receipts, + ...inactiveReceipts.map((entry) => entry.receipt), + ]); + const plans: PlannedChange[] = [ + ...await planFileReversal(chain.receipts, new Set(["activation"]), preserved), + ...await planFileReversal(artifactHistory, new Set(["artifact"]), preserved), + ...await planConfigReversal(chain.receipts, preserved), + planFromSnapshot( + activeStatePath(stateDir), + oldest.previousActive, + "remove AgentTab active activation", + expectationFromSnapshot(chain.activeSnapshot), + true, + ), + ]; + for (const reference of chain.references) { + const current = await readOptionalBytes(reference.receiptPath); + if (current && sha256(current) === reference.receiptSha256) { + plans.push({ + operation: "delete", + path: reference.receiptPath, + label: "remove exact AgentTab receipt", + expectedBefore: expectationFor(current, await modeOptional(reference.receiptPath, current)), + statePointer: true, + }); + } else { + preserved.push({ resource: reference.receiptPath, reason: "receipt changed after activation" }); + } + } + for (const entry of inactiveReceipts) { + const current = await readOptionalBytes(entry.path); + if (current?.equals(entry.bytes)) { + plans.push({ + operation: "delete", + path: entry.path, + label: "remove exact inactive AgentTab receipt", + expectedBefore: expectationFor(current, await modeOptional(entry.path, current)), + statePointer: true, + }); + } else { + preserved.push({ resource: entry.path, reason: "inactive receipt changed after activation" }); + } + } + const registry = platform === "win32" ? planRegistryReversal(chain.receipts, preserved) : []; + const current = chain.receipts[0]; + if (!options.dryRun) { + await reconcileDaemonService("deactivate", current, preserved, options.print); + } + try { + return await applyLifecycleTransaction({ + operation: "uninstall", + stateDir, + plans, + registry, + preserved, + activeVersion: null, + ...options, + }); + } catch (error) { + if (!options.dryRun) { + await reconcileDaemonService("activate", current, preserved, options.print); + } + throw error; + } +} + +export async function uninstall(options: LifecycleOptions = {}): Promise { + const { stateDir } = currentPaths(options); + return withInstallerStateMutation(stateDir, "uninstall", options.dryRun, () => uninstallUnlocked(options)); +} + +function newestReceiptHistory(receipts: InstallReceiptV2[]): InstallReceiptV2[] { + const seen = new Map(); + return [...receipts] + .sort((left, right) => right.activatedAt.localeCompare(left.activatedAt) || right.activationId.localeCompare(left.activationId)) + .filter((receipt) => { + const fingerprint = sha256(Buffer.from(JSON.stringify(receipt))); + const previous = seen.get(receipt.activationId); + if (previous && previous !== fingerprint) { + throw new Error(`AgentTab receipt history contains conflicting activation ${receipt.activationId}`); + } + if (previous) return false; + seen.set(receipt.activationId, fingerprint); + return true; + }); +} + +async function receiptFiles(stateDir: string): Promise> { + let names: string[]; + try { + names = await readdir(receiptDirectory(stateDir)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const found: Array<{ path: string; receipt: InstallReceiptV2; bytes: Buffer }> = []; + for (const name of names.sort()) { + if (!name.endsWith(".json")) continue; + const path = join(receiptDirectory(stateDir), name); + try { + const bytes = await readFile(path); + const value = JSON.parse(bytes.toString("utf8")) as { version?: unknown }; + if (typeof value.version !== "string") continue; + const loaded = await readReceipt({ version: value.version, receiptPath: path, receiptSha256: sha256(bytes) }, stateDir); + found.push({ path, receipt: loaded.receipt, bytes: loaded.bytes }); + } catch { + // An unknown or edited file in the receipt directory is never a prune target. + } + } + const seen = new Map(); + return found + .sort((left, right) => + right.receipt.activatedAt.localeCompare(left.receipt.activatedAt) || right.path.localeCompare(left.path) + ) + .filter((entry) => { + const fingerprint = sha256(entry.bytes); + const previous = seen.get(entry.receipt.activationId); + if (previous && previous !== fingerprint) { + throw new Error(`AgentTab receipt directory contains conflicting activation ${entry.receipt.activationId}`); + } + if (previous) return false; + seen.set(entry.receipt.activationId, fingerprint); + return true; + }); +} + +async function pruneUnlocked(options: LifecycleOptions & { keep?: number }): Promise { + const { stateDir } = currentPaths(options); + const keep = options.keep ?? 1; + if (!Number.isSafeInteger(keep) || keep < 0) throw new Error("prune keep must be a non-negative integer"); + if (!await loadActiveReceipt(stateDir)) { + return { + operation: "prune", + changed: [], + unchanged: [], + preserved: [], + activeVersion: null, + transaction: { changed: [], unchanged: [], backups: [] }, + }; + } + const chain = await loadReceiptChain(stateDir); + const protectedChain = chain.receipts.slice(0, keep + 1); + const protectedReceipts = new Set(chain.references.slice(0, keep + 1).map((reference) => reference.receiptPath)); + // A newer inactive receipt may claim ownership of the same immutable artifact + // reused by the active or retained rollback window. Receipt ownership alone is + // not permission to reverse a path still referenced by a protected activation. + const protectedArtifactPaths = new Set( + protectedChain.flatMap((receipt) => receipt.files + .filter((file) => file.role === "artifact") + .map((file) => file.path)), + ); + const preserved: LifecycleResult["preserved"] = []; + const inactive = (await receiptFiles(stateDir)).filter((entry) => !protectedReceipts.has(entry.path)); + const plans = await planFileReversal( + newestReceiptHistory(inactive.map((entry) => entry.receipt)), + new Set(["artifact"]), + preserved, + protectedArtifactPaths, + ); + return applyLifecycleTransaction({ + operation: "prune", + stateDir, + plans, + registry: [], + preserved, + activeVersion: chain.active.version, + ...options, + }); +} + +export async function prune(options: LifecycleOptions & { keep?: number } = {}): Promise { + const { stateDir } = currentPaths(options); + return withInstallerStateMutation(stateDir, "prune", options.dryRun, () => pruneUnlocked(options)); +} + +export type DoctorLayer = "installation" | "ipc" | "protocol" | "host" | "extension"; + +export interface DoctorCheck { + layer: DoctorLayer; + success: boolean; + detail: string; + recovery?: string; + evidence?: Record; +} + +export interface DoctorResult { + success: boolean; + version?: string; + checks: DoctorCheck[]; +} + +export interface DoctorOptions extends LifecycleOptions { + layer?: DoctorLayer | "all"; + connect?: typeof AgentTabClient.connect; + extensionDeadlineMs?: number; + waitForReady?: boolean; +} + +function check(layer: DoctorLayer, success: boolean, detail: string, evidence?: Record): DoctorCheck { + const recovery: Partial> = { + installation: "Run agenttab update with an exact signed version, or restore the reported changed file.", + ipc: "Open Chrome with AgentTab enabled, then rerun agenttab doctor --layer ipc.", + protocol: "Update AgentTab so the CLI, host, and extension use the same protocol version.", + host: "Run agenttab update with the intended exact version, then restart Chrome.", + extension: "Reload AgentTab in chrome://extensions and enable automation, then rerun this check.", + }; + return { layer, success, detail, ...(success ? {} : { recovery: recovery[layer] }), ...(evidence ? { evidence } : {}) }; +} + +export async function activeReceiptDrift( + receipt: InstallReceiptV2, + platform: NodeJS.Platform, +): Promise { + const failures: string[] = []; + for (const file of receipt.files) { + const entry = await lstat(file.path).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!entry?.isFile()) { + failures.push(file.path); + continue; + } + const bytes = await readOptionalBytes(file.path); + const mode = await modeOptional(file.path, bytes); + if ( + bytes === null + || sha256(bytes) !== file.installedSha256 + || (file.installedMode !== undefined && mode !== undefined && file.installedMode !== mode) + ) failures.push(file.path); + } + for (const config of receipt.configs) { + const source = await readFile(config.path, "utf8").catch(() => null); + if (source === null) { + failures.push(config.path); + continue; + } + if (config.kind === "json_property") { + try { + const parsed = JSON.parse(source) as { mcpServers?: { agenttab?: unknown } }; + if (!sameJson(parsed.mcpServers?.agenttab, config.installedValue)) failures.push(`${config.path}#mcpServers.agenttab`); + } catch { + failures.push(config.path); + } + } else { + const document = parseDocument(source); + const extensions = document.get("extensions", true); + const values = isSeq(extensions) ? extensions.items.map((item) => String(item)) : []; + if (values.includes(config.value) !== config.installedPresent) failures.push(`${config.path}#extensions:${config.value}`); + } + } + if (platform === "win32") { + for (const entry of receipt.registry) { + if (!sameRegistryValue(queryRegistryValue(entry.key), { existed: true, value: entry.installedValue })) { + failures.push(entry.key); + } + } + } + return failures; +} + +async function installationCheck(receipt: InstallReceiptV2, platform: NodeJS.Platform): Promise { + const failures = await activeReceiptDrift(receipt, platform); + const recoveryGuarantee = platform === "win32" + ? { + scope: "process_crash", + limitation: "Node does not expose a Windows directory durability barrier; sudden power-loss namespace atomicity is not claimed", + } + : { scope: "filesystem_barriers_requested" }; + return failures.length === 0 + ? check( + "installation", + true, + `receipt and ${receipt.files.length} installed files/config registrations match` + + (platform === "win32" ? "; transaction recovery covers process crashes, not sudden power loss" : ""), + { version: receipt.version, transactionRecovery: recoveryGuarantee }, + ) + : check("installation", false, `${failures.length} owned resource(s) no longer match the active receipt`, { + resources: failures, + transactionRecovery: recoveryGuarantee, + }); +} + +async function connectWithRetry( + connect: typeof AgentTabClient.connect, + deadline: number, + endpoint: string, +): Promise { + let lastError: unknown; + while (Date.now() < deadline) { + try { + return await connect({ endpoint, connectTimeoutMs: 500, requestTimeoutMs: 10_000 }); + } catch (error) { + lastError = error; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + throw lastError instanceof Error ? lastError : new Error("AgentTab did not become ready"); +} + +export async function doctor(options: DoctorOptions = {}): Promise { + const { stateDir, platform } = currentPaths(options); + const selected = options.layer ?? "all"; + const wants = (layer: DoctorLayer): boolean => selected === "all" || selected === layer; + const checks: DoctorCheck[] = []; + let active: Awaited>; + try { + active = await loadActiveReceipt(stateDir); + if (!active) throw new Error("no active AgentTab receipt"); + if (wants("installation")) checks.push(await installationCheck(active.receipt, platform)); + } catch (error) { + if (wants("installation") || selected === "all") { + checks.push(check("installation", false, error instanceof Error ? error.message : String(error))); + } + return { success: false, checks }; + } + if (selected === "installation") return { success: checks.every((entry) => entry.success), version: active.receipt.version, checks }; + + const connect = options.connect ?? AgentTabClient.connect.bind(AgentTabClient); + const endpoint = resolveEndpoint({ ...process.env, AGENTTAB_STATE_DIR: stateDir }); + const runtimeLayers = (["ipc", "protocol", "host"] as const).filter(wants); + let runtimeClient: AgentTabClient | undefined; + if (runtimeLayers.length > 0) { + try { + runtimeClient = options.waitForReady + ? await connectWithRetry(connect, Date.now() + (options.extensionDeadlineMs ?? 20_000), endpoint) + : await connect({ endpoint, connectTimeoutMs: 500, requestTimeoutMs: 10_000 }); + if (wants("ipc")) checks.push(check("ipc", true, "connected to the per-user AgentTab host endpoint")); + let status: Record | undefined; + if (wants("protocol") || wants("host")) { + const deadline = Date.now() + (options.extensionDeadlineMs ?? 20_000); + do { + status = await runtimeClient.call<"agenttab.status", Record>("agenttab.status", {}); + if (!options.waitForReady || status.state === "ready" || Date.now() >= deadline) break; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } while (true); + } + if (wants("protocol")) { + const success = runtimeClient.connection.protocol === RPC_PROTOCOL + && runtimeClient.connection.version === RPC_VERSION + && status?.protocol_version === RPC_VERSION; + checks.push(check("protocol", success, success + ? `client, connection, and host agree on ${RPC_PROTOCOL} v${RPC_VERSION}` + : "client, connection, and host protocol versions do not agree", { + connectionProtocol: runtimeClient.connection.protocol, + connectionVersion: runtimeClient.connection.version, + hostVersion: status?.protocol_version, + })); + } + if (wants("host")) { + const exact = status?.host_version === active.receipt.version && status.state === "ready"; + checks.push(check("host", exact, exact + ? `host ${active.receipt.version} is ready and matches the active receipt` + : `running host ${String(status?.host_version ?? "unknown")} does not match active receipt ${active.receipt.version}`, { + lifecycle: status?.state, + runningVersion: status?.host_version, + installedVersion: active.receipt.version, + })); + } + } catch (error) { + for (const layer of runtimeLayers) { + if (layer === "ipc" && runtimeClient) continue; + checks.push(check(layer, false, error instanceof Error ? error.message : String(error))); + } + } finally { + runtimeClient?.close(); + } + } + + if (wants("extension")) { + let client: AgentTabClient | undefined; + try { + const deadline = Date.now() + (options.extensionDeadlineMs ?? 20_000); + client = await connect({ endpoint, connectTimeoutMs: 500, requestTimeoutMs: 10_000 }); + const extensionStatus = await client.call<"agenttab.status", Record>("agenttab.status", {}); + if (extensionStatus.extension_version !== active.receipt.extensionVersion) { + throw new Error( + `connected extension ${String(extensionStatus.extension_version ?? "unknown")} does not match active receipt ${active.receipt.extensionVersion}`, + ); + } + let opened: Record | undefined; + while (!opened && Date.now() < deadline) { + try { + opened = await client.call<"browser_open", Record>( + "browser_open", + { mode: "create", url: "about:blank", background: true }, + ); + } catch (error) { + if (!(error instanceof AgentTabError && error.code === "runtime_not_ready" && error.outcome === "not_started")) throw error; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + } + if (!opened || !Number.isInteger(Number(opened.tab_id)) || !Number.isInteger(Number(opened.page_revision))) { + throw new Error("extension did not return a disposable task tab and page revision"); + } + checks.push(check("extension", true, "extension created a disposable background task tab", { + extensionVersion: extensionStatus.extension_version, + tabId: opened.tab_id, + pageRevision: opened.page_revision, + })); + } catch (error) { + checks.push(check("extension", false, error instanceof Error ? error.message : String(error))); + } finally { + // The first task capability is deliberately left unconfirmed. Host disconnect + // semantics close that exact disposable task/tab without a broad browser sweep. + client?.close(); + } + } + return { success: checks.length > 0 && checks.every((entry) => entry.success), version: active.receipt.version, checks }; +} + +export async function verifyRuntimeReadiness(options: DoctorOptions & { version: string }): Promise { + const result = await doctor({ ...options, layer: "all", waitForReady: true }); + const failed = result.checks.find((entry) => !entry.success); + if (!result.success || failed || result.version !== options.version) { + const layer = failed?.layer ?? "host"; + const message = failed?.detail ?? `active version ${String(result.version)} does not match ${options.version}`; + const error = new Error(`${layer}: ${message}`) as Error & { layer?: string; recovery?: string }; + error.layer = layer; + error.recovery = failed?.recovery; + throw error; + } +} diff --git a/packages/installer/src/receipt.ts b/packages/installer/src/receipt.ts new file mode 100644 index 0000000..8b50830 --- /dev/null +++ b/packages/installer/src/receipt.ts @@ -0,0 +1,388 @@ +import { createHash, randomUUID } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import identityJson from "../../../config/identity.json" with { type: "json" }; +import type { ConfigOwnership } from "./configs"; +import type { DaemonServiceManager } from "./service"; +import type { FileExpectation, PlannedFile } from "./transaction"; + +const nativeHost = (identityJson as { nativeHost: string }).nativeHost; + +export interface MissingSnapshot { + exists: false; +} + +export interface ExistingSnapshot { + exists: true; + sha256: string; + contentBase64: string; + mode?: number; +} + +export type FileSnapshot = MissingSnapshot | ExistingSnapshot; + +export interface FileOwnership { + path: string; + role: "artifact" | "activation"; + installedSha256: string; + installedMode?: number; + previous: FileSnapshot; + owned: boolean; +} + +export type RegistrySnapshot = + | { existed: false; value: null } + | { existed: true; value: string }; + +export interface RegistryOwnership { + key: string; + installedValue: string; + previous: RegistrySnapshot; + owned: boolean; +} + +export interface ActiveReceiptReference { + version: string; + receiptPath: string; + receiptSha256: string; +} + +export interface InstallReceiptV2 { + schemaVersion: 2; + activationId: string; + activatedAt: string; + version: string; + target: string; + platform: NodeJS.Platform; + stateDir: string; + home: string; + manifestSha256: string; + assetSha256: string; + hostSha256: string; + shimSha256: string; + cliSha256: string; + ompSha256: string; + extensionSha256: string; + extensionVersion: string; + daemonService: { + manager: DaemonServiceManager; + managed: boolean; + }; + previousActive: FileSnapshot; + previousReceipt: ActiveReceiptReference | null; + files: FileOwnership[]; + configs: ConfigOwnership[]; + registry: RegistryOwnership[]; +} + +export interface ActiveInstallState extends ActiveReceiptReference { + schemaVersion: 1; +} + +export function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function canonicalJson(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +export async function readOptionalBytes(path: string): Promise { + try { + return await readFile(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +export async function snapshotFile(path: string): Promise { + const bytes = await readOptionalBytes(path); + if (bytes === null) return { exists: false }; + return snapshotForBytes( + bytes, + process.platform === "win32" ? undefined : (await stat(path)).mode & 0o777, + ); +} + +export function snapshotForBytes(bytes: Buffer, mode?: number): ExistingSnapshot { + return { + exists: true, + sha256: sha256(bytes), + contentBase64: bytes.toString("base64"), + ...(mode === undefined ? {} : { mode }), + }; +} + +export function expectationFromSnapshot(snapshot: FileSnapshot): FileExpectation { + return snapshot.exists + ? { exists: true, sha256: snapshot.sha256, ...(snapshot.mode === undefined ? {} : { mode: snapshot.mode }) } + : { exists: false }; +} + +export function snapshotBytes(snapshot: FileSnapshot): Buffer | null { + return snapshot.exists ? Buffer.from(snapshot.contentBase64, "base64") : null; +} + +export function plannedBytes(file: PlannedFile): Buffer { + return Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content, "utf8"); +} + +export async function ownershipForFile( + file: PlannedFile, + role: FileOwnership["role"], +): Promise { + const bytes = plannedBytes(file); + const previous = await snapshotFile(file.path); + const observed = expectationFromSnapshot(previous); + if (file.expectedBefore) { + const matches = file.expectedBefore.exists === observed.exists + && (!file.expectedBefore.exists || (observed.exists + && file.expectedBefore.sha256 === observed.sha256 + && (file.expectedBefore.mode === undefined + || observed.mode === undefined + || file.expectedBefore.mode === observed.mode))); + if (!matches) throw new Error(`AgentTab file changed before its ownership snapshot: ${file.path}`); + } else { + file.expectedBefore = observed; + } + const contentMatches = previous.exists && previous.sha256 === sha256(bytes); + const modeMatches = file.mode === undefined || !previous.exists || previous.mode === undefined || previous.mode === file.mode; + return { + path: file.path, + role, + installedSha256: sha256(bytes), + ...(file.mode === undefined ? {} : { installedMode: file.mode }), + previous, + owned: !contentMatches || !modeMatches, + }; +} + +export function activeStatePath(stateDir: string): string { + return join(stateDir, "active-install.json"); +} + +export function receiptDirectory(stateDir: string): string { + return join(stateDir, "receipts"); +} + +export function newReceiptPath(stateDir: string, version: string): string { + const timestamp = new Date().toISOString().replaceAll(/[-:.TZ]/g, ""); + return join(receiptDirectory(stateDir), `${timestamp}-${randomUUID()}-v${version}.json`); +} + +export function pathInside(root: string, candidate: string): boolean { + const relation = relative(resolve(root), resolve(candidate)); + return relation === "" || (relation !== ".." && !relation.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(relation)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validReference(value: unknown, stateDir: string): value is ActiveReceiptReference { + return isRecord(value) + && typeof value.version === "string" + && typeof value.receiptPath === "string" + && pathInside(receiptDirectory(stateDir), value.receiptPath) + && typeof value.receiptSha256 === "string" + && /^[0-9a-f]{64}$/.test(value.receiptSha256); +} + +function validSnapshot(value: unknown): value is FileSnapshot { + if (!isRecord(value) || typeof value.exists !== "boolean") return false; + if (!value.exists) return Object.keys(value).every((key) => key === "exists"); + if ( + typeof value.sha256 !== "string" + || !/^[0-9a-f]{64}$/.test(value.sha256) + || typeof value.contentBase64 !== "string" + || (value.mode !== undefined && (!Number.isSafeInteger(value.mode) || Number(value.mode) < 0 || Number(value.mode) > 0o777)) + ) return false; + const bytes = Buffer.from(value.contentBase64, "base64"); + return bytes.toString("base64") === value.contentBase64 && sha256(bytes) === value.sha256; +} + +function validConfig(value: unknown, _home: string): boolean { + if ( + !isRecord(value) + || typeof value.path !== "string" + || !isAbsolute(value.path) + || typeof value.owned !== "boolean" + || typeof value.client !== "string" + ) return false; + if (value.kind === "json_property") { + return Array.isArray(value.property) + && value.property.length === 2 + && value.property[0] === "mcpServers" + && value.property[1] === "agenttab" + && Object.prototype.hasOwnProperty.call(value, "installedValue") + && isRecord(value.previous) + && (value.previous.exists === false + ? !Object.prototype.hasOwnProperty.call(value.previous, "value") + : value.previous.exists === true && Object.prototype.hasOwnProperty.call(value.previous, "value")); + } + return value.kind === "yaml_sequence_item" + && value.client === "OMP" + && value.property === "extensions" + && typeof value.value === "string" + && typeof value.installedPresent === "boolean" + && typeof value.previousPresent === "boolean"; +} + +function validRegistry(value: unknown): boolean { + const allowed = [ + `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${nativeHost}`, + `HKCU\\Software\\Chromium\\NativeMessagingHosts\\${nativeHost}`, + `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${nativeHost}`, + ]; + return isRecord(value) + && typeof value.key === "string" + && allowed.includes(value.key) + && typeof value.installedValue === "string" + && typeof value.owned === "boolean" + && isRecord(value.previous) + && (value.previous.existed === false + ? value.previous.value === null + : value.previous.existed === true && typeof value.previous.value === "string"); +} + +function parseActiveState(bytes: Buffer, stateDir: string): ActiveInstallState { + const path = activeStatePath(stateDir); + let value: unknown; + try { + value = JSON.parse(bytes.toString("utf8")); + } catch { + throw new Error(`AgentTab active-install state is malformed: ${path}`); + } + if (!isRecord(value) || value.schemaVersion !== 1 || !validReference(value, stateDir)) { + throw new Error(`AgentTab active-install state is invalid: ${path}`); + } + return value as unknown as ActiveInstallState; +} + +export async function readActiveState(stateDir: string): Promise { + const bytes = await readOptionalBytes(activeStatePath(stateDir)); + return bytes === null ? null : parseActiveState(bytes, stateDir); +} + +export async function readReceipt( + reference: ActiveReceiptReference, + stateDir: string, +): Promise<{ receipt: InstallReceiptV2; bytes: Buffer }> { + if (!pathInside(receiptDirectory(stateDir), reference.receiptPath)) { + throw new Error("AgentTab receipt reference escapes the receipt directory"); + } + const bytes = await readFile(reference.receiptPath); + if (sha256(bytes) !== reference.receiptSha256) { + throw new Error(`AgentTab receipt hash mismatch: ${reference.receiptPath}`); + } + let value: unknown; + try { + value = JSON.parse(bytes.toString("utf8")); + } catch { + throw new Error(`AgentTab receipt is malformed: ${reference.receiptPath}`); + } + if ( + !isRecord(value) + || value.schemaVersion !== 2 + || typeof value.activationId !== "string" + || value.activationId.length === 0 + || typeof value.activatedAt !== "string" + || Number.isNaN(Date.parse(value.activatedAt)) + || new Date(value.activatedAt).toISOString() !== value.activatedAt + || value.version !== reference.version + || typeof value.target !== "string" + || !["darwin", "linux", "win32"].includes(String(value.platform)) + || value.stateDir !== stateDir + || typeof value.home !== "string" + || !isAbsolute(value.home) + || typeof value.extensionVersion !== "string" + || value.extensionVersion.length === 0 + || !isRecord(value.daemonService) + || !["launchd", "systemd", "scheduled_task"].includes(String(value.daemonService.manager)) + || typeof value.daemonService.managed !== "boolean" + || (value.platform === "darwin" && value.daemonService.manager !== "launchd") + || (value.platform === "linux" && value.daemonService.manager !== "systemd") + || (value.platform === "win32" && value.daemonService.manager !== "scheduled_task") + || [ + value.manifestSha256, + value.assetSha256, + value.hostSha256, + value.shimSha256, + value.cliSha256, + value.ompSha256, + value.extensionSha256, + ].some((hash) => typeof hash !== "string" || !/^[0-9a-f]{64}$/.test(hash)) + || typeof value.previousActive !== "object" + || !validSnapshot(value.previousActive) + || (value.previousReceipt !== null && !validReference(value.previousReceipt, stateDir)) + || !Array.isArray(value.files) + || !Array.isArray(value.configs) + || !Array.isArray(value.registry) + ) { + throw new Error(`AgentTab receipt has an invalid schema or identity: ${reference.receiptPath}`); + } + for (const file of value.files) { + if ( + !isRecord(file) + || typeof file.path !== "string" + || (file.role !== "artifact" && file.role !== "activation") + || (file.role === "artifact" + ? !pathInside(stateDir, file.path) + : !pathInside(stateDir, file.path) && !pathInside(value.home as string, file.path)) + || typeof file.installedSha256 !== "string" + || !/^[0-9a-f]{64}$/.test(file.installedSha256) + || (file.installedMode !== undefined + && (!Number.isSafeInteger(file.installedMode) || Number(file.installedMode) < 0 || Number(file.installedMode) > 0o777)) + || typeof file.owned !== "boolean" + || !validSnapshot(file.previous) + ) { + throw new Error(`AgentTab receipt contains an invalid file path: ${reference.receiptPath}`); + } + } + if (!value.configs.every((entry) => validConfig(entry, value.home as string))) { + throw new Error(`AgentTab receipt contains invalid client configuration ownership: ${reference.receiptPath}`); + } + if (!value.registry.every(validRegistry)) { + throw new Error(`AgentTab receipt contains invalid registry ownership: ${reference.receiptPath}`); + } + return { receipt: value as unknown as InstallReceiptV2, bytes }; +} + +export async function loadActiveReceipt( + stateDir: string, +): Promise<{ + state: ActiveInstallState; + stateBytes: Buffer; + stateSnapshot: FileSnapshot; + receiptSnapshot: FileSnapshot; + receipt: InstallReceiptV2; + bytes: Buffer; +} | null> { + const path = activeStatePath(stateDir); + const stateBytes = await readOptionalBytes(path); + if (stateBytes === null) return null; + const state = parseActiveState(stateBytes, stateDir); + const stateMode = process.platform === "win32" ? undefined : (await stat(path)).mode & 0o777; + const loaded = await readReceipt(state, stateDir); + const receiptMode = process.platform === "win32" ? undefined : (await stat(state.receiptPath)).mode & 0o777; + return { + state, + stateBytes, + stateSnapshot: snapshotForBytes(stateBytes, stateMode), + receiptSnapshot: snapshotForBytes(loaded.bytes, receiptMode), + ...loaded, + }; +} + +export function referenceForReceipt( + version: string, + receiptPath: string, + receiptBytes: Buffer, +): ActiveReceiptReference { + return { version, receiptPath, receiptSha256: sha256(receiptBytes) }; +} + +export function receiptParentDirectory(receiptPath: string): string { + return dirname(receiptPath); +} diff --git a/packages/installer/src/service.ts b/packages/installer/src/service.ts new file mode 100644 index 0000000..a06c953 --- /dev/null +++ b/packages/installer/src/service.ts @@ -0,0 +1,163 @@ +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import type { PlannedFile } from "./transaction"; + +export type DaemonServiceManager = "launchd" | "systemd" | "scheduled_task"; + +export interface DaemonServiceCommand { + executable: string; + args: string[]; + ignoreFailure?: boolean; +} + +export interface DaemonServicePlan { + manager: DaemonServiceManager; + files: PlannedFile[]; + commands: DaemonServiceCommand[]; +} + +interface PlanOptions { + platform: NodeJS.Platform; + home: string; + hostPath: string; + stateDir: string; + userId?: number; +} + +function rejectControlCharacters(value: string): string { + if (/[\0\r\n]/.test(value)) throw new Error("daemon service paths must not contain control characters"); + return value; +} + +function xmlEscape(value: string): string { + return rejectControlCharacters(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function systemdQuote(value: string): string { + return `"${rejectControlCharacters(value) + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("%", "%%")}"`; +} + +function windowsTaskCommand(hostPath: string): string { + const path = rejectControlCharacters(hostPath); + if (path.includes('"')) throw new Error("Windows daemon path must not contain a quote"); + return `"${path}" daemon`; +} + +export function planDaemonService(options: PlanOptions): DaemonServicePlan { + if (options.platform === "darwin") { + const label = "dev.agenttab.daemon"; + const path = join(options.home, "Library", "LaunchAgents", `${label}.plist`); + const domain = `gui/${options.userId ?? process.getuid?.() ?? 0}`; + const content = ` + + + + Label${label} + ProgramArguments + ${xmlEscape(options.hostPath)}daemon + EnvironmentVariables + AGENTTAB_STATE_DIR${xmlEscape(options.stateDir)} + RunAtLoad + KeepAlive + ProcessTypeBackground + ThrottleInterval1 + + +`; + return { + manager: "launchd", + files: [{ path, content, mode: 0o600, label: "AgentTab launchd agent" }], + commands: [ + { executable: "launchctl", args: ["bootout", `${domain}/${label}`], ignoreFailure: true }, + { executable: "launchctl", args: ["bootstrap", domain, path] }, + { executable: "launchctl", args: ["kickstart", "-k", `${domain}/${label}`] }, + ], + }; + } + + if (options.platform === "win32") { + const task = "AgentTab Daemon"; + return { + manager: "scheduled_task", + files: [], + commands: [ + { executable: "schtasks.exe", args: ["/End", "/TN", task], ignoreFailure: true }, + { + executable: "schtasks.exe", + args: [ + "/Create", "/TN", task, "/SC", "ONLOGON", "/TR", windowsTaskCommand(options.hostPath), + "/RL", "LIMITED", "/F", + ], + }, + { executable: "schtasks.exe", args: ["/Run", "/TN", task] }, + ], + }; + } + + if (options.platform !== "linux") throw new Error(`unsupported daemon service platform: ${options.platform}`); + const path = join(options.home, ".config", "systemd", "user", "agenttab.service"); + const content = `[Unit] +Description=AgentTab per-user browser runtime + +[Service] +Type=simple +ExecStart=${systemdQuote(options.hostPath)} daemon +Environment=${systemdQuote(`AGENTTAB_STATE_DIR=${options.stateDir}`)} +Restart=on-failure +RestartSec=1 + +[Install] +WantedBy=default.target +`; + return { + manager: "systemd", + files: [{ path, content, mode: 0o600, label: "AgentTab systemd user service" }], + commands: [ + { executable: "systemctl", args: ["--user", "daemon-reload"] }, + { executable: "systemctl", args: ["--user", "enable", "--now", "agenttab.service"] }, + { executable: "systemctl", args: ["--user", "restart", "agenttab.service"] }, + ], + }; +} + +export async function activateDaemonService(plan: DaemonServicePlan): Promise { + for (const command of plan.commands) { + try { + execFileSync(command.executable, command.args, { stdio: "pipe", env: { ...process.env } }); + } catch (error) { + if (!command.ignoreFailure) throw error; + } + } +} + +export function daemonServiceDeactivationCommands( + plan: DaemonServicePlan, +): DaemonServiceCommand[] { + if (plan.manager === "launchd") { + const bootout = plan.commands.find((command) => command.executable === "launchctl" && command.args[0] === "bootout"); + if (!bootout) throw new Error("launchd service plan has no bootout command"); + return [{ ...bootout, ignoreFailure: true }]; + } + if (plan.manager === "scheduled_task") { + return [ + { executable: "schtasks.exe", args: ["/End", "/TN", "AgentTab Daemon"], ignoreFailure: true }, + { executable: "schtasks.exe", args: ["/Delete", "/TN", "AgentTab Daemon", "/F"], ignoreFailure: true }, + ]; + } + return [ + { executable: "systemctl", args: ["--user", "disable", "--now", "agenttab.service"] }, + { executable: "systemctl", args: ["--user", "daemon-reload"] }, + ]; +} + +export async function deactivateDaemonService(plan: DaemonServicePlan): Promise { + await activateDaemonService({ ...plan, commands: daemonServiceDeactivationCommands(plan) }); +} diff --git a/packages/installer/src/state-lock.ts b/packages/installer/src/state-lock.ts new file mode 100644 index 0000000..41ba07a --- /dev/null +++ b/packages/installer/src/state-lock.ts @@ -0,0 +1,343 @@ +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { mkdir, open, readFile, readdir, rename, rm, stat, utimes } from "node:fs/promises"; +import { createConnection, createServer, type Server } from "node:net"; +import { basename, dirname, resolve } from "node:path"; + +interface LockClaim { + token: string; + pid: number; + osPid?: number; + endpoint: string | null; + processIdentity: string | null; + operation: string; + acquiredAt: string; + choosing: boolean; + ticket?: number; +} + +const HEARTBEAT_INTERVAL_MS = 1_000; +const HEARTBEAT_GRACE_MS = 5_000; + +export interface InstallerStateLock { + path: string; + release(): Promise; +} + +export function canonicalPathThroughExistingAncestor(path: string): string { + const resolved = resolve(path); + const missing: string[] = []; + let cursor = resolved; + while (true) { + try { + const canonicalAncestor = realpathSync.native(cursor); + return resolve(canonicalAncestor, ...missing.reverse()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parent = dirname(cursor); + if (parent === cursor) return resolved; + missing.push(basename(cursor)); + cursor = parent; + } + } +} + +export function canonicalStateDirectoryPath(stateDir: string): string { + return canonicalPathThroughExistingAncestor(stateDir); +} + +export function stateDirectoryLockPath(stateDir: string): string { + return `${canonicalStateDirectoryPath(stateDir)}.installer-lock`; +} + +async function syncDirectory(path: string): Promise { + try { + const handle = await open(path, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } catch { + // Directory fsync is not supported by every platform/filesystem. + } +} + +async function writeClaim(path: string, claim: LockClaim): Promise { + const staged = `${path}.stage-${randomUUID()}`; + const handle = await open(staged, "wx", 0o600); + try { + await handle.writeFile(`${JSON.stringify(claim)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(staged, path); + await syncDirectory(dirname(path)); +} + +function lockEndpoint(stateDir: string, token: string): string { + const stateHash = createHash("sha256").update(stateDirectoryLockPath(stateDir)).digest("hex").slice(0, 20); + return process.platform === "win32" + ? `\\\\.\\pipe\\agenttab-installer-${stateHash}-${token}` + : `/tmp/agenttab-installer-${stateHash}-${token}.sock`; +} + +async function listenForLiveness(endpoint: string): Promise { + const server = createServer((socket) => socket.end()); + try { + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(endpoint, () => { + server.off("error", reject); + resolveListen(); + }); + }); + return server; + } catch { + // Some sandboxed runtimes prohibit even local IPC. The OS birth-token + // fallback below still provides crash/PID-reuse-safe ownership checks. + return null; + } +} + +async function endpointIsLive(endpoint: string): Promise { + return new Promise((resolveProbe) => { + const socket = createConnection(endpoint); + let settled = false; + const finish = (live: boolean): void => { + if (settled) return; + settled = true; + socket.destroy(); + resolveProbe(live); + }; + socket.once("connect", () => finish(true)); + socket.once("error", (error: NodeJS.ErrnoException) => { + finish(error.code !== "ENOENT" && error.code !== "ECONNREFUSED"); + }); + socket.setTimeout(500, () => finish(true)); + }); +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +async function processIdentity(pid: number): Promise { + try { + if (process.platform === "linux") { + const [bootId, stat] = await Promise.all([ + readFile("/proc/sys/kernel/random/boot_id", "utf8"), + readFile(`/proc/${pid}/stat`, "utf8"), + ]); + const fieldsAfterName = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/); + return fieldsAfterName[19] ? `linux:${bootId.trim()}:${fieldsAfterName[19]}` : null; + } + if (!processExists(pid)) return null; + if (process.platform === "darwin") { + const started = execFileSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, + }).trim(); + return started ? `darwin:${started}` : null; + } + if (process.platform === "win32") { + const started = execFileSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`, + ], { encoding: "utf8", windowsHide: true }).trim(); + return started ? `win32:${started}` : null; + } + } catch { + // If no OS birth identity is available, conservatively keep a live PID. + } + return null; +} + +async function operatingSystemPid(): Promise { + if (process.platform !== "linux") return process.pid; + try { + const stat = await readFile("/proc/self/stat", "utf8"); + const pid = Number(stat.slice(0, stat.indexOf(" "))); + return Number.isSafeInteger(pid) && pid > 0 ? pid : process.pid; + } catch { + return process.pid; + } +} + +async function heartbeatIsFresh(path: string): Promise { + try { + return Date.now() - (await stat(path)).mtimeMs <= HEARTBEAT_GRACE_MS; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + // An unreadable owner claim is not safe to steal while its PID exists. + return true; + } +} + +async function claimIsLive(path: string, claim: LockClaim): Promise { + if (!Number.isInteger(claim.pid) || claim.pid <= 0) return false; + if (claim.endpoint && await endpointIsLive(claim.endpoint)) return true; + const identityPid = claim.osPid ?? claim.pid; + if (!claim.processIdentity) { + return processExists(claim.pid) && await heartbeatIsFresh(path); + } + const current = await processIdentity(identityPid); + if (current !== null) return current === claim.processIdentity; + // Identity probing can fail transiently. A live PID plus a fresh owner + // heartbeat receives a bounded grace period, never an indefinite PID-only + // lease that could survive PID reuse. + return processExists(claim.pid) && await heartbeatIsFresh(path); +} + +async function readLiveClaims(directory: string): Promise> { + const names = await readdir(directory); + const claims: Array<{ path: string; claim: LockClaim }> = []; + for (const name of names) { + if (!name.endsWith(".json")) continue; + const path = `${directory}/${name}`; + let claim: LockClaim; + try { + claim = JSON.parse(await readFile(path, "utf8")) as LockClaim; + if ( + !claim || + typeof claim.token !== "string" || + (claim.osPid !== undefined && (!Number.isInteger(claim.osPid) || claim.osPid <= 0)) || + (claim.endpoint !== null && typeof claim.endpoint !== "string") || + (claim.processIdentity !== null && typeof claim.processIdentity !== "string") || + typeof claim.operation !== "string" || + typeof claim.choosing !== "boolean" + ) { + throw new Error("invalid lock claim"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw new Error(`AgentTab installer lock claim is unreadable: ${path}`); + } + if (await claimIsLive(path, claim)) { + claims.push({ path, claim }); + } else { + // Claim names are unique and immutable to their owner, so removing a claim + // proven dead cannot race with a replacement process. + await rm(path, { force: true }); + if (claim.endpoint && process.platform !== "win32") await rm(claim.endpoint, { force: true }); + } + } + return claims; +} + +function compareClaims(left: LockClaim, right: LockClaim): number { + return (left.ticket ?? Number.MAX_SAFE_INTEGER) - (right.ticket ?? Number.MAX_SAFE_INTEGER) || + left.token.localeCompare(right.token); +} + +export async function acquireInstallerStateLock( + stateDir: string, + operation: string, +): Promise { + const directory = stateDirectoryLockPath(stateDir); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const token = randomUUID(); + const proposedEndpoint = lockEndpoint(stateDir, token); + const livenessServer = await listenForLiveness(proposedEndpoint); + const endpoint = livenessServer ? proposedEndpoint : null; + const osPid = await operatingSystemPid(); + const ownerIdentity = await processIdentity(osPid); + if (!livenessServer && !ownerIdentity) { + throw new Error("AgentTab cannot establish an OS-backed installer state lock in this runtime"); + } + const claimPath = `${directory}/${token}.json`; + const base: LockClaim = { + token, + pid: process.pid, + osPid, + endpoint, + processIdentity: ownerIdentity, + operation, + acquiredAt: new Date().toISOString(), + choosing: true, + }; + try { + await writeClaim(claimPath, base); + } catch (error) { + if (livenessServer) await new Promise((resolveClose) => livenessServer.close(() => resolveClose())); + if (endpoint && process.platform !== "win32") await rm(endpoint, { force: true }); + throw error; + } + + let released = false; + let heartbeat: ReturnType | undefined; + const release = async (): Promise => { + if (released) return; + await rm(claimPath, { force: true }); + released = true; + if (heartbeat) clearInterval(heartbeat); + if (livenessServer) await new Promise((resolveClose) => livenessServer.close(() => resolveClose())); + if (endpoint && process.platform !== "win32") await rm(endpoint, { force: true }); + await syncDirectory(directory); + }; + + try { + const initial = await readLiveClaims(directory); + const maxTicket = initial.reduce( + (maximum, entry) => Math.max(maximum, entry.claim.choosing ? 0 : (entry.claim.ticket ?? 0)), + 0, + ); + const chosen: LockClaim = { ...base, choosing: false, ticket: maxTicket + 1 }; + await writeClaim(claimPath, chosen); + heartbeat = setInterval(() => { + const now = new Date(); + void utimes(claimPath, now, now).catch(() => undefined); + }, HEARTBEAT_INTERVAL_MS); + heartbeat.unref(); + + // Let contenders that published `choosing` finish selecting their ticket. + const deadline = Date.now() + 1_000; + let contenders = await readLiveClaims(directory); + while (contenders.some((entry) => entry.claim.token !== token && entry.claim.choosing)) { + if (Date.now() >= deadline) { + throw new Error("Timed out while another AgentTab installer chose a state lock ticket"); + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 10)); + contenders = await readLiveClaims(directory); + } + + const winner = contenders + .map((entry) => entry.claim) + .filter((claim) => !claim.choosing) + .sort(compareClaims)[0]; + if (!winner || winner.token !== token) { + throw new Error( + `AgentTab installer state is locked by ${winner?.operation ?? "another operation"}` + + `${winner ? ` (pid ${winner.pid})` : ""}`, + ); + } + return { path: directory, release }; + } catch (error) { + await release(); + throw error; + } +} + +export async function withStateDirectoryLock( + stateDir: string, + operation: string, + action: () => Promise, +): Promise { + const lock = await acquireInstallerStateLock(stateDir, operation); + try { + return await action(); + } finally { + await lock.release(); + } +} + +export const withInstallerStateLock = withStateDirectoryLock; diff --git a/packages/installer/src/transaction.ts b/packages/installer/src/transaction.ts index 9452b8a..95ca094 100644 --- a/packages/installer/src/transaction.ts +++ b/packages/installer/src/transaction.ts @@ -1,36 +1,162 @@ import { createHash, randomUUID } from "node:crypto"; import { - chmod, - copyFile, mkdir, + link, + lstat, open, readFile, rename, rm, - stat, } from "node:fs/promises"; -import { dirname } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path"; +import { canonicalPathThroughExistingAncestor, canonicalStateDirectoryPath } from "./state-lock"; + +export interface MissingFileExpectation { + exists: false; +} + +export interface ExistingFileExpectation { + exists: true; + sha256: string; + mode?: number; +} + +export type FileExpectation = MissingFileExpectation | ExistingFileExpectation; export interface PlannedFile { + operation?: "write"; path: string; content: Buffer | string; mode?: number; label: string; semanticDiff?: string; + expectedBefore?: FileExpectation; + /** Active-state pointers are restored even when another resource must be preserved. */ + statePointer?: boolean; } +export interface PlannedDeletion { + operation: "delete"; + path: string; + label: string; + semanticDiff?: string; + expectedBefore?: FileExpectation; + statePointer?: boolean; +} + +export type PlannedChange = PlannedFile | PlannedDeletion; + export interface TransactionResult { changed: string[]; unchanged: string[]; backups: string[]; } -interface PreparedFile extends PlannedFile { - bytes: Buffer; +export interface DurableExternalChange { + kind: string; + resource: string; + before: unknown; + after: unknown; +} + +export interface ExternalRecoveryHandler { + inspect(change: DurableExternalChange): Promise<"before" | "after" | "conflict">; + restore(change: DurableExternalChange): Promise; +} + +interface PreparedChange { + operation: "write" | "delete"; + path: string; + label: string; + semanticDiff?: string; + mode?: number; + bytes: Buffer | null; original: Buffer | null; originalMode?: number; - stagedPath: string; + beforeIdentity?: string; + before: FileExpectation; + after: FileExpectation; + stagedPath?: string; + backupPath?: string; + backupPreexisting?: boolean; + quarantinePath?: string; + rollbackPath: string; + statePointer?: boolean; + /** Runtime-only causal state; never inferred from bytes that a user may reproduce. */ + mutationState?: "untouched" | "displaced" | "installed"; +} + +interface JournalFile { + operation: "write" | "delete"; + path: string; + before: FileExpectation; + after: FileExpectation; backupPath?: string; + backupPreexisting?: boolean; + stagedPath?: string; + quarantinePath?: string; + rollbackPath: string; + statePointer?: boolean; +} + +interface TransactionJournal { + schemaVersion: 1; + transactionId: string; + operation: string; + stateDir: string; + createdAt: string; + files: JournalFile[]; + external: DurableExternalChange[]; +} + +class InjectedCrashError extends Error { + constructor(message: string) { + super(message); + this.name = "InjectedCrashError"; + } +} + +class CommitPublicationUncertainError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "CommitPublicationUncertainError"; + } +} + +class IntentPublicationUncertainError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "IntentPublicationUncertainError"; + } +} + +interface InspectedFile { + bytes: Buffer | null; + mode?: number; + identity?: string; + expectation: FileExpectation; +} + +export class TransactionConflictError extends Error { + readonly resources: string[]; + readonly recoveryIncomplete: boolean; + + constructor(message: string, resources: string[], recoveryIncomplete = false) { + super(`${message}: ${resources.join(", ")}`); + this.name = "TransactionConflictError"; + this.resources = resources; + this.recoveryIncomplete = recoveryIncomplete; + } +} + +function digest(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +export function expectationFor(bytes: Buffer | null, mode?: number): FileExpectation { + return bytes === null + ? { exists: false } + : { exists: true, sha256: digest(bytes), ...(mode === undefined ? {} : { mode }) }; } async function readOptional(path: string): Promise { @@ -42,16 +168,74 @@ async function readOptional(path: string): Promise { } } +async function inspectFile(path: string): Promise { + let pathStats; + try { + pathStats = await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { bytes: null, expectation: { exists: false } }; + } + throw error; + } + if (!pathStats.isFile()) { + throw new TransactionConflictError( + "AgentTab preserved a non-regular file that cannot be represented by an install receipt", + [path], + ); + } + let handle; + try { + handle = await open(path, "r"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { bytes: null, expectation: { exists: false } }; + } + throw error; + } + try { + const opened = await handle.stat(); + if (opened.dev !== pathStats.dev || opened.ino !== pathStats.ino || !opened.isFile()) { + throw new TransactionConflictError("AgentTab preserved a file replaced during inspection", [path]); + } + const bytes = await handle.readFile(); + const afterRead = await handle.stat(); + if (afterRead.dev !== opened.dev || afterRead.ino !== opened.ino || !afterRead.isFile()) { + throw new TransactionConflictError("AgentTab preserved a file replaced during inspection", [path]); + } + const mode = process.platform === "win32" ? undefined : afterRead.mode & 0o777; + return { + bytes, + mode, + identity: `${afterRead.dev}:${afterRead.ino}`, + expectation: expectationFor(bytes, mode), + }; + } finally { + await handle.close(); + } +} + +function sameExpectation(left: FileExpectation, right: FileExpectation): boolean { + if (left.exists !== right.exists) return false; + if (!left.exists || !right.exists) return true; + return left.sha256 === right.sha256 + && (left.mode === undefined || right.mode === undefined || left.mode === right.mode); +} + +function sameFileIdentity(left: InspectedFile, right: InspectedFile | null): boolean { + return left.identity !== undefined && left.identity === right?.identity; +} + async function durableWrite(path: string, bytes: Buffer, mode: number): Promise { await mkdir(dirname(path), { recursive: true, mode: 0o700 }); const handle = await open(path, "wx", mode); try { await handle.writeFile(bytes); + await handle.chmod(mode); await handle.sync(); } finally { await handle.close(); } - await chmod(path, mode); } async function syncDirectory(path: string): Promise { @@ -63,100 +247,1088 @@ async function syncDirectory(path: string): Promise { await handle.close(); } } catch (error) { - if (process.platform !== "win32") throw error; + const code = (error as NodeJS.ErrnoException).code; + if ( + process.platform !== "win32" + || !["EACCES", "EBADF", "EISDIR", "EINVAL", "ENOSYS", "ENOTSUP", "EPERM"].includes(code ?? "") + ) throw error; } } function backupPath(path: string, original: Buffer): string { - return `${path}.agenttab-backup-${createHash("sha256").update(original).digest("hex").slice(0, 12)}`; + return `${path}.agenttab-backup-${digest(original).slice(0, 12)}`; +} + +export function transactionJournalPath(stateDir: string): string { + return join(canonicalStateDirectoryPath(stateDir), "transaction-intent.json"); +} + +export async function pendingTransactionExists(stateDir: string): Promise { + return (await readOptional(transactionJournalPath(stateDir))) !== null; } -export function renderDiff(label: string, before: Buffer | null, after: Buffer): string { - const digest = (value: Buffer | null): string => - value ? createHash("sha256").update(value).digest("hex").slice(0, 12) : "absent"; +function transactionCommitPath(stateDir: string): string { + return `${transactionJournalPath(stateDir)}.committed`; +} + +async function ensureBackup(file: PreparedChange): Promise { + if (file.original === null || !file.backupPath) return false; + const existing = await readOptional(file.backupPath); + if (existing !== null) { + if (!existing.equals(file.original)) { + throw new Error(`AgentTab transaction backup does not match its expected content: ${file.backupPath}`); + } + return false; + } + const staged = `${file.backupPath}.stage-${randomUUID()}`; + await durableWrite(staged, file.original, file.originalMode ?? 0o600); + try { + await link(staged, file.backupPath); + await syncDirectory(dirname(file.backupPath)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const raced = await readOptional(file.backupPath); + if (raced === null || !raced.equals(file.original)) { + throw new Error(`AgentTab transaction backup does not match its expected content: ${file.backupPath}`); + } + return false; + } finally { + await removeIfPresent(staged); + } +} + +async function preflightHardLinkSupport( + files: PreparedChange[], + options: { fail?: boolean; crashAfterProbe?: boolean } = {}, +): Promise { + for (const file of files) { + const source = file.operation === "write" ? file.stagedPath : file.backupPath; + if (!source) { + throw new Error(`AgentTab cannot preflight transaction publication for ${file.path}`); + } + if (options.fail) { + throw new Error(`AgentTab target filesystem does not support required same-directory hard links: ${dirname(file.path)}`); + } + try { + await link(source, file.rollbackPath); + } catch (error) { + throw new Error( + `AgentTab target filesystem does not support required same-directory hard links: ${dirname(file.path)}`, + { cause: error }, + ); + } + if (options.crashAfterProbe) { + throw new InjectedCrashError("Injected transaction crash after hard-link preflight publication"); + } + const [sourceEntry, probeEntry] = await Promise.all([ + inspectFile(source), + inspectFile(file.rollbackPath), + ]); + if (!sameFileIdentity(sourceEntry, probeEntry)) { + throw new Error(`AgentTab hard-link preflight did not preserve file identity: ${file.path}`); + } + await removeIfPresent(file.rollbackPath); + await syncDirectory(dirname(file.path)); + } +} + +async function removeIfPresent(path: string): Promise { + await rm(path, { force: true }); +} + +async function cleanupSidecars( + files: Array<{ stagedPath?: string; quarantinePath?: string; rollbackPath?: string }>, +): Promise { + const targetDirectories = new Set(); + for (const file of files) { + for (const path of [file.stagedPath, file.quarantinePath, file.rollbackPath]) { + if (!path) continue; + await removeIfPresent(path); + targetDirectories.add(dirname(path)); + } + } + for (const directory of targetDirectories) await syncDirectory(directory); +} + +async function cleanupJournal( + stateDir: string, + files: Array<{ stagedPath?: string; quarantinePath?: string; rollbackPath?: string }>, + transactionId?: string, + afterIntentRemoval?: () => Promise, +): Promise { + await cleanupSidecars(files); + + if (transactionId) { + await removeIfPresent(`${transactionJournalPath(stateDir)}.publishing`); + await removeIfPresent(`${transactionCommitPath(stateDir)}.publishing`); + await syncDirectory(resolve(stateDir)); + } + + // These are separate durability boundaries. If cleanup stops between them, + // intent-absent/marker-present still means committed; journal-present/marker- + // absent is never published for a committed transaction. + await removeIfPresent(transactionJournalPath(stateDir)); + await syncDirectory(resolve(stateDir)); + await afterIntentRemoval?.(); + await removeIfPresent(transactionCommitPath(stateDir)); + try { + await syncDirectory(resolve(stateDir)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +async function cleanupUncommittedJournal(journal: TransactionJournal): Promise { + const conflicts: string[] = []; + const backupDirectories = new Set(); + for (const file of journal.files) { + if (!file.backupPath || file.backupPreexisting !== false) continue; + try { + const current = await inspectFile(file.backupPath); + if (!current.expectation.exists) continue; + if (!sameExpectation(current.expectation, file.before)) { + conflicts.push(file.backupPath); + continue; + } + await removeIfPresent(file.backupPath); + backupDirectories.add(dirname(file.backupPath)); + } catch { + conflicts.push(file.backupPath); + } + } + for (const directory of backupDirectories) await syncDirectory(directory); + if (conflicts.length > 0) { + throw new TransactionConflictError( + "AgentTab preserved transaction backups changed during uncommitted cleanup", + conflicts, + true, + ); + } + await cleanupJournal(journal.stateDir, journal.files, journal.transactionId); +} + +function journalBytes(journal: TransactionJournal): Buffer { + return Buffer.from(`${JSON.stringify(journal, null, 2)}\n`, "utf8"); +} + +async function writeIntent(journal: TransactionJournal, afterPublish?: () => Promise): Promise { + const path = transactionJournalPath(journal.stateDir); + if (await readOptional(path)) throw new Error(`AgentTab has an unrecovered transaction intent: ${path}`); + if (await readOptional(transactionCommitPath(journal.stateDir))) { + throw new Error(`AgentTab has an unrecovered transaction commit marker: ${transactionCommitPath(journal.stateDir)}`); + } + const staged = `${path}.publishing`; + await removeIfPresent(staged); + await syncDirectory(dirname(path)); + await durableWrite(staged, journalBytes(journal), 0o600); + let published = false; + try { + await link(staged, path); + published = true; + await afterPublish?.(); + await syncDirectory(dirname(path)); + } catch (error) { + if (!published && (error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`AgentTab has an unrecovered transaction intent: ${path}`); + } + if (published) { + try { + await removeIfPresent(path); + await syncDirectory(dirname(path)); + } catch (cleanupError) { + throw new IntentPublicationUncertainError( + `AgentTab could not determine whether its transaction intent is durable: ${path}`, + { cause: cleanupError }, + ); + } + } + throw error; + } finally { + await removeIfPresent(staged); + } +} + +async function markCommitted(journal: TransactionJournal, afterPublish?: () => Promise): Promise { + const path = transactionCommitPath(journal.stateDir); + const staged = `${path}.publishing`; + await removeIfPresent(staged); + await durableWrite(staged, Buffer.from(`${journal.transactionId}\n`, "utf8"), 0o600); + let published = false; + try { + await link(staged, path); + published = true; + await afterPublish?.(); + await syncDirectory(dirname(path)); + } catch (error) { + if (!published && (error as NodeJS.ErrnoException).code === "EEXIST") { + throw new CommitPublicationUncertainError(`AgentTab transaction has an unexpected commit marker: ${path}`, { cause: error }); + } + if (published) { + try { + await removeIfPresent(path); + await syncDirectory(dirname(path)); + } catch (cleanupError) { + throw new CommitPublicationUncertainError( + `AgentTab could not determine whether its commit marker is durable: ${path}`, + { cause: cleanupError }, + ); + } + } + throw error; + } +} + +export function transactionPathIdentity(path: string, platform: NodeJS.Platform = process.platform): string { + const normalized = platform === "win32" ? win32.resolve(path) : resolve(path); + return platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized; +} + +function validSidecarPath( + candidate: unknown, + target: string, + marker: "stage" | "displaced" | "rollback", + platform: NodeJS.Platform = process.platform, +): candidate is string { + if (typeof candidate !== "string") return false; + const paths = platform === "win32" ? win32 : { basename, dirname, isAbsolute }; + if (!paths.isAbsolute(candidate)) return false; + if (transactionPathIdentity(paths.dirname(candidate), platform) !== transactionPathIdentity(paths.dirname(target), platform)) { + return false; + } + const candidateName = paths.basename(candidate); + const expectedPrefix = `${paths.basename(target)}.agenttab-${marker}-`; + const comparableName = platform === "win32" ? candidateName.toLocaleLowerCase("en-US") : candidateName; + const comparablePrefix = platform === "win32" ? expectedPrefix.toLocaleLowerCase("en-US") : expectedPrefix; + return comparableName.startsWith(comparablePrefix) + && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + candidateName.slice(expectedPrefix.length), + ); +} + +function validExpectation(value: unknown): value is FileExpectation { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const entry = value as Record; + if (entry.exists === false) return Object.keys(entry).every((key) => key === "exists"); + return entry.exists === true + && typeof entry.sha256 === "string" + && /^[0-9a-f]{64}$/.test(entry.sha256) + && (entry.mode === undefined || (Number.isSafeInteger(entry.mode) && Number(entry.mode) >= 0 && Number(entry.mode) <= 0o777)); +} + +function parseJournal(bytes: Buffer, stateDir: string): TransactionJournal { + let value: unknown; + try { + value = JSON.parse(bytes.toString("utf8")); + } catch { + throw new Error(`AgentTab transaction intent is malformed: ${transactionJournalPath(stateDir)}`); + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`AgentTab transaction intent is invalid: ${transactionJournalPath(stateDir)}`); + } + const journal = value as Partial; + if ( + journal.schemaVersion !== 1 + || typeof journal.transactionId !== "string" + || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(journal.transactionId) + || typeof journal.operation !== "string" + || typeof journal.stateDir !== "string" + || !isAbsolute(journal.stateDir) + || transactionPathIdentity(journal.stateDir) !== transactionPathIdentity(stateDir) + || !Array.isArray(journal.files) + || !Array.isArray(journal.external) + ) throw new Error(`AgentTab transaction intent is invalid: ${transactionJournalPath(stateDir)}`); + const paths = new Set(); + for (const file of journal.files) { + const pathIdentity = typeof file?.path === "string" ? transactionPathIdentity(file.path) : ""; + if ( + typeof file !== "object" + || file === null + || Array.isArray(file) + || (file.operation !== "write" && file.operation !== "delete") + || typeof file.path !== "string" + || !isAbsolute(file.path) + || paths.has(pathIdentity) + || !validExpectation(file.before) + || !validExpectation(file.after) + || (file.statePointer !== undefined && typeof file.statePointer !== "boolean") + || (file.before.exists + ? typeof file.backupPath !== "string" + || transactionPathIdentity(file.backupPath) !== transactionPathIdentity(`${file.path}.agenttab-backup-${file.before.sha256.slice(0, 12)}`) + || typeof file.backupPreexisting !== "boolean" + : file.backupPath !== undefined || file.backupPreexisting !== undefined) + || (file.operation === "write" + ? !validSidecarPath(file.stagedPath, file.path, "stage") + : file.stagedPath !== undefined) + || (file.before.exists + ? !validSidecarPath(file.quarantinePath, file.path, "displaced") + : file.quarantinePath !== undefined) + || !validSidecarPath(file.rollbackPath, file.path, "rollback") + ) throw new Error(`AgentTab transaction intent contains an invalid file entry: ${transactionJournalPath(stateDir)}`); + paths.add(pathIdentity); + } + for (const external of journal.external) { + if ( + typeof external !== "object" + || external === null + || Array.isArray(external) + || typeof external.kind !== "string" + || typeof external.resource !== "string" + || !("before" in external) + || !("after" in external) + ) throw new Error(`AgentTab transaction intent contains an invalid external entry: ${transactionJournalPath(stateDir)}`); + } + return journal as TransactionJournal; +} + +async function restoreFile( + file: Pick, + afterRollbackRename?: () => Promise, +): Promise { + const current = await inspectFile(file.path); + if (sameExpectation(current.expectation, file.before)) return; + const staged = file.stagedPath ? await inspectFile(file.stagedPath) : null; + const installedIdentityMatches = (entry: InspectedFile): boolean => file.operation === "delete" + ? !entry.expectation.exists + : staged !== null + && sameExpectation(staged.expectation, file.after) + && sameFileIdentity(entry, staged); + const original = file.before.exists + ? file.original ?? (file.backupPath ? await readOptional(file.backupPath) : null) + : null; + if (file.before.exists && (original === null || digest(original) !== file.before.sha256)) { + throw new TransactionConflictError("AgentTab cannot recover a missing or changed transaction backup", [file.backupPath ?? file.path], true); + } + + const publishOriginal = async (): Promise => { + if (!file.before.exists) return; + let source: string | undefined; + if (file.quarantinePath && sameExpectation((await inspectFile(file.quarantinePath)).expectation, file.before)) { + source = file.quarantinePath; + } + if (!source && file.backupPath && sameExpectation((await inspectFile(file.backupPath)).expectation, file.before)) { + source = file.backupPath; + } + if (!source) { + throw new TransactionConflictError("AgentTab cannot recover a missing or changed transaction backup", [file.backupPath ?? file.path], true); + } + try { + await link(source, file.path); + await syncDirectory(dirname(file.path)); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + throw new TransactionConflictError("AgentTab preserved a concurrent file created during transaction rollback", [file.path], true); + } + }; + + const rollbackDisplaced = await inspectFile(file.rollbackPath); + if (rollbackDisplaced.expectation.exists) { + if (current.expectation.exists) { + throw new TransactionConflictError("AgentTab preserved concurrent file states during transaction rollback", [file.path, file.rollbackPath], true); + } + if (!sameExpectation(rollbackDisplaced.expectation, file.after) || !installedIdentityMatches(rollbackDisplaced)) { + try { + await link(file.rollbackPath, file.path); + await syncDirectory(dirname(file.path)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw new TransactionConflictError("AgentTab could not relink a displaced concurrent file", [file.path, file.rollbackPath], true); + } + } + throw new TransactionConflictError("AgentTab preserved a file changed during transaction rollback", [file.path, file.rollbackPath], true); + } + await publishOriginal(); + await removeIfPresent(file.rollbackPath); + await syncDirectory(dirname(file.path)); + return; + } + + if (!current.expectation.exists && file.before.exists && file.quarantinePath) { + const quarantined = await inspectFile(file.quarantinePath); + if (quarantined.expectation.exists) { + try { + await link(file.quarantinePath, file.path); + await syncDirectory(dirname(file.path)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw new TransactionConflictError("AgentTab could not relink a displaced file", [file.path, file.quarantinePath], true); + } + } + if (!sameExpectation(quarantined.expectation, file.before)) { + throw new TransactionConflictError("AgentTab preserved a file changed before transaction mutation", [file.path, file.quarantinePath], true); + } + return; + } + } + if (!sameExpectation(current.expectation, file.after)) { + throw new TransactionConflictError("AgentTab preserved a file changed during transaction rollback", [file.path], true); + } + if (file.operation === "write" && !installedIdentityMatches(current)) { + throw new TransactionConflictError("AgentTab preserved a same-content file replaced during transaction rollback", [file.path], true); + } + if (!current.expectation.exists && !file.after.exists && file.before.exists) { + await publishOriginal(); + return; + } + try { + await rename(file.path, file.rollbackPath); + } catch { + throw new TransactionConflictError( + "AgentTab preserved a file changed during transaction rollback", + [file.path], + true, + ); + } + await afterRollbackRename?.(); + let displaced: InspectedFile | null = null; + try { + displaced = await inspectFile(file.rollbackPath); + } catch { + // Keep a raced non-regular entry visible at its live name without + // following it or overwriting a second concurrent entry. + } + if (!displaced || !sameExpectation(displaced.expectation, file.after) || !installedIdentityMatches(displaced)) { + try { + await link(file.rollbackPath, file.path); + await syncDirectory(dirname(file.path)); + } catch { + // Both names are retained when a concurrent writer occupied the target. + } + throw new TransactionConflictError("AgentTab preserved a file changed during transaction rollback", [file.path], true); + } + if (!file.before.exists) { + await removeIfPresent(file.rollbackPath); + await syncDirectory(dirname(file.path)); + return; + } + await publishOriginal(); + await removeIfPresent(file.rollbackPath); + await syncDirectory(dirname(file.path)); +} + +async function applyPreparedChange( + file: PreparedChange, + mutationState: (state: NonNullable) => void, + afterRename?: () => Promise, + afterQuarantine?: () => Promise, +): Promise { + if (!file.before.exists) { + if (file.operation === "delete") return; + try { + await link(file.stagedPath!, file.path); + mutationState("installed"); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + throw new TransactionConflictError("AgentTab preserved a file created immediately before mutation", [file.path], true); + } + } + + try { + await rename(file.path, file.quarantinePath!); + mutationState("displaced"); + } catch { + throw new TransactionConflictError("AgentTab preserved a file changed immediately before mutation", [file.path], true); + } + await afterRename?.(); + let displaced: InspectedFile | null = null; + try { + displaced = await inspectFile(file.quarantinePath!); + } catch { + // A raced symlink or other unsupported entry must be put back without + // following or rewriting it. + } + if ( + !displaced + || !sameExpectation(displaced.expectation, file.before) + || (file.beforeIdentity !== undefined && displaced.identity !== file.beforeIdentity) + ) { + try { + await link(file.quarantinePath!, file.path); + await removeIfPresent(file.quarantinePath!); + await syncDirectory(dirname(file.path)); + mutationState("untouched"); + } catch { + // Retain the displaced value if a concurrent writer occupied the target. + } + throw new TransactionConflictError("AgentTab preserved a file changed immediately before mutation", [file.path], true); + } + await afterQuarantine?.(); + if (file.operation === "delete") { + mutationState("installed"); + return; + } + try { + await link(file.stagedPath!, file.path); + mutationState("installed"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + throw new TransactionConflictError("AgentTab preserved a file created during mutation", [file.path], true); + } +} + +async function preflightRollback(files: PreparedChange[]): Promise { + const conflicts: string[] = []; + for (const file of files) { + try { + const current = await inspectFile(file.path); + if (!sameExpectation(current.expectation, file.after)) conflicts.push(file.path); + } catch (error) { + conflicts.push(...(error instanceof TransactionConflictError ? error.resources : [file.path])); + } + } + if (conflicts.length > 0) { + throw new TransactionConflictError("AgentTab preserved files changed during transaction rollback", conflicts, true); + } +} + +async function restoreRecoverableFiles( + files: PreparedChange[], + afterRollbackRename?: () => Promise, +): Promise { + const problems: string[] = []; + const reversed = [...files].reverse(); + const ordered = [ + ...reversed.filter((file) => !file.statePointer), + ...reversed.filter((file) => file.statePointer), + ]; + for (const file of ordered) { + try { + const current = await inspectFile(file.path); + if (sameExpectation(current.expectation, file.before)) continue; + if (file.mutationState === "displaced") { + const quarantined = file.quarantinePath ? await inspectFile(file.quarantinePath) : null; + if (current.expectation.exists || !quarantined || !sameExpectation(quarantined.expectation, file.before)) { + problems.push(file.path); + continue; + } + await restoreFile(file, afterRollbackRename); + continue; + } + if (!sameExpectation(current.expectation, file.after)) { + const quarantined = file.quarantinePath ? await inspectFile(file.quarantinePath) : null; + const displaced = !current.expectation.exists && quarantined?.expectation.exists; + if (!displaced) { + problems.push(file.path); + continue; + } + } + await restoreFile(file, afterRollbackRename); + } catch (error) { + if (error instanceof InjectedCrashError) throw error; + if (error instanceof TransactionConflictError) problems.push(...error.resources); + else problems.push(file.path); + } + } + return problems; +} + +export function renderDiff(label: string, before: Buffer | null, after: Buffer | null): string { + const shortDigest = (value: Buffer | null): string => value ? digest(value).slice(0, 12) : "absent"; return [ - `--- ${label} (sha256:${digest(before)})`, - `+++ ${label} (sha256:${digest(after)})`, + `--- ${label} (sha256:${shortDigest(before)})`, + `+++ ${label} (sha256:${shortDigest(after)})`, ].join("\n"); } +export async function recoverPendingTransaction( + stateDir: string, + handlers: Readonly> = {}, +): Promise<{ recovered: boolean; operation?: string }> { + const resolvedStateDir = canonicalStateDirectoryPath(stateDir); + const path = transactionJournalPath(resolvedStateDir); + const bytes = await readOptional(path); + if (bytes === null) { + await removeIfPresent(transactionCommitPath(resolvedStateDir)); + await removeIfPresent(`${transactionJournalPath(resolvedStateDir)}.publishing`); + await removeIfPresent(`${transactionCommitPath(resolvedStateDir)}.publishing`); + try { + await syncDirectory(resolvedStateDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + return { recovered: false }; + } + const journal = parseJournal(bytes, resolvedStateDir); + const commit = await readOptional(transactionCommitPath(resolvedStateDir)); + if (commit?.toString("utf8").trim() === journal.transactionId) { + // A marker observed after a process crash may have been linked immediately + // before that crash. Make the observed commit boundary durable before + // deleting any rollback evidence from target directories. + await syncDirectory(resolvedStateDir); + await cleanupJournal(resolvedStateDir, journal.files, journal.transactionId); + return { recovered: false, operation: journal.operation }; + } + if (commit !== null) { + throw new TransactionConflictError( + "AgentTab preserved a transaction with a mismatched commit marker", + [transactionCommitPath(resolvedStateDir)], + true, + ); + } + + const fileStates = new Map(); + const externalStates = new Map(); + const conflicts: string[] = []; + for (const file of journal.files) { + let current: InspectedFile; + let quarantined: InspectedFile | null; + let rollbackDisplaced: InspectedFile; + let staged: InspectedFile | null; + try { + [current, quarantined, rollbackDisplaced, staged] = await Promise.all([ + inspectFile(file.path), + file.quarantinePath ? inspectFile(file.quarantinePath) : Promise.resolve(null), + inspectFile(file.rollbackPath), + file.stagedPath ? inspectFile(file.stagedPath) : Promise.resolve(null), + ]); + } catch (error) { + // A non-regular entry is never followed or rewritten. If the live name + // vanished, try to relink one journaled displaced directory entry so it + // is visible to the user, then retain the journal and report the paths. + try { + const live = await lstat(file.path).catch((entryError: NodeJS.ErrnoException) => { + if (entryError.code === "ENOENT") return null; + throw entryError; + }); + if (!live) { + for (const source of [file.rollbackPath, file.quarantinePath]) { + if (!source) continue; + try { + await link(source, file.path); + await syncDirectory(dirname(file.path)); + break; + } catch (linkError) { + const code = (linkError as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "EEXIST") conflicts.push(source); + } + } + } + } catch { + // The durable journal retains every exact path for manual repair. + } + conflicts.push( + ...(error instanceof TransactionConflictError ? error.resources : [file.path]), + ...(file.quarantinePath ? [file.quarantinePath] : []), + file.rollbackPath, + ); + continue; + } + const relinkUnexpected = async (source: string): Promise => { + if (current.expectation.exists) return; + try { + await link(source, file.path); + await syncDirectory(dirname(file.path)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") conflicts.push(source); + } + }; + if (quarantined?.expectation.exists && !sameExpectation(quarantined.expectation, file.before)) { + await relinkUnexpected(file.quarantinePath!); + conflicts.push(file.path, file.quarantinePath!); + continue; + } + const rollbackIsPreflightProbe = rollbackDisplaced.expectation.exists + && sameExpectation(rollbackDisplaced.expectation, file.operation === "write" ? file.after : file.before) + && sameExpectation(current.expectation, file.before); + if ( + rollbackDisplaced.expectation.exists + && !sameExpectation(rollbackDisplaced.expectation, file.after) + && !rollbackIsPreflightProbe + ) { + await relinkUnexpected(file.rollbackPath); + conflicts.push(file.path, file.rollbackPath); + continue; + } + + let state: "before" | "after" | "displaced" | "rollback_displaced" | null = null; + if (sameExpectation(current.expectation, file.before)) { + state = "before"; + } else if (rollbackDisplaced.expectation.exists) { + const rollbackOwned = file.operation === "write" + && staged !== null + && sameExpectation(staged.expectation, file.after) + && sameFileIdentity(rollbackDisplaced, staged); + if (!current.expectation.exists && rollbackOwned) state = "rollback_displaced"; + } else if (file.operation === "write") { + const currentOwned = staged !== null + && sameExpectation(staged.expectation, file.after) + && sameExpectation(current.expectation, file.after) + && sameFileIdentity(current, staged); + if (currentOwned) state = "after"; + else if (!current.expectation.exists && file.before.exists && quarantined && sameExpectation(quarantined.expectation, file.before)) { + state = "displaced"; + } + } else if ( + !current.expectation.exists + && file.before.exists + && quarantined + && sameExpectation(quarantined.expectation, file.before) + ) { + state = "after"; + } + + if (state === null) { + conflicts.push(file.path, ...(rollbackDisplaced.expectation.exists ? [file.rollbackPath] : [])); + continue; + } + fileStates.set(file.path, state); + if (file.before.exists && state !== "before") { + const quarantineValid = quarantined !== null && sameExpectation(quarantined.expectation, file.before); + const backup = file.backupPath ? await inspectFile(file.backupPath).catch(() => null) : null; + if (!quarantineValid && (!backup || !sameExpectation(backup.expectation, file.before))) { + conflicts.push(file.backupPath ?? file.path); + } + } + } + for (const external of journal.external) { + const handler = handlers[external.kind]; + if (!handler) { + conflicts.push(external.resource); + continue; + } + try { + const state = await handler.inspect(external); + if (state === "conflict") conflicts.push(external.resource); + else externalStates.set(external, state); + } catch (error) { + conflicts.push(...(error instanceof TransactionConflictError ? error.resources : [external.resource])); + } + } + + // Restore every independently recoverable resource even if another one + // conflicts. This keeps a registry failure or raced artifact from preventing + // an active-state pointer restoration attempt. + for (const external of [...journal.external].reverse()) { + if (externalStates.get(external) !== "after") continue; + try { + await handlers[external.kind].restore(external); + } catch (error) { + conflicts.push(...(error instanceof TransactionConflictError ? error.resources : [external.resource])); + } + } + const recoverable = journal.files.filter((file) => + fileStates.get(file.path) === "after" + || fileStates.get(file.path) === "displaced" + || fileStates.get(file.path) === "rollback_displaced" + ); + const ordered = [ + ...[...recoverable].reverse().filter((file) => !file.statePointer), + ...[...recoverable].reverse().filter((file) => file.statePointer), + ]; + for (const file of ordered) { + try { + await restoreFile({ + operation: file.operation, + path: file.path, + before: file.before, + after: file.after, + original: null, + ...(file.before.exists ? { originalMode: file.before.mode } : {}), + backupPath: file.backupPath, + stagedPath: file.stagedPath, + quarantinePath: file.quarantinePath, + rollbackPath: file.rollbackPath, + }); + } catch (error) { + conflicts.push(...(error instanceof TransactionConflictError ? error.resources : [file.path])); + } + } + if (conflicts.length > 0) { + throw new TransactionConflictError( + `AgentTab preserved resources changed while recovering interrupted ${journal.operation}`, + [...new Set(conflicts)], + true, + ); + } + await cleanupUncommittedJournal(journal); + return { recovered: true, operation: journal.operation }; +} + export async function applyTransaction( - files: PlannedFile[], + files: PlannedChange[], options: { dryRun?: boolean; printDiff?: (diff: string) => void; failAfter?: number; + crashAfter?: number; + crashAfterQuarantineRename?: boolean; + crashAfterQuarantine?: boolean; + crashAfterRollbackRename?: boolean; + crashAfterExternal?: boolean; + /** Fault injection for proving unsupported target filesystems fail before mutation. */ + failHardLinkPreflight?: boolean; + /** Fault injection for recovery of the journaled hard-link probe name. */ + crashAfterHardLinkProbe?: boolean; + /** Test hook used to exercise a write occurring in the final pre-mutation window. */ + beforeMutation?: (path: string) => Promise; + /** Test hook immediately after intent publication but before its durability barrier. */ + afterIntentPublish?: () => Promise; + /** Test hook immediately after commit-marker publication but before its durability barrier. */ + afterCommitPublish?: () => Promise; + /** Test hook for validating that commit publication is a one-way boundary. */ + afterCommit?: () => Promise; + /** Test hook between durable intent cleanup and commit-marker cleanup. */ + afterIntentCleanup?: () => Promise; afterApply?: () => Promise; + applyExternal?: () => Promise<() => Promise>; + journal?: { stateDir: string; operation: string; external?: DurableExternalChange[] }; } = {}, ): Promise { - const prepared: PreparedFile[] = []; + const prepared: PreparedChange[] = []; const unchanged: string[] = []; + const seenPaths = new Set(); for (const file of files) { + if (!isAbsolute(file.path)) throw new Error(`AgentTab transaction path must be absolute: ${file.path}`); + const pathIdentity = transactionPathIdentity(file.path); + if (seenPaths.has(pathIdentity)) throw new Error(`AgentTab transaction contains duplicate path: ${file.path}`); + seenPaths.add(pathIdentity); + const current = await inspectFile(file.path); + if (file.expectedBefore && !sameExpectation(current.expectation, file.expectedBefore)) { + throw new TransactionConflictError("AgentTab preserved a file changed before transaction preparation", [file.path]); + } + if (file.operation === "delete") { + if (current.bytes === null) { + unchanged.push(file.path); + continue; + } + options.printDiff?.(file.semanticDiff ?? renderDiff(file.label, current.bytes, null)); + prepared.push({ + ...file, + operation: "delete", + bytes: null, + original: current.bytes, + originalMode: current.mode, + before: current.expectation, + after: { exists: false }, + backupPath: backupPath(file.path, current.bytes), + quarantinePath: `${file.path}.agenttab-displaced-${randomUUID()}`, + rollbackPath: `${file.path}.agenttab-rollback-${randomUUID()}`, + }); + continue; + } const bytes = Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content, "utf8"); - const original = await readOptional(file.path); - const originalMode = original && process.platform !== "win32" - ? (await stat(file.path)).mode & 0o777 - : undefined; - const modeMatches = file.mode === undefined || originalMode === undefined || originalMode === file.mode; - if (original?.equals(bytes) && modeMatches) { + const targetMode = file.mode ?? current.mode ?? 0o600; + const after = expectationFor(bytes, process.platform === "win32" ? undefined : targetMode); + if (current.bytes?.equals(bytes) && sameExpectation(current.expectation, after)) { unchanged.push(file.path); continue; } - options.printDiff?.(file.semanticDiff ?? renderDiff(file.label, original, bytes)); + options.printDiff?.(file.semanticDiff ?? renderDiff(file.label, current.bytes, bytes)); prepared.push({ ...file, + operation: "write", bytes, - original, - originalMode, + original: current.bytes, + originalMode: current.mode, + before: current.expectation, + after, stagedPath: `${file.path}.agenttab-stage-${randomUUID()}`, - ...(original ? { backupPath: backupPath(file.path, original) } : {}), + ...(current.bytes ? { backupPath: backupPath(file.path, current.bytes) } : {}), + ...(current.bytes ? { quarantinePath: `${file.path}.agenttab-displaced-${randomUUID()}` } : {}), + rollbackPath: `${file.path}.agenttab-rollback-${randomUUID()}`, }); } + const external = options.journal?.external ?? []; + const changedResources = [ + ...prepared.map((file) => file.path), + ...external.map((entry) => entry.resource), + ]; if (options.dryRun) { - return { changed: [], unchanged, backups: [] }; + return { changed: changedResources, unchanged, backups: [] }; } - if (prepared.length === 0) { + if ((external.length > 0) !== (options.applyExternal !== undefined)) { + throw new Error("AgentTab transaction external changes and apply handler must be provided together"); + } + if (prepared.length === 0 && external.length === 0) { await options.afterApply?.(); return { changed: [], unchanged, backups: [] }; } for (const file of prepared) { - await durableWrite(file.stagedPath, file.bytes, file.mode ?? file.originalMode ?? 0o600); + if (!file.backupPath) continue; + const existingBackup = await inspectFile(file.backupPath); + if (existingBackup.expectation.exists && !sameExpectation(existingBackup.expectation, file.before)) { + throw new TransactionConflictError("AgentTab preserved a changed transaction backup", [file.backupPath]); + } + file.backupPreexisting = existingBackup.expectation.exists; + } + + let journal: TransactionJournal | undefined; + if (options.journal) { + journal = { + schemaVersion: 1, + transactionId: randomUUID(), + operation: options.journal.operation, + stateDir: canonicalStateDirectoryPath(options.journal.stateDir), + createdAt: new Date().toISOString(), + files: prepared.map((file) => ({ + operation: file.operation, + path: file.path, + before: file.before, + after: file.after, + ...(file.backupPath ? { backupPath: file.backupPath } : {}), + ...(file.backupPath ? { backupPreexisting: file.backupPreexisting ?? false } : {}), + ...(file.stagedPath ? { stagedPath: file.stagedPath } : {}), + ...(file.quarantinePath ? { quarantinePath: file.quarantinePath } : {}), + rollbackPath: file.rollbackPath, + ...(file.statePointer ? { statePointer: true } : {}), + })), + external, + }; + await writeIntent(journal, options.afterIntentPublish); } - const applied: PreparedFile[] = []; const backups: string[] = []; try { for (const file of prepared) { - if (file.original && file.backupPath && !(await readOptional(file.backupPath))) { - await copyFile(file.path, file.backupPath); - await chmod(file.backupPath, file.originalMode ?? 0o600); - backups.push(file.backupPath); + if (file.operation === "write") { + await durableWrite(file.stagedPath!, file.bytes!, file.mode ?? file.originalMode ?? 0o600); + } + } + for (const file of prepared) { + if (await ensureBackup(file)) backups.push(file.backupPath!); + } + await preflightHardLinkSupport(prepared, { + fail: options.failHardLinkPreflight, + crashAfterProbe: options.crashAfterHardLinkProbe, + }); + } catch (error) { + if (error instanceof InjectedCrashError || error instanceof IntentPublicationUncertainError) throw error; + const cleanupProblems: string[] = []; + try { + if (journal) await cleanupUncommittedJournal(journal); + else { + for (const backup of backups) { + const file = prepared.find((entry) => entry.backupPath === backup)!; + const current = await inspectFile(backup); + if (sameExpectation(current.expectation, file.before)) await removeIfPresent(backup); + else cleanupProblems.push(backup); + } + await cleanupSidecars(prepared); + } + } catch (cleanupError) { + cleanupProblems.push(cleanupError instanceof Error ? cleanupError.message : String(cleanupError)); + } + if (cleanupProblems.length > 0) { + throw new TransactionConflictError( + `AgentTab transaction setup failed and cleanup is incomplete (${error instanceof Error ? error.message : String(error)})`, + cleanupProblems, + true, + ); + } + throw error; + } + + const applied: PreparedChange[] = []; + let undoExternal: (() => Promise) | undefined; + let committed = false; + try { + for (const file of prepared) { + if (file.statePointer) await preflightRollback(applied); + const immediatelyBefore = await inspectFile(file.path); + if (!sameExpectation(immediatelyBefore.expectation, file.before)) { + throw new TransactionConflictError("AgentTab preserved a file changed immediately before mutation", [file.path]); + } + file.beforeIdentity = immediatelyBefore.identity; + await options.beforeMutation?.(file.path); + file.mutationState = "untouched"; + try { + await applyPreparedChange( + file, + (state) => { file.mutationState = state; }, + options.crashAfterQuarantineRename + ? async () => { throw new InjectedCrashError("Injected transaction crash after quarantine rename"); } + : undefined, + options.crashAfterQuarantine + ? async () => { throw new InjectedCrashError("Injected transaction crash after quarantine"); } + : undefined, + ); + } catch (error) { + if (error instanceof InjectedCrashError) throw error; + if (file.mutationState !== "untouched") applied.push(file); + if (error instanceof TransactionConflictError) throw error; + throw new TransactionConflictError( + `AgentTab file mutation failed with recovery pending (${error instanceof Error ? error.message : String(error)})`, + [file.path], + true, + ); } - await rename(file.stagedPath, file.path); applied.push(file); await syncDirectory(dirname(file.path)); + if (options.crashAfter !== undefined && applied.length === options.crashAfter) { + throw new InjectedCrashError(`Injected transaction crash after ${applied.length} files`); + } if (options.failAfter !== undefined && applied.length === options.failAfter) { throw new Error(`Injected transaction failure after ${applied.length} files`); } } + await preflightRollback(applied); + undoExternal = await options.applyExternal?.(); + if (options.crashAfterExternal) throw new InjectedCrashError("Injected transaction crash after external changes"); await options.afterApply?.(); + await preflightRollback(applied); + if (journal) { + await markCommitted(journal, options.afterCommitPublish); + committed = true; + await options.afterCommit?.(); + await cleanupJournal(journal.stateDir, journal.files, journal.transactionId, options.afterIntentCleanup); + } else { + await cleanupSidecars(prepared); + } } catch (error) { - for (const file of applied.reverse()) { - if (file.original === null) { - await rm(file.path, { force: true }); - } else { - const restore = `${file.path}.agenttab-restore-${randomUUID()}`; - await durableWrite(restore, file.original, file.originalMode ?? 0o600); - await rename(restore, file.path); - await syncDirectory(dirname(file.path)); + if (error instanceof InjectedCrashError) throw error; + if (error instanceof CommitPublicationUncertainError) { + throw new Error( + `AgentTab transaction commit outcome is pending durable recovery: ${error.message}`, + ); + } + if (committed) { + throw new Error( + `AgentTab transaction committed but durable cleanup is pending: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const recoveryProblems: string[] = error instanceof TransactionConflictError && error.recoveryIncomplete + ? [...error.resources] + : []; + let externalIncomplete = undoExternal === undefined + && external.length > 0 + && (error as { recoveryIncomplete?: unknown }).recoveryIncomplete === true; + try { + await preflightRollback(applied); + } catch (rollbackError) { + if (rollbackError instanceof TransactionConflictError) recoveryProblems.push(...rollbackError.resources); + else recoveryProblems.push(rollbackError instanceof Error ? rollbackError.message : String(rollbackError)); + } + if (!externalIncomplete && undoExternal) { + try { + await undoExternal(); + } catch (undoError) { + externalIncomplete = true; + if (undoError instanceof TransactionConflictError) recoveryProblems.push(...undoError.resources); + else recoveryProblems.push(`external rollback: ${undoError instanceof Error ? undoError.message : String(undoError)}`); } } - for (const file of prepared) await rm(file.stagedPath, { force: true }); - throw error; + recoveryProblems.push(...await restoreRecoverableFiles( + applied, + options.crashAfterRollbackRename + ? async () => { throw new InjectedCrashError("Injected transaction crash after rollback rename"); } + : undefined, + )); + if (!externalIncomplete && recoveryProblems.length === 0) { + if (journal) await cleanupUncommittedJournal(journal); + else await cleanupSidecars(prepared); + throw error; + } + throw new TransactionConflictError( + `AgentTab transaction failed and preserved concurrent changes (${error instanceof Error ? error.message : String(error)})`, + [...new Set(recoveryProblems.length > 0 ? recoveryProblems : external.map((entry) => entry.resource))], + true, + ); } - return { changed: prepared.map((file) => file.path), unchanged, backups }; + return { changed: changedResources, unchanged, backups }; } diff --git a/packages/installer/test/cli.test.ts b/packages/installer/test/cli.test.ts index dbfcef0..a6f50f1 100644 --- a/packages/installer/test/cli.test.ts +++ b/packages/installer/test/cli.test.ts @@ -29,49 +29,58 @@ async function runCli(args: string[]) { } describe("installer CLI arguments", () => { - test("prints help without starting a command", async () => { - const result = await runCli(["--help"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Usage:"); - expect(result.stdout).toContain("agenttab install"); - expect(result.stderr).toBe(""); - }); + test("prints lifecycle help and package version without starting a command", async () => { + const help = await runCli(["--help"]); + expect(help.exitCode).toBe(0); + expect(help.stdout).toContain("agenttab update --version X.Y.Z"); + expect(help.stdout).toContain("agenttab uninstall"); + expect(help.stderr).toBe(""); - test("prints the package version", async () => { - const result = await runCli(["--version"]); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe(packageJson.version); - expect(result.stderr).toBe(""); + const version = await runCli(["--version"]); + expect(version.exitCode).toBe(0); + expect(version.stdout.trim()).toBe(packageJson.version); + expect(version.stderr).toBe(""); }); - test("rejects a value after a boolean flag before installation can start", async () => { - const root = await mkdtemp(join(tmpdir(), "agenttab-cli-test-")); + test("requires an exact update version before download or mutation", async () => { + const root = await mkdtemp(join(tmpdir(), "agenttab-cli-update-test-")); temporaryRoots.push(root); const stateDir = join(root, "state"); - const result = await runCli([ - "install", - "--dry-run", - "false", - "--state-dir", - stateDir, - ]); + const result = await runCli(["update", "--state-dir", stateDir]); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Unexpected argument for agenttab install: false"); + expect(result.stderr).toContain("agenttab update requires an exact --version X.Y.Z"); expect(existsSync(stateDir)).toBe(false); }); - test("rejects typo, duplicate, and command-specific options", async () => { + test("rejects malformed lifecycle and install flags before mutation", async () => { + const root = await mkdtemp(join(tmpdir(), "agenttab-cli-flags-test-")); + temporaryRoots.push(root); + const stateDir = join(root, "state"); + const cases = [ + [["install", "--dry-run", "false", "--state-dir", stateDir], "Unexpected argument for agenttab install: false"], + [["uninstall", "--dry-run=false", "--state-dir", stateDir], "--dry-run does not take a value"], + [["rollback", "--dry-rnu", "--state-dir", stateDir], "Unknown option for agenttab rollback: --dry-rnu"], + [["prune", "--keep", "-1", "--state-dir", stateDir], "--keep must be a non-negative integer"], + [["doctor", "--layer", "status", "--state-dir", stateDir], "--layer must be installation, ipc, protocol, host, extension, or all"], + ] as const; + for (const [args, message] of cases) { + const result = await runCli([...args]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(message); + } + expect(existsSync(stateDir)).toBe(false); + }, 20_000); + + test("rejects duplicate and command-specific options", async () => { const cases = [ - [["install", "--dry-rnu"], "Unknown option for agenttab install: --dry-rnu"], [["install", "--dry-run", "--dry-run"], "Duplicate option for agenttab install: --dry-run"], [["status", "--layer", "ipc"], "Unknown option for agenttab status: --layer"], [["install", "--version"], "--version requires a value"], - [["install", "--dry-run=false"], "--dry-run does not take a value"], ] as const; for (const [args, message] of cases) { const result = await runCli([...args]); expect(result.exitCode).toBe(1); expect(result.stderr).toContain(message); } - }); + }, 20_000); }); diff --git a/packages/installer/test/install.test.ts b/packages/installer/test/install.test.ts index 075dbb3..90cbdfa 100644 --- a/packages/installer/test/install.test.ts +++ b/packages/installer/test/install.test.ts @@ -11,9 +11,11 @@ import { detectLegacy, install, targetTriple, + update, verifySignedManifest, type RuntimeAssets, } from "../src/install"; +import { rollback } from "../src/lifecycle"; import { applyTransaction } from "../src/transaction"; import { AgentTabClient, AgentTabError } from "../../sdk-typescript/src/index"; @@ -202,10 +204,13 @@ async function signedFixture(root: string, version = "2.0.0-rc.1", assetUrl?: st const source = join(root, "source"); await mkdir(source, { recursive: true }); const binaryName = process.platform === "win32" ? "agenttab-host.exe" : "agenttab-host"; + const shimName = process.platform === "win32" ? "agenttab-native.exe" : "agenttab-native"; await copyFile(systemExecutable(), join(source, binaryName)); + await copyFile(systemExecutable(), join(source, shimName)); await chmod(join(source, binaryName), 0o755); + await chmod(join(source, shimName), 0o755); const archivePath = join(root, assetName); - execFileSync("tar", ["-czf", archivePath, "-C", source, binaryName]); + execFileSync("tar", ["-czf", archivePath, "-C", source, binaryName, shimName]); return signedArchiveFixture(root, { archive: await readFile(archivePath), assetName, @@ -290,6 +295,7 @@ describe("release trust", () => { try { const result = await install(options); expect(result.readiness).toEqual({ passed: false, skipped: true, reason: "dry_run" }); + expect(result.service.status).toBe("planned"); expect(fetchMock).toHaveBeenCalledTimes(3); } finally { fetchMock.mockRestore(); @@ -333,11 +339,19 @@ describe("Windows host archives", () => { const executable = process.platform === "win32" ? await readFile(systemExecutable()) : Buffer.from("AgentTab Windows host fixture"); - const fixture = await signedWindowsZipFixture(root, zipArchive([{ - name: "agenttab-host.exe", - bytes: executable, - }])); + const fixture = await signedWindowsZipFixture(root, zipArchive([ + { name: "agenttab-host.exe", bytes: executable }, + { name: "agenttab-native.exe", bytes: executable }, + ])); const restorePowerShell = await addPowerShellShim(root); + const registryShim = join(root, "reg.exe"); + await writeFile( + registryShim, + `#!/bin/sh\necho 'Registrierungswert wurde nicht gefunden.' >&2\nexit 3\n`, + { mode: 0o700 }, + ); + const previousRegistryExecutable = process.env.AGENTTAB_REG_EXE; + process.env.AGENTTAB_REG_EXE = registryShim; try { const result = await install({ version: "2.0.0-rc.1", @@ -355,8 +369,11 @@ describe("Windows host archives", () => { print: () => undefined, }); expect(result.target).toBe("x86_64-pc-windows-msvc"); - expect(result.transaction.changed).toEqual([]); + expect(result.transaction.changed.some((path) => path.endsWith("agenttab-host.exe"))).toBe(true); + expect(existsSync(join(root, "state"))).toBe(false); } finally { + if (previousRegistryExecutable === undefined) delete process.env.AGENTTAB_REG_EXE; + else process.env.AGENTTAB_REG_EXE = previousRegistryExecutable; restorePowerShell(); } }); @@ -476,10 +493,25 @@ describe("end-to-end development install", () => { status: "manual_load_required", }); expect(first.readiness).toEqual({ passed: false, skipped: true, reason: "manual_extension_load" }); + expect(first.service.status).toBe("shim_fallback"); expect(output.join("\n")).toContain("Open chrome://extensions in Chrome."); expect(output.join("\n")).toContain("Choose Load unpacked and select"); expect(output.join("\n")).not.toContain("bridge_policy.json\n{}"); + const targetRoot = join(stateDir, "versions", "v2.0.0-rc.1", targetTriple()); + const nativeManifestPath = process.platform === "darwin" + ? join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts", "dev.agenttab.host.json") + : join(home, ".config", "google-chrome", "NativeMessagingHosts", "dev.agenttab.host.json"); + if (process.platform !== "win32") { + expect(JSON.parse(await readFile(nativeManifestPath, "utf8")).path).toBe( + join(targetRoot, "agenttab-native"), + ); + } + expect(JSON.parse(await readFile(join(targetRoot, "agenttab-runtime.json"), "utf8"))).toEqual({ + schemaVersion: 1, + stateDir, + }); + const mtimes = new Map(); for (const path of first.transaction.changed) mtimes.set(path, (await stat(path)).mtimeMs); const second = await install(options); @@ -487,6 +519,53 @@ describe("end-to-end development install", () => { for (const [path, mtime] of mtimes) expect((await stat(path)).mtimeMs).toBe(mtime); }); + test("same-version install aborts without replacing a drifted installed file", async () => { + const root = await temporaryRoot(); + const home = join(root, "home"); + const stateDir = join(home, ".agenttab"); + const fixture = await signedFixture(root); + const assets = await runtimeAssets(root); + const options = { + version: "2.0.0-rc.1", + development: true, + manifestUrl: fixture.manifestUrl, + signatureUrl: fixture.signatureUrl, + publicKeyPem: fixture.publicKeyPem, + home, + stateDir, + runtimeAssets: assets, + openBrowser: false, + print: () => undefined, + } as const; + await install(options); + + const cliPath = join(stateDir, "versions", "v2.0.0-rc.1", "agenttab-cli.mjs"); + const activePath = join(stateDir, "active-install.json"); + const activeBefore = await readFile(activePath); + await writeFile(cliPath, "user-edited-cli\n"); + + await expect(install(options)).rejects.toThrow(); + expect(await readFile(cliPath, "utf8")).toBe("user-edited-cli\n"); + expect(await readFile(activePath)).toEqual(activeBefore); + + await writeFile(cliPath, await readFile(assets.cliBundlePath), { mode: 0o755 }); + await expect(install({ + ...options, + beforeTransaction: async () => writeFile(cliPath, "edit-after-repeat-preflight\n"), + })).rejects.toThrow("changed before transaction preparation"); + expect(await readFile(cliPath, "utf8")).toBe("edit-after-repeat-preflight\n"); + expect(await readFile(activePath)).toEqual(activeBefore); + + await writeFile(cliPath, await readFile(assets.cliBundlePath), { mode: 0o755 }); + const wrapperPath = join(stateDir, "bin", "agenttab"); + await expect(install({ + ...options, + beforeTransaction: async () => writeFile(wrapperPath, "activation-edit-after-repeat-preflight\n"), + })).rejects.toThrow("changed before transaction preparation"); + expect(await readFile(wrapperPath, "utf8")).toBe("activation-edit-after-repeat-preflight\n"); + expect(await readFile(activePath)).toEqual(activeBefore); + }); + test("dry-run verifies artifacts and produces no installation state", async () => { const root = await temporaryRoot(); const fixture = await signedFixture(root); @@ -506,25 +585,76 @@ describe("end-to-end development install", () => { openBrowser: false, print: () => undefined, }); - expect(result.transaction.changed).toEqual([]); + expect(result.transaction.changed.length).toBeGreaterThan(0); + expect(result.transaction.changed).toContain(join(stateDir, "active-install.json")); expect(existsSync(stateDir)).toBe(false); expect(result.extension.status).toBe("planned"); expect(result.readiness).toEqual({ passed: false, skipped: true, reason: "dry_run" }); }); - test("waits for the ready lifecycle and retries a pre-dispatch readiness race", async () => { + test("activates only a newer exact update and can roll back to the prior wrapper", async () => { + const firstRoot = await temporaryRoot(); + const home = join(firstRoot, "home"); + const stateDir = join(home, ".agenttab"); + const firstFixture = await signedFixture(firstRoot, "2.0.0"); + const assets = await runtimeAssets(firstRoot); + const firstOptions = { + version: "2.0.0", + development: true, + manifestUrl: firstFixture.manifestUrl, + signatureUrl: firstFixture.signatureUrl, + publicKeyPem: firstFixture.publicKeyPem, + home, + stateDir, + runtimeAssets: assets, + openBrowser: false, + print: () => undefined, + } as const; + await install(firstOptions); + const wrapper = join(stateDir, "bin", process.platform === "win32" ? "agenttab.cmd" : "agenttab"); + expect(await readFile(wrapper, "utf8")).toContain(join(stateDir, "versions", "v2.0.0")); + + await expect(update(firstOptions)).rejects.toThrow("requires a version newer than 2.0.0"); + await expect(update({ + ...firstOptions, + version: "2.0.1", + runtimeAssets: { ...assets, version: "2.0.0" }, + })).rejects.toThrow("installer runtime 2.0.0 cannot activate AgentTab 2.0.1"); + const secondRoot = await temporaryRoot(); + const secondFixture = await signedFixture(secondRoot, "2.0.1"); + await update({ + ...firstOptions, + version: "2.0.1", + manifestUrl: secondFixture.manifestUrl, + signatureUrl: secondFixture.signatureUrl, + publicKeyPem: secondFixture.publicKeyPem, + }); + expect(JSON.parse(await readFile(join(stateDir, "active-install.json"), "utf8")).version).toBe("2.0.1"); + expect(await readFile(wrapper, "utf8")).toContain(join(stateDir, "versions", "v2.0.1")); + + await rollback({ stateDir, home, platform: process.platform }); + expect(JSON.parse(await readFile(join(stateDir, "active-install.json"), "utf8")).version).toBe("2.0.0"); + expect(await readFile(wrapper, "utf8")).toContain(join(stateDir, "versions", "v2.0.0")); + }); + + test("checks exact host/protocol readiness and retries a pre-dispatch extension race", async () => { const root = await temporaryRoot(); const fixture = await signedFixture(root); const calls: string[] = []; - let statusCalls = 0; let openCalls = 0; const fakeClient = { - connection: { state: "starting" }, + connection: { + protocol: "agenttab.rpc", + version: 1, + kind: "connected", + connection_id: "readiness-fixture", + resumed: false, + state: "ready", + }, call: async (method: string): Promise> => { calls.push(method); if (method === "agenttab.status") { - statusCalls += 1; - return { state: statusCalls === 1 ? "reconciling" : "ready" }; + return { state: "ready", protocol_version: 1, host_version: "2.0.0-rc.1", extension_version: "2.0.0" }; } if (method === "browser_open") { openCalls += 1; @@ -543,8 +673,6 @@ describe("end-to-end development install", () => { } return { tab_id: 41, page_revision: 7 }; } - if (method === "browser_snapshot") return { page_revision: 8 }; - if (method === "browser_act") return {}; throw new Error(`unexpected readiness method: ${method}`); }, close: () => undefined, @@ -571,14 +699,63 @@ describe("end-to-end development install", () => { "agenttab.status", "browser_open", "browser_open", - "browser_snapshot", - "browser_act", ]); } finally { connect.mockRestore(); } }); + test("rolls back files and client config when readiness rejects the activated host version", async () => { + const root = await temporaryRoot(); + const fixture = await signedFixture(root); + const home = join(root, "readiness-rollback-home"); + const stateDir = join(home, ".agenttab"); + const configPath = join(home, ".config", "mcp", "mcp.json"); + const originalConfig = `${JSON.stringify({ mcpServers: { user: { command: "user" } } }, null, 2)}\n`; + await mkdir(join(home, ".config", "mcp"), { recursive: true }); + await writeFile(configPath, originalConfig); + const fakeClient = { + connection: { + protocol: "agenttab.rpc", + version: 1, + kind: "connected", + connection_id: "wrong-host-fixture", + resumed: false, + state: "ready", + }, + call: async (method: string): Promise> => { + if (method === "agenttab.status") { + return { state: "ready", protocol_version: 1, host_version: "9.9.9", extension_version: "2.0.0" }; + } + if (method === "browser_open") return { tab_id: 9, page_revision: 1 }; + throw new Error(`unexpected method: ${method}`); + }, + close: () => undefined, + } as unknown as AgentTabClient; + const connect = spyOn(AgentTabClient, "connect").mockResolvedValue(fakeClient); + + try { + await expect(install({ + version: "2.0.0-rc.1", + development: true, + manifestUrl: fixture.manifestUrl, + signatureUrl: fixture.signatureUrl, + publicKeyPem: fixture.publicKeyPem, + home, + stateDir, + runtimeAssets: await runtimeAssets(root), + verifyReadiness: true, + openBrowser: false, + print: () => undefined, + })).rejects.toThrow("running host 9.9.9 does not match active receipt 2.0.0-rc.1"); + expect(await readFile(configPath, "utf8")).toBe(originalConfig); + expect(existsSync(join(stateDir, "active-install.json"))).toBe(false); + expect(existsSync(join(stateDir, "bin", "agenttab"))).toBe(false); + } finally { + connect.mockRestore(); + } + }); + test("reports the frozen v1 recovery identity without mutating it", async () => { const root = await temporaryRoot(); const report = detectLegacy(root, process.platform); diff --git a/packages/installer/test/lifecycle.test.ts b/packages/installer/test/lifecycle.test.ts new file mode 100644 index 0000000..07ce568 --- /dev/null +++ b/packages/installer/test/lifecycle.test.ts @@ -0,0 +1,815 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { RPC_PROTOCOL, RPC_VERSION, type AgentTabClient } from "../../sdk-typescript/src/index"; +import { doctor, prune, queryRegistryValue, rollback, uninstall } from "../src/lifecycle"; +import { + activeStatePath, + canonicalJson, + referenceForReceipt, + sha256, + type ActiveReceiptReference, + type FileOwnership, + type FileSnapshot, + type InstallReceiptV2, + type RegistryOwnership, +} from "../src/receipt"; +import type { ConfigOwnership } from "../src/configs"; +import { TransactionConflictError, transactionJournalPath } from "../src/transaction"; + +const temporaryRoots: string[] = []; +const restoreEnvironment: Array<() => void> = []; + +afterEach(async () => { + for (const restore of restoreEnvironment.splice(0).reverse()) restore(); + await Promise.all(temporaryRoots.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "agenttab-lifecycle-test-")); + temporaryRoots.push(root); + return root; +} + +function snapshot(content: string | null, mode = 0o600): FileSnapshot { + if (content === null) return { exists: false }; + const bytes = Buffer.from(content); + return { + exists: true, + sha256: sha256(bytes), + contentBase64: bytes.toString("base64"), + mode, + }; +} + +function ownedFile(options: { + path: string; + role: FileOwnership["role"]; + installed: string; + previous: string | null; + mode?: number; +}): FileOwnership { + return { + path: options.path, + role: options.role, + installedSha256: sha256(Buffer.from(options.installed)), + installedMode: options.mode ?? 0o600, + previous: snapshot(options.previous, options.mode ?? 0o600), + owned: true, + }; +} + +async function writeReceipt( + stateDir: string, + name: string, + receipt: InstallReceiptV2, +): Promise<{ reference: ActiveReceiptReference; bytes: Buffer }> { + const path = join(stateDir, "receipts", `${name}.json`); + const bytes = canonicalJson(receipt); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes, { mode: 0o600 }); + return { reference: referenceForReceipt(receipt.version, path, bytes), bytes }; +} + +interface Fixture { + root: string; + home: string; + stateDir: string; + wrapper: string; + artifactV1: string; + artifactV2: string; + jsonConfig: string; + ompConfig: string; + v1: ActiveReceiptReference; + v2: ActiveReceiptReference; + registryKey: string; +} + +async function lifecycleFixture( + registry: RegistryOwnership[] = [], + artifactV1Previous: string | null = null, +): Promise { + const root = await temporaryRoot(); + const home = join(root, "home"); + const stateDir = join(home, ".agenttab"); + const wrapper = join(stateDir, "bin", "agenttab"); + const artifactV1 = join(stateDir, "versions", "v2.0.0", "host"); + const artifactV2 = join(stateDir, "versions", "v2.0.1", "host"); + const jsonConfig = join(home, ".config", "mcp", "mcp.json"); + const ompConfig = join(home, ".omp", "agent", "config.yml"); + const registryKey = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + await Promise.all([ + mkdir(dirname(wrapper), { recursive: true }), + mkdir(dirname(artifactV1), { recursive: true }), + mkdir(dirname(artifactV2), { recursive: true }), + mkdir(dirname(jsonConfig), { recursive: true }), + mkdir(dirname(ompConfig), { recursive: true }), + ]); + await writeFile(wrapper, "wrapper-v2", { mode: 0o600 }); + await writeFile(artifactV1, "artifact-v1", { mode: 0o600 }); + await writeFile(artifactV2, "artifact-v2", { mode: 0o600 }); + await writeFile(jsonConfig, `${JSON.stringify({ + unrelated: "later edit", + mcpServers: { + other: { command: "other" }, + agenttab: { command: wrapper, args: ["mcp"] }, + }, + }, null, 2)}\n`); + await writeFile(ompConfig, "extensions:\n - unrelated-extension\n - /adapter-v2.mjs\n"); + + const v1Configs: ConfigOwnership[] = [{ + kind: "json_property", + client: "stdio MCP", + path: jsonConfig, + property: ["mcpServers", "agenttab"], + installedValue: { command: wrapper, args: ["mcp"] }, + previous: { exists: true, value: { command: "user-agenttab", args: [] } }, + owned: true, + }, { + kind: "yaml_sequence_item", + client: "OMP", + path: ompConfig, + property: "extensions", + value: "/adapter-v1.mjs", + installedPresent: true, + previousPresent: false, + owned: true, + }]; + const v1Receipt: InstallReceiptV2 = { + schemaVersion: 2, + activationId: "v1", + activatedAt: "2026-01-01T00:00:00.000Z", + version: "2.0.0", + target: "fixture", + platform: "linux", + stateDir, + home, + manifestSha256: "1".repeat(64), + assetSha256: "2".repeat(64), + hostSha256: sha256(Buffer.from("artifact-v1")), + shimSha256: "8".repeat(64), + cliSha256: "3".repeat(64), + ompSha256: "4".repeat(64), + extensionSha256: "5".repeat(64), + extensionVersion: "2.0.0", + daemonService: { manager: "systemd", managed: false }, + previousActive: { exists: false }, + previousReceipt: null, + files: [ + ownedFile({ path: wrapper, role: "activation", installed: "wrapper-v1", previous: "user-wrapper" }), + ownedFile({ path: artifactV1, role: "artifact", installed: "artifact-v1", previous: artifactV1Previous }), + ], + configs: v1Configs, + registry: registry.map((entry) => ({ + ...entry, + installedValue: "/manifest-v1.json", + previous: { existed: false, value: null }, + })), + }; + const writtenV1 = await writeReceipt(stateDir, "v1", v1Receipt); + const activeV1Bytes = canonicalJson({ schemaVersion: 1, ...writtenV1.reference }); + + const v2Receipt: InstallReceiptV2 = { + ...v1Receipt, + activationId: "v2", + activatedAt: "2026-01-02T00:00:00.000Z", + version: "2.0.1", + manifestSha256: "6".repeat(64), + assetSha256: "7".repeat(64), + hostSha256: sha256(Buffer.from("artifact-v2")), + extensionVersion: "2.0.1", + previousActive: { + exists: true, + sha256: sha256(activeV1Bytes), + contentBase64: activeV1Bytes.toString("base64"), + mode: 0o600, + }, + previousReceipt: writtenV1.reference, + files: [ + ownedFile({ path: wrapper, role: "activation", installed: "wrapper-v2", previous: "wrapper-v1" }), + ownedFile({ path: artifactV2, role: "artifact", installed: "artifact-v2", previous: null }), + ], + configs: [{ + kind: "yaml_sequence_item", + client: "OMP", + path: ompConfig, + property: "extensions", + value: "/adapter-v1.mjs", + installedPresent: false, + previousPresent: true, + owned: true, + }, { + kind: "yaml_sequence_item", + client: "OMP", + path: ompConfig, + property: "extensions", + value: "/adapter-v2.mjs", + installedPresent: true, + previousPresent: false, + owned: true, + }], + registry, + }; + const writtenV2 = await writeReceipt(stateDir, "v2", v2Receipt); + await writeFile(activeStatePath(stateDir), canonicalJson({ schemaVersion: 1, ...writtenV2.reference }), { mode: 0o600 }); + return { + root, + home, + stateDir, + wrapper, + artifactV1, + artifactV2, + jsonConfig, + ompConfig, + v1: writtenV1.reference, + v2: writtenV2.reference, + registryKey, + }; +} + +describe("consumer lifecycle", () => { + test("rolls back activation, then uninstalls every exact artifact while preserving unrelated config", async () => { + const fixture = await lifecycleFixture(); + const rolledBack = await rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux" }); + expect(rolledBack.activeVersion).toBe("2.0.0"); + expect(await readFile(fixture.wrapper, "utf8")).toBe("wrapper-v1"); + const ompAfterRollback = await readFile(fixture.ompConfig, "utf8"); + expect(ompAfterRollback).toContain("/adapter-v1.mjs"); + expect(ompAfterRollback).not.toContain("/adapter-v2.mjs"); + expect(JSON.parse(await readFile(activeStatePath(fixture.stateDir), "utf8")).version).toBe("2.0.0"); + expect(await readFile(fixture.artifactV2, "utf8")).toBe("artifact-v2"); + + const removed = await uninstall({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux" }); + expect(removed.activeVersion).toBeNull(); + expect(await readFile(fixture.wrapper, "utf8")).toBe("user-wrapper"); + const json = JSON.parse(await readFile(fixture.jsonConfig, "utf8")); + expect(json.unrelated).toBe("later edit"); + expect(json.mcpServers.other).toEqual({ command: "other" }); + expect(json.mcpServers.agenttab).toEqual({ command: "user-agenttab", args: [] }); + const ompAfterUninstall = await readFile(fixture.ompConfig, "utf8"); + expect(ompAfterUninstall).toContain("unrelated-extension"); + expect(ompAfterUninstall).not.toContain("adapter-v1"); + await expect(readFile(fixture.artifactV1)).rejects.toThrow(); + await expect(readFile(fixture.artifactV2)).rejects.toThrow(); + await expect(readFile(activeStatePath(fixture.stateDir))).rejects.toThrow(); + const repeated = await uninstall({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux" }); + expect(repeated.changed).toEqual([]); + expect(repeated.activeVersion).toBeNull(); + }); + + test("preserves later user edits and reports every ownership conflict", async () => { + const fixture = await lifecycleFixture(); + await writeFile(fixture.wrapper, "user-edited-wrapper"); + const json = JSON.parse(await readFile(fixture.jsonConfig, "utf8")); + json.mcpServers.agenttab = { command: "user-edited-command" }; + await writeFile(fixture.jsonConfig, `${JSON.stringify(json, null, 2)}\n`); + + const result = await uninstall({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux" }); + expect(await readFile(fixture.wrapper, "utf8")).toBe("user-edited-wrapper"); + expect(JSON.parse(await readFile(fixture.jsonConfig, "utf8")).mcpServers.agenttab).toEqual({ command: "user-edited-command" }); + expect(result.preserved.map((entry) => entry.resource)).toContain(fixture.wrapper); + expect(result.preserved.some((entry) => entry.resource.includes("mcpServers.agenttab"))).toBe(true); + }); + + test("fault injection restores files and active state as one transaction", async () => { + const fixture = await lifecycleFixture(); + const activeBefore = await readFile(activeStatePath(fixture.stateDir)); + await expect(rollback({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "linux", + transactionFailAfter: 2, + })).rejects.toThrow("Injected transaction failure"); + expect(await readFile(fixture.wrapper, "utf8")).toBe("wrapper-v2"); + expect(await readFile(activeStatePath(fixture.stateDir))).toEqual(activeBefore); + expect(await readFile(fixture.ompConfig, "utf8")).toContain("/adapter-v2.mjs"); + }); + + test("aborts rollback before any mutation when an active resource drifted", async () => { + const fixture = await lifecycleFixture(); + const activeBefore = await readFile(activeStatePath(fixture.stateDir)); + await writeFile(fixture.wrapper, "user-edited-wrapper"); + + await expect(rollback({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "linux", + })).rejects.toThrow("rollback aborted because active resources drifted"); + + expect(await readFile(fixture.wrapper, "utf8")).toBe("user-edited-wrapper"); + expect(await readFile(fixture.ompConfig, "utf8")).toContain("/adapter-v2.mjs"); + expect(await readFile(activeStatePath(fixture.stateDir))).toEqual(activeBefore); + }); + + test("aborts rollback before changing activation when owned config drifted", async () => { + const fixture = await lifecycleFixture(); + const activeBefore = await readFile(activeStatePath(fixture.stateDir)); + await writeFile(fixture.ompConfig, "extensions:\n - user-extension\n"); + + await expect(rollback({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "linux", + })).rejects.toThrow("rollback aborted because active resources drifted"); + + expect(await readFile(fixture.wrapper, "utf8")).toBe("wrapper-v2"); + expect(await readFile(fixture.ompConfig, "utf8")).toContain("user-extension"); + expect(await readFile(activeStatePath(fixture.stateDir))).toEqual(activeBefore); + }); + + test("prune removes only exact artifacts outside the requested rollback window", async () => { + const fixture = await lifecycleFixture(); + const result = await prune({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux", keep: 0 }); + expect(result.activeVersion).toBe("2.0.1"); + await expect(readFile(fixture.artifactV1)).rejects.toThrow(); + expect(await readFile(fixture.artifactV2, "utf8")).toBe("artifact-v2"); + expect(await readFile(fixture.v1.receiptPath, "utf8")).toContain('"schemaVersion": 2'); + await expect(rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux" })) + .rejects.toThrow("previous-version artifact is unavailable"); + }); + + test("prune restores a pre-existing file displaced by an inactive artifact", async () => { + const fixture = await lifecycleFixture([], "pre-existing-host"); + const result = await prune({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux", keep: 0 }); + expect(result.preserved).toEqual([]); + expect(await readFile(fixture.artifactV1, "utf8")).toBe("pre-existing-host"); + expect(await readFile(fixture.artifactV2, "utf8")).toBe("artifact-v2"); + const repeated = await prune({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux", keep: 0 }); + expect(repeated.changed).toEqual([]); + expect(repeated.preserved).toEqual([]); + }); + + test("prune orders and deduplicates inactive ownership history newest to oldest", async () => { + const fixture = await lifecycleFixture([], "pre-existing-host"); + const v1 = JSON.parse(await readFile(fixture.v1.receiptPath, "utf8")) as InstallReceiptV2; + const inactive: InstallReceiptV2 = { + ...v1, + activationId: "inactive-newer", + activatedAt: "2026-01-03T00:00:00.000Z", + version: "2.0.0-repacked", + previousReceipt: null, + files: [ownedFile({ + path: fixture.artifactV1, + role: "artifact", + installed: "artifact-v1-newer", + previous: "artifact-v1", + })], + configs: [], + registry: [], + }; + await writeReceipt(fixture.stateDir, "inactive-newer", inactive); + await writeReceipt(fixture.stateDir, "inactive-newer-copy", inactive); + await writeFile(fixture.artifactV1, "artifact-v1-newer", { mode: 0o600 }); + + const result = await prune({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux", keep: 0 }); + expect(result.preserved).toEqual([]); + expect(await readFile(fixture.artifactV1, "utf8")).toBe("pre-existing-host"); + }); + + test("prune never reverses artifact paths referenced by active or kept receipts", async () => { + const fixture = await lifecycleFixture(); + + // v1 -> v2 -> rollback to v1 + await rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux" }); + const activeV1 = await readFile(activeStatePath(fixture.stateDir), "utf8"); + const oldV2 = JSON.parse(await readFile(fixture.v2.receiptPath, "utf8")) as InstallReceiptV2; + + // Reactivating v2 reuses its exact artifact. The old v2 receipt still claims + // ownership, while the new active receipt correctly records that it did not + // create the already-present path. + const v2Again: InstallReceiptV2 = { + ...oldV2, + activationId: "v2-again", + activatedAt: "2026-01-03T00:00:00.000Z", + previousActive: snapshot(activeV1), + previousReceipt: fixture.v1, + files: oldV2.files.map((file) => file.path === fixture.artifactV2 + ? { ...file, previous: snapshot("artifact-v2"), owned: false } + : file), + }; + const activeV2Again = await writeReceipt(fixture.stateDir, "v2-again", v2Again); + await writeFile( + activeStatePath(fixture.stateDir), + canonicalJson({ schemaVersion: 1, ...activeV2Again.reference }), + { mode: 0o600 }, + ); + + // A separate inactive receipt targets the artifact in the kept v1 receipt. + const v1 = JSON.parse(await readFile(fixture.v1.receiptPath, "utf8")) as InstallReceiptV2; + await writeReceipt(fixture.stateDir, "inactive-v1-owner", { + ...v1, + activationId: "inactive-v1-owner", + activatedAt: "2026-01-01T12:00:00.000Z", + previousReceipt: null, + files: [ownedFile({ + path: fixture.artifactV1, + role: "artifact", + installed: "artifact-v1", + previous: null, + })], + configs: [], + registry: [], + }); + + const result = await prune({ stateDir: fixture.stateDir, home: fixture.home, platform: "linux", keep: 1 }); + + expect(result.changed).not.toContain(fixture.artifactV1); + expect(result.changed).not.toContain(fixture.artifactV2); + expect(await readFile(fixture.artifactV1, "utf8")).toBe("artifact-v1"); + expect(await readFile(fixture.artifactV2, "utf8")).toBe("artifact-v2"); + }); + + test("doctor reports distinct installation, IPC, protocol, host, and extension evidence", async () => { + const fixture = await lifecycleFixture(); + const calls: string[] = []; + const connect = (async () => ({ + connection: { + protocol: RPC_PROTOCOL, + version: RPC_VERSION, + kind: "connected", + connection_id: "fixture", + resumed: false, + state: "ready", + }, + call: async (method: string) => { + calls.push(method); + if (method === "agenttab.status") { + return { + state: "ready", + protocol_version: RPC_VERSION, + host_version: "2.0.1", + extension_version: "2.0.1", + }; + } + if (method === "browser_open") return { tab_id: 41, page_revision: 7 }; + throw new Error(`unexpected method ${method}`); + }, + close: () => undefined, + }) as unknown as AgentTabClient) as typeof AgentTabClient.connect; + + const result = await doctor({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "linux", + connect, + extensionDeadlineMs: 100, + }); + expect(result.success).toBe(true); + expect(result.checks.map((entry) => entry.layer)).toEqual([ + "installation", + "ipc", + "protocol", + "host", + "extension", + ]); + expect(calls).toEqual(["agenttab.status", "agenttab.status", "browser_open"]); + + calls.length = 0; + const ipcOnly = await doctor({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "linux", + layer: "ipc", + connect, + }); + expect(ipcOnly.success).toBe(true); + expect(ipcOnly.checks.map((entry) => entry.layer)).toEqual(["ipc"]); + expect(calls).toEqual([]); + + const windowsInstallation = await doctor({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "win32", + layer: "installation", + }); + expect(windowsInstallation.success).toBe(true); + expect(windowsInstallation.checks[0].detail).toContain("process crashes, not sudden power loss"); + expect(windowsInstallation.checks[0].evidence?.transactionRecovery).toEqual({ + scope: "process_crash", + limitation: "Node does not expose a Windows directory durability barrier; sudden power-loss namespace atomicity is not claimed", + }); + }); +}); + +async function installRegistryShim( + root: string, + initial: Record, +): Promise<{ statePath: string; logPath: string }> { + const bin = join(root, "registry-bin"); + const statePath = join(root, "registry.json"); + const logPath = join(root, "registry.log"); + await mkdir(bin, { recursive: true }); + await writeFile(statePath, JSON.stringify(initial)); + await writeFile(join(bin, "reg.exe"), `#!/usr/bin/env python3 +import json, os, sys +path = os.environ["AGENTTAB_REGISTRY_FIXTURE"] +log = os.environ["AGENTTAB_REGISTRY_LOG"] +args = sys.argv[1:] +with open(log, "a", encoding="utf-8") as handle: + handle.write(json.dumps(args) + "\\n") +try: + with open(path, encoding="utf-8") as handle: + values = json.load(handle) +except FileNotFoundError: + values = {} +command, key = args[0], args[1] +if command == "query": + if os.environ.get("AGENTTAB_REGISTRY_QUERY_ERROR_KEY") == key: + print("ERROR: Access is denied.", file=sys.stderr) + sys.exit(5) + if key not in values: + print(os.environ.get("AGENTTAB_REGISTRY_NOT_FOUND_TEXT", "Registry value not found."), file=sys.stderr) + sys.exit(3) + entry = values[key] + value_type = entry.get("type", "REG_SZ") if isinstance(entry, dict) else "REG_SZ" + value = entry.get("value", "") if isinstance(entry, dict) else entry + if os.environ.get("AGENTTAB_REGISTRY_FAIL_QUERY_VALUE") == value: + print("ERROR: Injected registry query failure.", file=sys.stderr) + sys.exit(5) + print(" (Default) " + value_type + " " + value) + sys.exit(0) +if command == "add": + value = args[args.index("/d") + 1] + if os.environ.get("AGENTTAB_REGISTRY_FAIL_ADD_VALUE") == value: + print("ERROR: Injected registry add failure.", file=sys.stderr) + sys.exit(5) + values[key] = value +elif command == "delete": + if "/ve" not in args: + sys.exit(9) + if os.environ.get("AGENTTAB_REGISTRY_FAIL_DELETE_KEY") == key: + print("ERROR: Injected registry delete failure.", file=sys.stderr) + sys.exit(5) + values.pop(key, None) +else: + sys.exit(2) +with open(path, "w", encoding="utf-8") as handle: + json.dump(values, handle) +`, { mode: 0o700 }); + const previousPath = process.env.PATH; + const previousFixture = process.env.AGENTTAB_REGISTRY_FIXTURE; + const previousLog = process.env.AGENTTAB_REGISTRY_LOG; + const previousExecutable = process.env.AGENTTAB_REG_EXE; + process.env.PATH = previousPath ? `${bin}${delimiter}${previousPath}` : bin; + process.env.AGENTTAB_REGISTRY_FIXTURE = statePath; + process.env.AGENTTAB_REGISTRY_LOG = logPath; + process.env.AGENTTAB_REG_EXE = join(bin, "reg.exe"); + restoreEnvironment.push(() => { + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + if (previousFixture === undefined) delete process.env.AGENTTAB_REGISTRY_FIXTURE; + else process.env.AGENTTAB_REGISTRY_FIXTURE = previousFixture; + if (previousLog === undefined) delete process.env.AGENTTAB_REGISTRY_LOG; + else process.env.AGENTTAB_REGISTRY_LOG = previousLog; + if (previousExecutable === undefined) delete process.env.AGENTTAB_REG_EXE; + else process.env.AGENTTAB_REG_EXE = previousExecutable; + }); + return { statePath, logPath }; +} + +function setRegistryFault(name: string, value: string): void { + const previous = process.env[name]; + process.env[name] = value; + restoreEnvironment.push(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); +} + +describe("Windows lifecycle fixtures", () => { + test("distinguishes absence and exact REG_SZ data from query failures or unsupported types", async () => { + const root = await temporaryRoot(); + const empty = "HKCU\\Software\\AgentTabTest\\Empty"; + const spaces = "HKCU\\Software\\AgentTabTest\\Spaces"; + const unsupported = "HKCU\\Software\\AgentTabTest\\Unsupported"; + const denied = "HKCU\\Software\\AgentTabTest\\Denied"; + await installRegistryShim(root, { + [empty]: "", + [spaces]: " C:\\manifest.json ", + [unsupported]: { type: "REG_DWORD", value: "1" }, + [denied]: "value", + }); + setRegistryFault("AGENTTAB_REGISTRY_NOT_FOUND_TEXT", "Der Registrierungsschluessel wurde nicht gefunden."); + + expect(queryRegistryValue("HKCU\\Software\\AgentTabTest\\Missing")) + .toEqual({ existed: false, value: null }); + expect(queryRegistryValue(empty)).toEqual({ existed: true, value: "" }); + expect(queryRegistryValue(spaces)).toEqual({ existed: true, value: " C:\\manifest.json " }); + expect(() => queryRegistryValue(unsupported)).toThrow("unsupported type REG_DWORD"); + setRegistryFault("AGENTTAB_REGISTRY_QUERY_ERROR_KEY", denied); + expect(() => queryRegistryValue(denied)).toThrow("could not query registry default"); + }); + + test("aborts before mutation when an active registry default has an unsupported type", async () => { + const root = await temporaryRoot(); + const key = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + await installRegistryShim(root, { [key]: { type: "REG_EXPAND_SZ", value: "/manifest-v2.json" } }); + const fixture = await lifecycleFixture([{ + key, + installedValue: "/manifest-v2.json", + previous: { existed: true, value: "/manifest-v1.json" }, + owned: true, + }]); + const activeBefore = await readFile(activeStatePath(fixture.stateDir)); + + await expect(rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" })) + .rejects.toThrow("unsupported type REG_EXPAND_SZ"); + expect(await readFile(fixture.wrapper, "utf8")).toBe("wrapper-v2"); + expect(await readFile(activeStatePath(fixture.stateDir))).toEqual(activeBefore); + }); + + test("aborts rollback atomically when the registry default drifted", async () => { + const root = await temporaryRoot(); + const key = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + await installRegistryShim(root, { [key]: "/user-manifest.json" }); + const fixture = await lifecycleFixture([{ + key, + installedValue: "/manifest-v2.json", + previous: { existed: true, value: "/manifest-v1.json" }, + owned: true, + }]); + const activeBefore = await readFile(activeStatePath(fixture.stateDir)); + + await expect(rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" })) + .rejects.toThrow("rollback aborted because active resources drifted"); + expect(await readFile(fixture.wrapper, "utf8")).toBe("wrapper-v2"); + expect(await readFile(activeStatePath(fixture.stateDir))).toEqual(activeBefore); + expect(queryRegistryValue(key)).toEqual({ existed: true, value: "/user-manifest.json" }); + }); + + test("restores only exact default values and never recursively deletes registry keys", async () => { + const root = await temporaryRoot(); + const key = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + const registry = await installRegistryShim(root, { [key]: "/manifest-v2.json" }); + const fixture = await lifecycleFixture([{ + key, + installedValue: "/manifest-v2.json", + previous: { existed: true, value: "/manifest-v1.json" }, + owned: true, + }]); + expect(queryRegistryValue(key)).toEqual({ existed: true, value: "/manifest-v2.json" }); + + const rolledBack = await rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" }); + expect(rolledBack.changed).toContain(key); + expect(JSON.parse(await readFile(registry.statePath, "utf8"))[key]).toBe("/manifest-v1.json"); + await uninstall({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" }); + expect(JSON.parse(await readFile(registry.statePath, "utf8"))[key]).toBeUndefined(); + const commands = (await readFile(registry.logPath, "utf8")).trim().split("\n").map((line) => JSON.parse(line) as string[]); + for (const args of commands.filter((entry) => entry[0] === "delete")) expect(args).toContain("/ve"); + }); + + test("preserves an existing empty registry default value", async () => { + const root = await temporaryRoot(); + const key = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + const registry = await installRegistryShim(root, { [key]: "/manifest-v2.json" }); + const fixture = await lifecycleFixture([{ + key, + installedValue: "/manifest-v2.json", + previous: { existed: true, value: "" }, + owned: true, + }]); + + await rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" }); + expect(queryRegistryValue(key)).toEqual({ existed: true, value: "" }); + expect(JSON.parse(await readFile(registry.statePath, "utf8"))[key]).toBe(""); + }); + + test("registry fault injection restores registry, files, and active receipt", async () => { + const root = await temporaryRoot(); + const key = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + const registry = await installRegistryShim(root, { [key]: "/manifest-v2.json" }); + const fixture = await lifecycleFixture([{ + key, + installedValue: "/manifest-v2.json", + previous: { existed: true, value: "/manifest-v1.json" }, + owned: true, + }]); + const activeBefore = await readFile(activeStatePath(fixture.stateDir)); + + await expect(rollback({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "win32", + registryFailAfter: 1, + })).rejects.toThrow("Injected registry failure"); + + expect(JSON.parse(await readFile(registry.statePath, "utf8"))[key]).toBe("/manifest-v2.json"); + expect(await readFile(fixture.wrapper, "utf8")).toBe("wrapper-v2"); + expect(await readFile(activeStatePath(fixture.stateDir))).toEqual(activeBefore); + }); + + test("retains its recovery journal when immediate registry rollback cannot restore", async () => { + const root = await temporaryRoot(); + const key = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + const registry = await installRegistryShim(root, { [key]: "/manifest-v2.json" }); + const fixture = await lifecycleFixture([{ + key, + installedValue: "/manifest-v2.json", + previous: { existed: true, value: "/manifest-v1.json" }, + owned: true, + }]); + const activeBefore = await readFile(activeStatePath(fixture.stateDir)); + setRegistryFault("AGENTTAB_REGISTRY_FAIL_ADD_VALUE", "/manifest-v2.json"); + + let failure: unknown; + try { + await rollback({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "win32", + registryFailAfter: 1, + }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(TransactionConflictError); + expect((failure as TransactionConflictError).recoveryIncomplete).toBe(true); + expect(existsSync(transactionJournalPath(fixture.stateDir))).toBe(true); + expect(JSON.parse(await readFile(registry.statePath, "utf8"))[key]).toBe("/manifest-v1.json"); + expect(await readFile(fixture.wrapper, "utf8")).toBe("wrapper-v2"); + expect(await readFile(activeStatePath(fixture.stateDir))).toEqual(activeBefore); + + delete process.env.AGENTTAB_REGISTRY_FAIL_ADD_VALUE; + const retried = await rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" }); + expect(retried.activeVersion).toBe("2.0.0"); + expect(existsSync(transactionJournalPath(fixture.stateDir))).toBe(false); + }); + + test("does not report uninstall success when deleting a registry default fails", async () => { + const root = await temporaryRoot(); + const key = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + const registry = await installRegistryShim(root, { [key]: "/manifest-v2.json" }); + const fixture = await lifecycleFixture([{ + key, + installedValue: "/manifest-v2.json", + previous: { existed: false, value: null }, + owned: true, + }]); + setRegistryFault("AGENTTAB_REGISTRY_FAIL_DELETE_KEY", key); + + await expect(uninstall({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" })) + .rejects.toThrow("could not delete registry default"); + expect(JSON.parse(await readFile(registry.statePath, "utf8"))[key]).toBe("/manifest-v2.json"); + }); + + test("recovers a crash after registry activation before retrying rollback", async () => { + const root = await temporaryRoot(); + const key = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + const registry = await installRegistryShim(root, { [key]: "/manifest-v2.json" }); + const fixture = await lifecycleFixture([{ + key, + installedValue: "/manifest-v2.json", + previous: { existed: true, value: "/manifest-v1.json" }, + owned: true, + }]); + + await expect(rollback({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "win32", + transactionCrashAfterExternal: true, + })).rejects.toThrow("Injected transaction crash after external changes"); + expect(JSON.parse(await readFile(registry.statePath, "utf8"))[key]).toBe("/manifest-v1.json"); + + const retried = await rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" }); + expect(retried.activeVersion).toBe("2.0.0"); + expect(await readFile(fixture.wrapper, "utf8")).toBe("wrapper-v1"); + expect(JSON.parse(await readFile(registry.statePath, "utf8"))[key]).toBe("/manifest-v1.json"); + }); + + test("retains the journal when registry restoration during crash recovery fails", async () => { + const root = await temporaryRoot(); + const key = "HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\dev.agenttab.host"; + const registry = await installRegistryShim(root, { [key]: "/manifest-v2.json" }); + const fixture = await lifecycleFixture([{ + key, + installedValue: "/manifest-v2.json", + previous: { existed: true, value: "/manifest-v1.json" }, + owned: true, + }]); + await expect(rollback({ + stateDir: fixture.stateDir, + home: fixture.home, + platform: "win32", + transactionCrashAfterExternal: true, + })).rejects.toThrow("Injected transaction crash after external changes"); + setRegistryFault("AGENTTAB_REGISTRY_FAIL_ADD_VALUE", "/manifest-v2.json"); + + let failure: unknown; + try { + await rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" }); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(TransactionConflictError); + expect((failure as TransactionConflictError).recoveryIncomplete).toBe(true); + expect(existsSync(transactionJournalPath(fixture.stateDir))).toBe(true); + expect(JSON.parse(await readFile(registry.statePath, "utf8"))[key]).toBe("/manifest-v1.json"); + + delete process.env.AGENTTAB_REGISTRY_FAIL_ADD_VALUE; + await rollback({ stateDir: fixture.stateDir, home: fixture.home, platform: "win32" }); + expect(existsSync(transactionJournalPath(fixture.stateDir))).toBe(false); + }); +}); diff --git a/packages/installer/test/service.test.ts b/packages/installer/test/service.test.ts new file mode 100644 index 0000000..a0eccad --- /dev/null +++ b/packages/installer/test/service.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { daemonServiceDeactivationCommands, planDaemonService } from "../src/service"; + +describe("persistent daemon service plans", () => { + test("uses a restartable per-user systemd service without privilege escalation", () => { + const plan = planDaemonService({ + platform: "linux", + home: "/home/agent user", + hostPath: "/home/agent user/.agenttab/agenttab-host", + stateDir: "/home/agent user/.agenttab", + }); + expect(plan.manager).toBe("systemd"); + expect(plan.files[0].path).toBe("/home/agent user/.config/systemd/user/agenttab.service"); + expect(String(plan.files[0].content)).toContain('ExecStart="/home/agent user/.agenttab/agenttab-host" daemon'); + expect(String(plan.files[0].content)).toContain("Restart=on-failure"); + expect(plan.commands.map((command) => [command.executable, ...command.args])).toEqual([ + ["systemctl", "--user", "daemon-reload"], + ["systemctl", "--user", "enable", "--now", "agenttab.service"], + ["systemctl", "--user", "restart", "agenttab.service"], + ]); + }); + + test("uses a KeepAlive launch agent in the current GUI domain", () => { + const plan = planDaemonService({ + platform: "darwin", + home: "/Users/test", + hostPath: "/Users/test/Agent & Tab/agenttab-host", + stateDir: "/Users/test/.agenttab", + userId: 501, + }); + expect(plan.manager).toBe("launchd"); + expect(String(plan.files[0].content)).toContain("KeepAlive"); + expect(String(plan.files[0].content)).toContain("Agent & Tab"); + expect(plan.commands[0]).toEqual({ + executable: "launchctl", + args: ["bootout", "gui/501/dev.agenttab.daemon"], + ignoreFailure: true, + }); + expect(plan.commands.at(-1)?.args).toEqual(["kickstart", "-k", "gui/501/dev.agenttab.daemon"]); + }); + + test("uses a current-user limited scheduled task and restarts it on upgrade", () => { + const plan = planDaemonService({ + platform: "win32", + home: "C:\\Users\\test", + hostPath: "C:\\Users\\test\\AgentTab\\agenttab-host.exe", + stateDir: "C:\\Users\\test\\AgentTab", + }); + expect(plan.manager).toBe("scheduled_task"); + expect(plan.commands[0]).toMatchObject({ + executable: "schtasks.exe", + args: ["/End", "/TN", "AgentTab Daemon"], + ignoreFailure: true, + }); + expect(plan.commands[1].args).toContain("LIMITED"); + expect(plan.commands[1].args).toContain('"C:\\Users\\test\\AgentTab\\agenttab-host.exe" daemon'); + expect(plan.commands[2].args).toEqual(["/Run", "/TN", "AgentTab Daemon"]); + expect(daemonServiceDeactivationCommands(plan).map((command) => command.args[0])).toEqual(["/End", "/Delete"]); + }); + + test("stops and disables the user service during uninstall", () => { + const plan = planDaemonService({ + platform: "linux", + home: "/home/test", + hostPath: "/home/test/.agenttab/agenttab-host", + stateDir: "/home/test/.agenttab", + }); + expect(daemonServiceDeactivationCommands(plan)).toEqual([ + { executable: "systemctl", args: ["--user", "disable", "--now", "agenttab.service"] }, + { executable: "systemctl", args: ["--user", "daemon-reload"] }, + ]); + }); + + test("rejects service-definition injection", () => { + expect(() => planDaemonService({ + platform: "linux", + home: "/home/test", + hostPath: "/home/test/host\nExecStart=/tmp/evil", + stateDir: "/home/test/.agenttab", + })).toThrow("control characters"); + expect(() => planDaemonService({ + platform: "win32", + home: "C:\\Users\\test", + hostPath: 'C:\\bad" /RU SYSTEM', + stateDir: "C:\\Users\\test\\AgentTab", + })).toThrow("must not contain a quote"); + }); +}); diff --git a/packages/installer/test/transaction.test.ts b/packages/installer/test/transaction.test.ts index 24f7b6e..2526e42 100644 --- a/packages/installer/test/transaction.test.ts +++ b/packages/installer/test/transaction.test.ts @@ -1,8 +1,17 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmod, mkdtemp, open, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { existsSync, writeFileSync } from "node:fs"; +import { chmod, mkdir, mkdtemp, open, readFile, readdir, readlink, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyTransaction } from "../src/transaction"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { acquireInstallerStateLock, stateDirectoryLockPath, withInstallerStateLock } from "../src/state-lock"; +import { + applyTransaction, + expectationFor, + recoverPendingTransaction, + transactionJournalPath, + transactionPathIdentity, +} from "../src/transaction"; const temporaryRoots: string[] = []; @@ -68,4 +77,556 @@ describe("file transaction", () => { expect(result.unchanged).toEqual([]); expect((await stat(destination)).mode & 0o777).toBe(0o600); }); + + test("restores an exact deletion when a later transactional change fails", async () => { + const root = await mkdtemp(join(tmpdir(), "agenttab-transaction-delete-test-")); + temporaryRoots.push(root); + const removed = join(root, "removed.txt"); + const changed = join(root, "changed.txt"); + await writeFile(removed, "owned"); + await writeFile(changed, "before"); + + await expect(applyTransaction([ + { operation: "delete", path: removed, label: "owned file" }, + { path: changed, content: "after", label: "changed file" }, + ], { failAfter: 2 })).rejects.toThrow("Injected transaction failure"); + + expect(await readFile(removed, "utf8")).toBe("owned"); + expect(await readFile(changed, "utf8")).toBe("before"); + }); + + test("reports dry-run changes without writing them", async () => { + const root = await mkdtemp(join(tmpdir(), "agenttab-transaction-dry-run-test-")); + temporaryRoots.push(root); + const destination = join(root, "planned.txt"); + const result = await applyTransaction([ + { path: destination, content: "planned", label: "planned file", expectedBefore: { exists: false } }, + ], { dryRun: true }); + expect(result.changed).toEqual([destination]); + expect(existsSync(destination)).toBe(false); + }); + + test("rejects stale plans and rechecks expected-before immediately before mutation", async () => { + const root = await mkdtemp(join(tmpdir(), "agenttab-transaction-cas-test-")); + temporaryRoots.push(root); + const destination = join(root, "config.json"); + await writeFile(destination, "planned-before"); + const expectedBefore = expectationFor(Buffer.from("planned-before")); + await writeFile(destination, "edit-before-transaction"); + await expect(applyTransaction([ + { path: destination, content: "installed", label: "config", expectedBefore }, + ])).rejects.toThrow("changed before transaction preparation"); + expect(await readFile(destination, "utf8")).toBe("edit-before-transaction"); + + await writeFile(destination, "planned-before"); + await expect(applyTransaction([ + { path: destination, content: "installed", label: "config" }, + ], { + printDiff: () => writeFileSync(destination, "edit-after-preparation"), + })).rejects.toThrow("changed immediately before mutation"); + expect(await readFile(destination, "utf8")).toBe("edit-after-preparation"); + + await writeFile(destination, "planned-before"); + await expect(applyTransaction([ + { path: destination, content: "installed", label: "config" }, + ], { + journal: { stateDir: root, operation: "cas-race" }, + beforeMutation: async () => writeFile(destination, "edit-in-final-window"), + })).rejects.toThrow("changed immediately before mutation"); + expect(await readFile(destination, "utf8")).toBe("edit-in-final-window"); + expect(existsSync(transactionJournalPath(root))).toBe(true); + }); + + test("never claims a concurrent same-content file after a no-clobber publication loses", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-equal-race-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "new.txt"); + + await expect(applyTransaction([ + { path: destination, content: "same-bytes", label: "new file", expectedBefore: { exists: false } }, + ], { + journal: { stateDir, operation: "equal-race" }, + beforeMutation: async () => writeFile(destination, "same-bytes"), + })).rejects.toThrow("preserved a file created immediately before mutation"); + + expect(await readFile(destination, "utf8")).toBe("same-bytes"); + await expect(recoverPendingTransaction(stateDir)).rejects.toThrow("preserved resources changed"); + expect(await readFile(destination, "utf8")).toBe("same-bytes"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + }); + + test("relinks a raced value after a crash immediately following quarantine rename", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-quarantine-race-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "expected-before"); + + await expect(applyTransaction([ + { path: destination, content: "installed", label: "active file" }, + ], { + journal: { stateDir, operation: "quarantine-race" }, + beforeMutation: async () => writeFile(destination, "concurrent-edit"), + crashAfterQuarantineRename: true, + })).rejects.toThrow("crash after quarantine rename"); + expect(existsSync(destination)).toBe(false); + + await expect(recoverPendingTransaction(stateDir)).rejects.toThrow("preserved resources changed"); + expect(await readFile(destination, "utf8")).toBe("concurrent-edit"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + }); + + test("preserves concurrent edits instead of overwriting them during exception rollback", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-rollback-cas-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + await expect(applyTransaction([ + { path: destination, content: "installed", label: "active file" }, + ], { + journal: { stateDir, operation: "test" }, + afterApply: async () => { + await writeFile(destination, "concurrent-user-edit"); + throw new Error("readiness failed"); + }, + })).rejects.toThrow("preserved concurrent changes"); + expect(await readFile(destination, "utf8")).toBe("concurrent-user-edit"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + await expect(recoverPendingTransaction(stateDir)).rejects.toThrow("preserved resources changed"); + expect(await readFile(destination, "utf8")).toBe("concurrent-user-edit"); + }); + + test("recovers an interrupted durable transaction idempotently", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-recovery-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + await expect(applyTransaction([ + { path: destination, content: "installed", label: "active file" }, + ], { + journal: { stateDir, operation: "update" }, + crashAfter: 1, + })).rejects.toThrow("Injected transaction crash"); + expect(await readFile(destination, "utf8")).toBe("installed"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + + expect(await recoverPendingTransaction(stateDir)).toEqual({ recovered: true, operation: "update" }); + expect(await readFile(destination, "utf8")).toBe("before"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(false); + expect(await recoverPendingTransaction(stateDir)).toEqual({ recovered: false }); + }); + + test("recovers a crash after the atomic destination quarantine", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-quarantine-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + + await expect(applyTransaction([ + { path: destination, content: "after", label: "active file" }, + ], { + journal: { stateDir, operation: "update" }, + crashAfterQuarantine: true, + })).rejects.toThrow("crash after quarantine"); + expect(existsSync(destination)).toBe(false); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + + expect(await recoverPendingTransaction(stateDir)).toEqual({ recovered: true, operation: "update" }); + expect(await readFile(destination, "utf8")).toBe("before"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(false); + }); + + test("recovers a journaled crash after rollback displaced the installed live target", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-rollback-displacement-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + + await expect(applyTransaction([ + { path: destination, content: "installed", label: "active file" }, + ], { + journal: { stateDir, operation: "rollback-displacement" }, + failAfter: 1, + crashAfterRollbackRename: true, + })).rejects.toThrow("crash after rollback rename"); + expect(existsSync(destination)).toBe(false); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + + expect(await recoverPendingTransaction(stateDir)).toEqual({ recovered: true, operation: "rollback-displacement" }); + expect(await readFile(destination, "utf8")).toBe("before"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(false); + }); + + test("fails hard-link preflight and cleans setup artifacts before target mutation", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-link-preflight-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + + await expect(applyTransaction([ + { path: destination, content: "after", label: "active file" }, + ], { + journal: { stateDir, operation: "unsupported-filesystem" }, + failHardLinkPreflight: true, + })).rejects.toThrow("does not support required same-directory hard links"); + + expect(await readFile(destination, "utf8")).toBe("before"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(false); + expect((await readdir(stateDir)).filter((name) => name.includes(".agenttab-") || name.startsWith("transaction-intent"))).toEqual([]); + }); + + test("removes an intent whose publication cannot cross its durability barrier", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-intent-barrier-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + + await expect(applyTransaction([ + { path: destination, content: "after", label: "active file" }, + ], { + journal: { stateDir, operation: "intent-barrier" }, + afterIntentPublish: async () => { throw new Error("Injected intent directory barrier failure"); }, + })).rejects.toThrow("Injected intent directory barrier failure"); + + expect(await readFile(destination, "utf8")).toBe("before"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(false); + expect((await readdir(stateDir)).filter((name) => name.startsWith("transaction-intent"))).toEqual([]); + }); + + test("recovers a crash that leaves the journaled hard-link capability probe", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-link-probe-crash-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + + await expect(applyTransaction([ + { path: destination, content: "after", label: "active file" }, + ], { + journal: { stateDir, operation: "probe-crash" }, + crashAfterHardLinkProbe: true, + })).rejects.toThrow("crash after hard-link preflight publication"); + expect(await readFile(destination, "utf8")).toBe("before"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + + expect(await recoverPendingTransaction(stateDir)).toEqual({ recovered: true, operation: "probe-crash" }); + expect(await readFile(destination, "utf8")).toBe("before"); + expect((await readdir(stateDir)).filter((name) => name.includes(".agenttab-") || name.startsWith("transaction-intent"))).toEqual([]); + }); + + test("rejects symlink targets without replacing the link or its referent", async () => { + if (process.platform === "win32") return; + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-symlink-test-")); + temporaryRoots.push(stateDir); + const referent = join(stateDir, "referent.txt"); + const destination = join(stateDir, "config.json"); + await writeFile(referent, "user-owned"); + await symlink(referent, destination); + + await expect(applyTransaction([ + { path: destination, content: "installed", label: "config" }, + ], { journal: { stateDir, operation: "symlink" } })).rejects.toThrow("non-regular file"); + expect(await readlink(destination)).toBe(referent); + expect(await readFile(referent, "utf8")).toBe("user-owned"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(false); + }); + + test("never rolls back after the durable commit boundary is published", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-commit-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + + await expect(applyTransaction([ + { path: destination, content: "committed", label: "active file" }, + ], { + journal: { stateDir, operation: "update" }, + afterCommit: async () => { throw new Error("Injected cleanup failure"); }, + })).rejects.toThrow("committed but durable cleanup is pending"); + expect(await readFile(destination, "utf8")).toBe("committed"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + + expect(await recoverPendingTransaction(stateDir)).toEqual({ recovered: false, operation: "update" }); + expect(await readFile(destination, "utf8")).toBe("committed"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(false); + }); + + test("treats intent-absent marker-present cleanup interruption as committed", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-cleanup-boundary-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + + await expect(applyTransaction([ + { path: destination, content: "committed", label: "active file" }, + ], { + journal: { stateDir, operation: "cleanup-boundary" }, + afterIntentCleanup: async () => { throw new Error("Injected crash between cleanup barriers"); }, + })).rejects.toThrow("committed but durable cleanup is pending"); + expect(await readFile(destination, "utf8")).toBe("committed"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(false); + expect(existsSync(`${transactionJournalPath(stateDir)}.committed`)).toBe(true); + + expect(await recoverPendingTransaction(stateDir)).toEqual({ recovered: false }); + expect(await readFile(destination, "utf8")).toBe("committed"); + expect(existsSync(`${transactionJournalPath(stateDir)}.committed`)).toBe(false); + }); + + test("rolls back when commit publication cannot cross its durability barrier", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-commit-barrier-test-")); + temporaryRoots.push(stateDir); + const destination = join(stateDir, "active.txt"); + await writeFile(destination, "before"); + + await expect(applyTransaction([ + { path: destination, content: "not-committed", label: "active file" }, + ], { + journal: { stateDir, operation: "commit-barrier" }, + afterCommitPublish: async () => { throw new Error("Injected commit directory barrier failure"); }, + })).rejects.toThrow("Injected commit directory barrier failure"); + expect(await readFile(destination, "utf8")).toBe("before"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(false); + }); + + test("restores external after-state and the active pointer despite an unrelated recovery conflict", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-external-recovery-test-")); + temporaryRoots.push(stateDir); + const artifact = join(stateDir, "artifact.txt"); + const pointer = join(stateDir, "active-install.json"); + await writeFile(artifact, "artifact-before"); + await writeFile(pointer, "pointer-before"); + let registryValue = "registry-before"; + const external = { + kind: "fixture", + resource: "fixture-registry", + before: "registry-before", + after: "registry-after", + }; + + await expect(applyTransaction([ + { path: artifact, content: "artifact-after", label: "artifact" }, + { path: pointer, content: "pointer-after", label: "pointer", statePointer: true }, + ], { + journal: { stateDir, operation: "external-recovery", external: [external] }, + applyExternal: async () => { + registryValue = "registry-after"; + return async () => { registryValue = "registry-before"; }; + }, + crashAfterExternal: true, + })).rejects.toThrow("crash after external changes"); + await writeFile(artifact, "concurrent-user-edit"); + + await expect(recoverPendingTransaction(stateDir, { + fixture: { + async inspect() { return registryValue === "registry-after" ? "after" : "before"; }, + async restore() { registryValue = "registry-before"; }, + }, + })).rejects.toThrow("preserved resources changed"); + expect(registryValue).toBe("registry-before"); + expect(await readFile(pointer, "utf8")).toBe("pointer-before"); + expect(await readFile(artifact, "utf8")).toBe("concurrent-user-edit"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + }); + + test("attempts active-pointer recovery even when external restoration fails", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "agenttab-transaction-external-failure-test-")); + temporaryRoots.push(stateDir); + const pointer = join(stateDir, "active-install.json"); + await writeFile(pointer, "pointer-before"); + const external = { kind: "fixture", resource: "fixture-registry", before: "before", after: "after" }; + + await expect(applyTransaction([ + { path: pointer, content: "pointer-after", label: "pointer", statePointer: true }, + ], { + journal: { stateDir, operation: "external-failure", external: [external] }, + applyExternal: async () => async () => undefined, + crashAfterExternal: true, + })).rejects.toThrow("crash after external changes"); + + await expect(recoverPendingTransaction(stateDir, { + fixture: { + async inspect() { return "after"; }, + async restore() { throw new Error("injected external restore failure"); }, + }, + })).rejects.toThrow("fixture-registry"); + expect(await readFile(pointer, "utf8")).toBe("pointer-before"); + expect(existsSync(transactionJournalPath(stateDir))).toBe(true); + }); + + test("uses case-insensitive physical journal identities on Windows", () => { + expect(transactionPathIdentity("C:\\Users\\Alice\\AgentTab", "win32")) + .toBe(transactionPathIdentity("c:\\users\\ALICE\\agenttab", "win32")); + expect(transactionPathIdentity("/Case/Sensitive", "linux")) + .not.toBe(transactionPathIdentity("/case/sensitive", "linux")); + }); + + test("rejects live cross-process contention without disturbing the owner's claim", async () => { + const root = await mkdtemp(join(tmpdir(), "agenttab-state-lock-test-")); + temporaryRoots.push(root); + const stateDir = join(root, "state"); + const ready = join(root, "ready"); + const release = join(root, "release"); + const moduleUrl = pathToFileURL(fileURLToPath(new URL("../src/state-lock.ts", import.meta.url))).href; + const child = Bun.spawn([process.execPath, "-e", [ + `import { existsSync } from "node:fs";`, + `import { writeFile } from "node:fs/promises";`, + `import { withInstallerStateLock } from ${JSON.stringify(moduleUrl)};`, + `await withInstallerStateLock(${JSON.stringify(stateDir)}, "child", async () => {`, + ` await writeFile(${JSON.stringify(ready)}, "ready");`, + ` while (!existsSync(${JSON.stringify(release)})) await new Promise((resolve) => setTimeout(resolve, 10));`, + "});", + ].join("\n")], { stdout: "ignore", stderr: "pipe" }); + let childExited = false; + try { + for (let attempt = 0; attempt < 500 && !existsSync(ready); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(existsSync(ready)).toBe(true); + const lockDirectory = stateDirectoryLockPath(stateDir); + const childClaims = (await readdir(lockDirectory)).filter((name) => name.endsWith(".json")); + expect(childClaims).toHaveLength(1); + await expect(withInstallerStateLock(stateDir, "parent", async () => undefined)).rejects.toThrow("locked by child"); + expect((await readdir(lockDirectory)).filter((name) => name.endsWith(".json"))).toEqual(childClaims); + + await writeFile(release, "release"); + expect(await child.exited).toBe(0); + childExited = true; + await withInstallerStateLock(stateDir, "parent", async () => undefined); + expect((await readdir(lockDirectory)).filter((name) => name.endsWith(".json"))).toEqual([]); + } finally { + if (!childExited) { + await writeFile(release, "release").catch(() => undefined); + child.kill(); + await child.exited.catch(() => undefined); + } + } + }); + + test("reclaims a claim left by a SIGKILLed lock owner", async () => { + if (process.platform === "win32") return; + const root = await mkdtemp(join(tmpdir(), "agenttab-state-lock-sigkill-test-")); + temporaryRoots.push(root); + const stateDir = join(root, "state"); + const ready = join(root, "ready"); + const moduleUrl = pathToFileURL(fileURLToPath(new URL("../src/state-lock.ts", import.meta.url))).href; + const child = Bun.spawn([process.execPath, "-e", [ + `import { writeFile } from "node:fs/promises";`, + `import { withInstallerStateLock } from ${JSON.stringify(moduleUrl)};`, + `await withInstallerStateLock(${JSON.stringify(stateDir)}, "killed-child", async () => {`, + ` await writeFile(${JSON.stringify(ready)}, "ready");`, + " await new Promise(() => undefined);", + "});", + ].join("\n")], { stdout: "ignore", stderr: "pipe" }); + let childExited = false; + try { + for (let attempt = 0; attempt < 500 && !existsSync(ready); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(existsSync(ready)).toBe(true); + const lockDirectory = stateDirectoryLockPath(stateDir); + expect((await readdir(lockDirectory)).filter((name) => name.endsWith(".json"))).toHaveLength(1); + + child.kill("SIGKILL"); + await child.exited; + childExited = true; + await withInstallerStateLock(stateDir, "recovery", async () => undefined); + expect((await readdir(lockDirectory)).filter((name) => name.endsWith(".json"))).toEqual([]); + } finally { + if (!childExited) { + child.kill("SIGKILL"); + await child.exited.catch(() => undefined); + } + } + }); + + test("does not confuse a reused live PID with the original lock owner", async () => { + if (process.platform !== "linux") return; + const root = await mkdtemp(join(tmpdir(), "agenttab-state-lock-pid-reuse-test-")); + temporaryRoots.push(root); + const stateDir = join(root, "state"); + const directory = stateDirectoryLockPath(stateDir); + await mkdir(directory, { recursive: true }); + const selfStat = await readFile("/proc/self/stat", "utf8"); + const operatingSystemPid = Number(selfStat.slice(0, selfStat.indexOf(" "))); + await writeFile(join(directory, "stale.json"), JSON.stringify({ + token: "stale", + pid: process.pid, + osPid: operatingSystemPid, + endpoint: null, + processIdentity: "linux:different-boot:different-start", + operation: "stale-owner", + acquiredAt: new Date(0).toISOString(), + choosing: false, + ticket: 1, + })); + + await withInstallerStateLock(stateDir, "replacement", async () => undefined); + expect((await readdir(directory)).filter((name) => name.endsWith(".json"))).toEqual([]); + }); + + test("bounds legacy PID-only claims by the owner heartbeat", async () => { + if (process.platform !== "linux") return; + const root = await mkdtemp(join(tmpdir(), "agenttab-state-lock-heartbeat-test-")); + temporaryRoots.push(root); + const stateDir = join(root, "state"); + const directory = stateDirectoryLockPath(stateDir); + await mkdir(directory, { recursive: true }); + const selfStat = await readFile("/proc/self/stat", "utf8"); + const operatingSystemPid = Number(selfStat.slice(0, selfStat.indexOf(" "))); + const claimPath = join(directory, "legacy.json"); + await writeFile(claimPath, JSON.stringify({ + token: "legacy", + pid: process.pid, + osPid: operatingSystemPid, + endpoint: null, + processIdentity: null, + operation: "legacy-owner", + acquiredAt: new Date(0).toISOString(), + choosing: false, + ticket: 1, + })); + + await expect(withInstallerStateLock(stateDir, "fresh-contender", async () => undefined)) + .rejects.toThrow("locked by legacy-owner"); + + const stale = new Date(Date.now() - 60_000); + await utimes(claimPath, stale, stale); + await withInstallerStateLock(stateDir, "stale-recovery", async () => undefined); + expect((await readdir(directory)).filter((name) => name.endsWith(".json"))).toEqual([]); + }); + + test("allows release to be retried after its claim removal fails", async () => { + const root = await mkdtemp(join(tmpdir(), "agenttab-state-lock-release-test-")); + temporaryRoots.push(root); + const stateDir = join(root, "state"); + const lock = await acquireInstallerStateLock(stateDir, "release-test"); + const claimName = (await readdir(lock.path)).find((name) => name.endsWith(".json"))!; + const claimPath = join(lock.path, claimName); + await rm(claimPath); + await mkdir(claimPath); + + await expect(lock.release()).rejects.toThrow(); + await rm(claimPath, { recursive: true }); + await lock.release(); + await withInstallerStateLock(stateDir, "after-retry", async () => undefined); + }); + + test("canonicalizes nested missing state paths through symlinked ancestors", async () => { + if (process.platform === "win32") return; + const root = await mkdtemp(join(tmpdir(), "agenttab-state-lock-alias-test-")); + temporaryRoots.push(root); + const physical = join(root, "physical"); + const alias = join(root, "alias"); + await mkdir(physical); + await symlink(physical, alias); + const physicalState = join(physical, "missing", "nested", "state"); + const aliasState = join(alias, "missing", "nested", "state"); + expect(stateDirectoryLockPath(aliasState)).toBe(stateDirectoryLockPath(physicalState)); + + const lock = await acquireInstallerStateLock(aliasState, "alias-owner"); + try { + await expect(acquireInstallerStateLock(physicalState, "physical-contender")) + .rejects.toThrow("locked by alias-owner"); + } finally { + await lock.release(); + } + }); }); diff --git a/scripts/package_host_archive.py b/scripts/package_host_archive.py index 514395a..f9aace6 100755 --- a/scripts/package_host_archive.py +++ b/scripts/package_host_archive.py @@ -30,10 +30,11 @@ class ArchiveError(Exception): def parse_args(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Package one AgentTab host binary") + parser = argparse.ArgumentParser(description="Package the AgentTab daemon and native shim") parser.add_argument("--version", required=True, help="Exact semantic version without v") parser.add_argument("--target", required=True, choices=sorted(TARGETS)) parser.add_argument("--binary", required=True, type=Path, help="Built agenttab-host executable") + parser.add_argument("--shim", required=True, type=Path, help="Built agenttab-native executable") parser.add_argument("--out-dir", required=True, type=Path, help="Directory for the release asset") return parser.parse_args(argv) @@ -42,53 +43,67 @@ def asset_name(version: str, target: str) -> str: return f"agenttab-host-v{version}-{target}.{TARGETS[target]}" -def package_tar(output: Path, name: str, data: bytes) -> None: +def package_tar(output: Path, entries: list[tuple[str, bytes]]) -> None: with output.open("wb") as raw: with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: with tarfile.open(fileobj=compressed, mode="w", format=tarfile.GNU_FORMAT) as archive: - member = tarfile.TarInfo(name) - member.size = len(data) - member.mode = 0o755 - member.mtime = 0 - member.uid = 0 - member.gid = 0 - member.uname = "" - member.gname = "" - archive.addfile(member, io.BytesIO(data)) - - -def package_zip(output: Path, name: str, data: bytes) -> None: + for name, data in entries: + member = tarfile.TarInfo(name) + member.size = len(data) + member.mode = 0o755 + member.mtime = 0 + member.uid = 0 + member.gid = 0 + member.uname = "" + member.gname = "" + archive.addfile(member, io.BytesIO(data)) + + +def package_zip(output: Path, entries: list[tuple[str, bytes]]) -> None: with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: - member = zipfile.ZipInfo(name, date_time=ZIP_TIMESTAMP) - member.create_system = 3 - member.compress_type = zipfile.ZIP_DEFLATED - member.external_attr = 0o100755 << 16 - archive.writestr(member, data) - - -def package(version: str, target: str, binary: Path, out_dir: Path) -> dict[str, object]: + for name, data in entries: + member = zipfile.ZipInfo(name, date_time=ZIP_TIMESTAMP) + member.create_system = 3 + member.compress_type = zipfile.ZIP_DEFLATED + member.external_attr = 0o100755 << 16 + archive.writestr(member, data) + + +def package( + version: str, + target: str, + binary: Path, + shim: Path, + out_dir: Path, +) -> dict[str, object]: if SEMVER.fullmatch(version) is None: raise ArchiveError("--version must be an exact semantic version without build metadata") if target not in TARGETS: raise ArchiveError(f"unsupported host target: {target}") expected_binary = "agenttab-host.exe" if TARGETS[target] == "zip" else "agenttab-host" + expected_shim = "agenttab-native.exe" if TARGETS[target] == "zip" else "agenttab-native" if binary.name != expected_binary: raise ArchiveError(f"{target} requires binary name {expected_binary}") - try: - data = binary.read_bytes() - except OSError as exc: - raise ArchiveError(f"cannot read {binary}: {exc}") from exc - if not data: - raise ArchiveError(f"host binary is empty: {binary}") + if shim.name != expected_shim: + raise ArchiveError(f"{target} requires shim name {expected_shim}") + entries: list[tuple[str, bytes]] = [] + for path, name in ((binary, expected_binary), (shim, expected_shim)): + try: + data = path.read_bytes() + except OSError as exc: + raise ArchiveError(f"cannot read {path}: {exc}") from exc + if not data: + raise ArchiveError(f"packaged executable is empty: {path}") + entries.append((name, data)) out_dir.mkdir(parents=True, exist_ok=True) output = out_dir / asset_name(version, target) if output.exists(): raise ArchiveError(f"refusing to replace existing release asset: {output}") if TARGETS[target] == "zip": - package_zip(output, expected_binary, data) + package_zip(output, entries) else: - package_tar(output, expected_binary, data) + package_tar(output, entries) digest = sha256(output.read_bytes()).hexdigest() return { "path": output.as_posix(), @@ -102,7 +117,7 @@ def package(version: str, target: str, binary: Path, out_dir: Path) -> dict[str, def main(argv: list[str] | None = None) -> int: args = parse_args(argv) try: - metadata = package(args.version, args.target, args.binary, args.out_dir) + metadata = package(args.version, args.target, args.binary, args.shim, args.out_dir) except ArchiveError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 diff --git a/scripts/test_package_host_archive.py b/scripts/test_package_host_archive.py index 7573e6c..9b08db8 100644 --- a/scripts/test_package_host_archive.py +++ b/scripts/test_package_host_archive.py @@ -15,45 +15,58 @@ def test_archives_are_deterministic_and_preserve_the_exact_binary(self) -> None: root = Path(temporary) binary = root / "agenttab-host" binary.write_bytes(b"agenttab-host-fixture\n") + shim = root / "agenttab-native" + shim.write_bytes(b"agenttab-native-fixture\n") first = root / "first" second = root / "second" - first_metadata = package("2.0.0-rc.1", "aarch64-apple-darwin", binary, first) - second_metadata = package("2.0.0-rc.1", "aarch64-apple-darwin", binary, second) + first_metadata = package("2.0.0-rc.1", "aarch64-apple-darwin", binary, shim, first) + second_metadata = package("2.0.0-rc.1", "aarch64-apple-darwin", binary, shim, second) first_archive = first / str(first_metadata["name"]) second_archive = second / str(second_metadata["name"]) self.assertEqual(first_archive.read_bytes(), second_archive.read_bytes()) with tarfile.open(first_archive, "r:gz") as archive: members = archive.getmembers() - self.assertEqual([member.name for member in members], ["agenttab-host"]) - self.assertEqual(members[0].mode, 0o755) - extracted = archive.extractfile(members[0]) - self.assertIsNotNone(extracted) - self.assertEqual(extracted.read(), binary.read_bytes()) + self.assertEqual( + [member.name for member in members], + ["agenttab-host", "agenttab-native"], + ) + self.assertTrue(all(member.mode == 0o755 for member in members)) + self.assertEqual(archive.extractfile(members[0]).read(), binary.read_bytes()) + self.assertEqual(archive.extractfile(members[1]).read(), shim.read_bytes()) def test_windows_archive_uses_the_installer_filename(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) binary = root / "agenttab-host.exe" binary.write_bytes(b"signed-windows-fixture\n") - metadata = package("2.0.0", "x86_64-pc-windows-msvc", binary, root / "release") + shim = root / "agenttab-native.exe" + shim.write_bytes(b"signed-windows-shim-fixture\n") + metadata = package( + "2.0.0", "x86_64-pc-windows-msvc", binary, shim, root / "release" + ) with zipfile.ZipFile(root / "release" / str(metadata["name"])) as archive: - self.assertEqual(archive.namelist(), ["agenttab-host.exe"]) + self.assertEqual( + archive.namelist(), ["agenttab-host.exe", "agenttab-native.exe"] + ) self.assertEqual(archive.read("agenttab-host.exe"), binary.read_bytes()) + self.assertEqual(archive.read("agenttab-native.exe"), shim.read_bytes()) def test_refuses_empty_or_preexisting_release_assets(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) binary = root / "agenttab-host" + shim = root / "agenttab-native" + shim.write_bytes(b"shim") binary.write_bytes(b"") with self.assertRaisesRegex(ArchiveError, "empty"): - package("2.0.0", "x86_64-unknown-linux-gnu", binary, root / "release") + package("2.0.0", "x86_64-unknown-linux-gnu", binary, shim, root / "release") binary.write_bytes(b"host") - package("2.0.0", "x86_64-unknown-linux-gnu", binary, root / "release") + package("2.0.0", "x86_64-unknown-linux-gnu", binary, shim, root / "release") with self.assertRaisesRegex(ArchiveError, "refusing to replace"): - package("2.0.0", "x86_64-unknown-linux-gnu", binary, root / "release") + package("2.0.0", "x86_64-unknown-linux-gnu", binary, shim, root / "release") if __name__ == "__main__": diff --git a/scripts/test_verify_release_archives.py b/scripts/test_verify_release_archives.py new file mode 100644 index 0000000..64dfa6c --- /dev/null +++ b/scripts/test_verify_release_archives.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import io +import tarfile +import tempfile +import unittest +from pathlib import Path + +from scripts.package_host_archive import package +from scripts.verify_release_archives import SmokeError, archive_binaries + + +class ReleaseArchiveSmokeTests(unittest.TestCase): + def test_reads_the_exact_daemon_and_shim_payloads(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + host = root / "agenttab-host" + shim = root / "agenttab-native" + host.write_bytes(b"host-fixture") + shim.write_bytes(b"shim-fixture") + metadata = package( + "2.0.0", "x86_64-unknown-linux-gnu", host, shim, root / "release" + ) + payloads = archive_binaries( + root / "release" / str(metadata["name"]), + "x86_64-unknown-linux-gnu", + ) + self.assertEqual( + payloads, + {"agenttab-host": b"host-fixture", "agenttab-native": b"shim-fixture"}, + ) + + def test_rejects_a_legacy_single_binary_archive(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + archive_path = Path(temporary) / "legacy.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + member = tarfile.TarInfo("agenttab-host") + member.size = 4 + archive.addfile(member, io.BytesIO(b"host")) + with self.assertRaisesRegex(SmokeError, "agenttab-native"): + archive_binaries(archive_path, "x86_64-unknown-linux-gnu") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_release_archives.py b/scripts/verify_release_archives.py index f7d9942..da47db0 100755 --- a/scripts/verify_release_archives.py +++ b/scripts/verify_release_archives.py @@ -47,31 +47,45 @@ def run(command: list[str], *, cwd: Path, env: dict[str, str]) -> None: raise SmokeError(f"command failed ({' '.join(command[:2])}): {detail[-1000:]}") -def host_binary_name(target: str) -> str: - return "agenttab-host.exe" if target.endswith("windows-msvc") else "agenttab-host" +def executable_names(target: str) -> tuple[str, str]: + suffix = ".exe" if target.endswith("windows-msvc") else "" + return f"agenttab-host{suffix}", f"agenttab-native{suffix}" -def archive_binary(archive: Path, target: str) -> bytes: - expected = host_binary_name(target) +def archive_binaries(archive: Path, target: str) -> dict[str, bytes]: + expected = executable_names(target) try: if target.endswith("windows-msvc"): with zipfile.ZipFile(archive) as contents: members = contents.infolist() - if len(members) != 1 or members[0].filename != expected or members[0].is_dir(): - raise SmokeError(f"{archive.name} must contain exactly {expected}") - if members[0].filename.startswith("/") or ".." in Path(members[0].filename).parts: - raise SmokeError(f"{archive.name} has an unsafe member path") - return contents.read(members[0]) + if len(members) != 2 or tuple(member.filename for member in members) != expected: + raise SmokeError(f"{archive.name} must contain exactly {' and '.join(expected)}") + if any( + member.is_dir() + or member.filename.startswith("/") + or ".." in Path(member.filename).parts + for member in members + ): + raise SmokeError(f"{archive.name} has an unsafe or non-file member") + return {member.filename: contents.read(member) for member in members} with tarfile.open(archive, "r:gz") as contents: members = contents.getmembers() - if len(members) != 1 or members[0].name != expected or not members[0].isfile(): - raise SmokeError(f"{archive.name} must contain exactly {expected}") - if members[0].name.startswith("/") or ".." in Path(members[0].name).parts: - raise SmokeError(f"{archive.name} has an unsafe member path") - extracted = contents.extractfile(members[0]) - if extracted is None: - raise SmokeError(f"{archive.name} does not contain an executable payload") - return extracted.read() + if len(members) != 2 or tuple(member.name for member in members) != expected: + raise SmokeError(f"{archive.name} must contain exactly {' and '.join(expected)}") + if any( + not member.isfile() + or member.name.startswith("/") + or ".." in Path(member.name).parts + for member in members + ): + raise SmokeError(f"{archive.name} has an unsafe or non-file member") + payloads: dict[str, bytes] = {} + for member in members: + extracted = contents.extractfile(member) + if extracted is None: + raise SmokeError(f"{archive.name} does not contain {member.name}") + payloads[member.name] = extracted.read() + return payloads except (OSError, tarfile.TarError, zipfile.BadZipFile) as exc: raise SmokeError(f"cannot read host archive {archive}: {exc}") from exc @@ -98,15 +112,17 @@ def current_target() -> str | None: def smoke_host(archive: Path, target: str, root: Path) -> None: if not archive.is_file(): raise SmokeError(f"missing host archive: {archive}") - payload = archive_binary(archive, target) - if not payload: - raise SmokeError(f"host archive is empty: {archive}") + payloads = archive_binaries(archive, target) + if any(not payload for payload in payloads.values()): + raise SmokeError(f"host archive contains an empty executable: {archive}") if current_target() != target: return root.mkdir(parents=True, exist_ok=True) - binary = root / host_binary_name(target) - binary.write_bytes(payload) - binary.chmod(0o755) + for name, payload in payloads.items(): + binary = root / name + binary.write_bytes(payload) + binary.chmod(0o755) + binary = root / executable_names(target)[0] home = root / "home" home.mkdir() env = os.environ.copy()