feat(ffi): Phase B and C — Python smoke harness, parity gates, build.rs codegen (#77)

Completes the FFI plan: U9 adds a Python ctypes smoke harness proving the C ABI from a non-Rust host; U10 adds cross-platform parity CI gates for header drift, codegen drift, panic-unwind, and stub-adapter passthrough; U11 generates the command-backed wrappers from build.rs templates with a committed drift-gated output. The C ABI stays byte-identical and the surface is Windows and Linux ready with no new FFI code.
This commit is contained in:
Lahfir 2026-06-26 19:14:14 -07:00 committed by GitHub
parent 6e86f1c299
commit 9023f331b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2457 additions and 366 deletions

View file

@ -28,8 +28,14 @@ else
HAS_RUST_CHANGES=0
fi
if [ "$HAS_RUST_CHANGES" -eq 0 ]; then
echo "pre-commit: no Rust changes staged, skipping cargo checks"
if git diff --cached --name-only --diff-filter=ACMR | grep -q '^crates/ffi/'; then
HAS_FFI_CHANGES=1
else
HAS_FFI_CHANGES=0
fi
if [ "$HAS_RUST_CHANGES" -eq 0 ] && [ "$HAS_FFI_CHANGES" -eq 0 ]; then
echo "pre-commit: no Rust or FFI changes staged, skipping cargo checks"
exit 0
fi
@ -65,4 +71,51 @@ run "cargo fmt --all -- --check" cargo fmt --all -- --check
run "cargo clippy --all-targets -- -D warnings" cargo clippy --all-targets -- -D warnings
run "cargo test --lib --workspace" cargo test --lib --workspace
# ── FFI-specific gates ────────────────────────────────────────────────────────
# Mirrors CI jobs: ffi-codegen-drift, ffi-passthrough, ffi-header-drift.
# Fast-skipped in full when no crates/ffi/** files are staged.
#
# Scope: only the fast structural gates run locally (codegen drift, stub
# passthrough, optional header drift). panic_spike (requires a release-ffi
# example build) and the Python smoke harness (requires a full cdylib build
# plus a Python runtime) are CI-only — jobs ffi-panic-guard and
# ffi-python-smoke — to keep commits fast.
if [ "$HAS_FFI_CHANGES" -eq 1 ]; then
# Gate 1: Codegen drift — build triggers build.rs which regenerates
# generated.rs in-source; diff detects a stale staged copy.
run "FFI codegen build" cargo build --locked -p agent-desktop-ffi --features stub-adapter
if ! git diff --quiet crates/ffi/src/commands/generated.rs; then
printf '\033[1;31m✗ FFI codegen drift\033[0m\n' >&2
printf '\n' >&2
printf 'pre-commit: crates/ffi/src/commands/generated.rs is stale.\n' >&2
printf ' The hook already regenerated it — just stage the updated file:\n' >&2
printf ' git add crates/ffi/src/commands/generated.rs\n' >&2
printf '\npre-commit: refusing the commit. Fix the issues above or rerun with SKIP_PRECOMMIT=1 to bypass.\n' >&2
exit 1
fi
# Gate 2: Stub-adapter passthrough tests.
run "FFI stub passthrough" cargo test --locked -p agent-desktop-ffi --features stub-adapter --test c_abi_passthrough
# Gate 3: Header drift — graceful skip if cbindgen is absent or not 0.29.4.
# The hard gate is CI job ffi-header-drift; local absence is not a commit blocker.
export PATH="${HOME}/.cargo/bin:${PATH}"
_cbindgen_cmd=""
if command -v cbindgen >/dev/null 2>&1; then
_cbindgen_cmd="cbindgen"
fi
if [ -n "${_cbindgen_cmd}" ]; then
_cbindgen_ver=$("${_cbindgen_cmd}" --version 2>&1 | head -1)
if [[ "${_cbindgen_ver}" == "cbindgen 0.29.4" ]]; then
run "FFI header drift (cbindgen --verify)" \
"${_cbindgen_cmd}" crates/ffi --config crates/ffi/cbindgen.toml \
--output crates/ffi/include/agent_desktop.h --verify
else
printf 'pre-commit: cbindgen version mismatch (want 0.29.4, got: %s) — skipping header drift check (CI gate)\n' "${_cbindgen_ver}"
fi
else
printf 'pre-commit: cbindgen not found in PATH or ~/.cargo/bin — skipping header drift check (CI gate)\n'
fi
fi
printf '\033[1;32m✓ pre-commit checks passed\033[0m\n'

View file

@ -134,3 +134,227 @@ jobs:
throw new Error('agent-desktop npm wrapper did not return version JSON');
}
"
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.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 and run crates/ffi/examples/panic_spike under the release-ffi
# profile: an extern-C fn panics, catch_unwind catches it, and the
# example prints "PANIC CAUGHT OK (code = -1)" and exits 0. If the
# profile were flipped to panic="abort" the process would SIGABRT
# instead, making this step a runtime proof — not just a string check.
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.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 catch_unwind survives the release-ffi profile
run: cargo run --locked --profile release-ffi --example panic_spike -p agent-desktop-ffi
# 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 (no main-thread guard off macOS, so the
# stub adapter is reached directly) to assert PLATFORM_NOT_SUPPORTED for
# every covered entrypoint — directly 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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.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: >
cargo test --locked -p agent-desktop-ffi
--features stub-adapter --test c_abi_passthrough
# Gate 4 — Codegen drift: the committed src/commands/generated.rs must
# match what build.rs produces from the current templates. A stale committed
# file means the source diverged from the templates — either a hand-edit
# bypassed the generator or a template was changed without rebuilding.
ffi-codegen-drift:
name: FFI Codegen Drift
runs-on: ubuntu-latest
timeout-minutes: 15
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@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.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: Regenerate FFI command wrappers via build.rs
run: cargo build --locked -p agent-desktop-ffi --features stub-adapter
- name: Assert no drift in commands/generated.rs
run: |
if ! git diff --exit-code crates/ffi/src/commands/generated.rs; then
echo ""
echo "FAIL: crates/ffi/src/commands/generated.rs is out of sync with build.rs templates."
echo " Run 'cargo build -p agent-desktop-ffi' locally and commit the updated file."
exit 1
fi
echo "OK: commands/generated.rs matches build.rs output"

View file

@ -239,6 +239,24 @@ jobs:
key: ${{ runner.os }}-${{ matrix.target }}-ffi-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
restore-keys: ${{ runner.os }}-${{ matrix.target }}-ffi-cargo-
# NEVER add --features stub-adapter to the release FFI build — it ships a no-op dylib
# where every adapter call returns PLATFORM_NOT_SUPPORTED. The guard step below enforces this.
- name: Guard — release dylib must not be a stub build
shell: bash
run: |
# Co-occurrence of the release profile token and the stub flag on the same
# line is the discriminator. Legitimate stub uses in this file (codegen regen,
# passthrough test) omit the release-ffi profile, so they do not trigger.
PROFILE_TOKEN="release-ffi"
STUB_TOKEN="stub-adapter"
if grep -nE "${PROFILE_TOKEN}.*${STUB_TOKEN}|${STUB_TOKEN}.*${PROFILE_TOKEN}" \
.github/workflows/release.yml; then
echo "FAIL: release-ffi build path must not enable"
echo " the stub-adapter feature — that would ship a no-op dylib"
exit 1
fi
echo "OK: no stub flag on the shipping release-ffi build path"
- name: Build FFI cdylib (release-ffi profile)
run: cargo build --locked --profile release-ffi -p agent-desktop-ffi --target ${{ matrix.target }}
@ -307,9 +325,91 @@ jobs:
agent-desktop-ffi-v*.zip.sha256
retention-days: 1
# Re-runs the three FFI integrity gates at release time so a hotfix or a PR
# that bypassed ci.yml cannot ship a dylib with a stale committed header or
# stale generated.rs. Gate 1 (header drift) must run on macos-latest because
# the committed header is macOS-generated and cbindgen may omit macOS-gated
# symbols on Linux. Gates 2 and 3 are consolidated here for a single job.
ffi-release-gates:
name: FFI Release Gates
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: macos-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.release-please.outputs.tag_name }}
- name: Install Rust toolchain
run: rustup show
- name: Cache cargo registry
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: ${{ runner.os }}-ffi-gates-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
restore-keys: ${{ runner.os }}-ffi-gates-cargo-
- name: Install pinned cbindgen 0.29.4
run: cargo install cbindgen --version 0.29.4 --locked
# Gate 1 — Header drift: committed crates/ffi/include/agent_desktop.h must
# match what cbindgen 0.29.4 generates from the current source. Exits
# non-zero when there is any diff.
- name: Header drift check (cbindgen --verify)
run: >
cbindgen crates/ffi
--config crates/ffi/cbindgen.toml
--output crates/ffi/include/agent_desktop.h
--verify
# Gate 2 — Codegen drift: build.rs must produce the exact same
# crates/ffi/src/commands/generated.rs that is committed.
- name: Regenerate FFI command wrappers via build.rs
run: cargo build --locked -p agent-desktop-ffi --features stub-adapter
- name: Codegen drift check (generated.rs)
run: |
if ! git diff --exit-code crates/ffi/src/commands/generated.rs; then
echo ""
echo "FAIL: crates/ffi/src/commands/generated.rs is out of sync with build.rs templates."
echo " Run 'cargo build -p agent-desktop-ffi' locally and commit the updated file."
exit 1
fi
echo "OK: commands/generated.rs matches build.rs output"
# Gate 3 — Stub passthrough: command-backed entrypoints must return
# structured PLATFORM_NOT_SUPPORTED envelopes via the stub adapter.
- name: Stub passthrough test
run: >
cargo test --locked -p agent-desktop-ffi
--features stub-adapter --test c_abi_passthrough
# Gate 4 — Panic-boundary proof: mirrors ffi-panic-guard in ci.yml so a
# tag-only flip to panic=abort cannot slip past release gates. Two steps:
# (a) assert the Cargo.toml profile setting; (b) run the panic_spike example
# which exits 0 if catch_unwind caught the panic, SIGABRTs if panic=abort.
- 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 catch_unwind survives the release-ffi profile
run: cargo run --locked --profile release-ffi --example panic_spike -p agent-desktop-ffi
publish-github:
name: Publish to GitHub Release
needs: [release-please, build, build-ffi]
needs: [release-please, build, build-ffi, ffi-release-gates]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:

View file

@ -171,10 +171,10 @@ Phases 24 add adapters, transports, and production readiness work. Nothing in
### File Rules
- **400 LOC hard limit per file.** If approaching 400, split by responsibility. No exceptions.
- **400 LOC hard limit per file.** If approaching 400, split by responsibility. No exceptions. _Exception: files bearing an `@generated` marker that are produced by `build.rs` codegen and validated by a CI drift gate are exempt — the limit applies to the hand-written templates and the build script itself, not the generated output. Do not hand-edit these files; fix the generator instead._
- **No inline comments.** Code must be self-documenting through naming. Only Rust doc-comments (`///`) on public items when the name alone is insufficient.
- **One struct/enum per file** for domain types. `node.rs` defines `AccessibilityNode`. `action.rs` defines `Action`.
- **One command per file.** Each CLI command lives in its own file under `commands/`. Filename matches the command name.
- **One command per file.** Each CLI command lives in its own file under `commands/`. Filename matches the command name. _This rule scopes to hand-written command files; a single `@generated` wrapper file that consolidates multiple command entrypoints is not a violation._
- **No God objects.** No struct with more than 7 fields. No function with more than 5 parameters. Use builder patterns or config structs.
- **Explicit pub boundaries.** Only `lib.rs` re-exports public items. Internal modules use `pub(crate)`. No wildcard re-exports.

View file

@ -93,35 +93,18 @@ Permission fields are explicit objects, for example:
## Language bindings (FFI)
Every GitHub Release ships a prebuilt C-ABI cdylib alongside the CLI tarballs. Hosts that need in-process calls (Python agents, Swift apps, Go services, Node tools, Ruby scripts, C/C++ code) `dlopen` the dylib and call the functions declared in `agent_desktop.h` — no fork-exec per command.
| Platform | Artifact |
|----------------------|----------|
| macOS arm64 | `agent-desktop-ffi-v<ver>-aarch64-apple-darwin.tar.gz` |
| macOS x86_64 | `agent-desktop-ffi-v<ver>-x86_64-apple-darwin.tar.gz` |
| Linux x86_64 (glibc) | `agent-desktop-ffi-v<ver>-x86_64-unknown-linux-gnu.tar.gz` |
| Linux arm64 (glibc) | `agent-desktop-ffi-v<ver>-aarch64-unknown-linux-gnu.tar.gz` |
| Windows x86_64 (MSVC)| `agent-desktop-ffi-v<ver>-x86_64-pc-windows-msvc.zip` |
Each archive contains `lib/libagent_desktop_ffi.{dylib,so,dll}`, `include/agent_desktop.h`, `LICENSE`, and a short README. Verify the download with the release's `checksums.txt`:
```bash
shasum -a 256 -c checksums.txt
gh attestation verify agent-desktop-ffi-v*.tar.gz --repo lahfir/agent-desktop # Sigstore provenance
```
Minimal Python round-trip:
Every GitHub Release ships a prebuilt C-ABI cdylib (`libagent_desktop_ffi`) for macOS, Linux, and Windows alongside the CLI tarballs. `dlopen` it and call the functions declared in `agent_desktop.h` for in-process calls instead of fork-exec per command.
```python
import ctypes
lib = ctypes.CDLL("./lib/libagent_desktop_ffi.dylib")
lib.ad_adapter_create.restype = ctypes.c_void_p
lib.ad_init(1) # verify ABI major (AD_ABI_VERSION_MAJOR) before any call
adapter = lib.ad_adapter_create()
# ... call ad_list_apps / ad_get_tree / ad_execute_action, see docs below
# observe -> act: ad_snapshot -> parse an @e ref -> ad_execute_by_ref ...
lib.ad_adapter_destroy(adapter)
```
Full consumer guide — error-handling contract, ownership rules, threading constraints, every entrypoint with Safety docs: [`skills/agent-desktop-ffi/`](skills/agent-desktop-ffi/).
Full consumer guide — entrypoints, ownership, threading, error-handling, build/link, release archives, and verification: **[`skills/agent-desktop-ffi/`](skills/agent-desktop-ffi/)**.
## Core Workflow for AI

View file

@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true
publish = false
[features]
stub-adapter = []
[lib]
crate-type = ["cdylib", "rlib"]

72
crates/ffi/README.md Normal file
View file

@ -0,0 +1,72 @@
# agent-desktop-ffi
C-ABI cdylib over [`PlatformAdapter`](../../crates/core/src/adapter.rs). Exposes
`libagent_desktop_ffi.{dylib,so,dll}` to Python (ctypes), Swift, Go (cgo),
Node (ffi-napi), and C++ consumers without spawning a CLI subprocess per call.
## Build
```bash
cargo build --profile release-ffi -p agent-desktop-ffi
```
The `release-ffi` profile keeps `panic = "unwind"`, which is required for the
`catch_unwind` traps that prevent panics from crossing the `extern "C"` boundary.
Do **not** use `--release` — that profile sets `panic = "abort"`, which silently
defeats every trap.
For the CI / stub-adapter path (no AX permission required):
```bash
cargo build --profile release-ffi -p agent-desktop-ffi --features stub-adapter
```
`--features stub-adapter` replaces the real platform adapter with a no-op that
returns `PLATFORM_NOT_SUPPORTED` for every adapter call.
## ABI Surface
| Group | Key symbols |
|-------|-------------|
| ABI handshake | `ad_abi_version()`, `ad_init(expected_major)` |
| Adapter lifecycle | `ad_adapter_create()`, `ad_adapter_destroy()` |
| Command-backed JSON entrypoints | `ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_version`, `ad_status` |
| Log callback | `ad_set_log_callback(fn(level, msg))` |
| Errno last-error | `ad_last_error_code()`, `ad_last_error_message()`, `ad_last_error_suggestion()`, `ad_last_error_platform_detail()` |
| Type accessors / size getters | `ad_*_size()`, `ad_*_list_{count,get,free}()`, `ad_image_buffer_*()` |
| Free helpers | `ad_free_string()`, `ad_free_handle()`, `ad_free_tree()`, `ad_free_action_result()` |
The full declaration list is in [`include/agent_desktop.h`](include/agent_desktop.h).
Consumers must call `ad_init(AD_ABI_VERSION_MAJOR)` after `dlopen` to verify the
loaded dylib matches the header the consumer was built against.
## Codegen
The 5 command-backed JSON entrypoints (`ad_snapshot`, `ad_execute_by_ref`,
`ad_wait`, `ad_version`, `ad_status`) are **generated by `build.rs`** from the
template files in [`codegen_templates/`](codegen_templates/) into
`src/commands/generated.rs`. Do not hand-edit `generated.rs`; edit the
corresponding `.rs.in` template instead. The build panics if a template file
exists in `codegen_templates/` but is not registered in `build.rs`, so stale
templates cannot silently accumulate.
## Thread Safety (macOS)
Every adapter-touching entry point must be called from the process's **main
thread**. The guard fires at runtime in all build profiles; a worker-thread call
returns `AD_RESULT_ERR_INTERNAL` with a `'static` diagnostic string. Operations
safe from any thread: `ad_abi_version`, `ad_init`, adapter create/destroy,
`ad_last_error_*` readers, and all accessor / free helpers.
## Error Model
Every `AdResult`-returning function sets thread-local last-error state on failure.
Read it with `ad_last_error_code()` / `ad_last_error_message()` /
`ad_last_error_suggestion()`. The pointer returned by `ad_last_error_message()`
stays valid until the next *failing* call on the same thread — successful calls
leave it untouched (POSIX errno semantics).
## Full Documentation
`skills/agent-desktop-ffi/` — build-and-link guide, ownership model, threading
rules, and error-handling patterns.

View file

@ -14,9 +14,126 @@
//! `CARGO_CFG_PANIC = "unwind"` in the build script regardless of the active
//! profile's `panic` setting. The invariant is therefore documented rather
//! than machine-enforced; CI gates the cdylib via `--profile release-ffi` only.
//!
//! # FFI command codegen
//!
//! Family-B wrappers (command-backed, JSON-returning) are generated from
//! per-command template files under `codegen_templates/`. Each file is named
//! `<command>.rs.in` and loaded via `include_str!` in `command_templates()`.
//! The template set is the single source of truth for the command universe.
//!
//! Adding a new Family-B command requires exactly these steps:
//! 1. Create `codegen_templates/<name>.rs.in` with the wrapper body.
//! 2. Add `m.insert("<name>", include_str!("codegen_templates/<name>.rs.in"))` to
//! `command_templates()` in this file.
//! 3. Add `"<name>"` to `EXPECTED_COMMANDS` in
//! `tests/codegen_exhaustiveness.rs`.
//!
//! The build fails at step 3 if steps 1 and 2 are done without step 3.
//! An orphan `.rs.in` file (present in the dir but not in the map) triggers
//! a build-time panic so stale templates cannot silently accumulate.
use std::collections::BTreeMap;
use std::path::PathBuf;
const GENERATED_HEADER_STATIC: &str = "\
//! @generated — produced by crates/ffi/build.rs codegen.
//! Edit the templates under crates/ffi/codegen_templates/, not this file.
";
/// Map from command name to its wrapper template (alphabetical by convention).
///
/// This map is the single source of truth for which Family-B commands exist.
/// Every `.rs.in` file in `codegen_templates/` must appear here; the build
/// panics otherwise.
fn command_templates() -> BTreeMap<&'static str, &'static str> {
let mut m = BTreeMap::new();
m.insert(
"execute_by_ref",
include_str!("codegen_templates/execute_by_ref.rs.in"),
);
m.insert("snapshot", include_str!("codegen_templates/snapshot.rs.in"));
m.insert("status", include_str!("codegen_templates/status.rs.in"));
m.insert("version", include_str!("codegen_templates/version.rs.in"));
m.insert("wait", include_str!("codegen_templates/wait.rs.in"));
m
}
fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("macos") {
println!("cargo:rustc-cdylib-link-arg=-Wl,-install_name,@rpath/libagent_desktop_ffi.dylib");
}
let manifest_dir =
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
let templates_dir = manifest_dir.join("codegen_templates");
let generated_path = manifest_dir.join("src/commands/generated.rs");
let templates = command_templates();
for entry in std::fs::read_dir(&templates_dir)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", templates_dir.display()))
{
let entry = entry.unwrap_or_else(|e| panic!("dir entry error in codegen_templates: {e}"));
let fname = entry.file_name();
let fname_str = fname.to_string_lossy();
if let Some(name) = fname_str.strip_suffix(".rs.in") {
if !templates.contains_key(name) {
panic!(
"orphan template file `{fname_str}` found in codegen_templates/ but not \
registered in `command_templates()` in build.rs add an entry or delete \
the file."
);
}
}
}
for name in templates.keys() {
println!(
"cargo:rerun-if-changed={}",
templates_dir.join(format!("{name}.rs.in")).display()
);
}
let command_list = templates.keys().copied().collect::<Vec<_>>().join(", ");
let dynamic_comment = format!("//! Commands in alphabetical order: {command_list}.\n");
let mut output = String::from(GENERATED_HEADER_STATIC);
output.push_str(&dynamic_comment);
let use_block = "\
\nuse crate::AdAdapter;\
\nuse crate::actions::conversion::action_from_c;\
\nuse crate::commands::app_error_to_adapter;\
\nuse crate::commands::envelope_out::write_command_envelope;\
\nuse crate::convert::string::{\
\n decode_optional_filter, optional_adapter_string, required_adapter_string,\
\n};\
\nuse crate::convert::surface::snapshot_surface_from_c;\
\nuse crate::error::{self, AdResult, set_last_error};\
\nuse crate::ffi_try::trap_panic;\
\nuse crate::main_thread::require_main_thread;\
\nuse crate::pointer_guard::guard_non_null;\
\nuse crate::types::wait_args::AdWaitArgs;\
\nuse crate::types::{AdAction, AdPolicyKind};\
\nuse agent_desktop_core::commands::snapshot::SnapshotArgs;\
\nuse agent_desktop_core::commands::status::execute_with_report_with_context;\
\nuse agent_desktop_core::commands::wait::{WaitArgs, WaitModeArgs, WaitPredicateArgs};\
\nuse agent_desktop_core::error::{AdapterError, AppError, ErrorCode};\
\nuse agent_desktop_core::refs::validate_ref_id;\
\nuse std::ffi::c_char;\
\nuse std::ptr;\n";
output.push_str(use_block);
for name in templates.keys() {
output.push_str(templates[name]);
}
let existing = std::fs::read_to_string(&generated_path).unwrap_or_default();
if existing != output {
std::fs::write(&generated_path, &output)
.unwrap_or_else(|e| panic!("failed to write {}: {e}", generated_path.display()));
}
}

View file

@ -1,17 +1,3 @@
use crate::AdAdapter;
use crate::actions::conversion::action_from_c;
use crate::commands::app_error_to_adapter;
use crate::commands::envelope_out::write_command_envelope;
use crate::convert::string::{optional_adapter_string, required_adapter_string};
use crate::error::{AdResult, set_last_error};
use crate::ffi_try::trap_panic;
use crate::main_thread::require_main_thread;
use crate::pointer_guard::guard_non_null;
use crate::types::{AdAction, AdPolicyKind};
use agent_desktop_core::error::{AdapterError, ErrorCode};
use agent_desktop_core::refs::validate_ref_id;
use std::ffi::c_char;
use std::ptr;
/// Drives a ref action (`@e5`, action) through the canonical ref-action
/// pipeline: `RefStore` load → `RefMap` lookup (→ `STALE_REF` on missing) →
@ -148,77 +134,3 @@ pub unsafe extern "C" fn ad_execute_by_ref(
unsafe { write_command_envelope("execute_by_ref", result, out) }
})
}
#[cfg(test)]
mod tests {
use super::*;
use agent_desktop_core::action::Action;
use agent_desktop_core::interaction_policy::InteractionPolicy;
#[test]
fn policy_kind_headless_maps_to_headless() {
assert_eq!(
AdPolicyKind::Headless.to_interaction_policy(),
InteractionPolicy::headless()
);
}
#[test]
fn policy_kind_focus_fallback_maps_to_focus_fallback() {
assert_eq!(
AdPolicyKind::FocusFallback.to_interaction_policy(),
InteractionPolicy::focus_fallback()
);
}
#[test]
fn policy_kind_headed_maps_to_headed() {
assert_eq!(
AdPolicyKind::Headed.to_interaction_policy(),
InteractionPolicy::headed()
);
}
#[test]
fn type_text_base_plus_headless_caller_gives_focus_fallback() {
let base = Action::TypeText("hi".into()).base_interaction_policy();
let effective = base.join(InteractionPolicy::headless());
assert_eq!(effective, InteractionPolicy::focus_fallback());
}
#[test]
fn click_base_plus_headless_caller_stays_headless() {
let base = Action::Click.base_interaction_policy();
let effective = base.join(InteractionPolicy::headless());
assert_eq!(effective, InteractionPolicy::headless());
}
#[test]
fn click_base_plus_headed_caller_becomes_headed() {
let base = Action::Click.base_interaction_policy();
let effective = base.join(InteractionPolicy::headed());
assert_eq!(effective, InteractionPolicy::headed());
}
#[test]
fn type_text_base_plus_headed_caller_becomes_headed() {
let base = Action::TypeText("x".into()).base_interaction_policy();
let effective = base.join(InteractionPolicy::headed());
assert_eq!(effective, InteractionPolicy::headed());
}
#[test]
fn headless_caller_cannot_downgrade_type_text_base() {
let base = Action::TypeText("x".into()).base_interaction_policy();
assert_eq!(base, InteractionPolicy::focus_fallback());
let effective = base.join(InteractionPolicy::headless());
assert_eq!(effective, InteractionPolicy::focus_fallback());
}
#[test]
fn click_base_plus_focus_fallback_caller_gives_focus_fallback() {
let base = Action::Click.base_interaction_policy();
let effective = base.join(InteractionPolicy::focus_fallback());
assert_eq!(effective, InteractionPolicy::focus_fallback());
}
}

View file

@ -1,15 +1,3 @@
use crate::AdAdapter;
use crate::commands::app_error_to_adapter;
use crate::commands::envelope_out::write_command_envelope;
use crate::convert::string::decode_optional_filter;
use crate::convert::surface::snapshot_surface_from_c;
use crate::error::{AdResult, set_last_error};
use crate::ffi_try::trap_panic;
use crate::main_thread::require_main_thread;
use crate::pointer_guard::guard_non_null;
use agent_desktop_core::commands::snapshot::SnapshotArgs;
use std::ffi::c_char;
use std::ptr;
/// Takes a full CLI-format snapshot of the target application window,
/// allocates `@e` refs for all interactive elements, persists the refmap

View file

@ -1,11 +1,3 @@
use crate::commands::app_error_to_adapter;
use crate::commands::envelope_out::write_command_envelope;
use crate::error::{self, AdResult};
use crate::ffi_try::trap_panic;
use crate::pointer_guard::guard_non_null;
use agent_desktop_core::commands::status::execute_with_report_with_context;
use agent_desktop_core::error::AppError;
use std::ffi::c_char;
/// Returns the adapter's current health and permission state as a JSON
/// envelope matching the `agent-desktop status` CLI output.
@ -34,7 +26,7 @@ pub unsafe extern "C" fn ad_status(
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = std::ptr::null_mut() };
unsafe { *out = ptr::null_mut() };
guard_non_null!(adapter, c"adapter is null");
trap_panic(|| {

View file

@ -1,8 +1,3 @@
use crate::commands::envelope_out::write_command_envelope;
use crate::error::AdResult;
use crate::ffi_try::trap_panic;
use crate::pointer_guard::guard_non_null;
use std::os::raw::c_char;
/// Returns the `agent-desktop` version envelope as an owned JSON C string.
///
@ -18,7 +13,7 @@ use std::os::raw::c_char;
pub unsafe extern "C" fn ad_version(out: *mut *mut c_char) -> AdResult {
trap_panic(|| unsafe {
guard_non_null!(out, c"out is null");
*out = std::ptr::null_mut();
*out = ptr::null_mut();
let result = agent_desktop_core::commands::version::execute();
write_command_envelope("version", result, out)
})

View file

@ -1,14 +1,3 @@
use crate::adapter::AdAdapter;
use crate::commands::app_error_to_adapter;
use crate::commands::envelope_out::write_command_envelope;
use crate::convert::string::decode_optional_filter;
use crate::error::{self, AdResult};
use crate::ffi_try::trap_panic;
use crate::main_thread::require_main_thread;
use crate::pointer_guard::guard_non_null;
use crate::types::wait_args::AdWaitArgs;
use agent_desktop_core::commands::wait::{WaitArgs, WaitModeArgs, WaitPredicateArgs};
use std::ffi::c_char;
/// Runs `wait` with the given args, blocking the calling thread until the
/// condition is met or `timeout_ms` elapses.
@ -48,7 +37,7 @@ pub unsafe extern "C" fn ad_wait(
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = std::ptr::null_mut() };
unsafe { *out = ptr::null_mut() };
guard_non_null!(args, c"args is null");
trap_panic(|| {

View file

@ -1,14 +1,26 @@
//! Regression example proving the custom `release-ffi` profile keeps
//! `catch_unwind` effective under the optimized cdylib build.
//! Regression guard against a `panic = "abort"` regression in the `release-ffi`
//! Cargo profile.
//!
//! ## What this proves
//!
//! The `release-ffi` profile keeps `panic = "unwind"`, so
//! `std::panic::catch_unwind` — the same primitive that
//! `crate::ffi_try::trap_panic` relies on — remains effective under that
//! optimized profile. If `panic = "abort"` were accidentally restored, this
//! example would SIGABRT instead of catching: a loud, immediate regression
//! signal.
//!
//! ## What this does NOT prove
//!
//! This example does NOT dlopen the shipped cdylib and trigger a panic inside
//! a real `ad_*` entrypoint's `trap_panic` fence. A full cdylib-dlopen
//! panic-injection test is a tracked follow-up (Phase C+).
//!
//! ## Build and run
//!
//! Build and run with:
//! cargo run --profile release-ffi --example panic_spike -p agent-desktop-ffi
//!
//! Expected: prints "PANIC CAUGHT OK (code = -1)" and exits with code 0.
//!
//! If someone accidentally flips `panic = "abort"` back on the `release-ffi`
//! profile (or removes the profile), this example will SIGABRT instead of
//! catching — a loud regression signal.
//! Expected output: `PANIC CAUGHT OK (code = -1)`, exit code 0.
use std::panic::AssertUnwindSafe;

View file

@ -11,6 +11,25 @@ pub struct AdAdapter {
pub(crate) session_id: Option<String>,
}
/// A no-op adapter used under the `stub-adapter` Cargo feature.
///
/// Every method delegates to the `PlatformAdapter` trait defaults, all of
/// which return `not_supported()` errors. `permission_report()` returns
/// `Denied` via the trait default, so `ad_check_permissions` yields
/// `ErrPermDenied` on a stub build — the expected signal for a CI runner
/// that has no OS accessibility permission.
#[cfg(feature = "stub-adapter")]
struct StubAdapter;
#[cfg(feature = "stub-adapter")]
impl PlatformAdapter for StubAdapter {}
#[cfg(feature = "stub-adapter")]
fn build_adapter() -> Box<dyn PlatformAdapter> {
Box::new(StubAdapter)
}
#[cfg(not(feature = "stub-adapter"))]
fn build_adapter() -> Box<dyn PlatformAdapter> {
#[cfg(target_os = "macos")]
{

View file

@ -0,0 +1,437 @@
//! @generated — produced by crates/ffi/build.rs codegen.
//! Edit the templates under crates/ffi/codegen_templates/, not this file.
//! Commands in alphabetical order: execute_by_ref, snapshot, status, version, wait.
use crate::AdAdapter;
use crate::actions::conversion::action_from_c;
use crate::commands::app_error_to_adapter;
use crate::commands::envelope_out::write_command_envelope;
use crate::convert::string::{
decode_optional_filter, optional_adapter_string, required_adapter_string,
};
use crate::convert::surface::snapshot_surface_from_c;
use crate::error::{self, AdResult, set_last_error};
use crate::ffi_try::trap_panic;
use crate::main_thread::require_main_thread;
use crate::pointer_guard::guard_non_null;
use crate::types::wait_args::AdWaitArgs;
use crate::types::{AdAction, AdPolicyKind};
use agent_desktop_core::commands::snapshot::SnapshotArgs;
use agent_desktop_core::commands::status::execute_with_report_with_context;
use agent_desktop_core::commands::wait::{WaitArgs, WaitModeArgs, WaitPredicateArgs};
use agent_desktop_core::error::{AdapterError, AppError, ErrorCode};
use agent_desktop_core::refs::validate_ref_id;
use std::ffi::c_char;
use std::ptr;
/// Drives a ref action (`@e5`, action) through the canonical ref-action
/// pipeline: `RefStore` load → `RefMap` lookup (→ `STALE_REF` on missing) →
/// strict element resolution (→ `STALE_REF`/`AMBIGUOUS_TARGET`) → live
/// actionability preflight → dispatch → handle release.
///
/// Policy: `TypeText` defaults to `focus_fallback` (matching the CLI `type`
/// command); `PressKey` shares that `focus_fallback` base (a ref-targeted key
/// press may need the target focused); every other action defaults to
/// `headless`. An explicit `policy` discriminant may *elevate* to headed but
/// must not downgrade an action below its base. Base and elevation are computed
/// by `agent_desktop_core::commands::execute_by_ref::execute` via
/// `Action::base_interaction_policy` + `InteractionPolicy::join`, so CLI and
/// FFI share a single source of policy truth.
///
/// `ref_id` tri-state: null → `ErrInvalidArgs`; non-null invalid UTF-8 →
/// `ErrInvalidArgs`; valid UTF-8 but bad `@e{N}` format → `ErrInvalidArgs`.
///
/// `snapshot_id` tri-state: null → use the latest snapshot for the session
/// (CLI `--snapshot` omitted); valid UTF-8 → pin to that snapshot id; non-null
/// invalid UTF-8 → `ErrInvalidArgs`.
///
/// `policy` is an `AdPolicyKind` discriminant (0=Headless, 1=FocusFallback,
/// 2=Headed). An out-of-range value returns `ErrInvalidArgs`. `Headless (0)`
/// accepts the action's own CLI base (so `TypeText` still uses
/// `focus_fallback`). `Headed (2)` opts in to cursor-based fallbacks.
///
/// On success `*out` is set to a NUL-terminated JSON envelope (command
/// `"execute_by_ref"`); free with `ad_free_string`. On guard or decode
/// failure (invalid args before the command runs) `*out` remains null.
/// On a command-level error (STALE_REF, AMBIGUOUS_TARGET, etc.) `*out`
/// holds the error JSON envelope and must still be freed with
/// `ad_free_string`. The last-error slot is populated on all failures.
///
/// **Dispatch-before-serialize ordering**: the action is dispatched (and any
/// side effects committed) before the result JSON is serialized. In the
/// near-impossible event that serialization of an already-valid
/// `ActionResult` fails, `*out` is null and `ErrInternal` is returned while
/// the side effect has already occurred. No pre-validation machinery is
/// needed because serialization of a valid envelope effectively never fails.
///
/// # Safety
///
/// `adapter` must be a non-null pointer from `ad_adapter_create[_with_session]`.
/// `ref_id` must be a non-null pointer to a NUL-terminated C string within
/// `AD_MAX_STRING_BYTES + 1` bytes; null is **not** optional — it is defined
/// behaviour (no UB) but is rejected immediately with `ErrInvalidArgs`.
/// `snapshot_id` may be null (meaning: use the latest snapshot for this
/// session) or a non-null NUL-terminated C string within
/// `AD_MAX_STRING_BYTES + 1` bytes. `action` must be a non-null pointer to a
/// valid `AdAction`. `out` must be a non-null writable pointer. All pointers
/// must remain valid for the duration of the call. Must be called from the
/// main thread on macOS.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ad_execute_by_ref(
adapter: *const AdAdapter,
ref_id: *const c_char,
snapshot_id: *const c_char,
action: *const AdAction,
policy: i32,
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = ptr::null_mut() };
trap_panic(|| {
if let Err(rc) = require_main_thread() {
return rc;
}
guard_non_null!(adapter, c"adapter is null");
guard_non_null!(action, c"action is null");
let ref_str = match required_adapter_string(ref_id, "ref_id") {
Ok(s) => s,
Err(e) => {
set_last_error(&e);
return AdResult::ErrInvalidArgs;
}
};
if let Err(app_err) = validate_ref_id(&ref_str) {
let ae = app_error_to_adapter(app_err);
set_last_error(&ae);
return crate::error::last_error_code();
}
let snapshot_str = match optional_adapter_string(snapshot_id, "snapshot_id") {
Ok(opt) => opt,
Err(e) => {
set_last_error(&e);
return AdResult::ErrInvalidArgs;
}
};
let caller_policy = match AdPolicyKind::from_c(policy) {
Some(p) => p,
None => {
set_last_error(&AdapterError::new(
ErrorCode::InvalidArgs,
"invalid policy kind discriminant",
));
return AdResult::ErrInvalidArgs;
}
};
let core_action = match unsafe { action_from_c(&*action) } {
Ok(a) => a,
Err(msg) => {
set_last_error(&AdapterError::new(ErrorCode::InvalidArgs, msg));
return AdResult::ErrInvalidArgs;
}
};
let caller_ip = caller_policy.to_interaction_policy();
let adapter_ref = unsafe { &*adapter };
let context = match adapter_ref.command_context() {
Ok(ctx) => ctx,
Err(e) => {
let ae = app_error_to_adapter(e);
set_last_error(&ae);
return crate::error::last_error_code();
}
};
let result = agent_desktop_core::commands::execute_by_ref::execute(
&ref_str,
snapshot_str.as_deref(),
core_action,
caller_ip,
adapter_ref.inner.as_ref(),
&context,
);
unsafe { write_command_envelope("execute_by_ref", result, out) }
})
}
/// Takes a full CLI-format snapshot of the target application window,
/// allocates `@e` refs for all interactive elements, persists the refmap
/// to disk, and writes the JSON envelope into `*out`.
///
/// The JSON shape matches `agent-desktop snapshot`:
/// `{"version":"2.0","ok":true,"command":"snapshot","data":{"app":"...","window":{...},"ref_count":N,"snapshot_id":"...","tree":{...}}}`.
///
/// **`*out` ownership and error behaviour:**
/// - On success (`AD_RESULT_OK`): `*out` is a heap-allocated JSON string with `"ok":true`.
/// Caller must free it with `ad_free_string`.
/// - On a command-level error (e.g. app not found, snapshot failure): `*out` is a
/// heap-allocated JSON string with `"ok":false` and an `"error"` payload. Caller
/// must still free it with `ad_free_string`. The last-error slot is also set.
/// - On an argument or infrastructure error (null adapter, off-main-thread, invalid
/// UTF-8, bad surface discriminant, context failure): `*out` is set to null and no
/// allocation is made. Only the last-error slot is set.
///
/// `app` is tri-state:
/// - null — snapshot the currently focused window (same as running the command with no `--app`).
/// - valid UTF-8 string — snapshot the named application's focused window.
/// - non-null but invalid UTF-8 or exceeding `AD_MAX_STRING_BYTES` — returns `ErrInvalidArgs`.
///
/// `surface` is an `AdSnapshotSurface` discriminant (0 = Window, 1 = Focused, …).
/// An out-of-range value returns `ErrInvalidArgs`.
///
/// This entrypoint always targets the active focused window of the requested
/// application; explicit window targeting (`window_id`) is not yet exposed
/// over the ABI. Progressive traversal (skeleton mode and `--root` drill-down)
/// is likewise not exposed here. Both are planned fast-follows to this
/// entrypoint — agents needing them should use the CLI in the meantime.
///
/// **Dispatch-before-serialize ordering**: the snapshot and refmap persistence
/// occur before the result JSON is serialised. In the near-impossible event
/// that serialisation of an already-valid result fails, `*out` is set to null
/// and `ErrInternal` is returned while the refmap is already written.
///
/// # Safety
///
/// `adapter` must be a non-null pointer from `ad_adapter_create` or
/// `ad_adapter_create_with_session`. `out` must be a non-null writable
/// `*mut *mut c_char`. `app` must be null or a NUL-terminated string within
/// `AD_MAX_STRING_BYTES + 1` bytes. All pointers must remain valid for the
/// duration of the call. `adapter` must be used from the main thread on macOS.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ad_snapshot(
adapter: *const AdAdapter,
app: *const c_char,
surface: i32,
max_depth: u8,
interactive_only: bool,
compact: bool,
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = ptr::null_mut() };
trap_panic(|| {
if let Err(rc) = require_main_thread() {
return rc;
}
guard_non_null!(adapter, c"adapter is null");
let app_filter = unsafe { decode_optional_filter!(app, "app") };
let core_surface = match snapshot_surface_from_c(surface, "surface") {
Ok(s) => s,
Err(e) => {
set_last_error(&e);
return AdResult::ErrInvalidArgs;
}
};
let adapter_ref = unsafe { &*adapter };
let context = match adapter_ref.command_context() {
Ok(ctx) => ctx,
Err(e) => {
let ae = app_error_to_adapter(e);
set_last_error(&ae);
return crate::error::last_error_code();
}
};
let args = SnapshotArgs {
app: app_filter,
window_id: None,
max_depth,
include_bounds: false,
interactive_only,
compact,
surface: core_surface,
skeleton: false,
root_ref: None,
snapshot_id: None,
};
let result = agent_desktop_core::commands::snapshot::execute(
args,
adapter_ref.inner.as_ref(),
&context,
);
unsafe { write_command_envelope("snapshot", result, out) }
})
}
/// Returns the adapter's current health and permission state as a JSON
/// envelope matching the `agent-desktop status` CLI output.
///
/// `ad_status` does not query the accessibility tree; it reads the
/// permission report and ref-store metadata only, so it is safe to call
/// from any thread (unlike tree-traversal commands that require the
/// macOS main thread). On success `*out` is a NUL-terminated,
/// heap-allocated JSON string freed with `ad_free_string`.
///
/// On a command-level failure `*out` is set to a heap-allocated JSON string
/// with `"ok":false` and an `"error"` payload. The caller must still release
/// it with `ad_free_string(*out)`. The last-error slot is also set.
///
/// On an argument or infrastructure failure (null adapter, null out, context
/// error) `*out` is zeroed and only the last-error slot is populated.
///
/// # Safety
///
/// `adapter` must be a non-null pointer returned by `ad_adapter_create`
/// that has not been destroyed. `out` must be a non-null writable
/// `*mut *mut c_char`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ad_status(
adapter: *const crate::AdAdapter,
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = ptr::null_mut() };
guard_non_null!(adapter, c"adapter is null");
trap_panic(|| {
let adapter = unsafe { &*adapter };
let ctx = match adapter.command_context() {
Ok(c) => c,
Err(app_err) => {
let ae = app_error_to_adapter(app_err);
error::set_last_error(&ae);
return error::last_error_code();
}
};
let report = adapter.inner.permission_report();
let result: Result<serde_json::Value, AppError> =
execute_with_report_with_context(&*adapter.inner, &report, &ctx);
unsafe { write_command_envelope("status", result, out) }
})
}
/// Returns the `agent-desktop` version envelope as an owned JSON C string.
///
/// The returned string has the same `{version, ok, command, data}` shape
/// as `agent-desktop version` on the CLI. Free it with `ad_free_string`.
///
/// On success `*out` points to the envelope JSON.
/// On error `*out` is null and the last-error slot is populated.
///
/// # Safety
/// `out` must be a non-null writable `*mut *mut c_char`.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ad_version(out: *mut *mut c_char) -> AdResult {
trap_panic(|| unsafe {
guard_non_null!(out, c"out is null");
*out = ptr::null_mut();
let result = agent_desktop_core::commands::version::execute();
write_command_envelope("version", result, out)
})
}
/// Runs `wait` with the given args, blocking the calling thread until the
/// condition is met or `timeout_ms` elapses.
///
/// On success `*out` is set to a freshly allocated JSON string containing the
/// CLI-format wait envelope (`{version, ok, command, data}`). The caller must
/// release the string with `ad_free_string(*out)`.
///
/// On a command-level failure (e.g. `TIMEOUT`, `ELEMENT_NOT_FOUND`) `*out` is
/// set to a freshly allocated JSON string with `"ok":false` and an `"error"`
/// payload. The caller must still release it with `ad_free_string(*out)`. The
/// last-error slot is also set.
///
/// On an argument or infrastructure failure (null adapter, null args, null out,
/// off-main-thread, invalid UTF-8 field) `*out` is zeroed, the last-error slot
/// is set, and a negative `AdResult` code is returned. No allocation is made.
///
/// # Safety
///
/// `adapter` must be a non-null pointer returned by `ad_adapter_create` that
/// has not been destroyed. `args` must be non-null and point to a valid
/// zero-initialized `AdWaitArgs`. `out` must be non-null and point to a
/// writable `*mut c_char`.
///
/// All `*const c_char` fields inside `AdWaitArgs` must be null or point to
/// readable, NUL-terminated memory within `AD_MAX_STRING_BYTES + 1` bytes.
///
/// `ad_wait` blocks the calling thread for up to `timeout_ms` milliseconds
/// while it holds a live reference into the adapter's allocation. The adapter
/// must outlive the call: do not call `ad_adapter_destroy` on this handle from
/// another thread while `ad_wait` is running — that is a use-after-free. Ensure
/// the wait has returned before destroying the adapter.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ad_wait(
adapter: *const AdAdapter,
args: *const AdWaitArgs,
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = ptr::null_mut() };
guard_non_null!(args, c"args is null");
trap_panic(|| {
if let Err(rc) = require_main_thread() {
return rc;
}
guard_non_null!(adapter, c"adapter is null");
let args = unsafe { &*args };
let adapter_ref = unsafe { &*adapter };
let ms = args.has_ms.then_some(args.ms);
let element = unsafe { decode_optional_filter!(args.element, "element") };
let window = unsafe { decode_optional_filter!(args.window, "window") };
let text = unsafe { decode_optional_filter!(args.text, "text") };
let snapshot_id = unsafe { decode_optional_filter!(args.snapshot_id, "snapshot_id") };
let predicate = unsafe { decode_optional_filter!(args.predicate, "predicate") };
let value = unsafe { decode_optional_filter!(args.value, "value") };
let action_field = unsafe { decode_optional_filter!(args.action, "action") };
let app = unsafe { decode_optional_filter!(args.app, "app") };
let wait_args = WaitArgs {
mode: WaitModeArgs {
ms,
element,
window,
text,
menu: args.menu,
menu_closed: args.menu_closed,
notification: args.notification,
},
predicate: WaitPredicateArgs {
snapshot_id,
predicate,
value,
action: action_field,
count: args.has_count.then_some(args.count),
},
timeout_ms: args.timeout_ms,
app,
};
let ctx = match adapter_ref.command_context() {
Ok(c) => c,
Err(app_err) => {
let adapter_err = app_error_to_adapter(app_err);
error::set_last_error(&adapter_err);
return error::last_error_code();
}
};
let result = agent_desktop_core::commands::wait::execute(
wait_args,
adapter_ref.inner.as_ref(),
&ctx,
);
unsafe { write_command_envelope("wait", result, out) }
})
}

View file

@ -1,9 +1,5 @@
pub(crate) mod envelope_out;
pub mod execute_by_ref;
pub mod snapshot;
pub(crate) mod status;
pub(crate) mod version;
pub mod wait;
pub(crate) mod generated;
use agent_desktop_core::error::{AdapterError, AppError, ErrorCode};

View file

@ -23,6 +23,7 @@ impl AdPolicyKind {
#[cfg(test)]
mod tests {
use super::*;
use agent_desktop_core::interaction_policy::InteractionPolicy;
#[test]
fn discriminants_are_abi_stable() {
@ -30,4 +31,28 @@ mod tests {
assert_eq!(AdPolicyKind::FocusFallback as i32, 1);
assert_eq!(AdPolicyKind::Headed as i32, 2);
}
#[test]
fn policy_kind_headless_maps_to_headless() {
assert_eq!(
AdPolicyKind::Headless.to_interaction_policy(),
InteractionPolicy::headless()
);
}
#[test]
fn policy_kind_focus_fallback_maps_to_focus_fallback() {
assert_eq!(
AdPolicyKind::FocusFallback.to_interaction_policy(),
InteractionPolicy::focus_fallback()
);
}
#[test]
fn policy_kind_headed_maps_to_headed() {
assert_eq!(
AdPolicyKind::Headed.to_interaction_policy(),
InteractionPolicy::headed()
);
}
}

View file

@ -0,0 +1,322 @@
/// Stub-adapter passthrough tests for Family-B command-backed entrypoints.
/// Gate with `--features stub-adapter`.
///
/// # Coverage
///
/// This file exercises:
/// - **Family-B command-backed entrypoints**: `ad_snapshot`, `ad_status`,
/// `ad_wait`, `ad_execute_by_ref`, `ad_version`
/// - **Adapter lifecycle**: `ad_adapter_create`, `ad_adapter_destroy`
/// - **Permissions**: `ad_check_permissions`
///
/// The ~35 Family-A entrypoints (`ad_find`, `ad_execute_action`,
/// `ad_list_windows`, `ad_screenshot`, clipboard, notifications, etc.) are
/// **not covered here**; broader Family-A passthrough is a documented
/// follow-up.
///
/// # Why the stub returns `not_supported`
///
/// The stub's `PlatformAdapter` impl delegates all methods to the trait
/// defaults, which uniformly return `AdapterError::not_supported(…)` →
/// `ErrorCode::PlatformNotSupported`. The JSON envelope therefore carries
/// `"ok":false` and `"error":{"code":"PLATFORM_NOT_SUPPORTED","suggestion":…}`.
///
/// # Exception — `ad_check_permissions`
///
/// The stub's `permission_report()` returns `PermissionState::Denied` (the
/// trait default), not `Unknown`. The FFI maps `Denied` to `ErrPermDenied
/// (-1)`, not `ErrPlatformNotSupported (-8)`. This is the documented signal
/// that permissions are unavailable on the platform; callers should treat
/// both `ErrPermDenied` and `ErrPlatformNotSupported` as "adapter not
/// operational here".
///
/// # Main-thread tolerance
///
/// `ad_snapshot`, `ad_wait`, and `ad_execute_by_ref` each call
/// `require_main_thread()` before touching the adapter. The libtest harness
/// spawns each `#[test]` on a worker thread, so on macOS those guards fire
/// first and return `ErrInternal` with no JSON envelope. The tests accept
/// either outcome:
/// - `ErrInternal` (off-main-thread on macOS, no envelope produced) — ok.
/// - `ErrPlatformNotSupported` + valid JSON envelope — ok.
///
/// Both outcomes are correct; neither is a regression.
///
/// Commands gated by `#[cfg(feature = "stub-adapter")]` so they compile only
/// when the feature is active. The normal test build (`cargo test -p
/// agent-desktop-ffi --tests`) never compiles or runs this file.
#[cfg(feature = "stub-adapter")]
mod common;
#[cfg(feature = "stub-adapter")]
use common::{
AdResult, AdWaitArgs, CStr, ad_adapter_create, ad_adapter_destroy, ad_check_permissions,
ad_execute_by_ref, ad_free_string, ad_last_error_code, ad_last_error_message, ad_snapshot,
ad_status, ad_version, ad_wait, default_action, with_adapter,
};
/// A helper that parses the JSON envelope written to `*out` and asserts
/// `PLATFORM_NOT_SUPPORTED` shape. Returns `true` when the envelope was
/// present and verified; `false` when `*out` is null (meaning the function
/// returned early — e.g. due to the macOS off-main-thread guard firing before
/// the adapter was reached).
#[cfg(feature = "stub-adapter")]
unsafe fn assert_platform_not_supported_envelope(out: *mut std::os::raw::c_char) -> bool {
if out.is_null() {
return false;
}
let json_str = unsafe { CStr::from_ptr(out) }
.to_str()
.expect("envelope must be valid UTF-8");
let parsed: serde_json::Value =
serde_json::from_str(json_str).expect("envelope must be valid JSON");
assert_eq!(
parsed["ok"].as_bool(),
Some(false),
"stub adapter must produce ok:false envelope — got: {json_str}"
);
assert_eq!(
parsed["error"]["code"].as_str(),
Some("PLATFORM_NOT_SUPPORTED"),
"error.code must be PLATFORM_NOT_SUPPORTED — got: {json_str}"
);
let suggestion = parsed["error"]["suggestion"].as_str().unwrap_or_default();
assert!(
!suggestion.is_empty(),
"error.suggestion must be non-empty — got: {json_str}"
);
true
}
/// `ad_version` has no adapter dependency and must always succeed even on a
/// stub build.
#[cfg(feature = "stub-adapter")]
#[test]
fn stub_ad_version_always_succeeds() {
unsafe {
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
let rc = ad_version(&mut out);
assert_eq!(
rc,
AdResult::Ok,
"ad_version must succeed regardless of adapter (no adapter dependency)"
);
assert!(!out.is_null(), "out must be non-null on success");
let json_str = CStr::from_ptr(out).to_str().expect("valid UTF-8");
let parsed: serde_json::Value = serde_json::from_str(json_str).expect("valid JSON");
assert_eq!(parsed["ok"].as_bool(), Some(true));
assert!(parsed["data"]["version"].is_string());
ad_free_string(out);
}
}
/// `ad_check_permissions` maps the stub adapter's `Denied` permission state
/// to `ErrPermDenied (-1)`, not `ErrPlatformNotSupported (-8)`. This is the
/// documented exception. Cross-platform callers must treat both codes as
/// "adapter not operational here".
#[cfg(feature = "stub-adapter")]
#[test]
fn stub_ad_check_permissions_returns_err_perm_denied() {
with_adapter(|adapter| unsafe {
let rc = ad_check_permissions(adapter);
assert_eq!(
rc,
AdResult::ErrPermDenied,
"stub adapter permission_report() returns Denied → ErrPermDenied (-1), \
not ErrPlatformNotSupported (-8). Both mean the adapter is not operational."
);
let msg = ad_last_error_message();
assert!(
!msg.is_null(),
"last-error message must be set on ErrPermDenied"
);
assert_eq!(ad_last_error_code(), AdResult::ErrPermDenied);
});
}
/// `ad_status` is not main-thread gated. Under the stub adapter it returns a
/// valid JSON envelope with `ok:true` because `execute_with_report_with_context`
/// reports the Denied permission state as a valid (non-error) status payload.
///
/// This test asserts the envelope is produced and is valid JSON — the specific
/// permission values are stub-specific but the shape must always match the CLI.
#[cfg(feature = "stub-adapter")]
#[test]
fn stub_ad_status_returns_valid_envelope() {
with_adapter(|adapter| unsafe {
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
let rc = ad_status(adapter, &mut out);
assert!(
!out.is_null(),
"ad_status must always produce an envelope (ok or error) — rc={rc:?}"
);
let json_str = CStr::from_ptr(out)
.to_str()
.expect("envelope must be valid UTF-8");
let parsed: serde_json::Value =
serde_json::from_str(json_str).expect("envelope must be valid JSON");
assert!(
parsed["ok"].is_boolean(),
"envelope must carry ok field — got: {json_str}"
);
assert_eq!(
parsed["command"].as_str(),
Some("status"),
"command field must be 'status'"
);
let _ = rc;
ad_free_string(out);
});
}
/// `ad_snapshot` calls `require_main_thread()` before reaching the adapter.
/// On macOS the libtest worker thread fires the guard first, returning
/// `ErrInternal` with no envelope. On non-macOS the call proceeds to the stub
/// adapter and must produce a `PLATFORM_NOT_SUPPORTED` error envelope.
#[cfg(feature = "stub-adapter")]
#[test]
fn stub_ad_snapshot_platform_not_supported_or_off_main_thread() {
with_adapter(|adapter| unsafe {
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
let rc = ad_snapshot(adapter, std::ptr::null(), 0, 6, false, false, &mut out);
match rc {
AdResult::ErrInternal => {
assert!(
out.is_null(),
"ErrInternal (off-main-thread guard) must leave out null"
);
}
AdResult::ErrPlatformNotSupported => {
let had_envelope = assert_platform_not_supported_envelope(out);
assert!(
had_envelope,
"ErrPlatformNotSupported must be accompanied by an error envelope"
);
ad_free_string(out);
}
other => {
panic!(
"stub ad_snapshot must return ErrInternal (macOS off-main-thread) or \
ErrPlatformNotSupported, got {other:?}"
);
}
}
});
}
/// `ad_wait` is exercised in its adapter-free `ms` mode: it sleeps for `ms`
/// and returns an Ok envelope without touching the adapter, proving the
/// entrypoint is callable and structured under the stub without crashing (or
/// `ErrInternal` off the main thread on macOS). ad_wait's adapter-dependent
/// element/predicate modes need a real refmap from a successful snapshot,
/// which the stub cannot produce, so PLATFORM_NOT_SUPPORTED parity for those
/// modes is covered by the real E2E harness rather than this stub gate.
#[cfg(feature = "stub-adapter")]
#[test]
fn stub_ad_wait_ms_mode_callable_under_stub() {
with_adapter(|adapter| unsafe {
let args = AdWaitArgs {
ms: 1,
has_ms: true,
element: std::ptr::null(),
window: std::ptr::null(),
text: std::ptr::null(),
menu: false,
menu_closed: false,
notification: false,
snapshot_id: std::ptr::null(),
predicate: std::ptr::null(),
value: std::ptr::null(),
action: std::ptr::null(),
count: 0,
has_count: false,
timeout_ms: 200,
app: std::ptr::null(),
};
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
let rc = ad_wait(adapter, &args, &mut out);
match rc {
AdResult::Ok => {
if !out.is_null() {
ad_free_string(out);
}
}
AdResult::ErrInternal => {
assert!(
out.is_null(),
"ErrInternal (off-main-thread guard) must leave out null"
);
}
other => {
panic!(
"stub ad_wait (ms mode) must return Ok (adapter-free timer) or \
ErrInternal (macOS off-main-thread), got {other:?}"
);
}
}
});
}
/// `ad_execute_by_ref` calls `require_main_thread()` before reaching the
/// adapter. Same main-thread tolerance applies. Uses a syntactically valid
/// ref-id so arg-decode succeeds before the thread check.
#[cfg(feature = "stub-adapter")]
#[test]
fn stub_ad_execute_by_ref_platform_not_supported_or_off_main_thread() {
with_adapter(|adapter| unsafe {
let ref_id = std::ffi::CString::new("@e1").unwrap();
let action = default_action();
let mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
let rc = ad_execute_by_ref(
adapter,
ref_id.as_ptr(),
std::ptr::null(),
&action,
0,
&mut out,
);
match rc {
AdResult::ErrInternal => {
assert!(
out.is_null(),
"ErrInternal (off-main-thread guard) must leave out null"
);
}
AdResult::ErrPlatformNotSupported
| AdResult::ErrSnapshotNotFound
| AdResult::ErrStaleRef => {
if !out.is_null() {
let json_str = CStr::from_ptr(out)
.to_str()
.expect("envelope must be valid UTF-8");
let parsed: serde_json::Value =
serde_json::from_str(json_str).expect("envelope must be valid JSON");
assert_eq!(parsed["ok"].as_bool(), Some(false));
assert!(!parsed["error"]["code"].as_str().unwrap_or("").is_empty());
ad_free_string(out);
}
}
other => {
panic!(
"stub ad_execute_by_ref must return ErrInternal, ErrPlatformNotSupported, \
ErrSnapshotNotFound, or ErrStaleRef, got {other:?}"
);
}
}
});
}
/// Confirm that `ad_adapter_create` itself does not panic under the stub
/// feature and produces a non-null handle that can be destroyed cleanly.
#[cfg(feature = "stub-adapter")]
#[test]
fn stub_adapter_create_and_destroy_round_trip() {
unsafe {
let adapter = ad_adapter_create();
assert!(
!adapter.is_null(),
"stub ad_adapter_create must not return null"
);
ad_adapter_destroy(adapter);
}
}

View file

@ -0,0 +1,127 @@
/// Codegen exhaustiveness + per-command policy pin tests.
///
/// Independently verifies that:
/// 1. Every expected Family-B command has a generated `ad_<name>` wrapper in
/// `src/commands/generated.rs` (the committed output).
/// 2. Each command's interaction-policy pin is preserved across refactors.
///
/// Adding a new Family-B command requires updating EXPECTED_COMMANDS here
/// and adding a template to build.rs. This test fails the build if either
/// side is out of sync with the generated file.
mod common;
use agent_desktop_core::action::Action;
use agent_desktop_core::interaction_policy::InteractionPolicy;
/// Known Family-B commands — the exhaustive set of command-backed JSON
/// wrappers that must appear in `src/commands/generated.rs`.
const EXPECTED_COMMANDS: &[&str] = &["execute_by_ref", "snapshot", "status", "version", "wait"];
#[test]
fn generated_file_contains_all_expected_wrappers() {
let src = include_str!("../src/commands/generated.rs");
for name in EXPECTED_COMMANDS {
let fn_sig = format!("pub unsafe extern \"C\" fn ad_{name}(");
assert!(
src.contains(&fn_sig),
"generated src/commands/generated.rs is missing `ad_{name}` — \
check templates in build.rs and run cargo build to regenerate"
);
}
}
#[test]
fn expected_command_count_matches_generated_wrapper_count() {
let src = include_str!("../src/commands/generated.rs");
let actual_count = src
.lines()
.filter(|l| l.contains("pub unsafe extern \"C\" fn ad_"))
.count();
assert_eq!(
actual_count,
EXPECTED_COMMANDS.len(),
"generated file has {actual_count} wrappers but EXPECTED_COMMANDS has {} — \
update EXPECTED_COMMANDS to match the templates in build.rs",
EXPECTED_COMMANDS.len()
);
}
#[test]
fn generated_wrappers_are_in_alphabetical_order() {
let src = include_str!("../src/commands/generated.rs");
let fn_names: Vec<&str> = src
.lines()
.filter(|l| l.contains("pub unsafe extern \"C\" fn ad_"))
.filter_map(|l| {
l.split("fn ad_")
.nth(1)
.and_then(|rest| rest.split('(').next())
})
.collect();
let sorted = {
let mut s = fn_names.clone();
s.sort_unstable();
s
};
assert_eq!(
fn_names, sorted,
"generated wrappers must appear in alphabetical order"
);
}
#[test]
fn policy_type_text_base_is_focus_fallback() {
let base = Action::TypeText("hi".into()).base_interaction_policy();
assert_eq!(
base,
InteractionPolicy::focus_fallback(),
"TypeText base policy must be focus_fallback (KTD6)"
);
}
#[test]
fn policy_click_base_is_headless() {
let base = Action::Click.base_interaction_policy();
assert_eq!(
base,
InteractionPolicy::headless(),
"Click base policy must be headless (KTD6)"
);
}
#[test]
fn policy_headless_caller_cannot_downgrade_type_text() {
let base = Action::TypeText("x".into()).base_interaction_policy();
let effective = base.join(InteractionPolicy::headless());
assert_eq!(
effective,
InteractionPolicy::focus_fallback(),
"headless caller must not downgrade TypeText below focus_fallback"
);
}
#[test]
fn policy_headed_caller_elevates_click_to_headed() {
let base = Action::Click.base_interaction_policy();
let effective = base.join(InteractionPolicy::headed());
assert_eq!(
effective,
InteractionPolicy::headed(),
"headed caller must elevate Click to headed"
);
}
#[test]
fn click_base_plus_focus_fallback_caller_gives_focus_fallback() {
let base = Action::Click.base_interaction_policy();
let effective = base.join(InteractionPolicy::focus_fallback());
assert_eq!(effective, InteractionPolicy::focus_fallback());
}
#[test]
fn type_text_base_plus_headed_caller_becomes_headed() {
let base = Action::TypeText("x".into()).base_interaction_policy();
let effective = base.join(InteractionPolicy::headed());
assert_eq!(effective, InteractionPolicy::headed());
}

View file

@ -591,9 +591,14 @@ Regular `release` profile keeps `panic = "abort"` for the CLI binary, so a panic
### CI Hooks Added
- `cargo build --profile release-ffi -p agent-desktop-ffi` on every PR
- `cargo test -p agent-desktop-ffi --tests` runs the 3 integration suites
- FFI header contract check compiles the committed header from C tests. Header regeneration is an explicit maintainer action via `scripts/update-ffi-header.sh`, not part of ordinary builds.
Current gates in `.github/workflows/ci.yml` on every PR:
- `cargo build --profile release-ffi -p agent-desktop-ffi` + `cargo test -p agent-desktop-ffi --tests` (`c_abi_harness`, `c_header_compile`, `error_lifetime` integration suites) in the main `test` job
- `ffi-python-smoke` — Python ctypes smoke harness (`tests/ffi-python/smoke.py`) with `AD_EXPECT_STUB=1`
- `ffi-header-drift``cbindgen --verify` (pinned 0.29.4) against the committed header; exits non-zero on any diff
- `ffi-panic-guard` — asserts `panic = "unwind"` in `Cargo.toml` and runs the `panic_spike` example to prove `catch_unwind` survives the `release-ffi` profile
- `ffi-passthrough``--test c_abi_passthrough --features stub-adapter` confirms command entrypoints return `PLATFORM_NOT_SUPPORTED` envelopes
- `ffi-codegen-drift` — regenerates `src/commands/generated.rs` and checks for drift against the committed file
### New Dependencies
@ -608,14 +613,19 @@ Regular `release` profile keeps `panic = "abort"` for the CLI binary, so a panic
- Any new `PlatformAdapter` method that lands in Phase 2/3 must add a matching `ad_*` FFI wrapper in the same PR that adds the adapter method.
- MCP server mode (Phase 4) is a parallel transport, not an FFI consumer — it calls `PlatformAdapter` directly.
### Known Gaps (surfaced by 2026-04-17 research)
### Gap Status (from 2026-04-17 research)
- `ad_abi_version()` export is still missing (consumers have no runtime compat check)
- CLI-flagship primitives (`snapshot` with refs + refmap, `batch`, `wait`, `version`, `status`) are not wired through FFI — consumers today cannot replay the `click @e5` idiom without shelling out to the CLI
- No `tracing::` log callback — in-process consumers lose debug output
- No `pyo3` / `maturin` wheel or `cffi` wrapper ships with the repo
**Resolved:**
These items are tracked under P2-O16 below: registry migration via `build.rs` filesystem enumeration, `ad_set_log_callback` with redaction, and `ad_execute_by_ref` + descriptor confirms.
- `ad_abi_version()` and `ad_init(expected_major)` now ship; consumers call `ad_init` after `dlopen` for a runtime compat check.
- `ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_version`, and `ad_status` are now exported. The 5 command-backed wrappers are generated by `build.rs` from `codegen_templates/*.rs.in` into the committed `src/commands/generated.rs`; do not hand-edit that file.
- `ad_set_log_callback(fn(level, msg))` ships; in-process consumers can install a tracing layer for debug output.
**Still open:**
- No `pyo3` / `maturin` wheel or `cffi` wrapper ships with the repo — the Python consumer path is ctypes. Potential Phase 2 follow-up.
P2-O16 (below) scopes to the remaining registry migration: full `CommandDescriptor`-registry codegen so adding a CLI command automatically generates an FFI entry, versus the current 5-command template set.
---

View file

@ -1,6 +1,6 @@
---
name: agent-desktop-ffi
version: 0.3.0
version: 0.4.0
tags: ffi, c-bindings, cdylib, python, swift, node, go, rust-ffi
requirements:
- agent-desktop-ffi
@ -8,7 +8,10 @@ description: >
C-ABI bindings over agent-desktop's PlatformAdapter. Consumers
(Python ctypes, Swift, Node ffi-napi, Go cgo, C++, Ruby fiddle)
link libagent_desktop_ffi.{dylib,so,dll} and call `ad_*` functions
directly instead of spawning the CLI binary per call.
directly instead of spawning the CLI binary per call. The canonical
observe-act workflow is: ad_init → ad_adapter_create[_with_session]
→ ad_snapshot → parse @e refs → ad_execute_by_ref → ad_free_string
→ ad_adapter_destroy.
---
# agent-desktop-ffi
@ -24,6 +27,11 @@ The output is `target/release-ffi/libagent_desktop_ffi.dylib`
(`.so` on Linux, `.dll` on Windows) plus a committed C header at
`crates/ffi/include/agent_desktop.h`.
A Python ctypes smoke harness lives at `tests/ffi-python/smoke.py` and
serves as a worked end-to-end example covering the ABI handshake, struct
size validation, `ad_version`, and the snapshot pipeline leg. See
`tests/ffi-python/README.md` for usage.
Four reference topics, loaded as needed:
- [ownership.md](references/ownership.md) — who allocates / who frees,
@ -33,84 +41,141 @@ Four reference topics, loaded as needed:
- [threading.md](references/threading.md) — macOS main-thread rule,
AXIsProcessTrusted inheritance when Python/Node dlopens the cdylib,
and the single-owner handle invariant.
- [build-and-link.md](references/build-and-link.md) — minimum working
example for Python ctypes and a C program that links the dylib.
- [build-and-link.md](references/build-and-link.md) — ABI handshake,
struct size validation, minimal C and Python examples, observe-act
workflow, and prebuilt archive locations.
## ⚠ Core constraints before you integrate
## Observe-act workflow (canonical path)
```
ad_init(AD_ABI_VERSION_MAJOR) // verify header ↔ dylib match
adapter = ad_adapter_create_with_session("s1") // or ad_adapter_create()
rc = ad_snapshot(adapter, "Finder", 0, 10, false, false, &json_out)
// parse json_out: locate @e refs in data.tree, note data.snapshot_id
ad_free_string(json_out)
// build action:
AdAction act = {0}; act.kind = AD_ACTION_KIND_CLICK;
rc = ad_execute_by_ref(adapter, "@e5", snapshot_id, &act, 0, &result_out)
ad_free_string(result_out)
ad_adapter_destroy(adapter)
```
`ad_snapshot` returns a `{version, ok, command, data}` JSON envelope
identical to the CLI output. The `data.tree` field contains `@e`-prefixed
ref IDs for interactive elements. Pass a ref ID and the `snapshot_id`
to `ad_execute_by_ref` to drive the CLI-semantics ref-action pipeline
(RefStore load → strict resolution → actionability preflight → dispatch).
## Core constraints
- **ABI handshake.** Call `ad_init(AD_ABI_VERSION_MAJOR)` once after loading the
dylib. A mismatch between the compiled-in constant and the loaded dylib returns
`AD_RESULT_ERR_INVALID_ARGS` — abort rather than proceed. You can also read the
raw dylib major via `ad_abi_version()` for diagnostic display. New `ad_*` symbols
and new error codes are additive (no bump required); removed or layout-changed
symbols increment the major.
- **Session adapters.** `ad_adapter_create_with_session("session-id")` associates
the adapter with a named session for refmap persistence. Passing the same session
ID across adapter lifetimes lets `ad_execute_by_ref` with `snapshot_id=NULL`
target the latest snapshot from that session. Session IDs: 164 chars, ASCII
alphanumeric / `-` / `_`. Invalid IDs return null (check `ad_last_error_*`).
- **Main thread only (macOS).** Call every adapter-touching entrypoint
(`ad_get_tree`, `ad_resolve_element`, `ad_execute_action`,
`ad_screenshot`, clipboard, launch/close, window ops, observation,
notifications, etc.) from the process's main thread. The FFI enforces
this at runtime in **every build profile** — a worker-thread call
returns `AD_RESULT_ERR_INTERNAL` with a diagnostic last-error. On
non-macOS platforms the check is a compile-time true; there is no
runtime cost.
(`ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_get_tree`, `ad_find`,
`ad_get`, `ad_is`, `ad_resolve_element`, `ad_execute_action`,
`ad_execute_action_with_policy`, `ad_execute_ref_action_with_policy`,
`ad_screenshot`, clipboard get/set/clear, mouse, drag, launch, close, focus,
window-op, list-apps/windows/surfaces, notification list/dismiss/action)
from the process's main thread. macOS accessibility and Cocoa APIs require
this. The FFI enforces this at runtime in every build profile — a worker-thread
call returns `AD_RESULT_ERR_INTERNAL` with a diagnostic last-error. On
non-macOS platforms the check is a compile-time true; there is no runtime cost.
- **Release profile.** `cargo build --release` produces
`panic = "abort"` — any Rust panic inside an `extern "C"` fn will
`SIGABRT` the host. Use `--profile release-ffi` to get the correct
`panic = "unwind"` profile. CI enforces this.
- **Release profile.** `cargo build --release` produces `panic = "abort"`
any Rust panic inside an `extern "C"` fn will `SIGABRT` the host. Use
`--profile release-ffi` to get the correct `panic = "unwind"` profile. CI
enforces this.
- **Last-error lifetime.** Pointers returned by `ad_last_error_*`
remain valid across any number of subsequent *successful* FFI calls
on the same thread. Only the next failing call rotates them. Cache
the pointer once, read it as many times as you need.
- **Last-error lifetime.** Pointers returned by `ad_last_error_*` remain valid
across any number of subsequent *successful* FFI calls on the same thread.
Only the next failing call rotates them. Cache the pointer once, read it as
many times as you need.
- **Handle release.** Every `ad_resolve_element` result must be
released with `ad_free_handle(adapter, handle)` on the same adapter
that produced it before that adapter is destroyed. On macOS this
balances the internal `CFRetain`; on Windows/Linux the call is a no-op
but safe to issue.
- **ad_last_error_details.** A fourth accessor, `ad_last_error_details()`,
returns a borrowed JSON string carrying structured details (e.g. the
actionability check report on `ACTION_FAILED`, candidate summaries on
`AMBIGUOUS_TARGET`). The details may contain element names, values, and window
titles from the user's screen — treat as sensitive diagnostics and avoid routing
to shared log surfaces.
- **Action policy.** `ad_execute_action` uses headless policy by default.
`ad_execute_ref_action_with_policy` should also use headless for semantic
ref actions that must fail closed, except `AD_ACTION_KIND_TYPE_TEXT`: use
`AD_POLICY_KIND_FOCUS_FALLBACK` for CLI `type` parity because typing needs
focus but not cursor movement. Use `AD_POLICY_KIND_HEADED` only for explicit
headed input semantics.
- **Handle release.** Every `ad_resolve_element` / `ad_find` result must be
released with `ad_free_handle(adapter, &handle)` on the same adapter that
produced it, before that adapter is destroyed. On macOS this balances the
internal `CFRetain`; on Windows/Linux the call is a no-op but safe to issue.
`ad_free_handle` zeroes `handle.ptr` so a follow-up call is a safe no-op.
- **Ref-action preflight.** `ad_execute_ref_action_with_policy` resolves the
ref strictly and runs the live actionability preflight (visible, stable,
enabled, supported action, policy, editable) before dispatching — a disabled
or unsupported target fails before any platform call. On
`AD_RESULT_ERR_ACTION_FAILED`, the structured check report is available as
JSON via `ad_last_error_details()`. Details may carry element names, values,
and window titles from the user's screen — treat them as sensitive
diagnostics and keep them out of shared log surfaces.
- **Primary ref-action path.** `ad_execute_by_ref` is the recommended entrypoint
for the observe-act loop: it loads the RefStore, looks up the ref in the refmap
(STALE_REF on miss), runs strict element re-identification (STALE_REF / AMBIGUOUS_TARGET),
runs the live actionability preflight, then dispatches. TypeText and PressKey
default to `focus_fallback` policy (matching CLI `type`/`press-key`); all other
actions default to `headless`. Pass `AD_POLICY_KIND_HEADED` (2) to opt in to
cursor-based fallbacks.
- **Action result steps.** `AdActionResult.steps` mirrors the CLI `steps`
array for activation-chain actions. Each entry has `label` and `outcome`
strings and is owned by the result; release it with
`ad_free_action_result(&out)`.
- **Low-level action paths.** `ad_execute_action` (headless, no preflight) and
`ad_execute_action_with_policy` are raw escape hatches for callers holding a live
`AdNativeHandle` from `ad_resolve_element` / `ad_find`. Use them when you need
to bypass the ref-action pipeline. `ad_execute_ref_action_with_policy` accepts a
pre-resolved `AdRefEntry` instead of a ref string — useful when you have
serialized an entry outside the RefStore pipeline.
- **Tracing.** CLI `--trace` is not inherited by the C ABI; FFI hosts should
record `AdResult`, `ad_last_error_*`, action results, and host correlation IDs
in their own logs.
- **Ref-action preflight.** `ad_execute_by_ref` and `ad_execute_ref_action_with_policy`
both resolve the element strictly and run the live actionability preflight
(visible, stable, enabled, supported action, policy, editable) before dispatching
— a disabled or unsupported target fails before any platform call. On
`AD_RESULT_ERR_ACTION_FAILED`, the structured check report is available as JSON
via `ad_last_error_details()`.
- **No wait surface.** The CLI's `wait` command (element predicates including
`--predicate actionable --action ...`, window/text/menu/notification waits)
is not exposed over the C ABI. FFI hosts own their own polling loops; the
actionability preflight inside `ad_execute_ref_action_with_policy` is the
equivalent per-call readiness check.
- **Action result steps.** `AdActionResult.steps` mirrors the CLI `steps` array
for activation-chain actions. Each entry has `label` and `outcome` strings and
is owned by the result; release with `ad_free_action_result(&out)`.
- **Tracing / log callback.** `ad_set_log_callback(cb)` installs a `tracing`
subscriber layer that delivers events as JSON to your callback. `cb` receives an
int32_t level (1=ERROR … 5=TRACE) and a `const char *msg` valid only for the
duration of the call. Pass `NULL` to unregister. The layer is installed on the
first non-null call; if a foreign global subscriber already owns the process at
that point, the install fails with `AD_RESULT_ERR_INTERNAL` and no events are
ever delivered. Sensitive field values (password, token, text, …) are replaced
with `{"redacted":true}` before formatting. A panicking callback is caught and
silently discarded. The callback may fire from threads other than the registering
thread, and may still fire briefly after a `NULL` unregister — keep the callback
and any data it captures valid for the process lifetime.
- **Wait.** `ad_wait(adapter, args, &out)` runs the full CLI `wait` command
(element-appear, window-appear, text-appear, menu-open/close, notification,
element predicates). Zero-initialize `AdWaitArgs`, set the fields you need, and
validate the struct size against `AD_WAIT_ARGS_SIZE` / `ad_wait_args_size()` before
calling. The output is a `{version, ok, command, data}` JSON envelope freed with
`ad_free_string`. `ad_wait` blocks the calling thread up to `timeout_ms` ms —
ensure the adapter is not destroyed from another thread while it is running.
- **Text input privacy.** On macOS, focus-fallback or headed text insertion may
briefly use the clipboard for non-ASCII text. For sensitive text, prefer
`AD_ACTION_KIND_SET_VALUE` with `AD_POLICY_KIND_HEADLESS` when the target
supports settable values. Do not use headless `AD_ACTION_KIND_TYPE_TEXT` as
CLI-parity ref input; actionability rejects it before dispatch.
supports settable values.
- **Enum discriminants.** Every `#[repr(i32)]` enum field is validated
at the C boundary — invalid discriminants return
`AD_RESULT_ERR_INVALID_ARGS` instead of undefined behavior.
- **Enum discriminants.** Every `#[repr(i32)]` enum field is validated at the C
boundary — invalid discriminants return `AD_RESULT_ERR_INVALID_ARGS` instead of
undefined behavior.
- **ABI is unstable before 1.0.** The header lists the exact current
shapes. Anything added or reordered in a later patch is a breaking
change; pin the version of libagent_desktop_ffi you link against.
- **ABI stability.** The major version in `AD_ABI_VERSION_MAJOR` increments on any
breaking change (removed symbol, incompatible layout). Additive changes (new
symbols, new error codes) do not bump it. Before 1.0, pin the exact version of
libagent_desktop_ffi you link against.
- **`ad_get_tree` returns a raw adapter tree, not the CLI snapshot.**
Ref IDs are always null, no skeleton/drill-down pipeline is wired
through, and `interactive_only` / `compact` follow adapter
semantics which may diverge slightly from the CLI's post-processed
shape. Use `ad_find` + `ad_get` / `ad_is` for point lookups, or
invoke the CLI if you need CLI-parity JSON snapshots.
- **`ad_get_tree` vs `ad_snapshot`.** `ad_get_tree` returns a raw flat BFS tree
without `@e` refs, no refmap persistence, and no JSON envelope — use it for
custom traversal or UI inspection. For observe-act agents that drive actions via
`ad_execute_by_ref`, always start with `ad_snapshot`.

View file

@ -8,14 +8,113 @@ cargo build --profile release-ffi -p agent-desktop-ffi
Output:
- macOS: `target/release-ffi/libagent_desktop_ffi.dylib` (~470 KB)
- macOS: `target/release-ffi/libagent_desktop_ffi.dylib`
- Linux: `target/release-ffi/libagent_desktop_ffi.so`
- Windows: `target/release-ffi/agent_desktop_ffi.dll`
The generated header is at `crates/ffi/include/agent_desktop.h`.
CI validates that the committed header matches what `cargo build`
The generated header is at `crates/ffi/include/agent_desktop.h`. CI
validates that the committed header matches what `cargo build`
regenerates — if you change a type in `crates/ffi/src/`, rebuild
locally and commit the header.
locally and commit the updated header.
`--profile release-ffi` keeps `panic = "unwind"`, which is required for
the `catch_unwind` traps inside every `extern "C"` entrypoint. The default
`release` profile uses `panic = "abort"` (for CLI binary-size reasons) and
silently defeats those traps.
## Prebuilt archives
Every GitHub release ships prebuilt archives for:
- macOS arm64 and x86_64
- Linux x64 and arm64 (glibc)
- Windows x64 MSVC
Each archive contains the dylib/so/dll, `include/agent_desktop.h`, and
`LICENSE`. Integrity: compare against `checksums.txt` in the release
assets. Supply-chain verification: each release is signed via Sigstore
attestation — verify with `cosign verify-blob` before deploying.
## ABI handshake (do this first)
After `dlopen` / `LoadLibrary`, compare the dylib major to the header you
compiled against before calling anything else:
```c
AdResult rc = ad_init(AD_ABI_VERSION_MAJOR);
if (rc != AD_RESULT_OK) {
// header and dylib have incompatible major versions
fprintf(stderr, "ABI mismatch: %s\n", ad_last_error_message());
return -1;
}
```
Alternatively, read the raw dylib major and compare yourself:
```c
uint32_t dylib_major = ad_abi_version();
if (dylib_major != AD_ABI_VERSION_MAJOR) {
fprintf(stderr, "ABI major: header=%u dylib=%u\n",
AD_ABI_VERSION_MAJOR, dylib_major);
abort();
}
```
`ad_init` returns `AD_RESULT_ERR_INVALID_ARGS` on mismatch (with a
diagnostic in `ad_last_error_message`). A mismatch means the header you
compiled against and the loaded dylib are incompatible — do not call
anything further.
## Struct size validation
Languages whose struct layout may diverge from C (Python ctypes, Go cgo,
JNI, etc.) must validate every size-pinned struct before passing it to
the library. The FFI exposes three validation layers:
1. **Header macros**: `AD_ACTION_SIZE`, `AD_WAIT_ARGS_SIZE`,
`AD_REF_ENTRY_SIZE`, `AD_DRAG_PARAMS_SIZE`, `AD_ACTION_RESULT_SIZE`,
`AD_ACTION_STEP_SIZE`, `AD_ELEMENT_STATE_SIZE`.
2. **Runtime getters**: `ad_action_size()`, `ad_wait_args_size()`,
`ad_ref_entry_size()`, `ad_drag_params_size()`, `ad_action_result_size()`,
`ad_action_step_size()`, `ad_element_state_size()` — each returns the
same value the macro encodes, compiled from the Rust side.
3. **C11 static asserts** in the header (`#ifndef AGENT_DESKTOP_ABI_ASSERTS`)
catch mismatches at C compile time.
Compare your binding's `sizeof` equivalent against the getter at load
time, before building or passing any of these structs. The Python smoke
harness (`tests/ffi-python/smoke.py` Leg 2) demonstrates this check for
all size-pinned structs in a single loop.
## Worked example: Python ctypes smoke harness
`tests/ffi-python/smoke.py` is the canonical reference for Python
consumers. It covers:
- Leg 1: `ad_abi_version()` vs `AD_ABI_VERSION_MAJOR` (header/dylib match)
- Leg 2: every `ad_*_size()` getter vs its `AD_*_SIZE` macro (struct layout)
- Leg 3: `ad_version()` → parse JSON → `ad_free_string` (basic pipeline)
- Leg 4: `ad_adapter_create``ad_snapshot``ad_free_string`
`ad_adapter_destroy` (full adapter lifecycle, stub passes through
`PLATFORM_NOT_SUPPORTED`)
Run it:
```bash
python3 tests/ffi-python/smoke.py \
target/release-ffi/libagent_desktop_ffi.dylib \
crates/ffi/include/agent_desktop.h
```
Or with environment variables:
```bash
AD_DYLIB_PATH=target/release-ffi/libagent_desktop_ffi.dylib \
AD_HEADER_PATH=crates/ffi/include/agent_desktop.h \
python3 tests/ffi-python/smoke.py
```
No `pip install` required — `smoke.py` uses the Python standard library only.
## Minimal C example
@ -24,9 +123,20 @@ locally and commit the header.
#include "agent_desktop.h"
int main(void) {
AdAdapter *adapter = ad_adapter_create();
if (!adapter) return 1;
// 1. ABI handshake
if (ad_init(AD_ABI_VERSION_MAJOR) != AD_RESULT_OK) {
fprintf(stderr, "ABI mismatch: %s\n", ad_last_error_message());
return 1;
}
// 2. Create adapter
AdAdapter *adapter = ad_adapter_create();
if (!adapter) {
fprintf(stderr, "adapter_create failed: %s\n", ad_last_error_message());
return 1;
}
// 3. Check permissions
AdResult rc = ad_check_permissions(adapter);
if (rc != AD_RESULT_OK) {
fprintf(stderr, "permission denied: %s\n", ad_last_error_message());
@ -34,20 +144,26 @@ int main(void) {
return 1;
}
/* Opaque list handle — walk via _count / _get, free with _free. */
AdAppList *list = NULL;
rc = ad_list_apps(adapter, &list);
if (rc == AD_RESULT_OK) {
uint32_t count = ad_app_list_count(list);
for (uint32_t i = 0; i < count; i++) {
const AdAppInfo *app = ad_app_list_get(list, i);
if (app) {
printf("%s (pid %d)\n", app->name, app->pid);
}
}
ad_app_list_free(list);
// 4. Snapshot the focused window
char *json_out = NULL;
rc = ad_snapshot(adapter,
NULL, // app (null = focused window)
0, // surface = Window
10, // max_depth
false, // interactive_only
false, // compact
&json_out);
if (rc == AD_RESULT_OK && json_out) {
printf("%s\n", json_out);
// parse JSON to find @e refs in data.tree
ad_free_string(json_out);
} else if (json_out) {
// command-level error — ok:false envelope, still must free
fprintf(stderr, "snapshot error: %s\n", json_out);
ad_free_string(json_out);
} else {
fprintf(stderr, "list_apps failed: %s\n", ad_last_error_message());
// infrastructure error — *out is null
fprintf(stderr, "snapshot failed: %s\n", ad_last_error_message());
}
ad_adapter_destroy(adapter);
@ -60,41 +176,102 @@ Compile:
```sh
clang -I./crates/ffi/include main.c \
-L./target/release-ffi -lagent_desktop_ffi \
-o list_apps
-o snapshot_demo
install_name_tool -change \
libagent_desktop_ffi.dylib \
@executable_path/target/release-ffi/libagent_desktop_ffi.dylib \
list_apps
snapshot_demo
```
## Minimal Python ctypes example
## Observe-act workflow in C
```python
import ctypes
from ctypes import c_int32, c_char_p, POINTER, Structure, c_uint32
After parsing the snapshot JSON and extracting a ref ID (e.g. `"@e5"`):
lib = ctypes.CDLL("./target/release-ffi/libagent_desktop_ffi.dylib")
```c
AdAction act = {0}; // zero-init before setting any field
act.kind = AD_ACTION_KIND_CLICK;
# Opaque adapter handle
class AdAdapter(ctypes.Structure):
pass
char *result = NULL;
AdResult rc = ad_execute_by_ref(
adapter,
"@e5", // ref ID from snapshot data.tree
NULL, // snapshot_id — NULL = latest for this session
&act,
0, // policy = Headless
&result
);
if (result) {
// parse JSON — ok:true on success, ok:false on STALE_REF etc.
printf("%s\n", result);
ad_free_string(result);
}
if (rc != AD_RESULT_OK) {
const char *det = ad_last_error_details(); // may be NULL; treat as sensitive
fprintf(stderr, "execute_by_ref failed (%d): %s\n",
(int)rc, ad_last_error_message());
}
```
lib.ad_adapter_create.restype = POINTER(AdAdapter)
lib.ad_adapter_destroy.argtypes = [POINTER(AdAdapter)]
lib.ad_check_permissions.restype = c_int32
lib.ad_check_permissions.argtypes = [POINTER(AdAdapter)]
lib.ad_last_error_message.restype = c_char_p
To type text, set the action kind and text field:
adapter = lib.ad_adapter_create()
rc = lib.ad_check_permissions(adapter)
if rc != 0:
msg = lib.ad_last_error_message()
print("permission denied:", msg.decode() if msg else "(no message)")
lib.ad_adapter_destroy(adapter)
```c
AdAction type_act = {0};
type_act.kind = AD_ACTION_KIND_TYPE_TEXT;
type_act.text = "hello";
// TypeText defaults to focus_fallback via ad_execute_by_ref; explicit policy:
rc = ad_execute_by_ref(adapter, "@e3", NULL, &type_act,
AD_POLICY_KIND_FOCUS_FALLBACK, &result);
```
## Call graph reminder
All FFI calls above must run on the **main thread** on macOS. For
Python that typically means the script's entry point, not a worker
All adapter-touching FFI calls must run on the **main thread** on macOS.
For Python that typically means the script's entry point, not a worker
spawned via `threading`. See [threading.md](threading.md).
## Minimal Python ctypes example
```python
import ctypes, json
from ctypes import c_int, c_int32, c_uint8, c_bool, c_char_p, POINTER, c_void_p
lib = ctypes.CDLL("./target/release-ffi/libagent_desktop_ffi.dylib")
# ABI handshake
lib.ad_init.restype = c_int32
lib.ad_init.argtypes = [ctypes.c_uint32]
AD_ABI_VERSION_MAJOR = 1 # sync with header macro
rc = lib.ad_init(AD_ABI_VERSION_MAJOR)
assert rc == 0, f"ABI mismatch: rc={rc}"
# Adapter lifecycle
lib.ad_adapter_create.restype = c_void_p
lib.ad_adapter_create.argtypes = []
lib.ad_adapter_destroy.restype = None
lib.ad_adapter_destroy.argtypes = [c_void_p]
# Snapshot
lib.ad_snapshot.restype = c_int32
lib.ad_snapshot.argtypes = [c_void_p, c_char_p, c_int32, c_uint8, c_bool, c_bool,
POINTER(c_char_p)]
lib.ad_free_string.restype = None
lib.ad_free_string.argtypes = [c_char_p]
lib.ad_last_error_message.restype = c_char_p
lib.ad_last_error_message.argtypes = []
adapter = lib.ad_adapter_create()
assert adapter, "ad_adapter_create() returned null"
out = c_char_p()
rc = lib.ad_snapshot(adapter, None, 0, 10, False, False, ctypes.byref(out))
if out.value:
envelope = json.loads(out.value)
lib.ad_free_string(out)
print("ok:", envelope.get("ok"))
else:
msg = lib.ad_last_error_message()
print("error:", msg.decode() if msg else "(no message)")
lib.ad_adapter_destroy(adapter)
```

View file

@ -21,12 +21,30 @@ if (rc != AD_RESULT_OK) {
ad_release_window_fields(&win);
```
## Last-error accessors
Four accessors share the same per-thread lifetime contract:
| Accessor | Returns |
|---------------------------------|----------------------------------------------------------------|
| `ad_last_error_code()` | The `AdResult` code of the last failure, or `AD_RESULT_OK` |
| `ad_last_error_message()` | Human-readable description, or null |
| `ad_last_error_suggestion()` | Recovery hint, or null |
| `ad_last_error_platform_detail()` | OS-specific diagnostic (AX codes, HRESULTs, AT-SPI), or null |
| `ad_last_error_details()` | Structured JSON details, or null — **sensitive** (see below) |
`ad_last_error_details()` returns a JSON string with structured context:
the actionability check report on `ACTION_FAILED`, candidate element
summaries on `AMBIGUOUS_TARGET`, the last observed state on a `wait`
`TIMEOUT`, etc. The details may contain element names, values, and window
titles from the user's screen. Treat as sensitive diagnostics and avoid
routing to shared log surfaces.
## Lifetime contract
The pointer returned by `ad_last_error_message()`,
`ad_last_error_suggestion()`, and `ad_last_error_platform_detail()`
remains valid across any number of subsequent **successful** FFI calls.
Only the next **failing** call rotates the slot.
The pointer returned by any `ad_last_error_*` accessor remains valid
across any number of subsequent **successful** FFI calls. Only the next
**failing** call rotates the slot.
Consequence: you can cache the pointer right after a failure and keep
reading it until the next failure — equivalent to POSIX `errno` /
@ -41,43 +59,72 @@ ad_check_permissions(adapter); // success
printf("%s\n", msg); // still valid
```
`ad_check_permissions` does not treat `Unknown` as success. Stub adapters that
cannot answer permission probes return `AD_RESULT_ERR_PLATFORM_NOT_SUPPORTED`.
The macOS adapter reports `AD_RESULT_ERR_INTERNAL` only if the platform probe
itself is ambiguous; read `ad_last_error_*` for the diagnostic.
Failure-path calls rotate: if a subsequent call fails, the prior
pointer may dangle. Read it before the next potentially-failing call.
Last-error is per-thread (thread-local storage) — Thread A's failure
does not affect Thread B's slot.
`ad_check_permissions` does not treat `Unknown` as success. Stub adapters
that cannot answer permission probes return
`AD_RESULT_ERR_PLATFORM_NOT_SUPPORTED`. The macOS adapter reports
`AD_RESULT_ERR_INTERNAL` only if the platform probe itself is ambiguous;
read `ad_last_error_*` for the diagnostic.
## Error codes
Numeric values are ABI-stable. New codes are appended; existing values are not renumbered.
Numeric values are ABI-stable. New codes are appended; existing values
are not renumbered. Always handle values outside this list — future
releases may add codes.
| Name | i32 | Meaning |
|--------------------------------------|-------|---------------------------------------|
| `AD_RESULT_OK` | 0 | Success |
| `AD_RESULT_ERR_PERM_DENIED` | -1 | Accessibility / input permission missing |
| `AD_RESULT_ERR_ELEMENT_NOT_FOUND` | -2 | Ref resolve / find miss |
| `AD_RESULT_ERR_APP_NOT_FOUND` | -3 | Bundle/PID lookup miss |
| `AD_RESULT_ERR_ACTION_FAILED` | -4 | Action dispatched but rejected |
| `AD_RESULT_ERR_ACTION_NOT_SUPPORTED` | -5 | Platform cannot perform this action |
| `AD_RESULT_ERR_STALE_REF` | -6 | Handle pre-dates a DOM change |
| `AD_RESULT_ERR_WINDOW_NOT_FOUND` | -7 | Window filter matched nothing |
| `AD_RESULT_ERR_PLATFORM_NOT_SUPPORTED`| -8 | API unavailable on this OS |
| `AD_RESULT_ERR_TIMEOUT` | -9 | Wait exceeded deadline |
| `AD_RESULT_ERR_INVALID_ARGS` | -10 | Null pointer, bad enum, invalid UTF-8 |
| `AD_RESULT_ERR_NOTIFICATION_NOT_FOUND`| -11 | Notification index out of range |
| `AD_RESULT_ERR_INTERNAL` | -12 | Internal failure or ambiguous platform probe |
| `AD_RESULT_ERR_SNAPSHOT_NOT_FOUND` | -13 | Requested snapshot ref store is missing |
| `AD_RESULT_ERR_POLICY_DENIED` | -14 | Current action policy blocks fallback |
| Name | i32 | Meaning |
|---------------------------------------|-------|--------------------------------------------|
| `AD_RESULT_OK` | 0 | Success |
| `AD_RESULT_ERR_PERM_DENIED` | -1 | Accessibility / input permission missing |
| `AD_RESULT_ERR_ELEMENT_NOT_FOUND` | -2 | Ref resolve / find miss |
| `AD_RESULT_ERR_APP_NOT_FOUND` | -3 | Bundle/PID lookup miss |
| `AD_RESULT_ERR_ACTION_FAILED` | -4 | Action dispatched but rejected |
| `AD_RESULT_ERR_ACTION_NOT_SUPPORTED` | -5 | Platform cannot perform this action |
| `AD_RESULT_ERR_STALE_REF` | -6 | Ref predates a UI change; re-snapshot |
| `AD_RESULT_ERR_WINDOW_NOT_FOUND` | -7 | Window filter matched nothing |
| `AD_RESULT_ERR_PLATFORM_NOT_SUPPORTED`| -8 | API unavailable on this OS |
| `AD_RESULT_ERR_TIMEOUT` | -9 | Wait exceeded deadline |
| `AD_RESULT_ERR_INVALID_ARGS` | -10 | Null pointer, bad enum, invalid UTF-8 |
| `AD_RESULT_ERR_NOTIFICATION_NOT_FOUND`| -11 | Notification index out of range or reordered |
| `AD_RESULT_ERR_INTERNAL` | -12 | Internal failure, off-main-thread, or foreign-subscriber conflict |
| `AD_RESULT_ERR_SNAPSHOT_NOT_FOUND` | -13 | Requested snapshot ref store is missing |
| `AD_RESULT_ERR_POLICY_DENIED` | -14 | Current action policy blocks this fallback |
| `AD_RESULT_ERR_AMBIGUOUS_TARGET` | -15 | Strict re-identification found multiple candidates; re-snapshot |
## Enum validation
Every `#[repr(i32)]` enum field is validated at the C boundary. An
out-of-range discriminant returns `AD_RESULT_ERR_INVALID_ARGS` with
diagnostic last-error text. This prevents the consumer from
accidentally triggering undefined behavior by stuffing an arbitrary
`int32_t` into an enum slot.
diagnostic last-error text. This prevents the consumer from accidentally
triggering undefined behavior by stuffing an arbitrary `int32_t` into an
enum slot. Affected fields: `AdAction.kind` (`AdActionKind`),
`AdMouseEvent.kind` (`AdMouseEventKind`), `AdMouseEvent.button`
(`AdMouseButton`), `AdScrollParams.direction` (`AdDirection`),
`AdTreeOptions.surface` (`AdSnapshotSurface`), `AdScreenshotTarget.kind`
(`AdScreenshotKind`), `AdWindowOp.kind` (`AdWindowOpKind`), and the
`policy` parameter of `ad_execute_by_ref` / `ad_execute_action_with_policy`
/ `ad_execute_ref_action_with_policy` (`AdPolicyKind`).
## Command-backed JSON entrypoints: dual-failure modes
`ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_status`, and
`ad_version` have two distinct failure modes:
- **Argument / infrastructure failure** (null adapter, off-main-thread,
invalid UTF-8, bad discriminant, context error): `*out` is set to null,
no allocation is made, and the last-error slot is the only failure
indication.
- **Command-level failure** (app not found, STALE_REF, TIMEOUT, etc.):
`*out` is set to a heap-allocated JSON string with `"ok":false` and an
`"error"` payload. The caller **must still free** it with
`ad_free_string(*out)`. The last-error slot is also set.
Always check `*out` for null before deciding whether to free.
## Panic safety

View file

@ -7,25 +7,68 @@ free function. Always call it; the allocator the FFI uses is Rust's
## Allocation / release table
| Allocates | Frees with |
|---------------------------------------------------------|-----------------------------------------|
| `ad_adapter_create()` | `ad_adapter_destroy(adapter)` |
| `ad_list_apps(adapter, &list)` | `ad_app_list_free(list)` |
| `ad_list_windows(adapter, app, focused, &list)` | `ad_window_list_free(list)` |
| `ad_list_surfaces(adapter, pid, &list)` | `ad_surface_list_free(list)` |
| `ad_list_notifications(adapter, filter, &list)` | `ad_notification_list_free(list)` |
| `ad_dismiss_all_notifications(adapter, f, &ok, &fail)` | `ad_notification_list_free` on each (or `ad_dismiss_all_notifications_free(ok, fail)`) |
| `ad_launch_app(adapter, id, timeout, &out)` | `ad_release_window_fields(&out)` (free interior strings; struct itself lives on caller's stack) |
| `ad_get_tree(adapter, win, opts, &out)` | `ad_free_tree(&out)` |
| `ad_resolve_element(adapter, entry, &handle)` | `ad_free_handle(adapter, &handle)``*mut AdNativeHandle`; the call zeroes `handle.ptr` on success so a follow-up call is a no-op |
| `ad_find(adapter, win, query, &handle)` | same as `ad_resolve_element` |
| `ad_execute_action(adapter, handle, action, &out)` | `ad_free_action_result(&out)` |
| `ad_execute_action_with_policy(adapter, handle, action, policy, &out)` | `ad_free_action_result(&out)` |
| `ad_execute_ref_action_with_policy(adapter, entry, action, policy, &out)` | `ad_free_action_result(&out)` |
| `ad_notification_action(adapter, idx, expected_app, expected_title, name, &out)` | `ad_free_action_result(&out)` — pass the `app_name`/`title` you observed in `ad_list_notifications` (either may be null) so NC reorder between list and press is caught with `ERR_NOTIFICATION_NOT_FOUND` instead of pressing a different notification |
| `ad_screenshot(adapter, target, &buf)` | `ad_image_buffer_free(buf)` (buf is opaque; read via `ad_image_buffer_{data,size,width,height,format}`) |
| `ad_get_clipboard(adapter, &text)` | `ad_free_string(text)` |
| `ad_get(adapter, handle, property, &text)` | `ad_free_string(text)` (text may be null on "property absent"; `ad_free_string(NULL)` is a no-op) |
### Command-backed JSON strings
These entrypoints write an owned, NUL-terminated JSON envelope into
`*out`; free with `ad_free_string`. See error-handling.md for the
dual-failure mode (command-level errors write `ok:false` JSON into
`*out`; infrastructure errors leave `*out` null with no allocation).
| Allocates | Frees with |
|-----------------------------------------------------------------------------------|-------------------------|
| `ad_version(&out)` | `ad_free_string(out)` |
| `ad_status(adapter, &out)` | `ad_free_string(out)` |
| `ad_snapshot(adapter, app, surface, max_depth, interactive_only, compact, &out)` | `ad_free_string(out)` |
| `ad_execute_by_ref(adapter, ref_id, snapshot_id, action, policy, &out)` | `ad_free_string(out)` |
| `ad_wait(adapter, args, &out)` | `ad_free_string(out)` |
### Adapter lifecycle
| Allocates | Frees with |
|------------------------------------------------------|-----------------------------------------|
| `ad_adapter_create()` | `ad_adapter_destroy(adapter)` |
| `ad_adapter_create_with_session(session)` | `ad_adapter_destroy(adapter)` |
### Opaque list handles
| Allocates | Frees with |
|--------------------------------------------------------------|-----------------------------------------|
| `ad_list_apps(adapter, &list)` | `ad_app_list_free(list)` |
| `ad_list_windows(adapter, app, focused, &list)` | `ad_window_list_free(list)` |
| `ad_list_surfaces(adapter, pid, &list)` | `ad_surface_list_free(list)` |
| `ad_list_notifications(adapter, filter, &list)` | `ad_notification_list_free(list)` |
| `ad_dismiss_all_notifications(adapter, f, &ok, &fail)` | `ad_notification_list_free` on each, or `ad_dismiss_all_notifications_free(ok, fail)` |
### App / window lifecycle
| Allocates | Frees with |
|-----------------------------------------------------------|---------------------------------------------------------------------|
| `ad_launch_app(adapter, id, timeout, &out)` | `ad_release_window_fields(&out)` — frees interior strings only; the `AdWindowInfo` struct lives on the caller's stack |
### Raw tree and element access
| Allocates | Frees with |
|----------------------------------------------------------------------------------------|-----------------------------------------|
| `ad_get_tree(adapter, win, opts, &out)` | `ad_free_tree(&out)` |
| `ad_resolve_element(adapter, entry, &handle)` | `ad_free_handle(adapter, &handle)` — zeroes `handle.ptr` so a follow-up call is a no-op |
| `ad_find(adapter, win, query, &handle)` | same as `ad_resolve_element` |
### Action results
| Allocates | Frees with |
|----------------------------------------------------------------------------------------|------------------------------|
| `ad_execute_action(adapter, handle, action, &out)` | `ad_free_action_result(&out)` |
| `ad_execute_action_with_policy(adapter, handle, action, policy, &out)` | `ad_free_action_result(&out)` |
| `ad_execute_ref_action_with_policy(adapter, entry, action, policy, &out)` | `ad_free_action_result(&out)` |
| `ad_notification_action(adapter, idx, expected_app, expected_title, name, &out)` — pass the `app_name`/`title` from `ad_list_notifications` (either may be null) so NC reorder between list and press returns `ERR_NOTIFICATION_NOT_FOUND` instead of pressing a different notification | `ad_free_action_result(&out)` |
### Clipboard and image buffers
| Allocates | Frees with |
|---------------------------------------------|---------------------------------------------------------------------|
| `ad_get_clipboard(adapter, &text)` | `ad_free_string(text)` |
| `ad_get(adapter, handle, property, &text)` | `ad_free_string(text)` — text may be null when the property is absent; `ad_free_string(NULL)` is a no-op |
| `ad_screenshot(adapter, target, &buf)` | `ad_image_buffer_free(buf)` (buf is opaque; read via `ad_image_buffer_{data,size,width,height,format}`) |
## Rules
@ -37,20 +80,21 @@ free function. Always call it; the allocator the FFI uses is Rust's
opaque wrappers are allocated by `Box::into_raw`; the second call
would invoke `Box::from_raw` on a freed allocation. Always set the
pointer to `NULL` after freeing.
- **`ad_free_handle` is safe to double-call** — it zeroes
`handle.ptr` after the platform release, so a follow-up call sees
`NULL` and returns `AD_RESULT_OK` without re-entering `CFRelease`.
- **`ad_free_handle` is safe to double-call** — it zeroes `handle.ptr`
after the platform release, so a follow-up call sees `NULL` and
returns `AD_RESULT_OK` without re-entering `CFRelease`.
- **Adapters must outlive their handles.** Free every handle with the
same adapter that produced it before calling `ad_adapter_destroy`.
Destroying the adapter first and later freeing its handles is
undefined behavior for FFI hosts.
undefined behavior.
- Pointers inside a struct (`.id`, `.title`, `.app_name`, each
`AdNotificationInfo.body`, etc.) are freed by the struct's owning
free function (list_free / release_fields) — do not
`ad_free_string()` them individually.
- `AdActionResult` owns `action`, `ref_id`, `post_state`, `post_state.states`,
`steps`, and each `steps[i].label` / `steps[i].outcome`; free all of them
only through `ad_free_action_result(&out)`. Treat the returned counts as
free function (list_free / release_fields) — do not call
`ad_free_string()` on them individually.
- `AdActionResult` owns `action`, `ref_id`, `post_state`,
`post_state.states`, `steps`, and each `steps[i].label` /
`steps[i].outcome`; free all of them only through
`ad_free_action_result(&out)`. Treat the returned counts as
read-only metadata; release the unmodified result struct.
- Ownership does **not** transfer back to Rust after you free. Keep a
local `NULL` to prevent accidental reuse.
@ -72,5 +116,9 @@ In particular:
struct is a no-op.
- `ad_screenshot` writes `*out = NULL` before allocating the image
buffer — no stale pointer when the screenshot fails.
- `ad_snapshot`, `ad_execute_by_ref`, `ad_wait`, `ad_status`,
`ad_version` write `*out = NULL` before any guard, so on
infrastructure failures no allocation is made and `ad_free_string(NULL)`
is a safe no-op.
- `ad_*_list` and `ad_resolve_element` / `ad_find` all apply the same
pattern to their handle / list out-params.

View file

@ -2,12 +2,23 @@
## macOS: main-thread rule
Every adapter-touching entrypoint (`ad_get_tree`, `ad_find`, `ad_get`,
`ad_is`, `ad_resolve_element`, `ad_execute_action`, `ad_screenshot`,
clipboard get/set/clear, mouse, drag, launch, close, focus, window-op,
list-apps/windows/surfaces, notification list/dismiss/action)
**must be invoked on the process's main thread**. macOS accessibility
and Cocoa APIs require this.
Every adapter-touching entrypoint **must be invoked on the process's
main thread**. macOS accessibility and Cocoa APIs require this.
Entrypoints subject to the main-thread guard:
- `ad_snapshot`, `ad_execute_by_ref`, `ad_wait`
- `ad_get_tree`, `ad_find`, `ad_get`, `ad_is`, `ad_resolve_element`
- `ad_execute_action`, `ad_execute_action_with_policy`,
`ad_execute_ref_action_with_policy`
- `ad_screenshot`
- Clipboard: `ad_get_clipboard`, `ad_set_clipboard`, `ad_clear_clipboard`
- Input: `ad_mouse_event`, `ad_drag`
- App / window: `ad_launch_app`, `ad_close_app`, `ad_focus_window`,
`ad_window_op`
- Listing: `ad_list_apps`, `ad_list_windows`, `ad_list_surfaces`
- Notifications: `ad_list_notifications`, `ad_dismiss_notification`,
`ad_dismiss_all_notifications`, `ad_notification_action`
The check runs at **runtime, in every build profile** — worker-thread
calls return `AD_RESULT_ERR_INTERNAL` with a `'static` diagnostic
@ -18,26 +29,46 @@ UB window in release builds.
On non-macOS targets the check is a compile-time `true` and has zero
runtime cost.
Operations that are **safe off-main-thread** (no runtime guard):
## Operations safe off the main thread
- `ad_adapter_create` / `ad_adapter_destroy`
- `ad_last_error_{code,message,suggestion,platform_detail}`
- `ad_check_permissions` (pure process-wide query)
- `ad_app_list_count` / `_get` / `_free`
- `ad_window_list_count` / `_get` / `_free`
- `ad_surface_list_count` / `_get` / `_free`
- `ad_notification_list_count` / `_get` / `_free`
These functions carry no runtime main-thread guard:
- `ad_adapter_create` / `ad_adapter_create_with_session` / `ad_adapter_destroy`
- `ad_init`, `ad_abi_version`
- `ad_version` (no adapter; pure serialization)
- `ad_status` (reads permission report and ref-store metadata only; no AX tree query)
- `ad_check_permissions` (pure process-wide AX trust query)
- `ad_set_log_callback`
- `ad_last_error_code`, `ad_last_error_message`, `ad_last_error_suggestion`,
`ad_last_error_platform_detail`, `ad_last_error_details`
- List accessors: `ad_app_list_count` / `_get` / `_free`,
`ad_window_list_count` / `_get` / `_free`,
`ad_surface_list_count` / `_get` / `_free`,
`ad_notification_list_count` / `_get` / `_free`
- `ad_image_buffer_data` / `_size` / `_width` / `_height` / `_format` / `_free`
- `ad_release_window_fields`
- `ad_free_handle` (invokes `CFRelease` which is thread-safe) — but
still prefer calling from the thread that produced the handle.
- `ad_free_handle` (invokes `CFRelease` which is thread-safe — but
still prefer calling from the thread that produced the handle)
- `ad_free_tree`, `ad_free_action_result`, `ad_free_string`
## Log callback threading
`ad_set_log_callback(cb)` is safe to call from any thread (no AX
involvement). However, the installed callback may be invoked from threads
other than the registering thread — tracing events can originate from any
thread that calls an `ad_*` function.
A callback unregistered via `NULL` may still receive one or more
invocations from a concurrent thread for a brief window after this call
returns. The callback (and any data it captures) must remain valid for the
process lifetime, or the caller must quiesce all active adapter calls
before unregistering.
## Python consumers
CPython's GIL serializes calls but does not pin them to the main
thread. If you're calling from anything other than the main interpreter
thread you will silently corrupt state on macOS.
CPython's GIL serializes calls but does not pin them to the main thread.
If you're calling from anything other than the main interpreter thread
you will silently corrupt state on macOS.
Two patterns work:
@ -48,10 +79,9 @@ Two patterns work:
## AXIsProcessTrusted inheritance
**Privilege-escalation vector.** `ad_check_permissions` calls macOS's
`AXIsProcessTrusted()`, which returns the trust status of the **hosting
executable** — i.e., the `python3` / `node` / `swift` process, not
`agent-desktop` itself.
`ad_check_permissions` calls macOS's `AXIsProcessTrusted()`, which
returns the trust status of the **hosting executable** — i.e., the
`python3` / `node` / `swift` process, not `agent-desktop` itself.
Consequence: granting accessibility permission to one Python script's
Python interpreter grants it to every Python script that dlopens
@ -61,9 +91,9 @@ rather than relying on macOS's binary-level grant.
## Thread-ownership of handles
`ad_resolve_element` returns an opaque `AdNativeHandle` that wraps a
platform pointer. The handle is **single-owner, single-thread** by FFI
contract:
`ad_resolve_element` and `ad_find` return an opaque `AdNativeHandle`
that wraps a platform pointer. The handle is **single-owner,
single-thread** by FFI contract:
- Create it on thread A → free it on thread A with `ad_free_handle`.
Transferring the handle to thread B is undefined behavior.
@ -74,3 +104,11 @@ contract:
Every thread has its own last-error slot. Thread A's failure does not
set thread B's last-error; `ad_last_error_*` accessors always see the
calling thread's state.
## ad_wait lifecycle
`ad_wait` blocks the calling thread for up to `timeout_ms` milliseconds
while it holds a live reference into the adapter's allocation. Do not
call `ad_adapter_destroy` on the same handle from another thread while
`ad_wait` is running — that is a use-after-free. Ensure the wait has
returned before destroying the adapter.

View file

@ -0,0 +1,68 @@
# FFI Python Smoke Harness
Proves the C ABI works from a non-Rust host (Python ctypes) and gates it in CI.
## What it tests
| Leg | Check |
|-----|-------|
| 1 | `ad_abi_version()` equals `AD_ABI_VERSION_MAJOR` from the header (catches header/dylib mismatch at import) |
| 2 | Every `ad_*_size()` getter equals the corresponding `AD_*_SIZE` macro (catches struct layout drift) |
| 3 | `ad_version()` returns `ok:true` JSON with `data.version` (basic command pipeline) |
| 4 | `ad_adapter_create``ad_snapshot``ad_free_string``ad_adapter_destroy` without crash or leak; stub adapter yields a clean `PLATFORM_NOT_SUPPORTED` envelope |
## Building the dylib
```bash
# From the repo root:
cargo build --locked --profile release-ffi -p agent-desktop-ffi --features stub-adapter
```
The dylib lands at `target/release-ffi/libagent_desktop_ffi.dylib` (macOS) or
`target/release-ffi/libagent_desktop_ffi.so` (Linux).
`--profile release-ffi` keeps `panic = "unwind"`, which is required for the
`catch_unwind` traps inside the FFI layer. Using the default `release` profile
(which has `panic = "abort"`) would silently defeat those traps.
`--features stub-adapter` replaces the real platform adapter with a no-op that
returns `PLATFORM_NOT_SUPPORTED` for every adapter call. This lets CI run the
harness without requiring macOS Accessibility permissions.
## Running locally
```bash
# macOS (arm64 or x86_64):
python3 tests/ffi-python/smoke.py \
target/release-ffi/libagent_desktop_ffi.dylib \
crates/ffi/include/agent_desktop.h
```
Alternatively, use environment variables:
```bash
export AD_DYLIB_PATH=target/release-ffi/libagent_desktop_ffi.dylib
export AD_HEADER_PATH=crates/ffi/include/agent_desktop.h
python3 tests/ffi-python/smoke.py
```
## Stub enforcement (`AD_EXPECT_STUB=1`)
Set `AD_EXPECT_STUB=1` when running the harness against a stub-adapter build.
With this variable set, the harness fails if `ad_snapshot()` returns `ok:true`,
which would indicate the dylib was **not** built with `--features stub-adapter`.
CI always sets this variable so a real-adapter dylib cannot accidentally pass the
stub-only gate.
## Dependencies
`smoke.py` uses only the Python standard library (`ctypes`, `json`, `re`,
`pathlib`, `sys`). No `pip install` required.
## Real-adapter happy path
The smoke harness gates the AX-independent surface (ABI version, struct sizes,
`ad_version`) and the `PLATFORM_NOT_SUPPORTED` passthrough path. The real-adapter
happy path (a successful `ad_snapshot` with `ok:true`) is covered by the E2E
harness (`tests/e2e/run.sh`), which requires AX permission and a release build
without the stub feature.

272
tests/ffi-python/smoke.py Normal file
View file

@ -0,0 +1,272 @@
"""
FFI smoke harness: proves the C ABI works from a non-Rust host (Python ctypes).
Usage:
python3 tests/ffi-python/smoke.py <dylib_path> <header_path>
Environment variables (override positional args):
AD_DYLIB_PATH path to libagent_desktop_ffi.{dylib,so,dll}
AD_HEADER_PATH path to crates/ffi/include/agent_desktop.h
Exit codes:
0 all legs passed
1 assertion failure or load error (message printed to stderr)
"""
import ctypes
import json
import os
import re
import sys
from ctypes import (
POINTER,
c_bool,
c_char_p,
c_int,
c_size_t,
c_uint,
c_uint8,
c_void_p,
byref,
)
from pathlib import Path
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def fail(msg: str) -> None:
print(f"FAIL: {msg}", file=sys.stderr)
sys.exit(1)
def ok(msg: str) -> None:
print(f" ok: {msg}")
def bind(lib: ctypes.CDLL, name: str, restype, argtypes: list) -> ctypes.CFUNCTYPE:
"""Bind one symbol; raises AttributeError with a clear name if missing."""
try:
fn = getattr(lib, name)
except AttributeError:
fail(f"symbol not found in dylib: {name}")
fn.restype = restype
fn.argtypes = argtypes
return fn
def parse_header_constants(header_path: str) -> dict:
"""
Parse numeric #define constants from the header.
Returns a dict mapping macro name int value.
Only plain integer literals are handled (no expressions).
"""
text = Path(header_path).read_text()
result = {}
for m in re.finditer(r"#define\s+(\w+)\s+(\d+)", text):
result[m.group(1)] = int(m.group(2))
return result
# ---------------------------------------------------------------------------
# resolve paths
# ---------------------------------------------------------------------------
def resolve_paths() -> tuple[str, str]:
dylib = os.environ.get("AD_DYLIB_PATH") or (sys.argv[1] if len(sys.argv) > 1 else "")
header = os.environ.get("AD_HEADER_PATH") or (sys.argv[2] if len(sys.argv) > 2 else "")
if not dylib:
fail("dylib path required: pass as argv[1] or set AD_DYLIB_PATH")
if not header:
fail("header path required: pass as argv[2] or set AD_HEADER_PATH")
if not Path(dylib).exists():
fail(f"dylib not found: {dylib}")
if not Path(header).exists():
fail(f"header not found: {header}")
return dylib, header
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main() -> None:
dylib_path, header_path = resolve_paths()
print(f"\nLoading dylib: {dylib_path}")
try:
lib = ctypes.CDLL(dylib_path)
except OSError as exc:
fail(f"failed to load dylib: {exc}")
constants = parse_header_constants(header_path)
# ------------------------------------------------------------------
# Bind all symbols upfront with explicit restype + argtypes.
# A missing or wrong-arity symbol surfaces an AttributeError here,
# before any assertion runs.
# ------------------------------------------------------------------
ad_abi_version = bind(lib, "ad_abi_version", c_uint, [])
# Size getters — restype c_size_t, no args
ad_action_size = bind(lib, "ad_action_size", c_size_t, [])
ad_action_result_size = bind(lib, "ad_action_result_size", c_size_t, [])
ad_action_step_size = bind(lib, "ad_action_step_size", c_size_t, [])
ad_drag_params_size = bind(lib, "ad_drag_params_size", c_size_t, [])
ad_element_state_size = bind(lib, "ad_element_state_size", c_size_t, [])
ad_ref_entry_size = bind(lib, "ad_ref_entry_size", c_size_t, [])
ad_wait_args_size = bind(lib, "ad_wait_args_size", c_size_t, [])
# String management
ad_version = bind(lib, "ad_version", c_int, [POINTER(c_char_p)])
ad_free_string = bind(lib, "ad_free_string", None, [c_char_p])
# Adapter lifecycle
ad_adapter_create = bind(lib, "ad_adapter_create", c_void_p, [])
ad_adapter_destroy = bind(lib, "ad_adapter_destroy", None, [c_void_p])
# Snapshot (adapter leg)
ad_snapshot = bind(
lib, "ad_snapshot", c_int,
[c_void_p, c_char_p, c_int, c_uint8, c_bool, c_bool, POINTER(c_char_p)],
)
print("\nLeg 1 — ABI version handshake")
expected_major = constants.get("AD_ABI_VERSION_MAJOR")
if expected_major is None:
fail("AD_ABI_VERSION_MAJOR not found in header")
got_major = ad_abi_version()
if got_major != expected_major:
fail(
f"ad_abi_version() returned {got_major}, "
f"expected {expected_major} (AD_ABI_VERSION_MAJOR from header)"
)
ok(f"ad_abi_version() == AD_ABI_VERSION_MAJOR == {expected_major}")
# ------------------------------------------------------------------
print("\nLeg 2 — struct size getters vs. header macros")
# ------------------------------------------------------------------
# Explicit table: (getter_fn, macro_name) — no name-mangling magic.
size_pairs = [
(ad_action_size, "AD_ACTION_SIZE"),
(ad_action_result_size, "AD_ACTION_RESULT_SIZE"),
(ad_action_step_size, "AD_ACTION_STEP_SIZE"),
(ad_drag_params_size, "AD_DRAG_PARAMS_SIZE"),
(ad_element_state_size, "AD_ELEMENT_STATE_SIZE"),
(ad_ref_entry_size, "AD_REF_ENTRY_SIZE"),
(ad_wait_args_size, "AD_WAIT_ARGS_SIZE"),
]
for getter, macro in size_pairs:
expected = constants.get(macro)
if expected is None:
fail(f"{macro} not found in header")
got = getter()
if got != expected:
fail(f"{macro}: dylib getter returned {got}, header says {expected} (struct layout drift)")
ok(f"{macro}: {got} bytes")
# ------------------------------------------------------------------
print("\nLeg 3 — ad_version returns parseable JSON with data.version")
# ------------------------------------------------------------------
out = c_char_p()
rc = ad_version(byref(out))
if rc != 0:
fail(f"ad_version() returned {rc}, expected 0 (AD_RESULT_OK)")
if out.value is None:
fail("ad_version() returned OK but *out is null")
raw = out.value # bytes — read before freeing
ad_free_string(out)
try:
envelope = json.loads(raw)
except json.JSONDecodeError as exc:
fail(f"ad_version() output is not valid JSON: {exc}\nraw: {raw!r}")
if not envelope.get("ok"):
fail(f"ad_version() envelope has ok!=true: {envelope}")
data = envelope.get("data")
if not isinstance(data, dict) or "version" not in data:
fail(f"ad_version() envelope missing data.version: {envelope}")
ok(f"ad_version() -> data.version = {data['version']!r}")
# ------------------------------------------------------------------
print("\nLeg 4 — adapter leg: create → snapshot → assert PLATFORM_NOT_SUPPORTED → destroy")
# ------------------------------------------------------------------
expect_stub = os.environ.get("AD_EXPECT_STUB") == "1"
adapter = ad_adapter_create()
if not adapter:
fail("ad_adapter_create() returned null")
ok("ad_adapter_create() non-null")
snap_out = c_char_p()
rc = ad_snapshot(
adapter, # adapter pointer
None, # app (null = focused window)
0, # surface = Window
10, # max_depth
False, # interactive_only
False, # compact
byref(snap_out),
)
# Under the stub adapter, a command-level error writes the error
# envelope to *out rather than nulling it. Parse regardless of rc.
if snap_out.value is not None:
raw_snap = snap_out.value
ad_free_string(snap_out)
try:
snap_env = json.loads(raw_snap)
except json.JSONDecodeError as exc:
ad_adapter_destroy(adapter)
fail(f"ad_snapshot() output is not valid JSON: {exc}\nraw: {raw_snap!r}")
if snap_env.get("ok") is True:
if expect_stub:
ad_adapter_destroy(adapter)
fail(
"ad_snapshot() returned ok:true but AD_EXPECT_STUB=1 — "
"the dylib appears to be built without --features stub-adapter; "
"a stub build cannot successfully complete a real snapshot"
)
# Real adapter succeeded (e.g. local run with AX grant + real adapter).
ok("ad_snapshot() -> ok:true (real adapter path)")
else:
# Expected stub-adapter path: ok:false + PLATFORM_NOT_SUPPORTED.
err = snap_env.get("error", {})
code = err.get("code", "")
if code != "PLATFORM_NOT_SUPPORTED":
ad_adapter_destroy(adapter)
fail(
f"ad_snapshot() envelope error.code = {code!r}, "
"expected 'PLATFORM_NOT_SUPPORTED' (stub adapter)"
)
ok(f"ad_snapshot() -> ok:false, error.code = {code!r}")
else:
# *out null means an argument/infrastructure error — rc is the code.
ad_adapter_destroy(adapter)
fail(
f"ad_snapshot() set *out=null (rc={rc}); "
"expected the error envelope to be written to *out"
)
ad_adapter_destroy(adapter)
ok("ad_adapter_destroy() — no crash, no leak")
print("\nAll legs passed.")
if __name__ == "__main__":
main()