mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-08-04 13:16:06 +00:00
* docs: correct the windows sub-phase facts research disproved Sixteen replacements, no annotations. The document is the product's source of truth and now reads true. The COM bootstrap is split by consumer. CoInitializeEx is right for the CLI and wrong for the cdylib, which fails RPC_E_CHANGED_MODE against any host thread already in an STA and whose per-thread balance can never be released from a Drop on another thread; the library path uses CoIncrementMTAUsage, whose cookie is thread-agnostic. MTA is recorded as a requirement rather than a preference, because the single-threaded apartment can prevent handler removal outright. Two things the sub-phase asserted were not reachable. Its private-file seam named a boundary that does not exist: every write site is in core with no adapter handle, and core may not depend on the platform crate. The document now states that constraint and leaves the mechanism to the plan. Its CI paragraph claimed all runners enforce clippy, isolation, and the size cap; only the macOS job does, and the other two lanes each run a single lib invocation. The event invariant said handlers are drained on the dedicated MTA thread. They are not: delivery arrives on several UIA-owned threads at once, the registering worker among them and the main thread never, so handler state has to be safe for concurrent delivery. Runner registration gains the hardening a public repository requires, and gains the measurement it already owed the ledger: it closes the deferred RDP row rather than merely documenting session isolation. The DPI bootstrap records that awareness cannot be verified by read-back, since the query API has no enumerant for the value being set. The ledger's hunk index moves with them, 37 to 40. Hunks backed by research rather than by a probe are labelled as such, so an external citation cannot pass itself off as a measurement. * docs: move the windows runner registration to the sub-phase that needs it Sub-phase 2.1 was to register a self-hosted interactive Windows runner that nothing in 2.1 through 2.11 uses. On a public repository that is a persistently registered runner sitting idle behind ten sub-phases, and GitHub's own guidance is that self-hosted runners should almost never be used for public repositories at all. Registration moves to 2.12, the first sub-phase whose gate needs a real desktop, and it takes the hardening requirements with it: dispatch-only triggering, a written fork-PR approval policy, and an explicit ephemeral-versus-persistent decision. The deferred RDP row moves with it. Its closure was never documentation -- it is a measurement that only exists once a second, non-console session does, so 2.12 now owns closing it rather than 2.1 owning a promise it could not keep. Two probe scripts hardcoded the old closure sub-phase in text they emit. The scripts were corrected and their probes re-run, so the captures were regenerated rather than edited by hand. * docs: plan the windows toolchain, ci, and com bootstrap sub-phase Eight units covering the CI lane extension, Win32 bindings in the platform crate, the COM and DPI bootstrap, the adapter session, the permission probe, and a private-file layer rebuilt from scratch against sub-phase 2.0's measured evidence. Three reviewers found what the first draft got wrong. It installed both the apartment and the private-file seam from ad_init, which the FFI header labels optional and whose own doc promises it initialises nothing -- so any host that skipped it would have run every private write through the unhardened default with nothing to distinguish the two states. The install moves to the site the macOS arm already uses, where a consumer cannot skip it. It also planned a layer that validates security state it never authors. The deleted implementation at least built an owner-only protected descriptor at creation; the rebuild now does too, and rejects reparse points per component the way the unix path rejects symlinks. The ACE validation order was still wrong in the same shape as the defect it exists to prevent: checking the type before forming the SID pointer still dereferences the header at an offset nothing has bounds-checked. The order is now extent-first, with the ACE count treated as advisory rather than trusted. The seam routes at core's five private-file primitives rather than at a list of call sites, which covers the lock and liveness paths a list had missed and needs no call-site edits at all. * docs: cut acl validation from the windows private-file design Reverses the ancestor-walk decision on measurement. A plain leaf under the user profile already inherits SYSTEM, Administrators and the user with no Users entry, all inherited and none explicit, so the walk would verify a property Windows already guarantees. Unix has to author its permission because the default there is world-readable; Windows does not, because the default is already private. The only principal the walk would additionally exclude is another administrator, who holds SeTakeOwnershipPrivilege and can seize any object whatever its DACL. Building a chain-walking validator against someone who can bypass it costs more than it buys, and the cost is specific: parsing ACEs is what the previous layer did, and its AceSize handling is the defect that took the layer down. Removing the parser removes the bug class rather than fixing it, so no successor module exists and a test asserts the ACE symbols never come back. What survives is the control with real unix parity and a real attack behind it: per-component reparse-point rejection. That is an integrity control, not a confidentiality one -- a junction planted on the path redirects where the product writes, which no permission on the intended destination prevents. Cross-admin isolation is now answered rather than open: out of scope, for the same reason macOS never raised the question. * test: resolve the test binary per platform via CARGO_BIN_EXE * feat: add target-gated windows-sys bindings to the windows crate * feat: route private-file primitives through a core-defined platform seam * feat: establish com apartment and dpi awareness at unskippable call sites * feat: report windows permissions truthfully from a host-independent seam * ci: extend the windows lane to the full package surface * feat: give the windows adapter a session that owns apartment lifetime * feat: rebuild windows private-file hardening behind the core seam * test: pin the inherited-acl assumption module-free on a real profile leaf * feat: install the windows private-file seam at both unskippable consumers * fix: scope the temp lease per write and fold duplicated windows helpers * fix: route windows session and trace directory creation through the private-file seam * fix: scope the com bootstrap to adapter commands and harden review-flagged tests * test: cover the owner, locality, replace-acl, and sweep-race branches of the windows hardening * fix: pin the private-file directory chain to close the junction-swap window * docs: settle the user-output seam exclusion and pin it with a test * refactor: split the trace file plumbing and ffi adapter tests into sibling modules
561 lines
24 KiB
YAML
561 lines
24 KiB
YAML
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches: [main, master]
|
||
pull_request:
|
||
workflow_dispatch:
|
||
|
||
# Cancel in-progress runs for the same ref so stale PR builds don't burn minutes.
|
||
concurrency:
|
||
group: ci-${{ github.ref }}
|
||
cancel-in-progress: true
|
||
|
||
# Deny all permissions at workflow level; each job declares only what it needs.
|
||
permissions: {}
|
||
|
||
env:
|
||
CARGO_TERM_COLOR: always
|
||
RUST_BACKTRACE: 1
|
||
|
||
jobs:
|
||
fmt:
|
||
name: Format
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 10
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
- run: rustup component add rustfmt
|
||
- run: cargo fmt --all -- --check
|
||
- name: Check E2E scripts without running native automation
|
||
run: |
|
||
scripts/check-bash3-compat.sh
|
||
shellcheck -x -e SC2034,SC2154 tests/e2e/run.sh tests/e2e/electron-live.sh tests/e2e/permission-contract.sh tests/e2e/guard-command.sh scripts/*.sh .githooks/pre-commit
|
||
PYTHONPYCACHEPREFIX=/tmp/agent-desktop-pycache python3 -m py_compile tests/e2e/*.py
|
||
PYTHONPYCACHEPREFIX=/tmp/agent-desktop-pycache python3 -m unittest discover -s tests/e2e -p 'test_*.py'
|
||
- name: Validate GitHub Actions workflows
|
||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.11
|
||
|
||
msrv:
|
||
name: Rust 1.89 MSRV
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 20
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
- run: rustup toolchain install 1.89.0 --profile minimal
|
||
- run: cargo +1.89.0 check --locked -p agent-desktop-core -p agent-desktop-linux -p agent-desktop --all-targets
|
||
|
||
platform-check:
|
||
name: Native check (${{ matrix.platform }})
|
||
runs-on: ${{ matrix.os }}
|
||
timeout-minutes: 25
|
||
permissions:
|
||
contents: read
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
include:
|
||
- platform: Linux
|
||
os: ubuntu-latest
|
||
package: agent-desktop-linux
|
||
- platform: Windows
|
||
os: windows-latest
|
||
package: agent-desktop-windows
|
||
- platform: macOS
|
||
os: macos-latest
|
||
package: agent-desktop-macos
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
- name: Install pinned Rust toolchain
|
||
run: rustup show
|
||
- name: Check native adapter and binary
|
||
run: cargo check --locked -p agent-desktop-core -p ${{ matrix.package }} -p agent-desktop --all-targets
|
||
|
||
test:
|
||
name: Test
|
||
runs-on: macos-latest
|
||
timeout-minutes: 30
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
- name: Install Rust toolchain
|
||
run: |
|
||
rustup show
|
||
rustup component add clippy
|
||
|
||
- name: Verify macOS Bash 3.2 compatibility
|
||
run: /bin/bash scripts/check-bash3-compat.sh
|
||
|
||
- name: Cache cargo registry
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
~/.cargo/registry/index/
|
||
~/.cargo/registry/cache/
|
||
~/.cargo/git/db/
|
||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||
restore-keys: ${{ runner.os }}-cargo-
|
||
|
||
- name: Cache build artifacts
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: target/
|
||
key: ${{ runner.os }}-build-ci-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}-${{ hashFiles('crates/**/*.rs', 'src/**/*.rs', 'tests/**/*.rs') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-build-ci-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}-
|
||
${{ runner.os }}-build-ci-
|
||
|
||
- name: Check dependency isolation
|
||
run: |
|
||
TREE=$(cargo tree --locked -p agent-desktop-core --edges normal,build)
|
||
if printf '%s\n' "$TREE" | grep -E 'agent-desktop-(macos|windows|linux)'; then
|
||
echo "FAIL: core crate depends on platform crates"
|
||
exit 1
|
||
fi
|
||
echo "OK: core crate has no platform dependencies"
|
||
|
||
- name: Check release metadata consistency
|
||
run: scripts/check-release-consistency.sh
|
||
|
||
- name: Enforce Rust source rules
|
||
run: scripts/check-rust-file-size.sh
|
||
|
||
- name: Clippy
|
||
run: cargo clippy --locked -p agent-desktop-core -p agent-desktop-macos -p agent-desktop -p agent-desktop-ffi --all-targets -- -D warnings
|
||
|
||
- name: Core and macOS unit tests
|
||
run: scripts/cargo-test-isolated-home.sh test --locked -p agent-desktop-core -p agent-desktop-macos --lib
|
||
|
||
- name: Locator benchmark adapter contract
|
||
run: scripts/cargo-test-isolated-home.sh test --locked -p agent-desktop-core --example locator_benchmark
|
||
|
||
- name: Deterministic permission acceptance contract
|
||
run: bash tests/e2e/permission-contract.sh
|
||
|
||
- name: Binary command tests
|
||
run: scripts/cargo-test-isolated-home.sh test --locked -p agent-desktop
|
||
|
||
# The FFI crate ships integration harnesses under crates/ffi/tests/ that
|
||
# exercise the C-ABI from outside the crate (raw extern "C" decls, enum
|
||
# fuzzing, out-param zeroing, last-error lifetimes). The workspace --lib
|
||
# run skips them, so wire them in explicitly so regressions fail PR CI.
|
||
- name: FFI integration tests
|
||
run: scripts/cargo-test-isolated-home.sh test --locked -p agent-desktop-ffi --tests
|
||
|
||
- name: Build stripped release binary
|
||
run: |
|
||
cargo build --locked --release -p agent-desktop
|
||
cargo build --locked --release -p agent-desktop-macos --bin agent-desktop-macos-helper
|
||
|
||
- name: Verify shipped binary version flag
|
||
run: |
|
||
PACKAGE_ID=$(cargo pkgid -p agent-desktop)
|
||
EXPECTED_VERSION=${PACKAGE_ID##*@}
|
||
EXPECTED_OUTPUT="agent-desktop $EXPECTED_VERSION"
|
||
ACTUAL_OUTPUT=$(target/release/agent-desktop --version)
|
||
if [ "$ACTUAL_OUTPUT" != "$EXPECTED_OUTPUT" ]; then
|
||
echo "FAIL: expected '$EXPECTED_OUTPUT', got '$ACTUAL_OUTPUT'"
|
||
exit 1
|
||
fi
|
||
|
||
- name: Check shipped binary size
|
||
run: |
|
||
SIZE=$(stat -f%z target/release/agent-desktop)
|
||
HELPER_SIZE=$(stat -f%z target/release/agent-desktop-macos-helper)
|
||
LIMIT=$((15 * 1024 * 1024))
|
||
echo "Binary size: $(du -sh target/release/agent-desktop | cut -f1)"
|
||
echo "Helper size: $(du -sh target/release/agent-desktop-macos-helper | cut -f1)"
|
||
if [ "$SIZE" -gt "$LIMIT" ] || [ "$HELPER_SIZE" -gt "$LIMIT" ]; then
|
||
echo "FAIL: shipped executable exceeds 15MB limit (binary=${SIZE}, helper=${HELPER_SIZE} bytes)"
|
||
exit 1
|
||
fi
|
||
echo "OK: shipped executables are within the 15MB per-file limit"
|
||
|
||
- name: FFI cdylib build (release-ffi profile)
|
||
run: cargo build --locked --profile release-ffi -p agent-desktop-ffi
|
||
|
||
- name: FFI dylib-adjacent helper discovery smoke
|
||
run: scripts/ci-ffi-helper-discovery-smoke.sh
|
||
|
||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||
with:
|
||
node-version: '24'
|
||
|
||
- name: NPM package contents
|
||
run: |
|
||
node --test tests/npm/*.test.js
|
||
node scripts/check-npm-package.js
|
||
|
||
- name: NPM wrapper smoke
|
||
run: scripts/ci-npm-wrapper-smoke.sh
|
||
|
||
test-linux:
|
||
name: Test (Linux)
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 20
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
- name: Install Rust toolchain
|
||
run: rustup show
|
||
|
||
- name: Cache cargo registry
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
~/.cargo/registry/index/
|
||
~/.cargo/registry/cache/
|
||
~/.cargo/git/db/
|
||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||
restore-keys: ${{ runner.os }}-cargo-
|
||
|
||
- name: Core and Linux unit tests
|
||
run: scripts/cargo-test-isolated-home.sh test --locked -p agent-desktop-core -p agent-desktop-linux --lib
|
||
|
||
test-windows:
|
||
name: Test (Windows)
|
||
runs-on: windows-latest
|
||
timeout-minutes: 45
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
- name: Install Rust toolchain
|
||
run: rustup show
|
||
|
||
# Core resolves its data dir via HOME then USERPROFILE (crates/core/src/refs.rs
|
||
# fn home_dir), and env assigned inside one run: block does not survive into
|
||
# the next step, so the isolation is hoisted here and exported through
|
||
# GITHUB_ENV for every later step. CARGO_HOME and RUSTUP_HOME must be resolved
|
||
# from the real USERPROFILE first (rustup/cargo default them to
|
||
# %USERPROFILE%\.rustup and %USERPROFILE%\.cargo) so repointing USERPROFILE at
|
||
# the empty test home doesn't strand the installed toolchain.
|
||
# ORIGINAL_USERPROFILE feeds the trailing profile-isolation guard.
|
||
- name: Isolate HOME for the whole lane
|
||
shell: pwsh
|
||
run: |
|
||
$cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $env:USERPROFILE '.cargo' }
|
||
$rustupHome = if ($env:RUSTUP_HOME) { $env:RUSTUP_HOME } else { Join-Path $env:USERPROFILE '.rustup' }
|
||
$testHome = Join-Path $env:RUNNER_TEMP ("agent-desktop-test-home-" + [guid]::NewGuid())
|
||
New-Item -ItemType Directory -Path $testHome | Out-Null
|
||
Add-Content -Path $env:GITHUB_ENV -Value "ORIGINAL_USERPROFILE=$env:USERPROFILE"
|
||
Add-Content -Path $env:GITHUB_ENV -Value "CARGO_HOME=$cargoHome"
|
||
Add-Content -Path $env:GITHUB_ENV -Value "RUSTUP_HOME=$rustupHome"
|
||
Add-Content -Path $env:GITHUB_ENV -Value "HOME=$testHome"
|
||
Add-Content -Path $env:GITHUB_ENV -Value "USERPROFILE=$testHome"
|
||
|
||
# HOME and USERPROFILE now point into RUNNER_TEMP for every remaining step
|
||
# (including the post-job cache save), so ~ would miss the real registry; the
|
||
# cache paths must go through the hoisted CARGO_HOME instead.
|
||
- name: Cache cargo registry
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
${{ env.CARGO_HOME }}/registry/index/
|
||
${{ env.CARGO_HOME }}/registry/cache/
|
||
${{ env.CARGO_HOME }}/git/db/
|
||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||
restore-keys: ${{ runner.os }}-cargo-
|
||
|
||
- name: Cache build artifacts
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: target/
|
||
key: ${{ runner.os }}-build-ci-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}-${{ hashFiles('crates/**/*.rs', 'src/**/*.rs', 'tests/**/*.rs') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-build-ci-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}-
|
||
${{ runner.os }}-build-ci-
|
||
|
||
# Widened relative to the macOS-lane check: dev edges are included (a Win32
|
||
# binding crate hiding in dev-dependencies otherwise passes clean) and the
|
||
# resolution is additionally pinned to the MSVC target.
|
||
- name: Check dependency isolation
|
||
shell: pwsh
|
||
run: |
|
||
$pattern = 'agent-desktop-(macos|windows|linux)|\bwindows-sys\b|\bwindows\b|\bwinapi\b'
|
||
foreach ($target in @('', 'x86_64-pc-windows-msvc')) {
|
||
$arguments = @('tree', '--locked', '-p', 'agent-desktop-core', '--edges', 'normal,build,dev')
|
||
if ($target) { $arguments += @('--target', $target) }
|
||
$tree = cargo @arguments
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
$violations = @($tree | Select-String -Pattern $pattern)
|
||
if ($violations.Count -gt 0) {
|
||
$violations | ForEach-Object { Write-Host $_.Line }
|
||
Write-Host "FAIL: core crate pulls platform or Win32 binding crates (target: '$target')"
|
||
exit 1
|
||
}
|
||
}
|
||
Write-Host 'OK: core crate has no platform dependencies on normal, build, or dev edges'
|
||
|
||
# The dependency check cannot see hand-written externs, so reject the raw
|
||
# Win32 surface at source level. The allowlist is the pair of portable no-op
|
||
# directory-sync shims in private_file.rs; the count is pinned so a third
|
||
# windows conditional cannot ride in on the allowlisted file either.
|
||
- name: Check core source for Win32 leakage
|
||
shell: pwsh
|
||
run: |
|
||
$sources = Get-ChildItem -Path crates/core/src -Recurse -Filter *.rs
|
||
$forbidden = @($sources | Select-String -Pattern 'extern "system|std::os::windows|\bwinapi\b|\bwindows_sys\b')
|
||
if ($forbidden.Count -gt 0) {
|
||
$forbidden | ForEach-Object { Write-Host "$($_.Path):$($_.LineNumber): $($_.Line.Trim())" }
|
||
Write-Host 'FAIL: core source declares a raw Win32 surface'
|
||
exit 1
|
||
}
|
||
$allowed = (Resolve-Path 'crates/core/src/private_file.rs').Path
|
||
$cfgMatches = @($sources | Select-String -Pattern 'cfg\(windows\)|cfg\(target_os = "windows"\)' -AllMatches)
|
||
$outside = @($cfgMatches | Where-Object { $_.Path -ne $allowed })
|
||
if ($outside.Count -gt 0) {
|
||
$outside | ForEach-Object { Write-Host "$($_.Path):$($_.LineNumber): $($_.Line.Trim())" }
|
||
Write-Host 'FAIL: windows cfg outside the private_file.rs allowlist'
|
||
exit 1
|
||
}
|
||
$shimCount = [int]($cfgMatches | ForEach-Object { $_.Matches.Count } | Measure-Object -Sum).Sum
|
||
if ($shimCount -ne 2) {
|
||
Write-Host "FAIL: expected exactly 2 windows cfg shims in private_file.rs, found $shimCount"
|
||
exit 1
|
||
}
|
||
Write-Host 'OK: core source keeps windows conditionals to the 2 allowlisted shims'
|
||
|
||
- name: Clippy
|
||
run: cargo clippy --locked -p agent-desktop-core -p agent-desktop-windows -p agent-desktop -p agent-desktop-ffi --all-targets -- -D warnings
|
||
|
||
- name: Core and Windows unit tests
|
||
run: cargo test --locked -p agent-desktop-core -p agent-desktop-windows --lib
|
||
|
||
- name: Binary command tests
|
||
run: cargo test --locked -p agent-desktop
|
||
|
||
# The FFI crate ships integration harnesses under crates/ffi/tests/ that a
|
||
# --lib run skips, and the Windows-gated reachability coverage landing there
|
||
# must execute on this runner, so wire them in explicitly.
|
||
- name: FFI integration tests
|
||
run: cargo test --locked -p agent-desktop-ffi --tests
|
||
|
||
- name: Build stripped release binary
|
||
run: cargo build --locked --release -p agent-desktop
|
||
|
||
# The macOS lane's stat -f%z is BSD-only and fails under Git Bash, so the
|
||
# size gate is native pwsh. There is no helper binary on Windows — the cap
|
||
# applies to the one shipped executable.
|
||
- name: Check shipped binary size
|
||
shell: pwsh
|
||
run: |
|
||
$binary = Get-Item target/release/agent-desktop.exe
|
||
$limit = 15MB
|
||
Write-Host "Binary size: $($binary.Length) bytes"
|
||
if ($binary.Length -gt $limit) {
|
||
Write-Host "FAIL: shipped executable exceeds 15MB limit (binary=$($binary.Length) bytes)"
|
||
exit 1
|
||
}
|
||
Write-Host 'OK: shipped executable is within the 15MB per-file limit'
|
||
|
||
# The isolation scenario this lane must prove: a test that writes a private
|
||
# artifact observes it under RUNNER_TEMP, never the runner profile.
|
||
- name: Guard profile isolation
|
||
shell: pwsh
|
||
run: |
|
||
if (-not $env:HOME -or -not $env:HOME.StartsWith($env:RUNNER_TEMP, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||
Write-Host "FAIL: HOME is not under RUNNER_TEMP: $env:HOME"
|
||
exit 1
|
||
}
|
||
if (-not $env:USERPROFILE -or -not $env:USERPROFILE.StartsWith($env:RUNNER_TEMP, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||
Write-Host "FAIL: USERPROFILE is not under RUNNER_TEMP: $env:USERPROFILE"
|
||
exit 1
|
||
}
|
||
$leak = Join-Path $env:ORIGINAL_USERPROFILE '.agent-desktop'
|
||
if (Test-Path $leak) {
|
||
Write-Host "FAIL: a test escaped the isolated HOME and wrote $leak"
|
||
exit 1
|
||
}
|
||
Write-Host 'OK: private artifacts stayed under RUNNER_TEMP'
|
||
|
||
ffi-python-smoke:
|
||
name: FFI Python Smoke
|
||
runs-on: macos-latest
|
||
timeout-minutes: 20
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
- name: Install Rust toolchain
|
||
run: rustup show
|
||
|
||
- name: Cache cargo registry
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
~/.cargo/registry/index/
|
||
~/.cargo/registry/cache/
|
||
~/.cargo/git/db/
|
||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||
restore-keys: ${{ runner.os }}-cargo-
|
||
|
||
- name: Build FFI dylib (stub adapter)
|
||
run: >
|
||
cargo build --locked --profile release-ffi
|
||
-p agent-desktop-ffi --features stub-adapter
|
||
|
||
- name: Locate dylib
|
||
id: dylib
|
||
run: |
|
||
DYLIB=$(find target/release-ffi -name 'libagent_desktop_ffi.*' \
|
||
\( -name '*.dylib' -o -name '*.so' -o -name '*.dll' \) | head -1)
|
||
if [ -z "$DYLIB" ]; then
|
||
echo "FAIL: dylib not found under target/release-ffi"
|
||
exit 1
|
||
fi
|
||
echo "path=$DYLIB" >> "$GITHUB_OUTPUT"
|
||
|
||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||
with:
|
||
python-version: '3.12'
|
||
|
||
- name: Run FFI smoke harness
|
||
env:
|
||
AD_EXPECT_STUB: "1"
|
||
run: >
|
||
python3 tests/ffi-python/smoke.py
|
||
"${{ steps.dylib.outputs.path }}"
|
||
crates/ffi/include/agent_desktop.h
|
||
|
||
# Gate 1 — Header-drift: the committed crates/ffi/include/agent_desktop.h must
|
||
# match what cbindgen 0.29.4 would generate from the current source. A stale
|
||
# header means a consumer compiled against it sees different symbols/layouts
|
||
# than the dylib ships. cbindgen --verify exits non-zero when there is a diff.
|
||
#
|
||
# The cbindgen version is pinned so a generator upgrade cannot produce a
|
||
# false-positive failure — only a real header change triggers this gate.
|
||
ffi-header-drift:
|
||
name: FFI Header Drift
|
||
runs-on: macos-latest
|
||
timeout-minutes: 20
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
- name: Install Rust toolchain
|
||
run: rustup show
|
||
|
||
- name: Cache cargo registry
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
~/.cargo/registry/index/
|
||
~/.cargo/registry/cache/
|
||
~/.cargo/git/db/
|
||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||
restore-keys: ${{ runner.os }}-cargo-
|
||
|
||
- name: Install pinned cbindgen 0.29.4
|
||
run: cargo install cbindgen --version 0.29.4 --locked
|
||
|
||
- name: Verify header matches source (cbindgen --verify)
|
||
run: >
|
||
cbindgen crates/ffi
|
||
--config crates/ffi/cbindgen.toml
|
||
--output crates/ffi/include/agent_desktop.h
|
||
--verify
|
||
|
||
# Gate 2 — Panic-trap guard: the release-ffi profile must keep panic="unwind"
|
||
# so the trap_panic boundary in the shipped dylib actually catches panics. A
|
||
# flip to panic="abort" silently defeats every catch_unwind in the cdylib and
|
||
# turns any Rust panic into an abort that kills the host process.
|
||
#
|
||
# This job proves the contract in two steps:
|
||
# 1. Assert the Cargo.toml profile setting directly (simpler and more
|
||
# reliable than a build.rs env check for a dual cdylib+rlib crate).
|
||
# 2. Build the actual feature-gated cdylib panic entrypoint, load it with
|
||
# dlopen from a C process, and require the exported ABI boundary to
|
||
# return ErrInternal instead of aborting the host.
|
||
ffi-panic-guard:
|
||
name: FFI Panic Guard
|
||
runs-on: macos-latest
|
||
timeout-minutes: 20
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
- name: Install Rust toolchain
|
||
run: rustup show
|
||
|
||
- name: Cache cargo registry
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
~/.cargo/registry/index/
|
||
~/.cargo/registry/cache/
|
||
~/.cargo/git/db/
|
||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||
restore-keys: ${{ runner.os }}-cargo-
|
||
|
||
- name: Assert release-ffi profile keeps panic=unwind
|
||
run: |
|
||
if ! grep -A5 '\[profile\.release-ffi\]' Cargo.toml | grep -q 'panic.*=.*"unwind"'; then
|
||
echo "FAIL: [profile.release-ffi] must set panic=\"unwind\" — a flip to"
|
||
echo " panic=\"abort\" defeats every trap_panic boundary in the dylib."
|
||
exit 1
|
||
fi
|
||
echo "OK: release-ffi profile retains panic=\"unwind\""
|
||
|
||
- name: Prove the shipped cdylib catches an exported-boundary panic
|
||
run: bash crates/ffi/tests/run_cdylib_panic_probe.sh
|
||
|
||
# Gate 3 — Stub-adapter passthrough tests: the Family-B command-backed
|
||
# entrypoints (ad_snapshot, ad_status, ad_wait, ad_execute_by_ref,
|
||
# ad_version) plus adapter lifecycle (ad_init / ad_destroy) and
|
||
# ad_check_permissions are exercised against the stub adapter. Each must
|
||
# return a structured PLATFORM_NOT_SUPPORTED envelope — or, for
|
||
# ad_check_permissions, the documented ErrPermDenied result. These tests are
|
||
# gated behind #[cfg(feature = "stub-adapter")] so the normal build is
|
||
# unaffected.
|
||
#
|
||
# Scope note: the ~35 Family-A entrypoints (ad_find, ad_execute_action,
|
||
# ad_list_windows, ad_screenshot, clipboard, notifications, etc.) are NOT
|
||
# covered by this passthrough job. Broader Family-A stub coverage is a
|
||
# documented follow-up.
|
||
#
|
||
# Per-target build coverage:
|
||
# - PR CI: this job runs on Linux and reaches the stub adapter directly to
|
||
# assert PLATFORM_NOT_SUPPORTED for every covered entrypoint, proving the
|
||
# non-macOS parity U10 targets.
|
||
# - Release: release.yml build-ffi matrix covers macOS×2 + Linux×2 +
|
||
# Windows×1 for the real cdylib. PR builds do not duplicate that
|
||
# cross-compile matrix to avoid bloating CI on every push.
|
||
ffi-passthrough:
|
||
name: FFI Stub-Adapter Passthrough
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 20
|
||
permissions:
|
||
contents: read
|
||
steps:
|
||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
- name: Install Rust toolchain
|
||
run: rustup show
|
||
|
||
- name: Cache cargo registry
|
||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
~/.cargo/registry/index/
|
||
~/.cargo/registry/cache/
|
||
~/.cargo/git/db/
|
||
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
|
||
restore-keys: ${{ runner.os }}-cargo-
|
||
|
||
- name: Run stub-adapter passthrough tests
|
||
run: >
|
||
scripts/cargo-test-isolated-home.sh test --locked -p agent-desktop-ffi
|
||
--features stub-adapter --test c_abi_passthrough
|