Skill: lahfir/agent-desktop-ffi (version 1.0.1)

--- name: agent-desktop-ffi version: 0.1.11 tags: ffi, c-bindings, cdylib, python, swift, node, go, rust-ffi requirements: - agent-desktop-ffi 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. --- # agent-desktop-ffi Direct C-ABI access to every PlatformAdapter operation. Build the cdylib with the workspace's `release-ffi` profile: ```sh cargo build --profile release-ffi -p agent-desktop-ffi ``` 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`. Four reference topics, loaded as needed: - [ownership.md](references/ownership.md) — who allocates / who frees, for every `*mut T` the FFI hands back to the caller. - [error-handling.md](references/error-handling.md) — errno-style last-error contract, enum validation, panic boundary. - [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. ## ⚠ Core constraints before you integrate - **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. - **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. - **Handle release.** Every `ad_resolve_element` result must be released with `ad_free_handle(adapter, handle)` on the same adapter that produced it. On macOS this balances the internal `CFRetain`; on Windows/Linux the call is a no-op but safe to issue. - **Action policy.** `ad_execute_action` uses the headless policy by default, matching CLI ref commands: no focus stealing and no cursor movement. Use `ad_execute_action_with_policy(..., AD_POLICY_KIND_FOCUS_FALLBACK, ...)` only when focus-changing behavior is intended, and `AD_POLICY_KIND_PHYSICAL` only for explicit physical/headed input semantics. - **Text input privacy.** On macOS, explicit focus/physical policy can use the clipboard briefly for non-ASCII text insertion. Keep the default headless policy or set values directly for sensitive text when the target supports it. - **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. - **`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.

path: references/build-and-link.md
content: # Build and link

## Building the cdylib

```sh
cargo build --profile release-ffi -p agent-desktop-ffi
```

Output:

- macOS: `target/release-ffi/libagent_desktop_ffi.dylib` (~470 KB)
- 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`
regenerates — if you change a type in `crates/ffi/src/`, rebuild
locally and commit the header.

## Minimal C example

```c
#include <stdio.h>
#include "agent_desktop.h"

int main(void) {
    AdAdapter *adapter = ad_adapter_create();
    if (!adapter) return 1;

    AdResult rc = ad_check_permissions(adapter);
    if (rc != AD_RESULT_OK) {
        fprintf(stderr, "permission denied: %s\n", ad_last_error_message());
        ad_adapter_destroy(adapter);
        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);
    } else {
        fprintf(stderr, "list_apps failed: %s\n", ad_last_error_message());
    }

    ad_adapter_destroy(adapter);
    return 0;
}
```

Compile:

```sh
clang -I./crates/ffi/include main.c \
      -L./target/release-ffi -lagent_desktop_ffi \
      -o list_apps
install_name_tool -change \
    libagent_desktop_ffi.dylib \
    @executable_path/target/release-ffi/libagent_desktop_ffi.dylib \
    list_apps
```

## Minimal Python ctypes example

```python
import ctypes
from ctypes import c_int32, c_char_p, POINTER, Structure, c_uint32

lib = ctypes.CDLL("./target/release-ffi/libagent_desktop_ffi.dylib")

# Opaque adapter handle
class AdAdapter(ctypes.Structure):
    pass

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

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)
```

## 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
spawned via `threading`. See [threading.md](threading.md).


path: references/error-handling.md
content: # Error handling

The FFI uses an errno-style last-error pattern. Every `AdResult`-returning
function returns `AD_RESULT_OK` (= 0) on success or a negative error
code on failure. When a failure occurs, thread-local last-error state is
populated; read it with the `ad_last_error_*` accessors.

## Minimal pattern

```c
AdResult rc = ad_launch_app(adapter, "com.apple.finder", 5000, &win);
if (rc != AD_RESULT_OK) {
    const char *msg = ad_last_error_message();
    const char *sug = ad_last_error_suggestion();   // may be NULL
    fprintf(stderr, "launch_app failed (%d): %s\n", (int)rc, msg ? msg : "(no message)");
    if (sug) fprintf(stderr, "  suggestion: %s\n", sug);
    // no need to release the struct — out-param was zero-initialized
    return -1;
}
// ...use win...
ad_release_window_fields(&win);
```

## 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.

Consequence: you can cache the pointer right after a failure and keep
reading it until the next failure — equivalent to POSIX `errno` /
`strerror`.

```c
AdResult rc = ad_some_call(...);
const char *msg = ad_last_error_message();   // snapshot

ad_check_permissions(adapter);                // success
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.

## Error codes

Numeric values are ABI-stable. New codes are appended; existing values are not renumbered.

| 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        |
| [REDACTED_SECRET] |  -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         |
| [REDACTED_SECRET]| -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 |
| [REDACTED_SECRET]| -11  | Notification index out of range       |
| `AD_RESULT_ERR_INTERNAL`             | -12   | Internal failure or ambiguous platform probe |
| [REDACTED_SECRET]   | -13   | Requested snapshot ref store is missing |
| `AD_RESULT_ERR_POLICY_DENIED`        | -14   | Current action policy blocks fallback |

## 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.

## Panic safety

Every `extern "C"` entrypoint wraps its body in `catch_unwind`. A
Rust panic inside the FFI surfaces as `AD_RESULT_ERR_INTERNAL` with
message `"rust panic in FFI boundary"`. No `SIGABRT`, no host crash.

The cdylib must be built under the `release-ffi` profile for this
guarantee to hold in optimized builds — the workspace `release` profile
uses `panic = "abort"` (for CLI binary-size reasons).


path: references/ownership.md
content: # Pointer ownership

Every `*mut T` / `*const T` returned by the FFI comes with a matching
free function. Always call it; the allocator the FFI uses is Rust's
`Box::from_raw` / `CString::from_raw`, which cannot be freed with C's
`free()`.

## 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_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) |

## Rules

- Every free function is **null-tolerant**. `ad_free_tree(NULL)`,
  `ad_free_handle(adapter, NULL)`, `ad_free_string(NULL)`, etc. are
  no-ops. List accessors (`ad_*_list_count`, `_get`) also accept null
  and return `0` / `NULL` respectively.
- **Double-free of list handles and `AdImageBuffer` is undefined.** The
  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`.
- 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.
- Ownership does **not** transfer back to Rust after you free. Keep a
  local `NULL` to prevent accidental reuse.

## Out-param zeroing

Every fallible FFI function zeroes its out-param **before** any guard
(pointer validation, main-thread check, UTF-8 validation). On error,
calling the paired free function is safe: all pointers inside are
guaranteed null, all counts zero, so the free is a no-op rather than
a double-free on a previous caller's allocation.

In particular:

- `ad_get_clipboard` writes `*out = NULL` before the adapter call —
  no stale buffer visible on error.
- `ad_launch_app` writes `*out = zeroed AdWindowInfo` before the
  platform call — `ad_release_window_fields(&out)` on the zero-init
  struct is a no-op.
- `ad_screenshot` writes `*out = NULL` before allocating the image
  buffer — no stale pointer when the screenshot fails.
- `ad_*_list` and `ad_resolve_element` / `ad_find` all apply the same
  pattern to their handle / list out-params.


path: references/threading.md
content: # Threading

## 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.

The check runs at **runtime, in every build profile** — worker-thread
calls return `AD_RESULT_ERR_INTERNAL` with a `'static` diagnostic
`"agent_desktop FFI entry called off the main thread (macOS requires
main-thread AX/Cocoa calls)"`. No build-config difference; no silent
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):

- `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`
- `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_tree`, `ad_free_action_result`, `ad_free_string`

## 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.

Two patterns work:

1. **Restrict FFI calls to the main thread.** Use `asyncio` with the
   default event loop pinned to main, or a synchronous entrypoint only.
2. **Marshal across threads yourself.** Use a queue; have a dedicated
   main-thread worker that dequeues and invokes the FFI.

## 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.

Consequence: granting accessibility permission to one Python script's
Python interpreter grants it to every Python script that dlopens
`libagent_desktop_ffi.dylib`. Document this prominently for your
consumers; consider requiring opt-in permission prompts in host code
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:

- Create it on thread A → free it on thread A with `ad_free_handle`.
  Transferring the handle to thread B is undefined behavior.
- Use it in FFI calls only from the same thread that produced it.

## Last-error is thread-local

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.


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)