From b5fbdb22d3f04799fca45bf7e09dcbef27f98be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A1=BE=E7=9B=BC?= Date: Sun, 23 Aug 2026 14:20:14 +0000 Subject: [PATCH] feat(cua-driver): add versioned Computer Use SDK and release pipeline (#9587) * chore(cua-driver): sync upstream v0.20.0 * feat(cua-driver): add Computer Use SDK with versioned observation revisions Wrap the typed driver SDK in a standalone Node wrapper and add accessibility.observation_revision.v1: base-anchored validated diffs with opaque element tokens, explicit full-resync reasons, full-only answers on Windows/Linux. Fix portable include_screenshot schema type and classify stable kAXErrorFailure refusals as complete in the macOS capture tracker. * feat(cua-driver): complete typed Computer Use capabilities * fix(cua-driver): use Qwen-owned npm identity * feat(cua-driver): publish one Qwen CUA SDK package * fix(cua-driver): verify Windows Rust targets * fix(cua-driver): verify macOS Rust targets * fix(cua-driver): pin release Rust toolchain * fix(cua-sdk): fail closed on incomplete releases * test(cua-sdk): import workflow test globals * ci(cua-sdk): retry Debian package downloads * fix(cua-driver): harden lifecycle and release gates --------- Co-authored-by: tutu --- .github/workflows/cd-cua-driver.yml | 348 +- .../design/cua-driver-0.20.0-upstream-sync.md | 70 + docs/design/cua-driver-computer-use-sdk.md | 143 + ...6-08-20-cua-driver-0.20.0-upstream-sync.md | 39 + .../2026-08-20-cua-driver-computer-use-sdk.md | 34 + packages/cua-driver/.vendored-from | 2 +- packages/cua-driver/.vendored-patches.md | 4 +- packages/cua-driver/README.md | 33 +- packages/cua-driver/compat-fixtures/cli.json | 12 +- packages/cua-driver/contract/README.md | 22 +- packages/cua-driver/contract/manifest.json | 1229 +++- .../cua-driver/docs/action-icon-catalog.md | 30 +- .../cua-driver/docs/action-result-contract.md | 3 +- ...ursor-modifier-badge-restructuring-plan.md | 11 +- ...dded-host-uniffi-implementation-journal.md | 7 +- .../docs/macos-background-input-v1-plan.md | 920 +++ .../cua-driver/docs/test-harnesses-guide.md | 20 + packages/cua-driver/docs/test-matrix.md | 8 +- .../docs/uniffi-sdk-implementation-journal.md | 33 +- ...y-cua-driver-uses-mcp-instead-of-uniffi.md | 22 +- .../cua-driver/examples/agent-sdks/README.md | 9 +- .../examples/agent-sdks/claude_agent.py | 98 +- .../examples/agent-sdks/claude_agent.ts | 75 +- .../examples/agent-sdks/codex_agent.py | 87 +- .../examples/agent-sdks/codex_agent.ts | 95 +- .../examples/agent-sdks/native-tools.ts | 54 +- .../examples/agent-sdks/native_driver.py | 39 +- .../examples/agent-sdks/native_driver.ts | 45 +- .../examples/agent-sdks/native_tools.py | 47 +- .../examples/agent-sdks/package-lock.json | 2208 +++++++ .../examples/agent-sdks/package.json | 2 +- .../examples/agent-sdks/requirements.txt | 2 +- packages/cua-driver/python/README.md | 11 +- packages/cua-driver/python/pyproject.toml | 2 +- .../python/src/cua_driver/__init__.py | 6 +- .../python/src/cua_driver/_native.py | 984 ++- .../python/src/cua_driver/_native_contract.py | 1673 +++++- .../python/tests/test_uniffi_loader.py | 7 +- packages/cua-driver/python/uv.lock | 2 +- packages/cua-driver/rust/CHANGELOG.md | 114 + packages/cua-driver/rust/Cargo.lock | 32 +- packages/cua-driver/rust/Cargo.toml | 6 +- .../rust/Skills/cua-driver/BROWSER.md | 64 +- .../rust/Skills/cua-driver/EMBEDDING.md | 13 +- .../rust/Skills/cua-driver/LINUX.md | 12 +- .../rust/Skills/cua-driver/MACOS.md | 11 +- .../rust/Skills/cua-driver/SKILL.md | 198 +- .../rust/Skills/cua-driver/WINDOWS.md | 63 +- packages/cua-driver/rust/VERSION | 2 +- .../cua-driver-contract/src/compatibility.rs | 19 + .../crates/cua-driver-contract/src/desktop.rs | 76 +- .../crates/cua-driver-contract/src/inputs.rs | 574 +- .../crates/cua-driver-contract/src/lib.rs | 127 +- .../crates/cua-driver-contract/src/outputs.rs | 193 + .../crates/cua-driver-contract/src/session.rs | 58 +- .../cua-driver-contract/src/verification.rs | 4 +- .../rust/crates/cua-driver-core/Cargo.toml | 11 + .../cua-driver-core/src/action_record.rs | 41 + .../cua-driver-core/src/action_target.rs | 146 + .../cua-driver-core/src/authorization.rs | 502 +- .../cua-driver-core/src/background_input.rs | 698 +++ .../cua-driver-core/src/browser/approval.rs | 299 - .../cua-driver-core/src/browser/download.rs | 6 +- .../cua-driver-core/src/browser/engine.rs | 20 +- .../crates/cua-driver-core/src/browser/mod.rs | 50 +- .../cua-driver-core/src/browser/platform.rs | 7 - .../cua-driver-core/src/browser/pointer.rs | 3 +- .../cua-driver-core/src/browser/prepare.rs | 88 +- .../cua-driver-core/src/browser/refusal.rs | 2 +- .../cua-driver-core/src/browser/tools.rs | 78 +- .../cua-driver-core/src/browser/v2_tests.rs | 173 +- .../cua-driver-core/src/capture_scope.rs | 87 +- .../crates/cua-driver-core/src/consent.rs | 94 +- .../cua-driver-core/src/element_token.rs | 68 + .../rust/crates/cua-driver-core/src/lib.rs | 3 + .../src/observation_revision.rs | 1759 ++++++ .../rust/crates/cua-driver-core/src/policy.rs | 64 + .../crates/cua-driver-core/src/protocol.rs | 23 +- .../crates/cua-driver-core/src/recording.rs | 83 +- .../rust/crates/cua-driver-core/src/server.rs | 125 +- .../crates/cua-driver-core/src/session.rs | 1356 ++++- .../src/session_authorization.rs | 171 +- .../cua-driver-core/src/session_manifest.rs | 322 +- .../cua-driver-core/src/session_tools.rs | 551 +- .../rust/crates/cua-driver-core/src/tool.rs | 905 ++- .../crates/cua-driver-core/src/tool_schema.rs | 95 +- .../cua-driver-core/src/window_inspection.rs | 85 +- .../rust/crates/cua-driver-sdk/src/abi.rs | 106 +- .../crates/cua-driver-sdk/src/embedded.rs | 86 +- .../rust/crates/cua-driver-sdk/src/lib.rs | 341 +- .../rust/crates/cua-driver-sdk/src/remote.rs | 4 + .../rust/crates/cua-driver-sdk/src/runtime.rs | 103 +- .../cua-driver-sdk/src/service_session.rs | 230 +- .../rust/crates/cua-driver-sdk/src/worker.rs | 7 + .../tests/runtime_configuration.rs | 13 +- .../rust/crates/cua-driver-testkit/Cargo.toml | 3 + .../crates/cua-driver-testkit/src/daemon.rs | 379 +- .../rust/crates/cua-driver-testkit/src/e2e.rs | 63 +- .../rust/crates/cua-driver-testkit/src/raw.rs | 7 + .../rust/crates/cua-driver/Cargo.toml | 3 + .../rust/crates/cua-driver/src/autostart.rs | 153 +- .../cua-driver/src/check_update_tool.rs | 4 +- .../rust/crates/cua-driver/src/cli.rs | 525 +- .../rust/crates/cua-driver/src/main.rs | 127 +- .../rust/crates/cua-driver/src/mcp_http.rs | 72 +- .../crates/cua-driver/src/private_worker.rs | 5 + .../rust/crates/cua-driver/src/proxy.rs | 210 +- .../crates/cua-driver/src/release_channel.rs | 178 + .../rust/crates/cua-driver/src/sdk_adapter.rs | 121 +- .../rust/crates/cua-driver/src/serve.rs | 517 +- .../rust/crates/cua-driver/src/skills.rs | 163 +- .../rust/crates/cua-driver/src/telemetry.rs | 35 +- .../crates/cua-driver/src/version_check.rs | 378 +- .../tests/agent_cursor_showcase_test.rs | 242 +- .../tests/agent_cursor_windows_test.rs | 426 +- .../tests/atspi_listener_startup_test.rs | 91 + .../tests/bring_to_front_macos_test.rs | 421 ++ .../tests/compatibility_contract_test.rs | 18 + .../tests/cross_platform_behavior_test.rs | 5 + .../cua-driver/tests/daemon_required_test.rs | 216 +- .../tests/embedded_host_sdk_mcp_test.rs | 65 +- .../cua-driver/tests/harness_appkit_test.rs | 125 +- .../cua-driver/tests/harness_gtk3_test.rs | 80 + .../cua-driver/tests/harness_gtk4_test.rs | 149 + .../cua-driver/tests/harness_web_test.rs | 14 +- .../cua-driver/tests/harness_wpf_test.rs | 202 +- .../tests/permission_policy_startup_test.rs | 180 +- .../tests/policy_tools_list_test.rs | 70 + .../cua-driver/tests/private_worker_test.rs | 8 + .../cua-driver/tests/protocol_schema_test.rs | 5 +- .../tests/protocol_tools_call_test.rs | 130 +- .../tests/release_channel_cli_test.rs | 59 + .../tests/schema_consistency_test.rs | 17 +- .../tests/session_capture_scope_test.rs | 18 +- .../tests/standalone_browser_behavior_test.rs | 161 +- .../tests/wayland_overlay_idle_test.rs | 178 + .../examples/export_gallery_frames.rs | 232 +- .../rust/crates/cursor-overlay/src/lib.rs | 12 +- .../cursor-overlay/src/session_badge.rs | 93 +- .../rust/crates/cursor-overlay/src/z_order.rs | 8 +- .../rust/crates/platform-linux/Cargo.toml | 8 +- .../rust/crates/platform-linux/src/a11y.rs | 51 +- .../crates/platform-linux/src/atspi/cache.rs | 50 +- .../crates/platform-linux/src/atspi/mod.rs | 212 +- .../crates/platform-linux/src/atspi/native.rs | 1096 +++- .../platform-linux/src/atspi/revision.rs | 437 ++ .../platform-linux/src/browser_consent_ui.rs | 1 + .../platform-linux/src/browser_platform.rs | 68 +- .../platform-linux/src/browser_setup_ui.rs | 348 +- .../rust/crates/platform-linux/src/capture.rs | 1561 ++++- .../crates/platform-linux/src/input/mod.rs | 322 +- .../rust/crates/platform-linux/src/lib.rs | 2 + .../rust/crates/platform-linux/src/overlay.rs | 3166 +++++++++- .../rust/crates/platform-linux/src/proc_fs.rs | 78 + .../crates/platform-linux/src/tools/impl_.rs | 1395 ++++- .../crates/platform-linux/src/wayland/mod.rs | 27 + .../platform-linux/src/wayland/overlay.rs | 289 +- .../src/wayland/shell_helper.rs | 37 +- .../platform-linux/src/wayland/sway_ipc.rs | 19 +- .../rust/crates/platform-linux/src/x11/mod.rs | 26 + .../crates/platform-macos/src/ax/bindings.rs | 528 +- .../crates/platform-macos/src/ax/cache.rs | 5 + .../platform-macos/src/ax/enablement.rs | 133 + .../platform-macos/src/ax/exact_target.rs | 262 + .../rust/crates/platform-macos/src/ax/mod.rs | 3 + .../crates/platform-macos/src/ax/revision.rs | 192 + .../rust/crates/platform-macos/src/ax/tree.rs | 398 +- .../platform-macos/src/background_mutation.rs | 99 + .../platform-macos/src/browser/consent_ui.rs | 2 + .../platform-macos/src/browser/platform.rs | 1 + .../platform-macos/src/browser/setup_ui.rs | 5 + .../rust/crates/platform-macos/src/capture.rs | 962 ++- .../platform-macos/src/cursor/overlay.rs | 64 +- .../platform-macos/src/input/ax_actions.rs | 23 + .../platform-macos/src/input/skylight.rs | 288 +- .../rust/crates/platform-macos/src/lib.rs | 2 + .../background_input_regression_tests.rs | 149 + .../src/tools/bring_to_front.rs | 612 +- .../crates/platform-macos/src/tools/click.rs | 112 +- .../platform-macos/src/tools/cursor_tools.rs | 4 + .../platform-macos/src/tools/double_click.rs | 110 +- .../crates/platform-macos/src/tools/drag.rs | 2 +- .../platform-macos/src/tools/get_config.rs | 4 +- .../src/tools/get_desktop_state.rs | 16 +- .../src/tools/get_window_state.rs | 273 +- .../crates/platform-macos/src/tools/hotkey.rs | 220 +- .../platform-macos/src/tools/list_windows.rs | 25 +- .../crates/platform-macos/src/tools/mod.rs | 306 +- .../platform-macos/src/tools/move_cursor.rs | 2 +- .../src/tools/perform_secondary_action.rs | 208 + .../platform-macos/src/tools/press_key.rs | 366 +- .../platform-macos/src/tools/right_click.rs | 102 +- .../crates/platform-macos/src/tools/scroll.rs | 209 +- .../platform-macos/src/tools/set_config.rs | 7 +- .../platform-macos/src/tools/set_value.rs | 117 +- .../platform-macos/src/tools/type_text.rs | 674 ++- .../src/tools/type_text_chars.rs | 62 +- .../src/window_change_detector.rs | 1 + .../rust/crates/platform-macos/src/windows.rs | 106 +- .../rust/crates/platform-windows/Cargo.toml | 2 + .../examples/mcp_config_parity.rs | 6 +- .../src/browser_consent_ui.rs | 1 + .../platform-windows/src/browser_platform.rs | 6 +- .../platform-windows/src/browser_setup_ui.rs | 25 +- .../platform-windows/src/diagnostics.rs | 46 +- .../platform-windows/src/input/inject.rs | 141 + .../platform-windows/src/input/keyboard.rs | 210 +- .../crates/platform-windows/src/input/mod.rs | 8 +- .../platform-windows/src/input/mouse.rs | 57 +- .../crates/platform-windows/src/launch_uwp.rs | 277 +- .../rust/crates/platform-windows/src/msaa.rs | 12 +- .../crates/platform-windows/src/overlay.rs | 165 +- .../platform-windows/src/tools/impl_.rs | 1621 ++++- .../crates/platform-windows/src/uia/cache.rs | 21 + .../src/uia/cache_uaf_repro.rs | 1 + .../crates/platform-windows/src/uia/mod.rs | 271 +- .../platform-windows/src/uia/revision.rs | 492 ++ .../platform-windows/src/uia/windows_enum.rs | 480 +- .../src/win32/installed_apps.rs | 230 +- .../crates/platform-windows/src/win32/mod.rs | 4 + .../platform-windows/src/win32/windows.rs | 282 +- .../cua-driver/scripts/_install-local-rust.sh | 79 +- packages/cua-driver/scripts/_install-rust.sh | 111 +- .../cua-driver/scripts/build-node-runtime.mjs | 23 +- .../cua-driver/scripts/build-npm-packages.mjs | 135 - packages/cua-driver/scripts/cursor-gallery.sh | 7 +- .../scripts/generate-uniffi-bindings.mjs | 20 +- packages/cua-driver/scripts/install.ps1 | 185 +- packages/cua-driver/scripts/install.sh | 6 +- .../cua-driver/scripts/post-install-hints.txt | 31 +- .../scripts/stage-uniffi-library.mjs | 42 +- .../tests/install-windows-regression.ps1 | 248 + .../scripts/tests/test_install_local.py | 169 + .../tests/test_install_version_fallback.py | 71 +- .../cua-driver/scripts/uninstall-local.sh | 10 +- .../scripts/verify-cua-sdk-package.mjs | 278 + .../cross-platform/electron/package-lock.json | 5252 +++++++++++++++++ .../tests/fixtures/apps/linux/gtk4/main.py | 30 + .../fixtures/apps/macos/appkit/main.swift | 98 + .../apps/windows/wpf/DeferredValueTextBox.cs | 50 + .../fixtures/apps/windows/wpf/MainWindow.xaml | 47 +- .../apps/windows/wpf/MainWindow.xaml.cs | 9 + .../cua-driver/tests/fixtures/build/linux.sh | 61 +- .../tests/fixtures/shared/web/index.html | 4 +- .../tests/runners/macos-lume/README.md | 84 +- .../tests/runners/windows-sandbox/README.md | 12 +- .../tests/runners/windows/README.md | 10 +- .../cua-driver/tools/cursor-gallery/README.md | 9 +- .../cua-driver/tools/cursor-gallery/app.js | 195 +- .../tools/cursor-gallery/capture-gallery.mjs | 9 +- .../tools/cursor-gallery/index.html | 153 +- .../tools/cursor-gallery/styles.css | 596 +- packages/cua-driver/typescript/.gitignore | 1 + packages/cua-driver/typescript/LICENSE.md | 21 + packages/cua-driver/typescript/NOTICE.md | 13 + packages/cua-driver/typescript/README.md | 50 +- .../typescript/computer-use/README.md | 73 + .../typescript/computer-use/index.d.ts | 179 + .../typescript/computer-use/index.js | 615 ++ .../computer-use/test/computer-use.test.mjs | 430 ++ .../test/daemon-integration.test.mjs | 74 + .../cua-driver/typescript/package-lock.json | 301 + packages/cua-driver/typescript/package.json | 27 +- .../typescript/scripts/install-native.mjs | 230 + .../typescript/src/native-assets.ts | 175 + .../src/native/cua_driver_contract-ffi.ts | 7 +- .../src/native/cua_driver_contract.ts | 1623 ++++- .../src/native/cua_driver_sdk-ffi.ts | 379 +- .../typescript/src/native/cua_driver_sdk.ts | 1161 +++- .../typescript/src/native/node-runtime.ts | 18 +- .../typescript/test/electron-main-fixture.mjs | 9 +- .../typescript/test/electron-main.test.mjs | 23 +- .../typescript/test/embedded.test.mjs | 21 +- .../typescript/test/native-assets.test.mjs | 187 + .../typescript/test/native-daemon-fixture.mjs | 2 +- .../typescript/test/native-loader.test.mjs | 33 +- .../wayland-helper/winrects@cua/extension.js | 23 +- .../tests/cua-driver-release-workflow.test.js | 59 + 278 files changed, 53164 insertions(+), 5784 deletions(-) create mode 100644 docs/design/cua-driver-0.20.0-upstream-sync.md create mode 100644 docs/design/cua-driver-computer-use-sdk.md create mode 100644 docs/plans/2026-08-20-cua-driver-0.20.0-upstream-sync.md create mode 100644 docs/plans/2026-08-20-cua-driver-computer-use-sdk.md create mode 100644 packages/cua-driver/docs/macos-background-input-v1-plan.md create mode 100644 packages/cua-driver/examples/agent-sdks/package-lock.json create mode 100644 packages/cua-driver/rust/crates/cua-driver-core/src/action_target.rs create mode 100644 packages/cua-driver/rust/crates/cua-driver-core/src/background_input.rs delete mode 100644 packages/cua-driver/rust/crates/cua-driver-core/src/browser/approval.rs create mode 100644 packages/cua-driver/rust/crates/cua-driver-core/src/observation_revision.rs create mode 100644 packages/cua-driver/rust/crates/cua-driver/src/release_channel.rs create mode 100644 packages/cua-driver/rust/crates/cua-driver/tests/atspi_listener_startup_test.rs create mode 100644 packages/cua-driver/rust/crates/cua-driver/tests/bring_to_front_macos_test.rs create mode 100644 packages/cua-driver/rust/crates/cua-driver/tests/harness_gtk4_test.rs create mode 100644 packages/cua-driver/rust/crates/cua-driver/tests/policy_tools_list_test.rs create mode 100644 packages/cua-driver/rust/crates/cua-driver/tests/release_channel_cli_test.rs create mode 100644 packages/cua-driver/rust/crates/cua-driver/tests/wayland_overlay_idle_test.rs create mode 100644 packages/cua-driver/rust/crates/platform-linux/src/atspi/revision.rs create mode 100644 packages/cua-driver/rust/crates/platform-macos/src/ax/enablement.rs create mode 100644 packages/cua-driver/rust/crates/platform-macos/src/ax/exact_target.rs create mode 100644 packages/cua-driver/rust/crates/platform-macos/src/ax/revision.rs create mode 100644 packages/cua-driver/rust/crates/platform-macos/src/background_mutation.rs create mode 100644 packages/cua-driver/rust/crates/platform-macos/src/tools/background_input_regression_tests.rs create mode 100644 packages/cua-driver/rust/crates/platform-macos/src/tools/perform_secondary_action.rs create mode 100644 packages/cua-driver/rust/crates/platform-windows/src/uia/revision.rs delete mode 100644 packages/cua-driver/scripts/build-npm-packages.mjs create mode 100644 packages/cua-driver/scripts/tests/install-windows-regression.ps1 create mode 100644 packages/cua-driver/scripts/verify-cua-sdk-package.mjs create mode 100644 packages/cua-driver/tests/fixtures/apps/cross-platform/electron/package-lock.json create mode 100644 packages/cua-driver/tests/fixtures/apps/linux/gtk4/main.py create mode 100644 packages/cua-driver/tests/fixtures/apps/windows/wpf/DeferredValueTextBox.cs create mode 100644 packages/cua-driver/typescript/LICENSE.md create mode 100644 packages/cua-driver/typescript/NOTICE.md create mode 100644 packages/cua-driver/typescript/computer-use/README.md create mode 100644 packages/cua-driver/typescript/computer-use/index.d.ts create mode 100644 packages/cua-driver/typescript/computer-use/index.js create mode 100644 packages/cua-driver/typescript/computer-use/test/computer-use.test.mjs create mode 100644 packages/cua-driver/typescript/computer-use/test/daemon-integration.test.mjs create mode 100644 packages/cua-driver/typescript/package-lock.json create mode 100644 packages/cua-driver/typescript/scripts/install-native.mjs create mode 100644 packages/cua-driver/typescript/src/native-assets.ts create mode 100644 packages/cua-driver/typescript/test/native-assets.test.mjs create mode 100644 scripts/tests/cua-driver-release-workflow.test.js diff --git a/.github/workflows/cd-cua-driver.yml b/.github/workflows/cd-cua-driver.yml index 69a3e03bac..f443454ec7 100644 --- a/.github/workflows/cd-cua-driver.yml +++ b/.github/workflows/cd-cua-driver.yml @@ -17,6 +17,8 @@ name: 'CD: Qwen CUA Driver' # QwenCuaDriver.app) + *-binary.tar.gz (runtime + SDK payload) # Linux : cua-driver-rs--linux-{x86_64,arm64}.tar.gz + *-binary.tar.gz # Windows: cua-driver-rs--windows-{x86_64,arm64}.zip + *-binary.zip +# npm : one platform-neutral @qwen-code/cua-sdk package. Its postinstall +# downloads and verifies the matching *-binary archive above. on: push: tags: ['cua-driver-rs-v*'] @@ -25,16 +27,18 @@ on: version: description: 'Version to release (without v prefix)' required: true - default: '0.17.0' + default: '0.20.0' notarize: - description: 'Codesign + notarize the macOS artifacts (false to skip during + description: + 'Codesign + notarize the macOS artifacts (false to skip during iteration)' required: false type: 'boolean' default: true dry_run: - description: 'Build + package only; do NOT create/update a GitHub Release. - Leave on (with notarize=false) to rehearse the build pipeline.' + description: + 'Build all release assets and clean-install the npm package without + creating a GitHub Release, npm version, tag, or installer sync PR.' required: false type: 'boolean' default: true @@ -44,11 +48,17 @@ jobs: validate-version: name: 'validate-release-version' runs-on: 'ubuntu-latest' + outputs: + version: '${{ steps.release.outputs.version }}' steps: - uses: 'actions/checkout@v4' + with: + fetch-depth: 0 - name: 'Validate release version' + id: 'release' shell: 'bash' run: | + set -euo pipefail if [[ "$GITHUB_REF" == refs/tags/cua-driver-rs-v* ]]; then VERSION="${GITHUB_REF#refs/tags/cua-driver-rs-v}" else @@ -60,7 +70,35 @@ jobs: echo "::error::Release version $VERSION does not match Cargo version $CARGO_VERSION" exit 1 fi - - uses: 'dtolnay/rust-toolchain@stable' + + SDK_NAME=$(node -p "require('./packages/cua-driver/typescript/package.json').name") + SDK_VERSION=$(node -p "require('./packages/cua-driver/typescript/package.json').version") + if [[ "$SDK_NAME" != "@qwen-code/cua-sdk" ]]; then + echo "::error::Expected @qwen-code/cua-sdk, found $SDK_NAME" + exit 1 + fi + if [[ "$SDK_VERSION" != "$VERSION" ]]; then + echo "::error::SDK version $SDK_VERSION does not match release version $VERSION" + exit 1 + fi + + if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.dry_run }}" != "true" && "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "::error::A production dispatch must run from protected main" + exit 1 + fi + if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.dry_run }}" != "true" && "${{ inputs.notarize }}" != "true" ]]; then + echo "::error::A production dispatch must enable macOS codesigning and notarization" + exit 1 + fi + if [[ "$GITHUB_REF" == refs/tags/cua-driver-rs-v* ]]; then + git fetch origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::Release tag must point to a commit reachable from main" + exit 1 + fi + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - uses: 'dtolnay/rust-toolchain@1.97.1' - name: 'Test model payload filtering' working-directory: 'packages/cua-driver/rust' run: 'cargo test -p cua-driver-core --locked model_payload' @@ -88,8 +126,8 @@ jobs: steps: - name: 'Install base tooling (container is bare)' run: | - apt-get update - apt-get install -y --no-install-recommends \ + apt-get -o Acquire::Retries=3 update + apt-get -o Acquire::Retries=3 install -y --no-install-recommends \ git ca-certificates curl build-essential pkg-config \ libx11-dev libxi-dev libxtst-dev libxext-dev libwayland-dev \ libxkbcommon-dev @@ -109,7 +147,7 @@ jobs: VERSION="${{ inputs.version }}" fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - uses: 'dtolnay/rust-toolchain@stable' + - uses: 'dtolnay/rust-toolchain@1.97.1' with: targets: '${{ matrix.target }}' - uses: 'actions/cache@v4' @@ -118,8 +156,9 @@ jobs: ~/.cargo/registry ~/.cargo/git packages/cua-driver/rust/target - key: 'cua-driver-linux-${{ matrix.arch }}-${{ hashFiles(''packages/cua-driver/rust/Cargo.lock'') - }}' + key: + "cua-driver-linux-${{ matrix.arch }}-${{ hashFiles('packages/cua-driver/rust/Cargo.lock') + }}" restore-keys: | cua-driver-linux-${{ matrix.arch }}- - name: 'Build (release)' @@ -187,7 +226,7 @@ jobs: VERSION="${{ inputs.version }}" fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - uses: 'dtolnay/rust-toolchain@stable' + - uses: 'dtolnay/rust-toolchain@1.97.1' with: targets: '${{ matrix.target }}' - uses: 'actions/cache@v4' @@ -196,8 +235,9 @@ jobs: ~/.cargo/registry ~/.cargo/git packages/cua-driver/rust/target - key: 'cua-driver-windows-${{ matrix.arch }}-${{ hashFiles(''packages/cua-driver/rust/Cargo.lock'') - }}' + key: + "cua-driver-windows-${{ matrix.arch }}-${{ hashFiles('packages/cua-driver/rust/Cargo.lock') + }}" restore-keys: | cua-driver-windows-${{ matrix.arch }}- - name: 'Build (release, unsigned)' @@ -243,7 +283,7 @@ jobs: env: # Sign+notarize on tag push (real release); on manual dispatch honor the # `notarize` input so the pipeline can be iterated without Apple's notary. - DO_NOTARIZE: '${{ startsWith(github.ref, ''refs/tags/cua-driver-rs-v'') || inputs.notarize == true }}' + DO_NOTARIZE: "${{ startsWith(github.ref, 'refs/tags/cua-driver-rs-v') || inputs.notarize == true }}" steps: - uses: 'actions/checkout@v4' - uses: 'actions/setup-node@v4' @@ -261,7 +301,7 @@ jobs: VERSION="${{ inputs.version }}" fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - uses: 'dtolnay/rust-toolchain@stable' + - uses: 'dtolnay/rust-toolchain@1.97.1' with: targets: 'aarch64-apple-darwin,x86_64-apple-darwin' - uses: 'actions/cache@v4' @@ -270,7 +310,7 @@ jobs: ~/.cargo/registry ~/.cargo/git packages/cua-driver/rust/target - key: 'cua-driver-darwin-universal-${{ hashFiles(''packages/cua-driver/rust/Cargo.lock'') }}' + key: "cua-driver-darwin-universal-${{ hashFiles('packages/cua-driver/rust/Cargo.lock') }}" restore-keys: | cua-driver-darwin-universal- - name: 'Build arm64' @@ -324,7 +364,7 @@ jobs: otool -l release/universal/libcua_driver_sdk.dylib | \ grep -A2 LC_RPATH | grep -Fq 'path @loader_path' - name: 'Import signing certificate + discover identity' - if: 'env.DO_NOTARIZE == ''true''' + if: "env.DO_NOTARIZE == 'true'" env: MAC_CSC_LINK: '${{ secrets.MAC_CSC_LINK }}' MAC_CSC_KEY_PASSWORD: '${{ secrets.MAC_CSC_KEY_PASSWORD }}' @@ -351,7 +391,7 @@ jobs: echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" echo "Signing identity: $IDENTITY" - name: 'Codesign universal binary (hardened runtime)' - if: 'env.DO_NOTARIZE == ''true''' + if: "env.DO_NOTARIZE == 'true'" working-directory: 'packages/cua-driver/rust' run: | codesign --force --timestamp --options runtime \ @@ -374,13 +414,15 @@ jobs: cp release/universal/cua-cursor-theme release/QwenCuaDriver.app/Contents/MacOS/cua-cursor-theme chmod +x release/QwenCuaDriver.app/Contents/MacOS/qwen-cua-driver chmod +x release/QwenCuaDriver.app/Contents/MacOS/cua-cursor-theme - plutil -replace CFBundleShortVersionString -string "$VERSION" release/QwenCuaDriver.app/Contents/Info.plist - plutil -replace CFBundleVersion -string "$VERSION" release/QwenCuaDriver.app/Contents/Info.plist + BUNDLE_VERSION="${VERSION%%-*}" + plutil -replace CFBundleShortVersionString -string "$BUNDLE_VERSION" release/QwenCuaDriver.app/Contents/Info.plist + plutil -replace CFBundleVersion -string "$BUNDLE_VERSION" release/QwenCuaDriver.app/Contents/Info.plist rm -f release/QwenCuaDriver.app/Contents/MacOS/.gitkeep plutil -p release/QwenCuaDriver.app/Contents/Info.plist | grep -E 'CFBundle(Short)?Version' - - name: 'Codesign + notarize + staple QwenCuaDriver.app (App Store Connect API + - name: + 'Codesign + notarize + staple QwenCuaDriver.app (App Store Connect API key)' - if: 'env.DO_NOTARIZE == ''true''' + if: "env.DO_NOTARIZE == 'true'" working-directory: 'packages/cua-driver/rust' env: APPLE_NOTARY_API_KEY_P8_BASE64: '${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }}' @@ -402,7 +444,7 @@ jobs: --issuer "$APPLE_NOTARY_ISSUER_ID" \ --wait --timeout 20m xcrun stapler staple release/QwenCuaDriver.app - spctl -a -vv -t exec release/QwenCuaDriver.app || true + spctl -a -vv -t exec release/QwenCuaDriver.app rm -f release/QwenCuaDriver.app.zip notary_key.p8 - name: 'Package' working-directory: 'packages/cua-driver/rust' @@ -470,20 +512,95 @@ jobs: esac done + verify-sdk-package: + name: 'single npm package dry-run' + needs: ['validate-version', 'build-linux', 'verify-release-artifacts'] + runs-on: 'ubuntu-latest' + permissions: + contents: 'read' + steps: + - uses: 'actions/checkout@v4' + - uses: 'actions/setup-node@v4' + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: 'packages/cua-driver/typescript/package-lock.json' + registry-url: 'https://registry.npmjs.org' + scope: '@qwen-code' + - name: 'Install npm 11' + run: 'npm install --global npm@11.19.0' + - uses: 'actions/download-artifact@v4' + with: + name: 'cua-driver-rs-linux-x86_64' + path: '${{ runner.temp }}/cua-driver-linux' + - name: 'Extract verified native payload' + id: 'native' + shell: 'bash' + run: | + set -euo pipefail + ARCHIVE=$(find "${RUNNER_TEMP}/cua-driver-linux" -type f -name '*-binary.tar.gz' -print -quit) + if [[ -z "$ARCHIVE" ]]; then + echo '::error::Linux x86_64 binary archive is missing' + exit 1 + fi + NATIVE_DIR="${RUNNER_TEMP}/cua-sdk-native" + RELEASE_DIR="${RUNNER_TEMP}/cua-sdk-release-fixture" + mkdir -p "$NATIVE_DIR" + mkdir -p "$RELEASE_DIR" + tar -xzf "$ARCHIVE" -C "$NATIVE_DIR" + test -f "$NATIVE_DIR/libcua_driver_sdk.so" + test -f "$NATIVE_DIR/cua_driver_node_runtime.node" + cp "$ARCHIVE" "$RELEASE_DIR/" + (cd "$RELEASE_DIR" && sha256sum "$(basename "$ARCHIVE")" > checksums.txt) + echo "directory=$NATIVE_DIR" >> "$GITHUB_OUTPUT" + echo "release_directory=$RELEASE_DIR" >> "$GITHUB_OUTPUT" + - name: 'Install SDK dependencies' + working-directory: 'packages/cua-driver/typescript' + run: 'npm ci --ignore-scripts --no-audit --no-fund' + - name: 'Test SDK and native loader' + working-directory: 'packages/cua-driver/typescript' + env: + CUA_DRIVER_REQUIRE_UNIFFI: '1' + QWEN_CUA_SDK_NATIVE_DIR: '${{ steps.native.outputs.directory }}' + run: 'npm test' + - name: 'Pack, publish-dry-run, and clean-install one SDK tarball' + env: + QWEN_CUA_SDK_NATIVE_DIR: '${{ steps.native.outputs.directory }}' + run: | + node packages/cua-driver/scripts/verify-cua-sdk-package.mjs \ + --native-dir "${QWEN_CUA_SDK_NATIVE_DIR}" \ + --release-dir "${{ steps.native.outputs.release_directory }}" \ + --output-dir "${RUNNER_TEMP}/cua-sdk-package" + - uses: 'actions/upload-artifact@v4' + with: + name: 'cua-sdk-npm-${{ needs.validate-version.outputs.version }}' + path: '${{ runner.temp }}/cua-sdk-package/*.tgz' + if-no-files-found: 'error' + retention-days: 7 + # ── Release ────────────────────────────────────────────────────────────── release: name: 'Create GitHub Release' - needs: ['build-linux', 'build-windows', 'build-macos', 'verify-release-artifacts'] + needs: + [ + 'build-linux', + 'build-windows', + 'build-macos', + 'verify-release-artifacts', + 'verify-sdk-package', + ] runs-on: 'ubuntu-latest' outputs: version: '${{ steps.version.outputs.version }}' - if: 'startsWith(github.ref, ''refs/tags/cua-driver-rs-v'') || (github.event_name - == ''workflow_dispatch'' && inputs.dry_run == false)' + if: + "startsWith(github.ref, 'refs/tags/cua-driver-rs-v') || (github.event_name + == 'workflow_dispatch' && inputs.dry_run == false)" steps: - uses: 'actions/checkout@v4' - uses: 'actions/download-artifact@v4' with: path: 'release-upload' + pattern: 'cua-driver-rs-*' merge-multiple: true - name: 'Determine version + tag' id: 'version' @@ -511,8 +628,54 @@ jobs: mkdir -p "$SKILLS_STAGE" cp -R packages/cua-driver/rust/Skills/cua-driver/. "$SKILLS_STAGE/" tar -czf "release-upload/${SKILLS_STAGE}.tar.gz" "$SKILLS_STAGE" - (cd release-upload && sha256sum cua-driver-rs-*.tar.gz cua-driver-rs-*.zip > checksums.txt) + ( + cd release-upload + : > checksums.txt + for file in *; do + [[ -f "$file" && "$file" != checksums.txt ]] || continue + sha256sum "$file" >> checksums.txt + done + ) + - name: 'Refuse to replace an existing release' + id: 'release-state' + env: + GH_TOKEN: '${{ github.token }}' + TAG: '${{ steps.version.outputs.tag }}' + shell: 'bash' + run: | + set -euo pipefail + if ! gh release view "$TAG" >/dev/null 2>&1; then + echo 'exists=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + + git fetch origin "refs/tags/${TAG}:refs/tags/${TAG}" + TAG_COMMIT=$(git rev-list -n 1 "$TAG") + if [[ "$TAG_COMMIT" != "$GITHUB_SHA" ]]; then + echo "::error::Existing $TAG points to $TAG_COMMIT, not $GITHUB_SHA" + exit 1 + fi + + EXISTING=$(mktemp -d) + trap 'rm -rf "$EXISTING"' EXIT + gh release download "$TAG" --dir "$EXISTING" + find release-upload -maxdepth 1 -type f -exec basename {} \; | sort > "$EXISTING/expected-assets.txt" + gh release view "$TAG" --json assets --jq '.assets[].name' | sort > "$EXISTING/actual-assets.txt" + if ! diff -u "$EXISTING/expected-assets.txt" "$EXISTING/actual-assets.txt"; then + echo "::error::Existing $TAG asset set is incomplete or unexpected" + exit 1 + fi + grep -v '^checksums.txt$' "$EXISTING/expected-assets.txt" > "$EXISTING/expected-checksums.txt" + sed -nE 's/^[0-9a-f]{64} [ *](.+)$/\1/p' "$EXISTING/checksums.txt" | sort > "$EXISTING/actual-checksums.txt" + if ! diff -u "$EXISTING/expected-checksums.txt" "$EXISTING/actual-checksums.txt"; then + echo "::error::Existing $TAG checksum manifest is incomplete or unexpected" + exit 1 + fi + (cd "$EXISTING" && sha256sum --check checksums.txt) + echo "Existing $TAG is complete and checksum-valid; reusing it without replacement" + echo 'exists=true' >> "$GITHUB_OUTPUT" - name: 'Create GitHub Release' + if: "steps.release-state.outputs.exists != 'true'" uses: 'softprops/action-gh-release@v2' with: tag_name: '${{ steps.version.outputs.tag }}' @@ -528,6 +691,9 @@ jobs: - **macOS**: codesigned + notarized universal binary + `QwenCuaDriver.app` - **Linux**: unsigned (x86_64 + arm64, glibc 2.31 floor) - **Windows**: unsigned (x86_64 + arm64) + - **Node.js**: the same workflow publishes the single + `@qwen-code/cua-sdk` npm package after a clean install against + these release assets Enable relative coordinates: `CUA_DRIVER_RS_COORDINATE_SPACE=1` (default `0` = off; optional `CUA_DRIVER_RS_COORDINATE_SCALE=1000`). @@ -535,9 +701,135 @@ jobs: Enable textual MCP payload filtering for affected model API routes: `MCP_MODEL_PAYLOAD_FILTER=1` (default off). + publish-sdk: + name: 'Publish @qwen-code/cua-sdk' + needs: ['validate-version', 'verify-sdk-package', 'release'] + if: + "startsWith(github.ref, 'refs/tags/cua-driver-rs-v') || (github.event_name + == 'workflow_dispatch' && inputs.dry_run == false)" + runs-on: 'ubuntu-latest' + environment: + name: 'production-release' + url: 'https://www.npmjs.com/package/@qwen-code/cua-sdk/v/${{ needs.validate-version.outputs.version }}' + permissions: + contents: 'read' + id-token: 'write' + steps: + - uses: 'actions/setup-node@v4' + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + scope: '@qwen-code' + - name: 'Install npm 11' + run: 'npm install --global npm@11.19.0' + - uses: 'actions/download-artifact@v4' + with: + name: 'cua-sdk-npm-${{ needs.validate-version.outputs.version }}' + path: '${{ runner.temp }}/cua-sdk-package' + - name: 'Require npm publishing token' + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + run: | + if [[ -z "$NODE_AUTH_TOKEN" ]]; then + echo '::error::production-release NPM_TOKEN is required for the first @qwen-code/cua-sdk publication' + exit 1 + fi + - name: 'Clean-install against the published GitHub Release' + env: + QWEN_CUA_SDK_CACHE_DIR: '${{ runner.temp }}/public-release-cache' + VERSION: '${{ needs.validate-version.outputs.version }}' + run: | + set -euo pipefail + TARBALL=$(find "${RUNNER_TEMP}/cua-sdk-package" -type f -name '*.tgz' -print -quit) + if [[ -z "$TARBALL" ]]; then + echo '::error::Packed @qwen-code/cua-sdk tarball is missing' + exit 1 + fi + CHECKSUMS_URL="https://github.com/QwenLM/qwen-code/releases/download/cua-driver-rs-v${VERSION}/checksums.txt" + for attempt in {1..12}; do + if curl -fsSL --connect-timeout 15 --max-time 60 "$CHECKSUMS_URL" -o "${RUNNER_TEMP}/checksums.txt"; then + break + fi + if [[ "$attempt" == 12 ]]; then + echo '::error::Published release checksums did not become available' + exit 1 + fi + sleep 10 + done + CONSUMER=$(mktemp -d) + trap 'rm -rf "$CONSUMER"' EXIT + cd "$CONSUMER" + npm init --yes >/dev/null + npm install "$TARBALL" --no-audit --no-fund + node --input-type=module --eval ' + import { CuaDriver } from "@qwen-code/cua-sdk"; + import { ComputerUse } from "@qwen-code/cua-sdk/computer-use"; + const driver = CuaDriver.create(undefined); + const computer = await ComputerUse.create({ session: "release-registry-smoke" }); + try { + const listing = JSON.parse(await driver.listToolsJson()); + if (!Array.isArray(listing.tools) || listing.tools.length === 0) { + throw new Error("native SDK returned no tools"); + } + const revisionSupported = await computer.supportsObservationRevision(); + if (typeof revisionSupported !== "boolean") { + throw new Error("ComputerUse capability probe returned an invalid result"); + } + console.log(JSON.stringify({ + toolCount: listing.tools.length, + revisionSupported, + })); + } finally { + await computer.close(); + await driver.shutdown(); + driver.uniffiDestroy(); + } + ' + - name: 'Publish immutable SDK tarball' + env: + NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + VERSION: '${{ needs.validate-version.outputs.version }}' + run: | + set -euo pipefail + TARBALL=$(find "${RUNNER_TEMP}/cua-sdk-package" -type f -name '*.tgz' -print -quit) + LOCAL_INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)" + REMOTE_INTEGRITY=$(npm view "@qwen-code/cua-sdk@${VERSION}" dist.integrity 2>/dev/null || true) + if [[ -n "$REMOTE_INTEGRITY" ]]; then + if [[ "$REMOTE_INTEGRITY" != "$LOCAL_INTEGRITY" ]]; then + echo "::error::@qwen-code/cua-sdk@${VERSION} already exists with different integrity" + exit 1 + fi + echo "@qwen-code/cua-sdk@${VERSION} already contains this exact tarball" + exit 0 + fi + NPM_TAG=latest + [[ "$VERSION" == *-* ]] && NPM_TAG=next + npm publish "$TARBALL" --provenance --access public --tag "$NPM_TAG" + - name: 'Verify npm registry integrity' + env: + VERSION: '${{ needs.validate-version.outputs.version }}' + run: | + set -euo pipefail + TARBALL=$(find "${RUNNER_TEMP}/cua-sdk-package" -type f -name '*.tgz' -print -quit) + LOCAL_INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)" + for attempt in {1..20}; do + REMOTE_INTEGRITY=$(npm view "@qwen-code/cua-sdk@${VERSION}" dist.integrity 2>/dev/null || true) + if [[ "$REMOTE_INTEGRITY" == "$LOCAL_INTEGRITY" ]]; then + echo "Verified npm registry integrity on attempt $attempt" + exit 0 + fi + if [[ -n "$REMOTE_INTEGRITY" ]]; then + echo '::error::Published npm artifact integrity does not match the verified tarball' + exit 1 + fi + sleep 10 + done + echo '::error::Published npm version did not become visible' + exit 1 + sync-installer-version: name: 'Sync installer version to main' - needs: ['release'] + needs: ['release', 'publish-sdk'] runs-on: 'ubuntu-latest' permissions: contents: 'write' diff --git a/docs/design/cua-driver-0.20.0-upstream-sync.md b/docs/design/cua-driver-0.20.0-upstream-sync.md new file mode 100644 index 0000000000..010a6f2d9a --- /dev/null +++ b/docs/design/cua-driver-0.20.0-upstream-sync.md @@ -0,0 +1,70 @@ +# CUA Driver 0.20.0 Upstream Sync + +## Source of truth + +The synchronization target is the stable Cua Driver release +`cua-driver-rs-v0.20.0` from `trycua/cua`, resolved to commit +`bb8c86049cad1bf0853c6d25c03c14875d0d047f`. The npm `latest` dist-tag for +`@trycua/cua-driver` also resolves to `0.20.0`. The newer `0.20.1` artifacts +are nightly prereleases and are not used as a release baseline. + +The existing Qwen snapshot is `cua-driver-rs-v0.17.0` at commit +`10279552e2bbe479e367a082f78b1b98ee85a697`. The supported +`packages/cua-driver/scripts/sync-from-upstream.sh` delta is checked against a +three-way merge with 0.17.0 as the common ancestor. + +## Adopted upstream behavior + +The 0.20.0 runtime becomes the vendored base, including: + +- transport-owned implicit lifecycle sessions and explicit per-action targets; +- capability manifests applied across permission profiles; +- typed SDK, contract, and generated binding updates; +- foreground-focus verification and background-input hardening; +- policy-filtered tool discovery and named CLI session behavior; +- stable/nightly release-channel support; and +- removal of legacy browser approval tokens. + +The removed browser-token path is not retained as a Qwen compatibility fork. +Existing-profile access continues only through trusted launch grants, bounded +manifests, or an embedding host's authorization callback. + +## Qwen invariants + +The sync must preserve these downstream-owned boundaries: + +- executable, app, bundle, service, install, update, and release identities + remain Qwen-owned; +- telemetry remains disabled by default and, when explicitly enabled, goes + directly to the documented upstream endpoint; +- `MCP_MODEL_PAYLOAD_FILTER=1` remains an explicit Qwen-facing payload filter; +- `CUA_DRIVER_RS_COORDINATE_SPACE=1` remains the opt-in normalized-coordinate + adapter, with absolute pixels as the default; +- the release installation continues to use `~/.cua-driver` for compatibility, + while source builds retain their separate Qwen identity; and +- the still-open `trycua/cua#2021` empty-title Windows-window patch remains + carried and documented. + +## Release and packaging boundary + +The Qwen release workflow keeps its existing signing, notarization, artifact +names, and publication ownership. It advances its manual default to 0.20.0, +uses the restored upstream TypeScript lockfile required by `npm ci`, and keeps +macOS bundle versions valid when a prerelease suffix is present. + +The upstream tag left its standalone Agent SDK examples pinned to 0.19.2 even +though both npm and PyPI publish 0.20.0. The vendored examples and their npm +lockfile are aligned to 0.20.0 so they exercise the synchronized contract. + +Upstream's monorepo-wide nightly orchestration, release-attribution services, +Python publishing, and documentation publishing are not copied into Qwen Code. +They depend on upstream-only scripts, secrets, repositories, and release +governance. Cross-platform signed release publication remains a CI gate. + +## Out of scope + +This change does not update Qwen Code's current built-in downloader pin, +Computer Use MCP adapter, bootstrap flow, permission UX, or model-visible tool +schemas. It also does not implement Issue #9334. Those integration changes +remain independently reviewable follow-ups on top of the verified 0.20.0 +runtime and TypeScript SDK. diff --git a/docs/design/cua-driver-computer-use-sdk.md b/docs/design/cua-driver-computer-use-sdk.md new file mode 100644 index 0000000000..a05bc65603 --- /dev/null +++ b/docs/design/cua-driver-computer-use-sdk.md @@ -0,0 +1,143 @@ +# CUA Driver Computer Use SDK + +## Goal + +Upgrade the vendored CUA Driver 0.20.0 implementation with a native, versioned accessibility observation revision protocol, then expose the resulting capability through its typed SDKs and a small JavaScript wrapper. + +The finished SDK must be directly importable from an ordinary Node.js program and independently testable without Qwen Code, Node REPL, a Skill, or any Qwen host integration. Stage 3 will teach Qwen Code to call this SDK from the Node REPL delivered by #9333. + +## Stage boundary + +This stage includes only: + +- cua-driver core and native platform changes; +- Rust, Python, and TypeScript SDK contract and generated bindings; +- a thin JavaScript Computer Use wrapper over the TypeScript cua-driver SDK; +- independent unit, compatibility, platform E2E, packaging, and release validation. + +This stage does not: + +- modify Qwen Code core, CLI, ACP, TUI, tool registry, scheduler, or permission manager; +- register any SDK or capability in the Node REPL; +- add a hidden Qwen bridge or Qwen-specific runtime session; +- track which observation reached a model; +- add a Computer Use Skill, prompt, default migration, or direct-tool replacement. + +## Runtime topology + +```text +ordinary Node.js program + -> @qwen-code/cua-sdk/computer-use + -> @qwen-code/cua-sdk typed driver API + -> cua-driver runtime + -> AX / UIA / AT-SPI platform implementation +``` + +The wrapper calls the typed SDK directly. It does not route calls through Qwen Code or a second tool protocol. It preserves cua-driver's existing runtime, permission, transport, and lifecycle behavior. + +The TypeScript SDK and Computer Use wrapper are distributed together as the +single `@qwen-code/cua-sdk` npm package. The package root exposes the generated +typed driver API and the `/computer-use` subpath exposes the high-level wrapper. +There is no driver npm package and there are no platform npm packages. + +The matching `qwen-cua-driver` GitHub Release remains the only native artifact +channel. During npm installation, `@qwen-code/cua-sdk` downloads the exact +same-version binary archive, verifies it against that release's +`checksums.txt`, and caches only the SDK library plus Node runtime. An explicit +native-directory override supports source builds and release dry-runs without +changing the production resolution path. The synchronized TryCua release is +source provenance only; no published Qwen artifact imports or resolves the +upstream npm package. + +## Release contract + +One version identifies both release surfaces: + +1. `cua-driver-rs-v` publishes the driver, SDK library, Node runtime, + installers, and checksums to the Qwen GitHub Release. +2. `@qwen-code/cua-sdk@` publishes the platform-neutral JavaScript, + generated bindings, declarations, downloader, and Computer Use wrapper. + +Production publication is ordered. The GitHub Release must be complete before +the packed npm artifact is installed without overrides against the public +release. Only after that real installation and native-load smoke test succeeds +may npm publication run. A retry accepts an already-published npm version only +when its registry integrity matches the packed artifact. + +The workflow dry-run builds every native target, verifies the release archive +contract, packs exactly one npm tarball, installs it into a clean consumer +project using the just-built native payload, and runs the native-load smoke +test. It creates no tag, GitHub Release, npm version, installer-version PR, or +machine-wide driver installation. + +## Observation revision contract + +CUA Driver adds the opt-in `accessibility.observation_revision.v1` capability. Existing callers that do not opt in continue receiving the current full snapshot and snapshot-token behavior. + +A revision request explicitly supplies: + +- protocol version; +- exact target; +- optional base revision ID; +- optional force-full flag; +- serializer/projection version. + +A response reports: + +- `full | diff | no_change` mode; +- current and actual base revision IDs; +- lineage, serializer, and projection versions; +- stable-element support; +- a closed resynchronization reason when full output is required. + +The caller, not cua-driver, selects the base revision. Missing, expired, foreign, or incompatible bases produce a full response. The driver never guesses whether a revision reached a model. + +## Stable identity and diff correctness + +Within one trusted driver session, runtime generation, exact target, and serializer lineage: + +- the same native element keeps its stable ID; +- rename, value change, reorder, and reparent retain the ID; +- inserted elements receive new IDs; +- removed IDs retire; +- destroyed and recreated look-alikes receive new IDs. + +Every candidate diff must replay from the requested base to the canonical current full rendering. Incomplete capture, unavailable identity, incompatible lineage, failed replay, or a diff not smaller than the full rendering returns full output with an explicit reason. + +Stable action tokens resolve through the current revision. An unchanged element remains actionable after compatible diffs; removed, recreated, foreign-session, or stale-generation tokens fail before native dispatch. + +## Platform identity + +| Platform path | Identity rule | Revision v1 behavior | +| ------------------ | ----------------------------------------------------------------- | ----------------------------------------------- | +| macOS AX | retained `AXUIElementRef`, compared with Core Foundation equality | diff when capture and identity are complete | +| Windows UIA | RuntimeId candidate confirmed by `IUIAutomation::CompareElements` | diff when identity is confirmed | +| Windows MSAA | no approved stable identity | explicit full-only fallback | +| Linux AT-SPI | unique D-Bus owner plus object path | diff while the owner remains live and unchanged | +| Linux X11 fallback | no approved stable identity | explicit full-only fallback | + +Provider invalidation, truncation, subtree read failure, target ambiguity, or fallback capture forces full output. + +## SDK wrapper + +The JavaScript wrapper exposes a small Computer Use API while directly using the generated TypeScript cua-driver SDK. It hides raw low-level constructors and arbitrary tool dispatch from its public surface, but it does not depend on Qwen Code. + +The observation API returns the revision ID and accepts an explicit base revision ID on the next call. The caller owns that state. Actions consume opaque element tokens returned by the driver. The wrapper does not compute a second semantic diff or invent element identity. + +The wrapper must run in a standalone Node.js integration test before Stage 3 begins. + +## Validation + +Completion requires: + +- deterministic core tests for full, diff, no-change, replay, eviction, isolation, and forced-full reasons; +- unchanged compatibility fixtures for existing Rust, Python, and TypeScript applications; +- generated SDK drift checks; +- real signed macOS, Windows UIA, and Linux AT-SPI E2E evidence; +- explicit MSAA and X11 full-only evidence; +- stable-token action tests after rename, insertion, reparent, removal, and recreation; +- a standalone Node.js test importing and using the JavaScript wrapper directly; +- at least 30 deterministic real transitions and a median accessibility-text reduction of at least 40% for small UI changes; +- clean build, typecheck, packaging, and release artifacts. + +No Qwen Code or model-in-the-loop result is part of this stage's completion claim. diff --git a/docs/plans/2026-08-20-cua-driver-0.20.0-upstream-sync.md b/docs/plans/2026-08-20-cua-driver-0.20.0-upstream-sync.md new file mode 100644 index 0000000000..6fb248858d --- /dev/null +++ b/docs/plans/2026-08-20-cua-driver-0.20.0-upstream-sync.md @@ -0,0 +1,39 @@ +# CUA Driver 0.20.0 Upstream Sync Plan + +## Delivery boundary + +Deliver one pure prerequisite change before Issue #9334: move the vendored CUA +Driver from the released 0.17.0 snapshot to released 0.20.0 while preserving +the documented Qwen distribution and compatibility patches. Do not change the +built-in Computer Use adapter or downloader pin. + +## Sequence + +1. Verify the official Git tag, commit, npm dist-tag, and release metadata. +2. Apply the 0.17.0 to 0.20.0 `libs/cua-driver` delta with the repository sync + script. +3. Cross-check every reject through a three-way merge using upstream 0.17.0 as + the common ancestor. +4. Adopt 0.20 lifecycle, target, authorization, SDK, and browser-token behavior; + retain Qwen identity, telemetry, coordinate, payload-filter, and Windows + empty-title-window changes. +5. Restore the package lockfiles required by clean `npm ci` builds, align the + standalone SDK examples with the published 0.20.0 packages, and advance + Qwen release metadata to 0.20.0. +6. Validate source provenance, version parity, generated contracts, language + SDKs, Rust packages, installer behavior, release wiring, and the enclosing + Qwen workspace. +7. Run a debug runtime smoke test and record platform-specific or signed-release + gaps separately from source failures. +8. Read the complete tracked and untracked diff in open-ended passes and require + two consecutive clean passes after the final fix. + +## Stop conditions requiring a new decision + +- changing Qwen's built-in Computer Use downloader or bootstrap behavior; +- replacing Qwen identities, install paths, signing ownership, or telemetry + default with upstream values; +- copying upstream-only publication infrastructure or introducing new release + secrets; +- dropping the documented local Windows patch before it is merged upstream; or +- adding Issue #9334 model-facing APIs to this synchronization change. diff --git a/docs/plans/2026-08-20-cua-driver-computer-use-sdk.md b/docs/plans/2026-08-20-cua-driver-computer-use-sdk.md new file mode 100644 index 0000000000..7ad65a83cf --- /dev/null +++ b/docs/plans/2026-08-20-cua-driver-computer-use-sdk.md @@ -0,0 +1,34 @@ +# CUA Driver Computer Use SDK implementation plan + +1. Add the opt-in `accessibility.observation_revision.v1` request and response contract while preserving all legacy full-snapshot callers. +2. Finish the shared revision lineage, deterministic renderer, replay validation, bounded retention, session cleanup, stable IDs, and current-element resolution in cua-driver core. +3. Complete macOS AX identity, capture-completeness, invalidation/refetch, and signed real-platform E2E. +4. Implement Windows UIA identity with RuntimeId candidates plus `CompareElements`; keep MSAA explicitly full-only. +5. Implement Linux AT-SPI identity with unique D-Bus owner plus object path; keep X11 fallback explicitly full-only. +6. Expose revision-v1 through the Rust contract and generated Python and TypeScript SDKs, including compatibility/version negotiation. +7. Publish the generated TypeScript SDK and thin standalone Computer Use wrapper + as one `@qwen-code/cua-sdk` package. Load native code only from the matching + Qwen GitHub Release; do not create driver or platform npm packages. +8. Make the CUA Driver release workflow build every native target, pack and + clean-install the single SDK package in dry-run, and publish the GitHub + Release before the npm package in production. +9. Validate deterministic transitions, stable-token actions, lifecycle cleanup, + compatibility fixtures, generated binding drift, platform E2E, context + reduction, packaging, and release artifacts. + +## Explicit exclusions + +Do not modify Qwen Code core, CLI, ACP, TUI, Node REPL, tool registration, scheduler, permission manager, prompts, or Skills in this stage. Do not add a Qwen host bridge or model-delivery tracking. Those belong to Stage 3 (#9335). + +## Current checkpoint + +The shared revision core, macOS AX, Windows UIA, Linux AT-SPI, generated SDK +exposure, direct JavaScript wrapper, and lifecycle cleanup are present in the +working tree. macOS has real native E2E evidence, including 30 deterministic +transitions and token-bound actions. Windows UIA/MSAA and Linux AT-SPI/X11 have +compile-time and deterministic-test coverage but still require real target E2E +before this stage can claim cross-platform completion. The earlier root-plus- +platform npm staging result is invalid for the approved distribution model. A +single-package local clean install, Release-fixture postinstall, native smoke, +and packaged AppKit E2E now pass; the GitHub-hosted all-platform workflow +dry-run remains the release gate. diff --git a/packages/cua-driver/.vendored-from b/packages/cua-driver/.vendored-from index 990feb5555..8a4ac8159b 100644 --- a/packages/cua-driver/.vendored-from +++ b/packages/cua-driver/.vendored-from @@ -1 +1 @@ -cua-driver-rs-v0.17.0 +cua-driver-rs-v0.20.0 diff --git a/packages/cua-driver/.vendored-patches.md b/packages/cua-driver/.vendored-patches.md index 66c6ed3504..021496b979 100644 --- a/packages/cua-driver/.vendored-patches.md +++ b/packages/cua-driver/.vendored-patches.md @@ -1,12 +1,12 @@ # Cherry-picked upstream PRs This vendored copy is based on the tag in [`.vendored-from`](./.vendored-from) -(`cua-driver-rs-v0.17.0`), **plus** a small set of upstream trycua/cua pull +(`cua-driver-rs-v0.20.0`), **plus** a small set of upstream trycua/cua pull requests applied as ordinary commits on top of the base snapshot. | Upstream PR | Fix | Status | Touches | |---|---|---|---| -| [trycua/cua#2021](https://github.com/trycua/cua/pull/2021) | `list_windows` includes empty-/null-title top-level windows | open (draft); `resolve_uwp_host_window` is already in the 0.17.0 base | `platform-windows` | +| [trycua/cua#2021](https://github.com/trycua/cua/pull/2021) | `list_windows` includes empty-/null-title top-level windows | open; `resolve_uwp_host_window` is already in the 0.20.0 base | `platform-windows` | ## Retired patches (subsumed by 0.7.0) diff --git a/packages/cua-driver/README.md b/packages/cua-driver/README.md index bb2829ae19..c77ef535c3 100644 --- a/packages/cua-driver/README.md +++ b/packages/cua-driver/README.md @@ -4,7 +4,7 @@ Qwen Code's vendored distribution of the cross-platform Cua Driver runtime. It provides native desktop and browser automation over MCP, a one-shot CLI, and in-process Python and TypeScript SDKs. -This tree is based on upstream `cua-driver-rs-v0.17.0`. The upstream snapshot +This tree is based on upstream `cua-driver-rs-v0.20.0`. The upstream snapshot is recorded in [`.vendored-from`](.vendored-from); Qwen-owned differences are documented in [`.vendored-patches.md`](.vendored-patches.md) and [`docs/relative-coordinates-design.md`](docs/relative-coordinates-design.md). @@ -14,18 +14,18 @@ documented in [`.vendored-patches.md`](.vendored-patches.md) and macOS and Linux: ```bash -CUA_DRIVER_RS_VERSION=0.17.0 \ +CUA_DRIVER_RS_VERSION=0.20.0 \ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/QwenLM/qwen-code/main/packages/cua-driver/scripts/install.sh)" ``` Windows PowerShell: ```powershell -$env:CUA_DRIVER_RS_VERSION = "0.17.0" +$env:CUA_DRIVER_RS_VERSION = "0.20.0" irm https://raw.githubusercontent.com/QwenLM/qwen-code/main/packages/cua-driver/scripts/install.ps1 | iex ``` -Expected: qwen-cua-driver 0.17.0. +Expected: qwen-cua-driver 0.20.0. The released product uses Qwen-owned identities throughout: @@ -59,16 +59,20 @@ qwen mcp add cua-driver qwen-cua-driver mcp Other MCP clients can use the same executable and arguments. Shell-oriented automation can call tools through `qwen-cua-driver call`. -## 0.17 runtime and SDK surface +## 0.20 runtime and SDK surface -The 0.17 base includes the SDK-owned runtime and versioned C ABI, generated +The 0.20 base includes the SDK-owned runtime and versioned C ABI, generated Python and TypeScript UniFFI bindings, typed browser automation, permission -modes, runtime-owned consent adapters, per-session capture scope, -snapshot-bound element tokens, closed action results, `verify_state`, native -menu invocation, clipboard tools, window framing, and semantic cursor themes. +modes, runtime-owned consent adapters, transport-owned implicit lifecycle +sessions, per-action target selection, capability manifests across permission +profiles, snapshot-bound element tokens, closed action results, `verify_state`, +foreground-focus verification, native menu and clipboard operations, window +framing, and semantic cursor themes. Browser approval tokens are retired; +existing-profile access now requires a trusted launch grant, bounded manifest, +or embedding-host authorization. -Python applications import `cua_driver`. TypeScript applications retain the -upstream-compatible `@trycua/cua-driver` package name. Both use the same +Python applications import `cua_driver`. TypeScript applications import the +Qwen-owned `@qwen-code/cua-sdk` package. Both use the same in-process native runtime; MCP remains the agent-facing boundary implemented by `qwen-cua-driver`. @@ -82,6 +86,13 @@ and generated bindings are documented in [`contract/README.md`](contract/README. admits only reviewed tools and resources. `unrestricted` requires `--dangerously-bypass-approvals`. +The mode belongs to the process that owns the runtime and is fixed at launch: +`qwen-cua-driver serve` takes the flags, while `qwen-cua-driver mcp` and embedding hosts +use the matching `CUA_DRIVER_PERMISSION_MODE`, +`CUA_DRIVER_CAPABILITY_MANIFEST_FILE`, and +`CUA_DRIVER_CAPABILITY_MANIFEST_APPROVED` variables. Choose it before starting +the daemon; a running daemon must be restarted to change it. + Attaching to an existing logged-in Chromium profile remains explicit: ```bash diff --git a/packages/cua-driver/compat-fixtures/cli.json b/packages/cua-driver/compat-fixtures/cli.json index 0529e28a03..2dd1c67f1e 100644 --- a/packages/cua-driver/compat-fixtures/cli.json +++ b/packages/cua-driver/compat-fixtures/cli.json @@ -2,14 +2,12 @@ "help_lines": [ "cross-platform computer-use automation driver", "Usage: cua-driver [SUBCOMMAND] [OPTIONS]", - "Subcommands: mcp, list-tools, describe, call, serve, stop, revoke, status, config, telemetry, recording, update, check-update, doctor, diagnose, permissions, autostart, skills, browser-approve, manifest" + "Subcommands: mcp, list-tools, describe, call, serve, stop, revoke, status, config, telemetry, recording, update, check-update, doctor, diagnose, permissions, autostart, skills, manifest" ], "manifest": { "schema_version": "1", "binary_version_format": "semver", - "mcp_args": [ - "mcp" - ], + "mcp_args": ["mcp"], "subcommands": { "mcp": [ ["--socket", "string"], @@ -23,6 +21,8 @@ ["--permission-mode", "string"], ["--grant", "repeatable-string"], ["--dangerously-bypass-approvals", "flag"], + ["--capability-manifest", "string"], + ["--approve-capability-manifest", "flag"], ["--session-policy", "string"], ["--approve-session-policy", "flag"], ["--no-permissions-gate", "flag"], @@ -35,9 +35,7 @@ ["--screenshot-out-file", "string"], ["--socket", "string"] ], - "manifest": [ - ["--pretty", "flag"] - ] + "manifest": [["--pretty", "flag"]] } } } diff --git a/packages/cua-driver/contract/README.md b/packages/cua-driver/contract/README.md index bd76a090b4..536ffdb124 100644 --- a/packages/cua-driver/contract/README.md +++ b/packages/cua-driver/contract/README.md @@ -18,19 +18,25 @@ surface through their runtime's existing MCP client. The typed slice covers the cross-platform session lifecycle tools: - `start_session` -- `escalate_session` -- `get_session_state` +- `get_session` +- `list_sessions` - `end_session` +Ordinary calls do not need `start_session`. The runtime creates one implicit +session for the authenticated transport lease and reuses it until transport +close, explicit end, or five minutes of inactivity. `escalate_session` and +`get_session_state` remain deprecated compatibility tools for legacy +capture-scope sessions. There is no `deescalate_session` tool. + It also covers the portable whole-desktop loop: - `get_desktop_state` - `get_screen_size` - `get_cursor_position` -- `move_cursor` with the required `scope="desktop"` +- `move_cursor` with an exact per-call window or desktop `target` - `set_window_frame` for exact, read-back-verified top-level window geometry - `invoke_menu` for an exact native application-menu path resolved live at each hop -- `click` with the required `scope="desktop"` +- `click` with an exact per-call window or desktop `target` - `drag` and `scroll` in native desktop coordinates - `type_text`, `press_key`, and `hotkey` against the foreground application - `clipboard_read` for available types and opt-in plain-text readback @@ -61,7 +67,11 @@ The checked-observation slice is also shared by MCP and both generated SDKs: harness to interpret. Cua Driver does not OCR or assign task meaning to it. Session contracts are marked `canonical_runtime`: the same typed Rust input, -output, and metadata declaration builds the live MCP tool. Desktop contracts +output, and metadata declaration builds the live MCP tool. The preferred action +target is a tagged union: `{kind:"window", pid, window_id}` or +`{kind:"desktop", display_id:"primary"}`. Legacy flat `scope`, `pid`, and +`window_id` fields remain accepted during the compatibility window but cannot +be mixed with `target`. Desktop contracts are marked `portable_subset`: their typed Rust inputs are a deliberately narrower projection of the richer macOS, Linux, and Windows runtime schemas. Each platform's desktop branch deserializes that projection before acting, @@ -89,7 +99,7 @@ Compatibility is tracked separately at each boundary: | Field | Current | Meaning | | --- | --- | --- | -| `contract_version` | `0.6.0` | Generated manifest and typed SDK shape | +| `contract_version` | `0.7.0` | Generated manifest and typed SDK shape | | `tools_list_schema_version` | `1` | cua-driver `tools/list` extension shape | | `capability_version` | `1` | Additive capability-token vocabulary | | `mcp_protocol_version` | `2025-06-18` | MCP initialization protocol served to agent runtimes | diff --git a/packages/cua-driver/contract/manifest.json b/packages/cua-driver/contract/manifest.json index 0860a225f3..dda80db764 100644 --- a/packages/cua-driver/contract/manifest.json +++ b/packages/cua-driver/contract/manifest.json @@ -1,7 +1,7 @@ { "generated_notice": "Generated by cua-contract-gen; do not edit by hand.", "experimental": true, - "contract_version": "0.6.0", + "contract_version": "0.7.0", "tools_list_schema_version": "1", "capability_version": "1", "mcp_protocol_version": "2025-06-18", @@ -47,12 +47,67 @@ "type": "integer" }, "scope": { - "const": "desktop" + "const": "desktop", + "description": "Deprecated flat desktop target retained for wire compatibility." }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" }, + "target": { + "anyOf": [ + { + "description": "Exact capture/input target selected independently for each action.\n\n`display_id=\"primary\"` is the portable desktop target in this release.\nPlatforms that cannot address another display reject it explicitly rather\nthan silently changing coordinate spaces.", + "oneOf": [ + { + "additionalProperties": true, + "properties": { + "kind": { + "const": "window", + "type": "string" + }, + "pid": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "window_id": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "pid", + "window_id" + ], + "type": "object" + }, + { + "additionalProperties": true, + "properties": { + "display_id": { + "type": "string" + }, + "kind": { + "const": "desktop", + "type": "string" + } + }, + "required": [ + "kind", + "display_id" + ], + "type": "object" + } + ] + }, + { + "type": "null" + } + ] + }, "x": { "type": "number" }, @@ -62,8 +117,7 @@ }, "required": [ "x", - "y", - "scope" + "y" ], "type": "object" }, @@ -213,7 +267,7 @@ "type": "boolean" }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" } }, @@ -296,7 +350,7 @@ "type": "string" }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" }, "text": { @@ -389,10 +443,11 @@ "type": "array" }, "scope": { - "const": "desktop" + "const": "desktop", + "description": "Deprecated flat desktop target retained for wire compatibility." }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" }, "steps": { @@ -400,6 +455,60 @@ "minimum": 1, "type": "integer" }, + "target": { + "anyOf": [ + { + "description": "Exact capture/input target selected independently for each action.\n\n`display_id=\"primary\"` is the portable desktop target in this release.\nPlatforms that cannot address another display reject it explicitly rather\nthan silently changing coordinate spaces.", + "oneOf": [ + { + "additionalProperties": true, + "properties": { + "kind": { + "const": "window", + "type": "string" + }, + "pid": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "window_id": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "pid", + "window_id" + ], + "type": "object" + }, + { + "additionalProperties": true, + "properties": { + "display_id": { + "type": "string" + }, + "kind": { + "const": "desktop", + "type": "string" + } + }, + "required": [ + "kind", + "display_id" + ], + "type": "object" + } + ] + }, + { + "type": "null" + } + ] + }, "to_x": { "type": "number" }, @@ -411,8 +520,7 @@ "from_x", "from_y", "to_x", - "to_y", - "scope" + "to_y" ], "type": "object" }, @@ -533,7 +641,7 @@ }, { "name": "end_session", - "description": "End a session declared with `start_session`: removes its agent cursor, stops any recording it owns, and clears its per-session config. Call this when a run finishes so its cursor doesn't linger (otherwise the idle-TTL reclaims it after a period of inactivity). Idempotent.", + "description": "End one visible lifecycle session and run its cursor, recording, configuration, and other cleanup hooks exactly once. Omit `session` to end the authenticated transport's implicit session. Idempotent.", "platforms": [ "macos", "windows", @@ -556,13 +664,11 @@ "additionalProperties": true, "properties": { "session": { - "description": "The session id to end.", + "description": "Optional public label to end. When omitted, end the caller's attached\nimplicit session.", "type": "string" } }, - "required": [ - "session" - ], + "required": [], "type": "object" }, "success_output_schema": { @@ -584,7 +690,7 @@ }, { "name": "escalate_session", - "description": "Unlock the desktop phase of an auto capture-scope session after the window action ladder has been exhausted and verified. This is a one-way transition for the live session and records a bounded reason.", + "description": "Deprecated compatibility tool for legacy capture-scope sessions. New callers select window or desktop modality on each action. No deescalate_session tool exists.", "platforms": [ "macos", "windows", @@ -944,7 +1050,7 @@ "additionalProperties": false, "properties": { "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" } }, @@ -1000,7 +1106,7 @@ "type": "string" }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" } }, @@ -1081,7 +1187,7 @@ "additionalProperties": false, "properties": { "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" } }, @@ -1109,9 +1215,114 @@ "type": "object" } }, + { + "name": "get_session", + "description": "Read content-free lifecycle, cursor, recording, and idle status for one session visible to this authenticated transport. Omit `session` to inspect its implicit session.", + "platforms": [ + "macos", + "windows", + "linux" + ], + "capabilities": [ + "session.lifecycle.read" + ], + "annotations": { + "read_only": true, + "destructive": false, + "idempotent": true, + "open_world": false + }, + "schema_mode": "canonical_runtime", + "cursor_semantics": { + "action": "system" + }, + "input_schema": { + "additionalProperties": true, + "properties": { + "session": { + "description": "Optional public label. When omitted, inspect the caller's attached\nimplicit session.", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "success_output_schema": { + "additionalProperties": true, + "properties": { + "client_kind": { + "enum": [ + "cli", + "direct", + "mcp", + "python_sdk", + "typescript_sdk" + ], + "type": "string" + }, + "cursor_visible": { + "type": "boolean" + }, + "expires_in_seconds": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "idle_seconds": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "implicit": { + "type": "boolean" + }, + "recording_active": { + "type": "boolean" + }, + "session": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "state": { + "enum": [ + "active", + "ending" + ], + "type": "string" + }, + "transport": { + "enum": [ + "cli", + "daemon", + "mcp_stdio", + "mcp_http" + ], + "type": "string" + } + }, + "required": [ + "session", + "implicit", + "state", + "client_kind", + "transport", + "cursor_visible", + "recording_active", + "idle_seconds", + "expires_in_seconds" + ], + "type": "object" + } + }, { "name": "get_session_state", - "description": "Read the live session's capture policy and effective scope.", + "description": "Deprecated compatibility alias that reads a live legacy session's capture policy. Use get_session for lifecycle state.", "platforms": [ "macos", "windows", @@ -1134,12 +1345,11 @@ "additionalProperties": true, "properties": { "session": { + "description": "Optional public label. When omitted, inspect the caller's attached\nimplicit session.", "type": "string" } }, - "required": [ - "session" - ], + "required": [], "type": "object" }, "success_output_schema": { @@ -1205,6 +1415,182 @@ "type": "object" } }, + { + "name": "get_window_state", + "description": "Walk one exact window's accessibility tree and return the actionable element snapshot, optionally as a versioned observation revision (`accessibility.observation_revision.v1`) diffed against a caller-supplied base.", + "platforms": [ + "macos", + "windows", + "linux" + ], + "capabilities": [ + "accessibility.window_state", + "accessibility.tree", + "accessibility.tree.structured", + "accessibility.tree.bounded", + "accessibility.element_tokens", + "accessibility.observation_revision.v1", + "screen.capture", + "screen.capture.window" + ], + "annotations": { + "read_only": true, + "destructive": false, + "idempotent": false, + "open_world": false + }, + "schema_mode": "portable_subset", + "cursor_semantics": { + "action": "observe" + }, + "input_schema": { + "additionalProperties": false, + "properties": { + "include_screenshot": { + "description": "Default true. Set false to skip the screenshot and return the tree only.", + "type": "boolean" + }, + "max_depth": { + "description": "Cap on the accessibility-tree walk depth.", + "minimum": 1, + "type": "integer" + }, + "max_elements": { + "description": "Cap on the total number of accessibility nodes walked.", + "minimum": 1, + "type": "integer" + }, + "observation_revision": { + "additionalProperties": false, + "description": "Opt in to `accessibility.observation_revision.v1`. Requires a bound\ndriver session. Omit to preserve the legacy full-snapshot contract.", + "properties": { + "base_revision_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "force_full": { + "default": false, + "type": "boolean" + }, + "projection_version": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "serializer_version": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "version": { + "const": 1, + "type": "integer" + } + }, + "required": [ + "version", + "serializer_version", + "projection_version" + ], + "type": "object" + }, + "pid": { + "description": "Target process ID.", + "minimum": 1, + "type": "integer" + }, + "query": { + "description": "Case-insensitive projection filter. Incompatible with `observation_revision`.", + "type": "string" + }, + "screenshot_out_file": { + "description": "Write the PNG here instead of returning base64.", + "type": "string" + }, + "session": { + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", + "type": "string" + }, + "window_id": { + "description": "Exact window to observe.", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "pid", + "window_id" + ], + "type": "object" + }, + "success_output_schema": { + "additionalProperties": true, + "properties": { + "observation_revision": { + "additionalProperties": true, + "properties": { + "base_revision_id": { + "type": "string" + }, + "capability": { + "const": "accessibility.observation_revision.v1" + }, + "lineage_id": { + "minLength": 1, + "type": "string" + }, + "mode": { + "enum": [ + "full", + "diff", + "no_change" + ], + "type": "string" + }, + "projection_version": { + "minLength": 1, + "type": "string" + }, + "resync_reason": { + "type": "string" + }, + "revision_id": { + "minLength": 1, + "type": "string" + }, + "serializer_version": { + "minLength": 1, + "type": "string" + }, + "stable_element_ids": { + "type": [ + "boolean", + "null" + ] + }, + "version": { + "type": "integer" + } + }, + "required": [ + "capability", + "version", + "serializer_version", + "projection_version", + "mode", + "lineage_id", + "revision_id" + ], + "type": [ + "object", + "null" + ] + } + }, + "type": "object" + } + }, { "name": "hotkey", "description": "Press a key chord in the foreground desktop application.", @@ -1214,7 +1600,8 @@ "linux" ], "capabilities": [ - "input.keyboard.hotkey" + "input.keyboard.hotkey", + "accessibility.element_tokens" ], "annotations": { "read_only": false, @@ -1237,16 +1624,70 @@ "type": "array" }, "scope": { - "const": "desktop" + "const": "desktop", + "description": "Deprecated flat desktop target retained for wire compatibility." }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" + }, + "target": { + "anyOf": [ + { + "description": "Exact capture/input target selected independently for each action.\n\n`display_id=\"primary\"` is the portable desktop target in this release.\nPlatforms that cannot address another display reject it explicitly rather\nthan silently changing coordinate spaces.", + "oneOf": [ + { + "additionalProperties": true, + "properties": { + "kind": { + "const": "window", + "type": "string" + }, + "pid": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "window_id": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "pid", + "window_id" + ], + "type": "object" + }, + { + "additionalProperties": true, + "properties": { + "display_id": { + "type": "string" + }, + "kind": { + "const": "desktop", + "type": "string" + } + }, + "required": [ + "kind", + "display_id" + ], + "type": "object" + } + ] + }, + { + "type": "null" + } + ] } }, "required": [ - "keys", - "scope" + "keys" ], "type": "object" }, @@ -1406,7 +1847,7 @@ "type": "integer" }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" }, "window_id": { @@ -1536,6 +1977,142 @@ "type": "object" } }, + { + "name": "list_sessions", + "description": "List content-free lifecycle summaries attached to this authenticated transport lease. It does not enumerate other callers' sessions.", + "platforms": [ + "macos", + "windows", + "linux" + ], + "capabilities": [ + "session.lifecycle.list" + ], + "annotations": { + "read_only": true, + "destructive": false, + "idempotent": true, + "open_world": false + }, + "schema_mode": "canonical_runtime", + "cursor_semantics": { + "action": "system" + }, + "input_schema": { + "additionalProperties": true, + "properties": { + "cursor": { + "description": "Opaque continuation cursor returned by a previous call.", + "type": "string" + }, + "limit": { + "description": "Maximum number of content-free summaries to return (default 50, max\n100). Ordinary agent transports are scoped to their own lease.", + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [], + "type": "object" + }, + "success_output_schema": { + "additionalProperties": true, + "properties": { + "next_cursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sessions": { + "items": { + "properties": { + "client_kind": { + "enum": [ + "cli", + "direct", + "mcp", + "python_sdk", + "typescript_sdk" + ], + "type": "string" + }, + "cursor_visible": { + "type": "boolean" + }, + "expires_in_seconds": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "idle_seconds": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "implicit": { + "type": "boolean" + }, + "recording_active": { + "type": "boolean" + }, + "session": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "state": { + "enum": [ + "active", + "ending" + ], + "type": "string" + }, + "transport": { + "enum": [ + "cli", + "daemon", + "mcp_stdio", + "mcp_http" + ], + "type": "string" + } + }, + "required": [ + "session", + "implicit", + "state", + "client_kind", + "transport", + "cursor_visible", + "recording_active", + "idle_seconds", + "expires_in_seconds" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "sessions", + "next_cursor" + ], + "type": "object" + } + }, { "name": "move_cursor", "description": "Move the real OS pointer in get_desktop_state coordinates.", @@ -1562,12 +2139,68 @@ "additionalProperties": false, "properties": { "scope": { - "const": "desktop" + "const": "desktop", + "description": "Deprecated flat desktop target retained for wire compatibility." }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" }, + "target": { + "anyOf": [ + { + "description": "Exact capture/input target selected independently for each action.\n\n`display_id=\"primary\"` is the portable desktop target in this release.\nPlatforms that cannot address another display reject it explicitly rather\nthan silently changing coordinate spaces.", + "oneOf": [ + { + "additionalProperties": true, + "properties": { + "kind": { + "const": "window", + "type": "string" + }, + "pid": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "window_id": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "pid", + "window_id" + ], + "type": "object" + }, + { + "additionalProperties": true, + "properties": { + "display_id": { + "type": "string" + }, + "kind": { + "const": "desktop", + "type": "string" + } + }, + "required": [ + "kind", + "display_id" + ], + "type": "object" + } + ] + }, + { + "type": "null" + } + ], + "description": "Preferred per-call target. New callers should set this field." + }, "x": { "type": "number" }, @@ -1577,8 +2210,171 @@ }, "required": [ "x", - "y", - "scope" + "y" + ], + "type": "object" + }, + "success_output_schema": { + "additionalProperties": false, + "properties": { + "delivery": { + "additionalProperties": false, + "properties": { + "delivered_count": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "mode": { + "enum": [ + "background", + "foreground", + "not_applicable", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "mode" + ], + "type": [ + "object", + "null" + ] + }, + "effect": { + "enum": [ + "confirmed", + "partial", + "unverifiable", + "suspected_noop", + "refused" + ], + "type": "string" + }, + "escalation": { + "additionalProperties": false, + "properties": { + "reason": { + "enum": [ + "route_unavailable", + "delivery_failed", + "effect_unconfirmed", + "suspected_noop", + "permission_required" + ], + "type": "string" + }, + "target": { + "enum": [ + "pixel", + "foreground", + "page", + "session" + ], + "type": "string" + } + }, + "required": [ + "target", + "reason" + ], + "type": [ + "object", + "null" + ] + }, + "evidence": { + "items": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "value_readback", + "window_change" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "type": [ + "array", + "null" + ] + }, + "route": { + "enum": [ + "accessibility", + "synthetic_events", + "global_input", + "system_api", + "dom", + "trusted_input" + ], + "type": "string" + } + }, + "required": [ + "effect", + "route" + ], + "type": "object" + } + }, + { + "name": "perform_secondary_action", + "description": "Perform one explicitly named action advertised by the exact current accessibility element. Missing, ambiguous, disabled, or stale actions fail closed without a pixel fallback.", + "platforms": [ + "macos", + "windows", + "linux" + ], + "capabilities": [ + "accessibility.action.secondary", + "accessibility.element_tokens" + ], + "annotations": { + "read_only": false, + "destructive": true, + "idempotent": false, + "open_world": true + }, + "schema_mode": "portable_subset", + "cursor_semantics": { + "action": "app" + }, + "input_schema": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "element_token": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "pid": { + "minimum": 1, + "type": "integer" + }, + "window_id": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "pid", + "element_token", + "action" ], "type": "object" }, @@ -1732,16 +2528,70 @@ "type": "array" }, "scope": { - "const": "desktop" + "const": "desktop", + "description": "Deprecated flat desktop target retained for wire compatibility." }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" + }, + "target": { + "anyOf": [ + { + "description": "Exact capture/input target selected independently for each action.\n\n`display_id=\"primary\"` is the portable desktop target in this release.\nPlatforms that cannot address another display reject it explicitly rather\nthan silently changing coordinate spaces.", + "oneOf": [ + { + "additionalProperties": true, + "properties": { + "kind": { + "const": "window", + "type": "string" + }, + "pid": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "window_id": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "pid", + "window_id" + ], + "type": "object" + }, + { + "additionalProperties": true, + "properties": { + "display_id": { + "type": "string" + }, + "kind": { + "const": "desktop", + "type": "string" + } + }, + "required": [ + "kind", + "display_id" + ], + "type": "object" + } + ] + }, + { + "type": "null" + } + ] } }, "required": [ - "key", - "scope" + "key" ], "type": "object" }, @@ -1907,12 +2757,67 @@ "type": "string" }, "scope": { - "const": "desktop" + "const": "desktop", + "description": "Deprecated flat desktop target retained for wire compatibility." }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" }, + "target": { + "anyOf": [ + { + "description": "Exact capture/input target selected independently for each action.\n\n`display_id=\"primary\"` is the portable desktop target in this release.\nPlatforms that cannot address another display reject it explicitly rather\nthan silently changing coordinate spaces.", + "oneOf": [ + { + "additionalProperties": true, + "properties": { + "kind": { + "const": "window", + "type": "string" + }, + "pid": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "window_id": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "pid", + "window_id" + ], + "type": "object" + }, + { + "additionalProperties": true, + "properties": { + "display_id": { + "type": "string" + }, + "kind": { + "const": "desktop", + "type": "string" + } + }, + "required": [ + "kind", + "display_id" + ], + "type": "object" + } + ] + }, + { + "type": "null" + } + ] + }, "x": { "type": "number" }, @@ -1923,8 +2828,7 @@ "required": [ "x", "y", - "direction", - "scope" + "direction" ], "type": "object" }, @@ -2351,6 +3255,170 @@ "type": "object" } }, + { + "name": "set_value", + "description": "Set the value of the exact current accessibility element named by an opaque element token.", + "platforms": [ + "macos", + "windows", + "linux" + ], + "capabilities": [ + "accessibility.value.set", + "accessibility.element_tokens" + ], + "annotations": { + "read_only": false, + "destructive": true, + "idempotent": true, + "open_world": true + }, + "schema_mode": "portable_subset", + "cursor_semantics": { + "action": "text" + }, + "input_schema": { + "additionalProperties": false, + "properties": { + "element_token": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "pid": { + "minimum": 1, + "type": "integer" + }, + "value": { + "type": "string" + }, + "window_id": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "pid", + "element_token", + "value" + ], + "type": "object" + }, + "success_output_schema": { + "additionalProperties": false, + "properties": { + "delivery": { + "additionalProperties": false, + "properties": { + "delivered_count": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "mode": { + "enum": [ + "background", + "foreground", + "not_applicable", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "mode" + ], + "type": [ + "object", + "null" + ] + }, + "effect": { + "enum": [ + "confirmed", + "partial", + "unverifiable", + "suspected_noop", + "refused" + ], + "type": "string" + }, + "escalation": { + "additionalProperties": false, + "properties": { + "reason": { + "enum": [ + "route_unavailable", + "delivery_failed", + "effect_unconfirmed", + "suspected_noop", + "permission_required" + ], + "type": "string" + }, + "target": { + "enum": [ + "pixel", + "foreground", + "page", + "session" + ], + "type": "string" + } + }, + "required": [ + "target", + "reason" + ], + "type": [ + "object", + "null" + ] + }, + "evidence": { + "items": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "value_readback", + "window_change" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + "type": [ + "array", + "null" + ] + }, + "route": { + "enum": [ + "accessibility", + "synthetic_events", + "global_input", + "system_api", + "dom", + "trusted_input" + ], + "type": "string" + } + }, + "required": [ + "effect", + "route" + ], + "type": "object" + } + }, { "name": "set_window_frame", "description": "Set one exact top-level window's frame in the desktop-coordinate space reported by list_windows and verify the resulting geometry through an independent readback.", @@ -2384,7 +3452,7 @@ "type": "integer" }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" }, "width": { @@ -2529,7 +3597,7 @@ }, { "name": "start_session", - "description": "Declare a session — a named, color-coded identity for THIS agent run. Pass a stable `session` id and choose capture_scope=auto|window|desktop; the agent cursor, capture policy, per-session config, and recording all key on it, and it follows the run across any apps/windows. The cursor's color is derived from the id, so distinct runs are visually distinct. A cursor is shown only for a declared session — call this (or pass `session` on your first action) to opt in. Idempotent: re-calling with the same id just refreshes its idle-TTL. End it with `end_session` (or let the idle-TTL reclaim it). Concurrent runs/subagents each pass their own `session` to get their own cursor.", + "description": "Optionally create or return a lifecycle session before acting. For multi-call work, prefer a short public `session` label and repeat it on every call that accepts it; an omitted value uses the authenticated transport lease's implicit session instead. This tool is optional because an ordinary action can create or reuse a named run directly. Use it to set the initial cursor theme before acting or to revive a public name after it has ended; ordinary actions never revive ended names. `capture_scope` is deprecated compatibility input; new callers select window or desktop modality per action. Idempotent.", "platforms": [ "macos", "windows", @@ -2553,8 +3621,7 @@ "additionalProperties": true, "properties": { "capture_scope": { - "default": "auto", - "description": "Per-session perception/action modality. auto starts window-only and requires explicit escalation before desktop tools; window and desktop are strict. Immutable for the live session.", + "description": "Deprecated compatibility policy. New callers select window or desktop\nmodality on each action instead of storing it on the session.", "enum": [ "auto", "window", @@ -2587,13 +3654,11 @@ ] }, "session": { - "description": "Stable session id for this run (e.g. \"research-run-1\").", + "description": "Optional stable public label for this run (e.g. \"research-run-1\").\nWhen omitted, the authenticated transport lease's implicit session is\ncreated or returned.", "type": "string" } }, - "required": [ - "session" - ], + "required": [], "type": "object" }, "success_output_schema": { @@ -2694,19 +3759,73 @@ "additionalProperties": false, "properties": { "scope": { - "const": "desktop" + "const": "desktop", + "description": "Deprecated flat desktop target retained for wire compatibility." }, "session": { - "description": "Optional session id.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session.", "type": "string" }, + "target": { + "anyOf": [ + { + "description": "Exact capture/input target selected independently for each action.\n\n`display_id=\"primary\"` is the portable desktop target in this release.\nPlatforms that cannot address another display reject it explicitly rather\nthan silently changing coordinate spaces.", + "oneOf": [ + { + "additionalProperties": true, + "properties": { + "kind": { + "const": "window", + "type": "string" + }, + "pid": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "window_id": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "pid", + "window_id" + ], + "type": "object" + }, + { + "additionalProperties": true, + "properties": { + "display_id": { + "type": "string" + }, + "kind": { + "const": "desktop", + "type": "string" + } + }, + "required": [ + "kind", + "display_id" + ], + "type": "object" + } + ] + }, + { + "type": "null" + } + ] + }, "text": { "type": "string" } }, "required": [ - "text", - "scope" + "text" ], "type": "object" }, @@ -2974,7 +4093,7 @@ "type": "integer" }, "session": { - "description": "Optional session id for capture-scope and authorization continuity.", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that\naccepts it. Omit it to use the authenticated transport's implicit lifecycle session. This\nfield never selects capture modality or authorization.", "type": "string" }, "stable_samples": { diff --git a/packages/cua-driver/docs/action-icon-catalog.md b/packages/cua-driver/docs/action-icon-catalog.md index 3d868fff1a..7d381e66e0 100644 --- a/packages/cua-driver/docs/action-icon-catalog.md +++ b/packages/cua-driver/docs/action-icon-catalog.md @@ -170,10 +170,12 @@ distinct resolved action IDs. | Public tool | Resolved action keys | | -------------------------- | ----------------------------------------------------- | -| `start_session` | `session.start.{auto\|window\|desktop}` | -| `escalate_session` | `session.escalate.desktop` | -| `get_session_state` | `session.inspect` | +| `start_session` | `session.start.{implicit\|named}` | +| `get_session` | `session.inspect.one` | +| `list_sessions` | `session.inspect.list` | | `end_session` | `session.end` | +| `escalate_session` | `session.legacy.escalate.desktop` | +| `get_session_state` | `session.legacy.inspect_capture` | | `start_recording` | `recording.start.trajectory`, `recording.start.video` | | `stop_recording` | `recording.stop` | | `get_recording_state` | `recording.inspect` | @@ -215,7 +217,7 @@ public tool names. | 16 | `get_screen_size` | Inspection | All | Screen dimensions | | 17 | `get_desktop_state` | Inspection | All | Desktop capture | | 18 | `get_cursor_position` | Inspection | All | Cursor location | -| 19 | `move_cursor` | Pointer/overlay input | All | Agent-cursor move in window scope; real-pointer move in desktop scope | +| 19 | `move_cursor` | Pointer/overlay input | All | Agent-cursor or real-pointer move selected by the per-call target | | 20 | `set_agent_cursor_enabled` | Agent cursor | All | Show/hide cursor | | 21 | `set_agent_cursor_motion` | Agent cursor | All | Motion configuration | | 22 | `set_agent_cursor_theme` | Agent cursor | All | Select an installed visual theme | @@ -241,16 +243,18 @@ public tool names. | 42 | `get_recording_state` | Recording | All | Recording inspect | | 43 | `replay_trajectory` | Recording | All | Replay | | 44 | `install_ffmpeg` | Maintenance | Windows/Linux; no-op when unnecessary | Plan/install dependency | -| 45 | `start_session` | Session lifecycle | All | Start session plus capture scope | -| 46 | `escalate_session` | Session lifecycle | All | Escalate to desktop | -| 47 | `get_session_state` | Session lifecycle | All | Session inspect | +| 45 | `start_session` | Session lifecycle | All | Optional implicit or named lifecycle start | +| 46 | `get_session` | Session lifecycle | All | Inspect one visible lifecycle session | +| 47 | `list_sessions` | Session lifecycle | All | List transport-visible lifecycle sessions | | 48 | `end_session` | Session lifecycle | All | End session | -| 49 | `check_for_update` | Maintenance | All | Update check | -| 50 | `debug_window_info` | Diagnostic | Windows only | Window diagnostic | -| 51 | `mouse_button_down` | Low-level pointer | Linux only | Hold button | -| 52 | `mouse_drag` | Low-level pointer | Linux only | Move held pointer | -| 53 | `mouse_button_up` | Low-level pointer | Linux only | Release button | -| 54 | `parallel_mouse_drag` | Multi-pointer | Linux only | Concurrent drags | +| 49 | `escalate_session` | Legacy session compatibility | All | Deprecated capture-scope escalation | +| 50 | `get_session_state` | Legacy session compatibility | All | Deprecated capture-scope inspect | +| 51 | `check_for_update` | Maintenance | All | Update check | +| 52 | `debug_window_info` | Diagnostic | Windows only | Window diagnostic | +| 53 | `mouse_button_down` | Low-level pointer | Linux only | Hold button | +| 54 | `mouse_drag` | Low-level pointer | Linux only | Move held pointer | +| 55 | `mouse_button_up` | Low-level pointer | Linux only | Release button | +| 56 | `parallel_mouse_drag` | Multi-pointer | Linux only | Concurrent drags | ## Linux low-level pointer action keys diff --git a/packages/cua-driver/docs/action-result-contract.md b/packages/cua-driver/docs/action-result-contract.md index 06b0c39413..409dbfcc7e 100644 --- a/packages/cua-driver/docs/action-result-contract.md +++ b/packages/cua-driver/docs/action-result-contract.md @@ -39,7 +39,8 @@ The action-result tools are: `click`, `double_click`, `right_click`, `scroll`, `drag`, `mouse_drag`, `parallel_mouse_drag`, `move_cursor`, `mouse_button_down`, `mouse_button_up`, `type_text`, `type_text_chars`, `press_key`, `hotkey`, `set_value`, -`browser_click`, `browser_pointer`, and `browser_type`. +`set_window_frame`, `invoke_menu`, `browser_click`, `browser_pointer`, and +`browser_type`. Other mutating tools such as application launch, window activation, browser navigation, dialogs, uploads, and downloads retain their own typed results. diff --git a/packages/cua-driver/docs/cursor-modifier-badge-restructuring-plan.md b/packages/cua-driver/docs/cursor-modifier-badge-restructuring-plan.md index 0696b71175..c5d8286968 100644 --- a/packages/cua-driver/docs/cursor-modifier-badge-restructuring-plan.md +++ b/packages/cua-driver/docs/cursor-modifier-badge-restructuring-plan.md @@ -13,7 +13,7 @@ Cua Driver should present two kinds of cursor information in two clear places: - delivery: background or foreground - target: AX, pixel, browser, or desktop -The badge remains lightweight. The session name fades using the existing two-second hold and 400-millisecond fade. Modifier chips appear only while their action is active, followed by a 400-millisecond trailing fade. When the name is hidden, the badge contracts to a compact session orb and active modifier chips. Hovering the pointer restores the full session name where hover observation is supported. +The badge remains lightweight. The session name fades using the existing two-second hold and 400-millisecond fade. Modifier chips appear only while their action is active, followed by a 400-millisecond trailing fade. When the name is hidden, the badge contracts to a compact session-colored capsule and active modifier chips. Hovering the pointer restores the full session name where hover observation is supported. This is visual feedback only. It is not an authorization indicator, a security boundary, or evidence that an action succeeded. @@ -40,13 +40,13 @@ Use one pill rather than adding a second persistent status capsule. | Badge content | Visibility | | ------------------------- | --------------------------------------------------------------------------------------- | -| Session orb and name | Existing reveal, two-second hold, 400-millisecond fade; hover restores full opacity | +| Session name | Existing reveal, two-second hold, 400-millisecond fade; hover restores full opacity | | Delivery and target chips | Full opacity while modifiers are active; 400-millisecond trailing fade after they clear | | Badge chrome | Visible while either the name or modifier chips are visible | Starting an action must not restart the session-name timer. Otherwise a long session name would flash on every tool call. Modifier changes reveal or update the chips only. -When the name has faded, the pill contracts to the session orb and active chips. If no public session label exists, modifier-bearing actions still get this compact session-colored capsule. This preserves context without requiring a display name. +When the name has faded, the pill contracts to the session-colored capsule and active chips. If no public session label exists, modifier-bearing actions still get this compact session-colored capsule. This preserves context without requiring a display name. ### Host-owned modifier glyphs @@ -127,7 +127,6 @@ pub struct SessionBadgeInput<'a> { pub struct SessionBadgeLayout { pub rect: Rect, - pub orb: Rect, pub label: Option, pub delivery_chip: Option, pub target_chip: Option, @@ -284,8 +283,8 @@ Keep the D-Bus action payload unchanged. Bump the bundled helper version and its 3. Add: - full label with one chip - full label with two chips - - contracted orb with one chip - - contracted orb with two chips + - contracted capsule with one chip + - contracted capsule with two chips - no-label modifier capsule 4. Regenerate the public delivery and target GIF. 5. Update cursor personalization documentation and theme author instructions. diff --git a/packages/cua-driver/docs/embedded-host-uniffi-implementation-journal.md b/packages/cua-driver/docs/embedded-host-uniffi-implementation-journal.md index d17956cdc2..8a48901063 100644 --- a/packages/cua-driver/docs/embedded-host-uniffi-implementation-journal.md +++ b/packages/cua-driver/docs/embedded-host-uniffi-implementation-journal.md @@ -46,9 +46,10 @@ surfaces: not inherited by an embedded child. - `/embedded` may provide ergonomic adapters, but canonical lifecycle behavior belongs to Rust and is generated into Python and TypeScript. -- Python wheels, the public npm package, and native npm platform packages ship - from the same Cua Driver tag and exact version. Release validation rejects - source version drift before registry publication. +- Python wheels and the single public `@qwen-code/cua-sdk` package ship from + the same Cua Driver tag and exact version. Native Node payloads remain GitHub + Release assets. Release validation rejects source version drift before + registry publication. ## Validation matrix diff --git a/packages/cua-driver/docs/macos-background-input-v1-plan.md b/packages/cua-driver/docs/macos-background-input-v1-plan.md new file mode 100644 index 0000000000..30ac341015 --- /dev/null +++ b/packages/cua-driver/docs/macos-background-input-v1-plan.md @@ -0,0 +1,920 @@ +# Exact macOS Background Input v1 + +**Status:** Proposed implementation plan + +**Base:** `origin/main` at `d21e3447f9b08c761c090946648d5aca5e6c9cf1` + +**Date:** 2026-08-04 + +## Goal + +Let an agent operate ordinary macOS applications in the background without +moving the real pointer, changing the user's active application, or silently +acting on another window owned by the same process. + +V1 is deliberately conservative. It expands the routes for which Cua Driver can +prove an exact `(pid, CGWindowID)` destination and returns a structured refusal +for every other route. A refused action is preferable to a process-scoped key +event that appears successful after mutating a sibling window. + +## Success criteria + +V1 is complete when all of the following are true: + +1. Every window-scoped macOS mutation carries the requested `(pid, + CGWindowID)` from target resolution through route selection, dispatch, and + post-action verification. +2. A result can be `confirmed` only from evidence belonging to that exact + window or to an exact browser target bound to it. State from another + same-process window is never accepted as evidence. +3. Background routing is ordered as semantic Accessibility, exact browser/CDP, + exact window-local pointer, and finally PID keyboard with an independent + exact-delivery proof. If none is safe, the driver refuses before sending + input. +4. Background mode never silently crosses to global HID, activates another + application, moves the real pointer, changes Space, or restores a minimized + window. +5. Minimized and hidden windows retain safe semantic AX actions and current + one-shot capture. Raw key commits are refused; callers use `AXConfirm`, an + exact `AXPress`, or an exact browser action when one exists. +6. An off-Space window that is absent from the process's `AXWindows` is + observation-only. Cua Driver does not use another window's AX tree or send + process-scoped input while the target is unresolved. +7. Ordinary Electron applications get per-process AX enablement, exact + `BrowserWindow` mapping, bounded fresh-tree acquisition, delayed effect + verification, and explicit two-window safety coverage. +8. Existing action request schemas and the closed `ActionResult` wire shape + remain compatible. +9. Focused unit/contract tests run in ordinary CI. Exact-SHA native evidence is + collected separately on a signed macOS Namespace runner and in the + canonical Lume acceptance lane. + +## Non-goals + +V1 does not attempt to provide: + +- live minimized-window PiP or a window-scoped ScreenCaptureKit stream; +- continuously changing Chromium pixels while the application is occluded, + hidden, or minimized; +- full Accessibility parity across Spaces; +- automatic Space switching, temporary Space membership, sticky windows, or + switch-and-restore input; +- deminiaturize/order-behind/re-minimize transactions; +- minimize-button interception or application-specific window swizzling; +- a background raw-key guarantee for multi-window applications; +- a global-HID fallback in background mode; +- background control of games, Metal surfaces, protected video, secure input, + or other surfaces that do not expose an exact semantic or routed target; +- generic WKWebView mutation, Safari JavaScript fallback expansion, or a new + browser attachment mechanism; +- a redesign of the public action-result contract; or +- changes to application-owned Chromium settings such as + `backgroundThrottling`. + +The existing caller-selected `delivery_mode: "foreground"` remains a separate +last-resort behavior. This plan does not remove it, but the background router +must never enter it implicitly. + +## Current source-grounded state + +### Exact AX scoping exists + +[`ax/window_scope.rs`](../rust/crates/platform-macos/src/ax/window_scope.rs) +already defines `Matched`, `NotFound`, `OwnerPidMismatch`, and `AxUnresolved`. +Its structural invariant is the right foundation: every non-`Matched` result +walks no elements. + +[`ax/tree.rs`](../rust/crates/platform-macos/src/ax/tree.rs) unions +`AXChildren` and `AXWindows`, maps top-level AX windows to CG window IDs through +`_AXUIElementGetWindow`, and bounds the default walk at 2,000 nodes and depth +25. [`get_window_state.rs`](../rust/crates/platform-macos/src/tools/get_window_state.rs) +preflights WindowServer ownership, empties the element cache when the requested +window does not resolve, and still returns an exact-window screenshot for the +`AxUnresolved` degradation. + +These checks prevent an unresolved request from returning a sibling window's +tree. Mutation needs to carry the same invariant farther than it does today. + +### Explicit `window_id` does not make PID keyboard delivery window-scoped + +[`window_target.rs`](../rust/crates/cua-driver-core/src/window_target.rs) +correctly promotes a unique PID-only target and refuses a PID with multiple +eligible top-level windows. Explicit `window_id` and element-token requests, +however, pass through that cardinality guard unchanged. + +On macOS, [`keyboard.rs`](../rust/crates/platform-macos/src/input/keyboard.rs) +posts through `SLEventPostToPid` with a public `CGEvent::post_to_pid` fallback. +Both transports are process-scoped. [`press_key.rs`](../rust/crates/platform-macos/src/tools/press_key.rs) +best-effort focuses an element and then posts the key to the PID. Its pixel form +first invokes the click path to focus `(x, y)` and then posts the same +process-scoped key, so that combined pointer-plus-key branch needs one exact +route decision rather than bypassing keyboard safety after the focus click. + +[`type_text.rs`](../rust/crates/platform-macos/src/tools/type_text.rs) has a +second exactness gap: when no explicit element pointer is supplied, +`read_axvalue(pid, None)` reads `focused_element_of_pid(pid)`. Its before/after +verification can therefore observe whichever same-process field is focused, +not necessarily a descendant of the requested `window_id`. Passing an explicit +window ID does not change that readback. + +The required same-PID regression test follows directly from these source +semantics: unresolved target window A, focused sibling window B, process-routed +key, and PID-global focused-element readback. V1 must refuse before the key is +posted and prove from fixture journals that neither window changed. + +### Exact semantic and window-local primitives already exist + +[`ax_actions.rs`](../rust/crates/platform-macos/src/input/ax_actions.rs) +supports `AXPress`, `AXConfirm`, selection, focus, and enabled-state checks. +[`set_value.rs`](../rust/crates/platform-macos/src/tools/set_value.rs) already +does native AX value readback and correctly distrusts AXValue echo from an +`AXWebArea`. + +[`mouse.rs`](../rust/crates/platform-macos/src/input/mouse.rs) has a routed +pointer primitive that stamps window-local location plus the target PID and +CGWindowID fields before SkyLight/PID posting. It does not move the real +pointer. The same file also contains private no-raise activation and +Chromium-specific event recipes. These are implementation candidates, not by +themselves proof that an action reached the requested window. V1 must gate them +on exact target facts and exact post-action evidence. + +[`click.rs`](../rust/crates/platform-macos/src/tools/click.rs) already prefers +AX, can perform window-local pointer fallback for specific selection controls, +and distinguishes verified AX selection from an unverified dispatch. The new +router should consolidate those decisions rather than add a second hidden +fallback ladder. + +### Electron AX enablement exists but needs lifecycle and mutation discipline + +[`ax/bindings.rs`](../rust/crates/platform-macos/src/ax/bindings.rs) writes +`AXManualAccessibility` and falls back to `AXEnhancedUserInterface` only when +the modern attribute is unsupported. The tree walker enables it once per PID, +waits 500 ms when accepted, and then walks the bounded tree. + +The current cache is a set of PIDs. V1 should associate enablement with the +observed process lifetime so PID reuse cannot inherit a stale "enabled" +decision. Electron mutation must reacquire an exact bounded tree rather than +using title order, the first AX window, or stale process-global focus. + +### Exact browser mutation is already unique-or-refuse + +[`browser/binding.rs`](../rust/crates/cua-driver-core/src/browser/binding.rs) +contains the pure native-window-to-CDP correlation. [`browser/engine.rs`](../rust/crates/cua-driver-core/src/browser/engine.rs) +checks native ownership, process fingerprint, endpoint ownership, CDP window +geometry/cardinality, tab identity, and generation again before every +mutation. [`browser/tools.rs`](../rust/crates/cua-driver-core/src/browser/tools.rs) +uses those exact capabilities for typed browser actions. + +V1 should reuse this rung rather than creating a macOS-specific browser +shortcut. The bounded Electron fallback remains exact only when the endpoint +exposes one page and the owner has exactly one native window. Two Electron +`BrowserWindow`s must not use that fallback; they need a proven CDP-window +mapping or a refusal. WKWebView mutation remains unsupported. + +### Capture and Spaces metadata are limited + +[`capture.rs`](../rust/crates/platform-macos/src/capture.rs) currently obtains +a one-shot window image with `screencapture -l -x -o`. The +ScreenCaptureKit implementation is display-scoped recording, not a +window-scoped live stream. V1 keeps the current one-shot path and does not +claim frame freshness from API success. + +[`list_windows.rs`](../rust/crates/platform-macos/src/tools/list_windows.rs) +describes minimized, hidden, and off-Space enumeration, but the current macOS +records do not reliably populate `current_space_id`, `on_current_space`, or +`space_ids`. V1 must therefore use exact AX-window presence as its input safety +gate instead of inventing a complete Spaces model. + +### The result contract is intentionally closed + +[`outputs.rs`](../rust/crates/cua-driver-contract/src/outputs.rs) defines the +closed `ActionResult`: effect, route, delivery, evidence, and escalation. +[`action_record.rs`](../rust/crates/cua-driver-core/src/action_record.rs) is the +functional core that conservatively projects native attempts to that public +shape. [`action-result-contract.md`](action-result-contract.md) explicitly +excludes echoed selectors, coordinates, and request targets. + +V1 keeps that public result shape. Exact target facts belong to the request, +the internal execution record, a read-only capability report, and structured +refusals. Any future proposal to add optional result fields must update the +contract and every generated strict SDK together; it is not part of this work. + +## Locked design decisions + +### 1. Exact-window invariant + +The canonical target key is the requested `(pid, CGWindowID)`. It is carried +unchanged through planning, execution, and verification. + +The following rules are non-negotiable: + +- WindowServer must attribute the CGWindowID to the requested PID immediately + before mutation. +- An AX element route must prove that the live element ascends to an AX window + whose `_AXUIElementGetWindow` is the requested CGWindowID. +- A browser route must revalidate its existing exact native-window capability + immediately before mutation. +- A pointer route must use the requested window's validated pixel frame and + stamped CGWindowID, then verify an effect on that same target. +- A keyboard readback must read the addressed element or a fresh element + proven to descend from the requested AX window. It must never call a + PID-global focused-element reader as target evidence. +- A sibling window's state may be retained as a negative canary, but it can + never confirm the requested action. +- If the target disappears, changes owner, becomes AX-unresolved where the + chosen route needs AX, or becomes ambiguous between planning and dispatch, + no fallback actuator runs. + +An explicit `window_id` is an address supplied by the caller, not proof that a +process-scoped actuator will honor it. + +### 2. Functional core and imperative shell + +Add a small pure background-input decision module in +`cua-driver-core`. It owns no macOS objects and performs no I/O. Its inputs are +facts collected by a platform shell: + +- exact target and WindowServer ownership; +- AX scope (`matched`, `not_found`, `owner_mismatch`, `unresolved`); +- requested action semantics and trust class; +- target element ancestry and available semantic actions; +- target state (`visible`, `occluded`, `offscreen`, `minimized`, `hidden`, or + unknown where macOS cannot distinguish safely); +- same-PID top-level-window cardinality; +- exact browser-binding eligibility; +- exact window-local pointer eligibility; +- keyboard destination and available verifier; +- baseline foreground PID, real pointer position, and Space posture; and +- permission/private-symbol readiness. + +The pure output is one route with prerequisites and a verification plan, or a +typed refusal reason. Keep names internal, but model at least: + +```text +ExactWindowTarget(pid, window_id) +TargetResolution +BackgroundRoute +VerificationPlan +BackgroundInputDecision = Execute(...) | Refuse(...) +``` + +Use a pure truth table for route choice and result classification. Extend the +existing `ActionExecutionRecord` projection rather than adding macOS wire +semantics. Platform adapters translate a chosen internal route to the existing +public `ActionRoute` values. + +The macOS imperative shell: + +1. serializes mutation planning per PID; +2. gathers fresh WindowServer, AX, browser, and foreground facts; +3. asks the pure core for exactly one decision; +4. revalidates route-specific facts immediately before dispatch; +5. invokes only that actuator; +6. polls only the planned exact-target verifier within a bounded deadline; +7. checks foreground PID, real pointer, and Space posture; and +8. emits the existing action result or a structured refusal. + +The core never says "try all routes." The shell cannot improvise a second +actuator after a failed proof. A caller may take a newly reported route after +fresh state, preserving the existing agent-owned action ladder. + +### 3. Background routing ladder + +The decision order is: + +| Order | Route | Required exactness | V1 behavior | +| --- | --- | --- | --- | +| 1 | AX semantic action/value | Exact AX window and exact element ancestry | Prefer `AXPress`, `AXConfirm`, selection, or value mutation with target-bound readback. AX API acceptance alone is unverified. | +| 2 | Browser/CDP | Existing exact native-window, endpoint, CDP-window, tab, generation, and ref proof | Reuse typed browser tools. Do not guess or auto-convert an AX token into a DOM ref. | +| 3 | Window-local routed pointer | Exact owner, AX-window presence, validated pixel frame, stamped CGWindowID, supported target state, and exact postcondition | Never move the real pointer. Private SPI absence or failed revalidation refuses. | +| 4 | PID keyboard | Exact target element/window, no competing same-PID keyboard destination, and an exact verifier available before posting | Native single-window text insertion may qualify. Generic keys, multi-window apps, minimized/hidden windows, and unresolved off-Space windows refuse. | +| 5 | Refusal | No safe route | Return the exact reason and available alternatives without sending input. | + +The ladder preserves action semantics. It must not silently replace a trusted +browser click with a synthetic DOM event, infer a submit button from a label, +or turn a desktop request into a browser request without an exact capability. +Where request shapes cannot be translated exactly, the result advises the +explicit tool the caller may use next. + +Global HID is not in this table. Only an explicit foreground request may use +the existing guarded foreground/HID path. + +### 4. Semantic AX behavior and commit policy + +For a token/index action, revalidate the retained element's ancestry against +the requested top-level AX window. A cached element under the right cache key +is not sufficient after a lifecycle or Space transition. + +Safe commit behavior is explicit: + +- use an advertised `AXConfirm` on the exact field when available; +- use `AXPress` on an explicitly addressed commit button; +- use an exact browser action when the caller has a live page/ref capability; +- otherwise refuse a background `Return` for minimized, hidden, unresolved, + or multi-window targets. + +Do not heuristically find the first default button or press another window's +button. Do not turn `press_key(Return)` into `AXPress` unless the request itself +identifies the semantic target. + +Native `AXValue` equality can confirm a value operation on the exact retained +element. AX values under `AXWebArea` remain untrusted because the accessibility +shim can echo a write that the renderer did not consume. Electron/web content +requires browser/DOM readback or another exact application effect. + +### 5. Electron policy + +Electron support is part of v1, not a generic Chromium exception. + +For each observed process lifetime: + +1. Create the AX application element and try `AXManualAccessibility=true`. +2. Fall back to `AXEnhancedUserInterface=true` only when the modern attribute + returns `kAXErrorAttributeUnsupported`; transient failures do not trigger a + second private write or a success claim. +3. If enablement is accepted, wait the existing bounded settle and perform a + fresh exact-window walk. Key the enablement cache by process fingerprint or + generation, not an immortal numeric PID. +4. Build the top-level `BrowserWindow` map only from `_AXUIElementGetWindow`. + Window title, array order, focused-window status, and first-match selection + are not identity proofs. +5. Require the requested BrowserWindow to be in `AXWindows` before native + mutation. An absent target returns no elements and no input route. +6. Before an element action, prove its ancestry to that BrowserWindow. After + the action, poll a fresh bounded exact-window tree or exact CDP/DOM state. + Never verify against process-global focus or a stale cached tree. +7. Use a delayed bounded verifier because renderer and AX state publication are + asynchronous. Timeouts produce `unverifiable` or a proven delivery failure, + never a fabricated confirmation. +8. Retain the existing distrust of web-area AXValue echo. Normal DOM text and + key semantics should use the exact browser rung when a binding is available. +9. With two BrowserWindows, disable the single-page/single-native-window CDP + shortcut and every raw PID-key route. A route is available only when the + exact native/CDP mapping or exact semantic AX action survives revalidation. + +The signed fixture launches once without `--force-renderer-accessibility` to +prove the driver's per-PID enablement works for a normal `BrowserWindow`, and +once with the existing fixture flag as a diagnostic control. It also covers +default `backgroundThrottling` and `backgroundThrottling:false`. The setting +may affect changing pixels, but it must not weaken exact-target input policy or +become a driver prerequisite. + +### 6. Minimized and hidden windows + +For minimized or application-hidden targets: + +- keep exact one-shot capture as observation when it succeeds; +- label capture freshness as unknown rather than promising live frames; +- allow exact semantic AX actions with target-bound verification; +- allow an already-exact browser/CDP action only if all native and browser + binding checks still pass; +- use semantic commit rather than a raw Return key; +- refuse PID keyboard and native routed pointer in v1; and +- do not deminiaturize, unhide, activate, reorder, move, or re-minimize the + window as an implementation detail. + +This preserves foreground state by construction. A no-activate restore +transaction can be investigated as a later opt-in mode, but it is not a v1 +fallback. + +### 7. Other-Space policy + +Observation is allowed: `list_windows`, exact one-shot capture, and any honest +degradation metadata may still be returned. + +For mutation, query a fresh application `AXWindows` and map every candidate via +`_AXUIElementGetWindow`: + +- If the requested CGWindowID is absent, refuse all input with an + `off_space_or_ax_unresolved` reason. Do not inspect or act on a sibling + window, even if it is on the current Space. +- If the exact target is present, v1 may use a semantic AX or exact browser + route whose own preconditions and postcondition pass. A native pixel or + PID-key route is not promoted merely because capture succeeded. +- Unknown Space metadata remains unknown. Do not claim `on_current_space` + without a real source. + +Explicit switch/restore, temporarily adding a window to the current Space, and +sticky-window behavior require a later opt-in design with its own visible-state +and cleanup contract. V1 adds no SkyLight Spaces manipulation. + +### 8. Exact pointer route + +The existing window-local pointer implementation may be selected only when: + +- WindowServer ownership and AX-window identity both match the requested + target immediately before dispatch; +- the current screenshot dimensions have a validated mapping to the requested + WindowServer bounds; +- the local point falls inside that exact window frame; +- the target is neither minimized, hidden, nor AX-unresolved/off-Space; +- the required private symbols resolve on the running macOS version; +- baseline frontmost PID and real pointer position have been captured; and +- an exact postcondition exists for the selected control. + +Use the existing stamped local coordinate and CGWindowID fields. Treat private +no-raise activation as part of the actuator, not as delivery proof. If it +changes the user's frontmost application, pointer, or Space, mark the route +failed, restore only state owned by the driver when that is safe, and disable +the route for the remainder of the process session. + +The v1 router does not use unverified pixel input for a mutation whose only +result is "event posted." It may remain available through current APIs as +`unverifiable` outside the new exact capability, but it must not be advertised +as exact background input. + +### 9. PID keyboard gate + +PID-routed keyboard is a last background rung because macOS accepts a process, +not a CGWindowID. + +Before posting text, require all of: + +- a live explicit element proven to descend from the requested AX window; +- that exact element is the PID's focused UI element and the PID's focused AX + window maps to the requested CGWindowID; +- the requested target is the only eligible same-PID keyboard window at the + pre-dispatch revalidation point; +- the target is visible or occluded on the current Space, not minimized or + hidden; +- a target-bound value verifier is readable before dispatch; and +- the value surface is native, not an `AXWebArea` echo surface. + +Post the event only after those facts pass, then poll the same retained target +or a freshly reacquired equivalent inside the exact requested tree. If any +precondition cannot be proven, refuse before input. Generic `press_key` and +hotkeys normally lack a safe target-specific postcondition and therefore +refuse in background exact mode unless a future action-specific verifier is +added. + +The singleton rule is a conservative v1 proof, not a claim that +`SLEventPostToPid` is window-addressed. It prevents the known class of +same-process sibling delivery while semantic and browser routes cover most +ordinary controls. + +### 10. Capability and result reporting + +Add an additive `background_input` section to the read-only exact-window state +output. It should be produced from the same pure decision facts and contain no +window titles, control values, or private symbol addresses. A representative +shape is: + +```json +{ + "background_input": { + "exact_window": { + "status": "matched", + "pid": 123, + "window_id": 456, + "ax_window": "matched" + }, + "routes": [ + {"route": "accessibility", "status": "available"}, + { + "route": "pid_keyboard", + "status": "refused", + "reason": "same_pid_sibling_windows" + } + ], + "observation": { + "one_shot_capture": "available", + "frame_freshness": "unknown" + } + } +} +``` + +The capability route and status strings above are illustrative and are not new +values for the closed public `ActionRoute` enum. + +The exact enums and generated types should be reviewed with the implementation, +but the semantics are fixed: + +- `available` means route prerequisites are currently proven, not that a + future call is guaranteed to succeed; +- every action revalidates instead of trusting this report; +- unknown facts stay unknown; +- route refusal includes one stable machine-readable reason; and +- the report does not expose user content. + +Keep successful action outputs on the current `ActionResult` contract: + +- `route` uses the existing enum, including accessibility, synthetic events, + system API, DOM, and trusted input for relevant background actions; +- `delivery.mode` reports what actually occurred; +- `confirmed` requires publishable exact-target readback; +- `unverifiable` means the route ran but exact effect is unknown; +- `refused` means no actuator ran; and +- escalation is advice for an explicit caller decision. + +Structured refusals should include `code`, `effect: "refused"`, the requested +`pid` and `window_id`, the failed exactness fact, and the safe next route when +one exists. Initial codes should distinguish at least ownership mismatch, +AX-window unresolved/off-Space, same-PID keyboard ambiguity, unavailable +verification, and unavailable private route. + +Do not report `delivery_failed` merely because the driver lacks evidence. +Use it only when exact negative evidence proves delivery did not occur. A +missing/unreadable postcondition is `unverifiable` with +`effect_unconfirmed`; an unchanged readable exact target after the bounded +delivery window may be `suspected_noop` or delivery failure according to the +existing action's semantics. API acceptance is never confirmation. + +## State policy matrix + +This is the required decision policy, not a claim that every route already has +native evidence: + +| Target state | Observe | AX semantic | Exact browser | Window-local pointer | PID keyboard | +| --- | --- | --- | --- | --- | --- | +| Frontmost | Current state/capture | Yes when exact | Yes when exact | Yes when exact and verified | Only with singleton exact element and verifier | +| Background visible | Current state/capture | Yes when exact | Yes when exact | Yes when exact and verified | Same conservative gate | +| Fully occluded | One-shot capture; freshness honest | Yes when exact | Yes when exact | Yes only after signed proof and exact verifier | Same conservative gate | +| Fully offscreen on current Space | One-shot capture when valid | Yes when exact | Yes when exact | Signed-evidence gated; no geometry guess | Refuse in v1 | +| Minimized | One-shot capture, freshness unknown | Yes when exact | Yes only if exact binding revalidates | Refuse | Refuse; semantic commit instead | +| Application hidden | One-shot capture, freshness unknown | Yes when exact | Yes only if exact binding revalidates | Refuse | Refuse; semantic commit instead | +| Other Space, exact AX window present | Observation allowed | Yes when exact | Yes when exact | Refuse in v1 | Refuse in v1 | +| Other Space/AX window absent | Observation only | Refuse | Refuse | Refuse | Refuse | +| Owner mismatch/stale window | Refuse stale target; report owner when safe | Refuse | Refuse | Refuse | Refuse | + +## Compatibility expectations + +These are route expectations to certify, not unsupported parity claims: + +| Surface | V1 expectation | Evidence required | +| --- | --- | --- | +| AppKit | Primary supported lane: exact AX actions/value, verified window-local pointer where semantic AX is unavailable, narrowly gated native text | Runtime AppKit fixture, including two windows under one PID | +| SwiftUI | Exact AX on the current Space; truthful refusal when an off-Space window disappears from `AXWindows` | Runtime SwiftUI fixture on current and another Space | +| Electron DOM | AX enablement and exact BrowserWindow tree; exact CDP route when bound; no multi-window PID keys | Runtime two-BrowserWindow fixture with per-window DOM journals | +| Mac Catalyst | Semantic AX or verified pointer only; raw keyboard remains gated | Source-supported candidate; runtime row required before advertising | +| Qt/Java | Use exposed exact AX semantics; refuse absent/custom AX and raw-key ambiguity | Source-based until dedicated signed fixtures exist | +| Browser canvas/WebGL | Exact browser action when a supported ref/route exists; otherwise no semantic claim | Source-based browser binding plus route-specific runtime tests | +| Games/Metal/custom event loops | No v1 background guarantee | Explicitly unsupported until an exact app-owned route exists | +| Secure/protected surfaces and secure input | No capture or input bypass | Refusal/permission tests; never weaken platform protection | +| System UI and out-of-process panels | Require actual WindowServer owner and exact AX window; never follow a PID silently | Existing owner-mismatch tests plus signed panel coverage | + +`backgroundThrottling:false` may preserve Electron painting in cases where the +default renderer throttles. That is an application compatibility option, not a +Cua Driver route guarantee. Canvas/WebGL, trusted-event filters, and custom +event loops can still behave differently from ordinary DOM controls. + +## Staged implementation + +### Stage 0: Lock the safety regression + +- Extend the AppKit and Electron fixtures with two fixed-title windows, one + field and one commit control per window, and a run-owned per-window journal. +- Reproduce process-scoped keyboard delivery with target A unresolved or + minimized and sibling B focused. +- Add a test that requests A by exact `(pid, window_id)` and requires a refusal + before dispatch; both journals and both field values must remain unchanged. +- Add a unit regression showing that a sibling's focused-element readback can + never satisfy A's verification plan. + +Do not refactor routes until this failing behavioral shape and pure regression +are both captured. + +### Stage 1: Pure exact-target router + +- Add the target facts, route decision, verifier requirement, and refusal + reasons to common core. +- Encode the complete state matrix as table-driven unit tests. +- Make "explicit `window_id`" distinct from "exact delivery proven." +- Extend internal action records with diagnostic exact-target proof state only + as needed; do not change the public `ActionResult` shape. +- Add a per-PID mutation coordinator so two concurrent background decisions + cannot change process focus underneath one another. + +### Stage 2: Fresh macOS target acquisition + +- Centralize WindowServer owner preflight and fresh `AXWindows` mapping. +- Add a bounded ancestry check from a retained element to its exact AX window. +- Make Chromium/Electron AX enablement process-lifetime aware and preserve the + modern-attribute/fallback error distinction. +- Reacquire bounded exact-window state for delayed postconditions and clear + stale caches on every unresolved result. +- Change the `AxUnresolved` input advice from unconditional pixel mutation to + observation-only/refusal when exact input cannot be proven. + +### Stage 3: Semantic actions and exact verification + +- Route exact `AXPress`, `AXConfirm`, selection, and native value mutations + first. +- Bind every readback to the requested target element/window. +- Preserve web-area AX echo distrust and add browser/DOM verification where an + exact capability already exists. +- Make process-level window changes diagnostic only unless the changed window + is the exact target postcondition. +- Normalize `confirmed`, `unverifiable`, `suspected_noop`, and proven delivery + failure through the functional core. + +### Stage 4: Browser, pointer, and keyboard rungs + +- Reuse browser mutation revalidation without a second Electron-only binding + implementation. +- Gate the existing window-local pointer route on exact target, state, pixel + frame, private-symbol readiness, and verifier facts. +- Route `press_key`'s existing pixel-focus-then-key form through the same pure + decision. A successful focus click cannot waive the exact AX-window, + singleton destination, target-bound verifier, or state gates for the key. +- Remove PID-global focused-element readback from window-scoped keyboard + verification. +- Permit native PID text only under the singleton exact-element/verifier gate. +- Refuse generic keys and every unsafe minimized/hidden/off-Space raw-key case. +- Assert that the background router has no call edge to global HID or + foreground assist. + +### Stage 5: Capabilities, documentation, and rollout + +- Add the read-only per-window capability report and structured refusal codes. +- Keep request and `ActionResult` schemas compatible; regenerate/check SDK + artifacts only if the read-only output has a generated typed surface. +- Update the background-delivery and known-limit documentation to match the + certified matrix, not the intended matrix. +- Ship the high-risk same-PID refusal before expanding routes. If a new route + regresses, disable that route while retaining the exact-target guard. +- Run the full desktop acceptance matrix once against the final exact SHA, as + required by repository guidance. + +## Test and acceptance plan + +### Ordinary CI + +Run these on every implementation pull request without a GUI or TCC grants: + +- pure route-decision table for every state and route; +- owner mismatch, stale ID, AX-unresolved, and exact matched scope; +- explicit-window versus exact-delivery distinction; +- same-PID sibling rejection before actuator invocation; +- wrong-sibling readback ignored by verification; +- pixel-focus-then-key cannot bypass keyboard refusal or dispatch a second + actuator after target facts change; +- no background decision can produce a global-HID transport; +- native value, missing readback, unchanged value, partial text, delayed + success, and negative-delivery result classification; +- Electron PID-lifetime enablement cache and fallback behavior; +- exact BrowserWindow map cardinality and no first/title-only match; +- existing CDP binding/revalidation, including one-page/one-window fallback + refusal after a second native window appears; +- minimized/hidden/off-Space policy truth tables; +- read-only capability and structured-refusal schema snapshots; +- existing closed `ActionResult` invariants and generated SDK parity; and +- fixture build/lint tests. + +### Signed macOS behavior matrix + +The native harness must use only repository fixtures and a fixture-owned +occlusion sentinel. Each action records pre/post foreground PID, real pointer +position, requested CGWindowID, AX window IDs, route, result, exact fixture +journal entry, and cleanup state. + +Required rows: + +1. **AppKit:** frontmost, background visible, fully occluded, offscreen, + minimized, and hidden. Cover AXPress, AXConfirm, AXValue, exact pointer, and + singleton native text. +2. **AppKit same PID/two windows:** request each window independently; minimize + or make one AX-unresolved while the sibling is focused; raw key/text must + refuse and neither sibling journal may change. +3. **SwiftUI:** background AX actions and value readback on the current Space; + move only the fixture window to another preconfigured Space, query from the + other Space, and require refusal whenever its CGWindowID is absent from + `AXWindows`. +4. **Electron DOM/two BrowserWindows:** run without force-renderer AX first, + then the diagnostic flag; run default throttling and + `backgroundThrottling:false`; prove exact AX window mapping, AXPress, + semantic commit, exact browser type/click, delayed readback, and raw-key + refusal for minimized/unresolved targets. Every effect must appear in the + addressed window's own fixture journal and not its sibling's. +5. **Browser routes:** exact bind and mutation, stale generation, geometry + mismatch, second native window, ambiguous tab/window, and unavailable + endpoint. Every ambiguous row refuses without mutation. +6. **Foreground preservation:** for every background success and refusal, the + fixture sentinel remains frontmost, the real pointer is unchanged, and the + current Space is unchanged. +7. **Capture:** record whether one-shot capture succeeds in each state and + never infer live animation from a successful PNG alone. +8. **Permissions/private APIs:** missing Accessibility, missing Screen + Recording, and missing private symbols return an honest unavailable/refused + result without prompting or global fallback. +9. **Cleanup:** close fixture windows, stop all fixture/driver processes, + restore the initial fixture foreground, pointer, and Space, and leave no + run-owned artifact outside the selected artifact directory. + +The other-Space setup belongs to the disposable runner image or a test-only +harness. Product code must not create, join, or switch Spaces. The test records +its starting Space and always restores it in a trap/finally path. + +### Canonical Lume gate + +The repository's canonical signed/TCC acceptance remains +[`tests/runners/macos-lume/README.md`](../tests/runners/macos-lume/README.md) +and [`run-all.sh`](../tests/runners/macos-lume/run-all.sh). It provides the +logged-in Aqua session, certificate-backed `CuaDriverLocal.app`, normal TCC +grant flow, and exact-source checks. + +After focused Namespace evidence passes, add the new fixture rows to the Lume +entrypoint and run once on the final candidate SHA. Preserve its existing +requirements: + +- use a disposable worker cloned from the stopped private seed; +- install with `install-local.sh --release --autostart + --require-stable-signing`; +- never edit `TCC.db`; +- execute the GUI suite from the guest's logged-in Terminal, not SSH; +- retain exact SHA, environment, code-signing requirement, permission status, + structured results, fixture journals, screenshots/recording where allowed, + and cleanup evidence; and +- retrieve evidence, then delete the worker. + +Namespace validation complements this gate; it does not replace the Lume +golden-image contract. + +## Namespace macOS runner workflow + +### Discovery recorded for this plan + +The planning checkout was inspected on 2026-08-04: + +- project `.amp/plugins` was absent; +- the surrounding workspace `.amp/plugins` was absent; +- user `~/.config/amp/plugins` contained + `namespace-mac-runners.ts` and `namespace-mac-runners.js`; +- no repository-local Namespace runner instructions were found; and +- the TypeScript source registered the exact tool name + `namespace_mac_runner`. + +The user plugin is not versioned with this repository, so a later agent must +repeat discovery before use. Check project `.amp/plugins`, user +`~/.config/amp/plugins`, the active workspace plugin location, Amp plugin +documentation, and the loaded plugin source. If the tool is absent, record +those checks and make plugin discovery/setup step one; do not guess an API. + +The discovered tool accepts: + +```text +action: list | spawn | destroy +runner_id: optional hostname-compatible ID for spawn +repository_url: optional HTTPS github.com URL for spawn +duration_minutes: optional integer from 5 through 720 +instance_id: required for destroy +``` + +The discovered implementation defaults to macOS `26.x`, arm64, 6 vCPU, +14,336 MB, and a 60-minute TTL. It requires `nsc login`. Spawn creates billable +infrastructure immediately without confirmation, clones into +`$HOME/workspace/repository` through Amp's GitHub credential helper, starts +`amp --no-tui --runner-id "$AMP_RUNNER_ID" --remote-control-terminal` in tmux, +and waits for `Registered. Create threads`. Failed provisioning attempts to +destroy the instance it created. Destroy accepts only plugin-managed instance +IDs and asks for confirmation. These details must be re-read from the installed +plugin because it can change independently of this plan. + +### Exact later validation workflow + +1. Re-run the plugin discovery above and record the resolved source/config. +2. Call `namespace_mac_runner` with `{"action":"list"}`. Do not modify or + destroy unrelated runners. +3. Obtain explicit approval for the billable spawn. The approval for this plan + PR does not authorize a later infrastructure charge. +4. Push the implementation candidate and record its full SHA. Spawn with a + unique hostname-compatible ID and bounded TTL, for example: + + ```json + { + "action": "spawn", + "runner_id": "cua-bg-input-", + "repository_url": "https://github.com/trycua/cua.git", + "duration_minutes": 180 + } + ``` + +5. Record the returned runner ID, Namespace instance ID, URL, resolved macOS + selector, repository URL, and deadline. TTL is a backstop, not cleanup. +6. Call Amp's `list_runners` immediately before `create_thread`. Start a child + with `executor: "runner"` and that exact live runner ID; do not silently use + an orb. Instruct it to fetch and detach-checkout the exact candidate SHA, + because the plugin's shallow clone starts from repository default HEAD. +7. On the Namespace Mac, record `sw_vers`, OS build, architecture, SIP status, + Xcode/Rust/Node versions, exact git SHA, and a clean worktree before testing. +8. Use repository-supported `CuaDriverLocal.app` setup only. Follow + [`scripts/README.md`](../scripts/README.md), use an ephemeral + certificate-backed local signing identity, and install with + `--require-stable-signing`. Never copy a maintainer private key or signing + keychain to Namespace. Never edit `TCC.db` or bypass TCC. +9. Grant Accessibility and Screen Recording only through normal macOS UI if the + disposable Namespace environment and explicit test approval support it. + Record `permissions status --json` and the `codesign -d -r-` designated + requirement. The Namespace plugin provisions neither a signing identity nor + TCC. If stable signing, interactive UI, or either grant is unavailable, + mark the signed native lane blocked and run only pure/build tests; do not + claim native behavior passed. +10. Use only fixed-title repository fixtures and run-owned artifact paths. + Capture the matrix evidence listed above, including exact fixture journal + effects, requested and sibling window IDs, AX counts/mapping, result + payloads, foreground PID, real pointer, Space posture, and action timing. + Do not collect unrelated application state, window titles, or user data. +11. In a finally/trap path, stop fixture and driver processes, close fixture + windows, restore fixture focus/pointer/Space state, remove run-owned + temporary files, and write a cleanup assertion. Retrieve only sanitized + evidence needed for review. +12. From the controlling thread, call + `namespace_mac_runner` with + `{"action":"destroy","instance_id":""}` and confirm + destruction. Call `{"action":"list"}` again and require the instance to be + absent. + +## Risk analysis + +### macOS and private APIs + +`_AXUIElementGetWindow`, `AXManualAccessibility`, +`AXEnhancedUserInterface`, SkyLight event posting, private event fields, and +no-raise activation may change between macOS releases. Every lookup must be +bounded and fail closed. Missing symbols remove a route; they never cause a +global or PID-only fallback. + +V1 adds no private Spaces API. Space membership remains incomplete, and the +policy deliberately refuses when AX cannot prove the exact target. + +### TCC and signing identity + +Accessibility and capture evidence is valid only for the process identity that +performed it. Ad-hoc rebuilds can invalidate grants. Native acceptance requires +the certificate-backed `CuaDriverLocal.app` flow and normal permission UI. +Neither CI mocks nor a successful build substitutes for this evidence. + +### Application semantic differences + +AX API success may be a no-op, Chromium AXValue may be an echo, and custom +renderers may reject synthetic input. Confirmation always comes from an +independent exact-target effect. Unsupported applications receive narrower +capabilities, not a misleading parity claim. + +### Races and lifecycle + +Windows can close, change owner, add a modal, or restart between observation +and input. Per-PID serialization and immediate revalidation reduce the window; +they do not make stale capabilities permanent. Any changed fact invalidates +the decision and requires fresh state. + +### Compatibility and rollout + +Request schemas and successful action results remain stable. Some calls that +currently post best-effort PID keys will begin refusing when exact delivery is +not provable. That is an intentional safety correction. Refusals include a +recoverable semantic/browser/foreground alternative when one actually exists. + +Roll out one route at a time after its fixture lane passes. The rollback is to +disable the new route decision while retaining the same-PID/unresolved-target +guard. Do not restore the unsafe process-scoped fallback to improve success +rates. + +Telemetry and retained artifacts may contain route, refusal reason, OS build, +target PID/window ID, timing, and boolean invariants. They must not contain +window titles, field values, typed text, screenshots of unrelated apps, +browser URLs, or fixture logs outside the fixed test vocabulary. + +## Alternatives rejected for v1 + +- **Post to the PID and verify any focused field.** This is the wrong-window + bug, not a verification strategy. +- **Assume explicit `window_id` makes `post_to_pid` exact.** The keyboard + transport has no window parameter. +- **Restore without activation, order behind, act, and re-minimize.** This + changes target state, can still change internal key-window routing, and adds + cleanup races. It belongs in a later opt-in design. +- **Move the window offscreen or to another Space.** This mutates user desktop + state and does not solve AX or renderer suspension reliably. +- **Temporarily make a window sticky or add it to the current Space.** This is + full Spaces manipulation, outside v1's contract. +- **Treat capture success as input exactness.** A CGWindowID image proves the + capture target, not the destination of a process-scoped key. +- **Treat AX API acceptance or event-post return as confirmation.** Both can + report success without the intended application effect. +- **Silently use foreground HID.** It violates the background contract and can + act on the user's current application. +- **Add request echoes to the closed `ActionResult`.** Callers already own the + request; a wire change would require coordinated strict-SDK migration without + improving route safety. + +## Open implementation questions + +Resolve these during Stage 0/1 review, before enabling a route: + +1. Which existing read-only output surface should own the additive + `background_input` capability in every SDK without duplicating native + window state? +2. Which stable process fingerprint should replace the PID-only Chromium AX + enablement cache on macOS? +3. Which native text-field properties are sufficient to predeclare a safe + postcondition for the singleton PID-key rung? +4. Can the current private no-raise pointer preparation satisfy foreground, + pointer, and Space invariants on every supported macOS build? Builds without + signed evidence must keep the route disabled. +5. What bounded delayed-verification deadline gives Electron enough time while + staying below the public tool timeout? Use evidence from the fixture rather + than reusing an unrelated settle constant blindly. +6. How will the disposable Lume/Namespace image provide a deterministic second + Space for tests while guaranteeing restoration? This is a test-environment + decision, not a product Space API. diff --git a/packages/cua-driver/docs/test-harnesses-guide.md b/packages/cua-driver/docs/test-harnesses-guide.md index 98bef2c69c..a9c2f48302 100644 --- a/packages/cua-driver/docs/test-harnesses-guide.md +++ b/packages/cua-driver/docs/test-harnesses-guide.md @@ -30,6 +30,26 @@ The OS workflow may fan the complete matrix out into independent jobs for reporting and failure isolation. That is an execution detail; contributors should think of it as one canonical suite. +### Evidence authority + +A canonical result is the complete repository harness run at the exact source +SHA. A one-off app smoke, manually assembled script, video, or environment +replay can diagnose a failure or provide release presentation evidence, but it +does not replace the complete matrix. + +Windows and Linux use the repository's GitHub-hosted workflows when their +strict environment preflights pass. Windows Azure RDP runs are optional +environment-parity replays or a fallback when the hosted preflight cannot prove +a required capability. macOS uses the logged-in Lume maintainer wrapper. +For browser-facing changes or browser-use release certification, also run the +standalone Chrome/Edge matrix: the macOS wrapper accepts +`--standalone-browser`, while the Windows and Linux workflow is +`.github/workflows/e2e-rust-standalone-browsers.yml`. + +Historical `*-plan.md`, `*-journal.md`, and release evidence documents record +what was run at that time. They are not current execution instructions and do +not override this guide or `scripts/ci/README.md`. + ## Repository Map ```text diff --git a/packages/cua-driver/docs/test-matrix.md b/packages/cua-driver/docs/test-matrix.md index 446c996e89..ebf883c646 100644 --- a/packages/cua-driver/docs/test-matrix.md +++ b/packages/cua-driver/docs/test-matrix.md @@ -45,7 +45,7 @@ These run without the repo-local GUI applications: | --- | --- | --- | | Core driver logic | `rust/crates/*/src/**` | Protocol values, sessions, schemas, image helpers, input helpers, configuration, telemetry, CLI behavior | | MCP and CLI boundary | `rust/crates/cua-driver/tests/protocol_*` | Handshake, tool registration, tool calls, media, sessions, and errors | -| Session capture scope | `session_capture_scope_test.rs` plus core `capture_scope` tests | Per-session isolation, immutable live policy, explicit auto escalation, end/revive race safety, transport-mirror refusal, and retired persistent key | +| Lifecycle sessions and target migration | Core `session`, `session_tools`, `action_target`, and compatibility `capture_scope` tests plus `session_capture_scope_test.rs` | One implicit session per transport, five-minute idle policy, in-flight and exact-once cleanup, owner-scoped inspection and revival, per-call target validation, and legacy capture-scope compatibility | | Tool-contract gate | `schema_consistency_test.rs` | Shared tool schema parity and reviewed risk metadata across OS backends | | Configuration transport | `transport_config_persistence_test.rs` | CLI and MCP configuration persistence | | Token and protocol surfaces | `protocol_element_token_test.rs`, related tests | JSON-RPC-visible contract behavior | @@ -109,7 +109,7 @@ Windows native harnesses are repo-local applications built from source: | WPF | `harness_wpf_test.rs` | UIA controls, text, keys, pointer actions, scroll, drag, popups, menus, modal windows | | WinUI3 | `harness_winui3_test.rs` | XAML controls, text, checkbox/radio, slider, combo, popup | | WebView2 | `harness_web_test.rs` | Window discovery, CDP page access, JavaScript, DOM click path | -| Desktop scope | `desktop_scope_windows_test.rs` | Concurrent strict window/desktop sessions, full-display capture, screen-absolute click/scroll, and strict rejection | +| Desktop target | `desktop_scope_windows_test.rs` | Per-call window/desktop modality, full-display capture, screen-absolute click/scroll, and strict rejection | | Desktop invariants | Testkit `DesktopObserver` plus typed launch/capture/cursor owners | Cross-cutting focus, z-order, minimized-launch, screenshot, cursor, and desktop checks | Native controls use AX/UIA state as their oracle. Pointer actions also use PX @@ -123,7 +123,7 @@ background delivery and attach desktop-side-effect oracles. | AppKit | `harness_appkit_test.rs` | AX tree/capture, AX value/text, AX scroll, PX clicks, and foreground slider drag across the proven delivery modes; background drag is an exact refusal | | SwiftUI | `harness_swiftui_test.rs` | AX tree/capture, background click/value, and foreground popover-trigger state | | WKWebView | `cross_platform_behavior_test.rs` | Dedicated native host running the full 40-cell shared web catalog | -| Desktop scope | `desktop_scope_macos_test.rs` | Concurrent strict window/desktop sessions, screen-absolute action delivery, and strict rejection | +| Desktop target | `desktop_scope_macos_test.rs` | Per-call window/desktop modality, screen-absolute action delivery, and strict rejection | | Installed-app launch/focus | `installed_app_launch_macos_test.rs` | Real Calculator/TextEdit launch and focus behavior in the canonical logged-in lane | | Installed-app text | `installed_app_textedit_macos_test.rs` | Real TextEdit AX background write and verification in the canonical logged-in lane | @@ -143,7 +143,7 @@ transient-panel AX discovery gap. | Electron | `cross_platform_behavior_test.rs` | X11 and hosted Sway | Shared web action matrix | | Tauri | `cross_platform_behavior_test.rs` | X11 and hosted Sway | Shared web action matrix | | GTK3 | `harness_gtk3_test.rs` | X11/AT-SPI and Wayland/AT-SPI where configured | Native GTK controls and input | -| Desktop scope | `desktop_scope_linux_test.rs` | X11/Wayland | Concurrent strict window/desktop sessions, screen-absolute action delivery, and strict rejection | +| Desktop target | `desktop_scope_linux_test.rs` | X11/Wayland | Per-call window/desktop modality, screen-absolute action delivery, and strict rejection | Nix provides the Linux build and desktop environment. X11 and Wayland are separate matrix dimensions because their capture and input contracts differ. diff --git a/packages/cua-driver/docs/uniffi-sdk-implementation-journal.md b/packages/cua-driver/docs/uniffi-sdk-implementation-journal.md index 83e440bdb6..8aa10fc805 100644 --- a/packages/cua-driver/docs/uniffi-sdk-implementation-journal.md +++ b/packages/cua-driver/docs/uniffi-sdk-implementation-journal.md @@ -41,7 +41,7 @@ The public integration split remains: The product boundary is reflected directly in packaging: - Python client applications import the Rust-backed SDK from `cua_driver`. -- TypeScript client applications import it from `@trycua/cua-driver`. +- TypeScript client applications import it from `@qwen-code/cua-sdk`. - Agents configure `cua-driver mcp` through their runtime's existing MCP client and do not import either language package. - The language-native MCP facades and their contract generator were removed. @@ -121,9 +121,10 @@ The product boundary is reflected directly in packaging: - A built macOS arm64 wheel was installed into a clean Python 3.12 environment; `cua_driver.CuaDriver.connect(None)` loaded the packaged library and returned the canonical daemon socket path. -- An actual 382.3 kB npm tarball (1.2 MB unpacked) was installed into a clean - project; the root imported without native code and - `@trycua/cua-driver` loaded the packaged dylib successfully. +- A pre-rename 382.3 kB npm tarball (1.2 MB unpacked) was installed into a + clean project and loaded the packaged dylib successfully. That result used + the upstream npm identity and is historical only; it is not current Qwen + release evidence. - The macOS dylib install ID is the relocatable `@rpath/libcua_driver_sdk.dylib`, and release CI asserts it before signing. - After promoting the SDK to each package root, a fresh macOS arm64 wheel and @@ -143,9 +144,25 @@ The product boundary is reflected directly in packaging: - Rust release artifacts now carry the SDK library on Linux, macOS, and Windows. The Python release matrix installs every built wheel and imports the generated binding before PyPI publication. -- Release CI assembles optional native npm packages for every supported Node - OS/architecture from the verified Cua Driver release assets, smoke-tests the - installed root package, and publishes all artifacts at the Rust tag version. - A developer's host-local tarball is never published. +- Release CI packs one platform-neutral `@qwen-code/cua-sdk` artifact, + clean-installs it against the verified Cua Driver release payload, and + publishes it at the Rust tag version only after the GitHub Release exists. A + developer's host-local tarball is never published. - MCP/CLI remains unchanged as the agent boundary; the language package roots are the Rust-backed application SDK. + +### Qwen-owned npm identity + +- The TypeScript SDK and `/computer-use` wrapper are one + `@qwen-code/cua-sdk` package. There is no driver npm package and there are no + platform npm packages. +- The package version identifies the matching Qwen CUA Driver GitHub Release. + Its postinstall verifies `checksums.txt` before caching the SDK library and + Node runtime. It never imports or resolves the published upstream driver + package. +- Release dry-run clean-installs that one tarball against the just-built native + payload. Production repeats the install against the public Qwen Release + before npm publication. +- The frozen 0.12.6 TypeScript compatibility fixture keeps its historical + import unchanged and passes when the Qwen SDK tarball is installed under a + test-only npm alias. It is not a production dependency or publication target. diff --git a/packages/cua-driver/docs/why-cua-driver-uses-mcp-instead-of-uniffi.md b/packages/cua-driver/docs/why-cua-driver-uses-mcp-instead-of-uniffi.md index adfa06fded..cac31bcea3 100644 --- a/packages/cua-driver/docs/why-cua-driver-uses-mcp-instead-of-uniffi.md +++ b/packages/cua-driver/docs/why-cua-driver-uses-mcp-instead-of-uniffi.md @@ -20,7 +20,7 @@ are not competing protocol choices. | Surface | Consumer | Public shape | What provides runtime portability | | -------------------------- | --------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Cua as an agent MCP or CLI | Codex, Claude Code, another agent, or a shell | `qwen-cua-driver mcp` and `qwen-cua-driver call` | MCP and the executable protocol already work from any capable runtime. | -| Cua as an imported SDK | Python, TypeScript, Swift, Kotlin, or another application | `cua_driver` or `@trycua/cua-driver` | Python and Node call a shared Rust daemon-client implementation through experimental UniFFI bindings. | +| Cua as an imported SDK | Python, TypeScript, Swift, Kotlin, or another application | `cua_driver` or `@qwen-code/cua-sdk` | Python and Node call a shared Rust daemon-client implementation through experimental UniFFI bindings. | The [Codex and Claude Agent SDK examples](../examples/agent-sdks/README.md) make the first surface concrete. Each agent SDK receives the same stdio MCP @@ -31,7 +31,7 @@ imports a generated Cua client. 1. Keep MCP and the CLI as the canonical agent boundary. Do not require a Cua language package merely to connect an MCP-capable agent. -2. Make `cua-driver` and `@trycua/cua-driver` application SDK packages backed +2. Make `cua-driver` and `@qwen-code/cua-sdk` application SDK packages backed by UniFFI. Remove their language-native MCP facades because those duplicate a runtime-neutral protocol client that agent runtimes already provide. This deliberately makes the package API breaking before publication. @@ -122,7 +122,7 @@ The package name is the SDK entrypoint; no transport suffix is required: | Runtime | Rust-backed application SDK | Agent integration | | --- | --- | --- | | Python | `cua_driver` | Configure `qwen-cua-driver mcp` in the agent runtime | -| TypeScript | `@trycua/cua-driver` | Configure `qwen-cua-driver mcp` in the agent runtime | +| TypeScript | `@qwen-code/cua-sdk` | Configure `qwen-cua-driver mcp` in the agent runtime | There is no public `/sdk`, `/mcp`, or `/native` entrypoint. “Native” describes the private generated loader and platform library, not the product API. The @@ -330,7 +330,7 @@ architecture is described in the [contract README](../contract/README.md). Mozilla UniFFI `0.31.0` generates Python from the compiled `cdylib` metadata. Node generation uses the separately maintained `uniffi-bindgen-react-native`/UBRN `0.31.0-3` N-API target plus pinned -`@ubjs/core` and `@ubjs/node` runtimes. The checked-in outputs are regenerated +`@ubjs/core`. The checked-in outputs are regenerated into temporary roots, tracked by ownership manifests, and compared byte for byte in CI. @@ -345,12 +345,12 @@ Python wheels are platform-specific and the Rust release workflow places the matching SDK library beside the CLI in each release runtime archive. The wheel builder moves that library next to the generated Python modules, and CI inspects the wheel and runs it across the FFI boundary. The public Node -package uses generated platform resolution and optional native packages for -macOS arm64/x64, Linux glibc arm64/x64, and Windows arm64/x64. Release CI builds -those packages from the already verified Cua Driver assets, smoke-tests the -installed root plus matching native package, publishes native packages first, -then publishes the root. Python, npm, and Rust versions come from one release -tag and are attached to the same GitHub release. +package is the single platform-neutral `@qwen-code/cua-sdk`. Its postinstall +selects the matching macOS, glibc Linux, or Windows release archive, verifies +the archive checksum, and caches the SDK library plus Node runtime. Release CI +smoke-tests the installed npm tarball against the native release payload and +publishes npm only after the GitHub Release exists. Python, npm, and Rust +versions come from one release tag. ## UniFFI follow-up gates @@ -359,7 +359,7 @@ Evaluate the two SDK targets separately. Before expanding the UniFFI path to another runtime or platform: 1. Load-test the native artifact on the added release OS and architecture. -2. Add the platform package to generated resolution and the release matrix. +2. Add the target archive to native resolution and the release matrix. 3. Preserve release signing/notarization evidence for the native library everywhere the platform requires it. 4. Run the executable MCP boundary and imported SDK through the same daemon diff --git a/packages/cua-driver/examples/agent-sdks/README.md b/packages/cua-driver/examples/agent-sdks/README.md index 607ed7a018..c548513d67 100644 --- a/packages/cua-driver/examples/agent-sdks/README.md +++ b/packages/cua-driver/examples/agent-sdks/README.md @@ -86,13 +86,16 @@ npm run codex -- \ "Inspect the active app and summarize what is visible without changing it" ``` -Set `CODEX_MODEL` optionally. Both scripts create a unique Cua session outside -the agent, configure `qwen-cua-driver mcp`, and end the session in `finally`. +Set `CODEX_MODEL` optionally. Both scripts configure one long-lived +`qwen-cua-driver mcp` transport. Its first admitted stateful call creates an +implicit session, later unnamed calls reuse it, and transport shutdown runs +the same cleanup as an explicit `end_session`. ## Environment and safety - Put `qwen-cua-driver` on `PATH`, or set `CUA_DRIVER_BIN` for MCP examples. -- Set `CUA_CAPTURE_SCOPE` to `auto`, `window`, or `desktop` for MCP examples. +- Select an exact window or desktop target on each action. Sessions do not + store capture modality. - Authenticate the chosen agent SDK using its normal local login or API key. - Run only trusted tasks. These examples remove interactive approval prompts. - Keep purchases, messages, deletion, credential entry, and other irreversible diff --git a/packages/cua-driver/examples/agent-sdks/claude_agent.py b/packages/cua-driver/examples/agent-sdks/claude_agent.py index 7a5fe435a1..3e2db41c7d 100644 --- a/packages/cua-driver/examples/agent-sdks/claude_agent.py +++ b/packages/cua-driver/examples/agent-sdks/claude_agent.py @@ -4,12 +4,10 @@ from __future__ import annotations import argparse import asyncio -import json import os import shutil from pathlib import Path -from typing import Literal, cast -from uuid import uuid4 +from typing import Literal from claude_agent_sdk import ( ClaudeAgentOptions, @@ -21,7 +19,6 @@ from claude_agent_sdk import ( from native_tools import NativeDesktopTools -CaptureScope = Literal["auto", "window", "desktop"] Route = Literal["native", "mcp"] @@ -47,45 +44,20 @@ def driver_binary() -> str: raise RuntimeError("qwen-cua-driver is not on PATH; set CUA_DRIVER_BIN") -def capture_scope() -> CaptureScope: - value = os.environ.get("CUA_CAPTURE_SCOPE", "auto") - if value not in {"auto", "window", "desktop"}: - raise ValueError("CUA_CAPTURE_SCOPE must be auto, window, or desktop") - return cast(CaptureScope, value) - - -async def driver_call(binary: str, name: str, arguments: dict[str, str]) -> None: - process = await asyncio.create_subprocess_exec( - binary, - "call", - name, - json.dumps(arguments), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30) - except TimeoutError: - process.kill() - await process.wait() - raise RuntimeError(f"qwen-cua-driver call {name} timed out") from None - if process.returncode != 0: - detail = stderr.decode().strip() or stdout.decode().strip() - raise RuntimeError(f"qwen-cua-driver call {name} failed: {detail}") - - -def prompt(task: str, route: Route, session: str | None = None) -> str: +def prompt(task: str, route: Route) -> str: lifecycle = ( - f"The host already started session {session!r}. Pass it to every Cua " - "tool that accepts a session; do not call start_session or end_session." - if session - else "The host owns the native driver session; the custom tools do not expose lifecycle." + "The MCP transport owns one implicit lifecycle session. Omit the session " + "field for ordinary calls so the runtime creates and reuses it." + if route == "mcp" + else "The native SDK client owns one implicit lifecycle session; custom " + "tools do not expose lifecycle." ) return f"""Complete this trusted desktop task through Cua Driver: {task} -Route: {route}. {lifecycle} +Route: {route}. {lifecycle} Select an exact window or desktop target for every +action; session identity never selects capture modality or permission authority. Use only the supplied Cua tools for desktop observation and interaction. Inspect state before each action and verify state after it. A timeout after a mutation means the outcome is unknown: observe before retrying and never blindly replay. @@ -152,7 +124,6 @@ async def run_agent(options: ClaudeAgentOptions, task_prompt: str) -> None: async def run_native(task: str) -> None: runtime = NativeDesktopTools() try: - await runtime.start() options = ClaudeAgentOptions( model=os.environ.get("CLAUDE_MODEL"), cwd=Path.cwd(), @@ -175,38 +146,25 @@ async def run_native(task: str) -> None: async def run_mcp(task: str) -> None: binary = driver_binary() - scope = capture_scope() - session = f"claude-mcp-{uuid4().hex[:12]}" - started = False - try: - await driver_call( - binary, - "start_session", - {"session": session, "capture_scope": scope}, - ) - started = True - options = ClaudeAgentOptions( - model=os.environ.get("CLAUDE_MODEL"), - cwd=Path.cwd(), - tools=[], - mcp_servers={ - "cua_driver": { - "type": "stdio", - "command": binary, - "args": ["mcp"], - } - }, - strict_mcp_config=True, - # Claude's MCP allowlist accepts the server name to enable all - # tools from that server; wildcard tool-name globs are unsupported. - allowed_tools=["mcp__cua_driver"], - permission_mode="dontAsk", - max_turns=40, - ) - await run_agent(options, prompt(task, "mcp", session)) - finally: - if started: - await driver_call(binary, "end_session", {"session": session}) + options = ClaudeAgentOptions( + model=os.environ.get("CLAUDE_MODEL"), + cwd=Path.cwd(), + tools=[], + mcp_servers={ + "cua_driver": { + "type": "stdio", + "command": binary, + "args": ["mcp"], + } + }, + strict_mcp_config=True, + # Claude's MCP allowlist accepts the server name to enable all + # tools from that server; wildcard tool-name globs are unsupported. + allowed_tools=["mcp__cua_driver"], + permission_mode="dontAsk", + max_turns=40, + ) + await run_agent(options, prompt(task, "mcp")) async def main() -> None: diff --git a/packages/cua-driver/examples/agent-sdks/claude_agent.ts b/packages/cua-driver/examples/agent-sdks/claude_agent.ts index 0ef874e4ac..e9680b49ab 100644 --- a/packages/cua-driver/examples/agent-sdks/claude_agent.ts +++ b/packages/cua-driver/examples/agent-sdks/claude_agent.ts @@ -1,9 +1,5 @@ /** Run a Claude Agent SDK desktop task through native Cua tools or MCP. */ -import { execFile } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { promisify } from 'node:util'; - import { createSdkMcpServer, query, @@ -16,9 +12,6 @@ import { z } from 'zod'; import { NativeDesktopTools } from './native-tools.js'; type Route = 'native' | 'mcp'; -type CaptureScope = 'auto' | 'window' | 'desktop'; - -const execFileAsync = promisify(execFile); function argumentsFromCli(): { route: Route; task: string } { const args = process.argv.slice(2); @@ -37,22 +30,6 @@ function argumentsFromCli(): { route: Route; task: string } { return { route, task }; } -function captureScope(): CaptureScope { - const value = process.env.CUA_CAPTURE_SCOPE ?? 'auto'; - if (value !== 'auto' && value !== 'window' && value !== 'desktop') { - throw new Error('CUA_CAPTURE_SCOPE must be auto, window, or desktop'); - } - return value; -} - -async function driverCall( - binary: string, - name: string, - args: Record -): Promise { - await execFileAsync(binary, ['call', name, JSON.stringify(args)], { timeout: 30_000 }); -} - async function runAgent(options: Options, prompt: string): Promise { let result: SDKResultMessage | undefined; for await (const message of query({ prompt, options })) { @@ -98,15 +75,17 @@ function nativeServer(runtime: NativeDesktopTools) { }); } -function taskPrompt(task: string, route: Route, session?: string): string { - const lifecycle = session - ? `The host already started session ${JSON.stringify(session)}. Pass it to every Cua tool that accepts a session; do not call start_session or end_session.` - : 'The host owns the native driver session; custom tools do not expose lifecycle.'; +function taskPrompt(task: string, route: Route): string { + const lifecycle = + route === 'mcp' + ? 'The MCP transport owns one implicit lifecycle session. Omit the session field for ordinary calls so the runtime creates and reuses it.' + : 'The native SDK client owns one implicit lifecycle session; custom tools do not expose lifecycle.'; return `Complete this trusted desktop task through Cua Driver: ${task} -Route: ${route}. ${lifecycle} +Route: ${route}. ${lifecycle} Select an exact window or desktop target for every +action; session identity never selects capture modality or permission authority. Use only supplied Cua tools for desktop observation and interaction. Inspect before each action and verify afterward. If a mutation times out, observe before any retry; never blindly replay an action with an unknown outcome. Do not @@ -117,7 +96,6 @@ action unless the task explicitly requests it. Name anything unverified.`; async function runNative(task: string): Promise { const runtime = new NativeDesktopTools(); try { - await runtime.start(); await runAgent( { model: process.env.CLAUDE_MODEL, @@ -142,31 +120,22 @@ async function runNative(task: string): Promise { async function runMcp(task: string): Promise { const binary = process.env.CUA_DRIVER_BIN ?? 'qwen-cua-driver'; - const scope = captureScope(); - const session = `claude-mcp-${randomUUID().slice(0, 12)}`; - let started = false; - try { - await driverCall(binary, 'start_session', { session, capture_scope: scope }); - started = true; - await runAgent( - { - model: process.env.CLAUDE_MODEL, - tools: [], - mcpServers: { - cua_driver: { type: 'stdio', command: binary, args: ['mcp'] }, - }, - strictMcpConfig: true, - // A bare MCP server name enables all of its tools. Claude does not - // support wildcard tool-name globs in this allowlist. - allowedTools: ['mcp__cua_driver'], - permissionMode: 'dontAsk', - maxTurns: 40, + await runAgent( + { + model: process.env.CLAUDE_MODEL, + tools: [], + mcpServers: { + cua_driver: { type: 'stdio', command: binary, args: ['mcp'] }, }, - taskPrompt(task, 'mcp', session) - ); - } finally { - if (started) await driverCall(binary, 'end_session', { session }); - } + strictMcpConfig: true, + // A bare MCP server name enables all of its tools. Claude does not + // support wildcard tool-name globs in this allowlist. + allowedTools: ['mcp__cua_driver'], + permissionMode: 'dontAsk', + maxTurns: 40, + }, + taskPrompt(task, 'mcp') + ); } const args = argumentsFromCli(); diff --git a/packages/cua-driver/examples/agent-sdks/codex_agent.py b/packages/cua-driver/examples/agent-sdks/codex_agent.py index c54a0bd92b..bb18e3fe40 100644 --- a/packages/cua-driver/examples/agent-sdks/codex_agent.py +++ b/packages/cua-driver/examples/agent-sdks/codex_agent.py @@ -7,13 +7,9 @@ import asyncio import json import os import shutil -from typing import Literal, cast -from uuid import uuid4 from openai_codex import AsyncCodex, CodexConfig, Sandbox -CaptureScope = Literal["auto", "window", "desktop"] - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) @@ -31,76 +27,37 @@ def driver_binary() -> str: raise RuntimeError("qwen-cua-driver is not on PATH; set CUA_DRIVER_BIN") -def capture_scope() -> CaptureScope: - value = os.environ.get("CUA_CAPTURE_SCOPE", "auto") - if value not in {"auto", "window", "desktop"}: - raise ValueError("CUA_CAPTURE_SCOPE must be auto, window, or desktop") - return cast(CaptureScope, value) - - -async def driver_call(binary: str, name: str, arguments: dict[str, str]) -> None: - process = await asyncio.create_subprocess_exec( - binary, - "call", - name, - json.dumps(arguments), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - try: - stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30) - except TimeoutError: - process.kill() - await process.wait() - raise RuntimeError(f"qwen-cua-driver call {name} timed out") from None - if process.returncode != 0: - detail = stderr.decode().strip() or stdout.decode().strip() - raise RuntimeError(f"qwen-cua-driver call {name} failed: {detail}") - - async def main() -> None: args = parse_args() binary = driver_binary() - scope = capture_scope() - session = f"codex-python-{uuid4().hex[:12]}" - started = False - - try: - await driver_call( - binary, - "start_session", - {"session": session, "capture_scope": scope}, + config = CodexConfig( + config_overrides=( + f"mcp_servers.cua_driver.command={json.dumps(binary)}", + 'mcp_servers.cua_driver.args=["mcp"]', + "mcp_servers.cua_driver.required=true", ) - started = True - config = CodexConfig( - config_overrides=( - f'mcp_servers.cua_driver.command={json.dumps(binary)}', - 'mcp_servers.cua_driver.args=["mcp"]', - "mcp_servers.cua_driver.required=true", - ) + ) + async with AsyncCodex(config) as codex: + thread = await codex.thread_start( + model=os.environ.get("CODEX_MODEL"), + sandbox=Sandbox.read_only, ) - async with AsyncCodex(config) as codex: - thread = await codex.thread_start( - model=os.environ.get("CODEX_MODEL"), - sandbox=Sandbox.read_only, - ) - result = await thread.run( - f"""Complete this trusted desktop task through the cua_driver MCP server: + result = await thread.run( + f"""Complete this trusted desktop task through the cua_driver MCP server: {args.task} -The host already started session {session!r} with capture scope {scope!r}. -Use only cua_driver MCP tools for desktop observation and interaction. Pass -session {session!r} to every tool that accepts a session. Inspect before each -action and verify afterward. Do not call start_session or end_session. If a -mutation times out, observe before any retry; never blindly replay an action -with an unknown outcome. Return a concise result and name anything unverified. +This MCP transport owns one implicit lifecycle session. Omit the session field +for ordinary calls so the runtime creates and reuses it. Select an exact window +or desktop target on every action; session identity never selects capture +modality or permission authority. Use only cua_driver MCP tools for desktop +observation and interaction. Inspect before each action and verify afterward. +If a mutation times out, observe before any retry; never blindly replay an +action with an unknown outcome. Return a concise result and name anything +unverified. """ - ) - print(result.final_response) - finally: - if started: - await driver_call(binary, "end_session", {"session": session}) + ) + print(result.final_response) if __name__ == "__main__": diff --git a/packages/cua-driver/examples/agent-sdks/codex_agent.ts b/packages/cua-driver/examples/agent-sdks/codex_agent.ts index 7eba05e6e3..7193727775 100644 --- a/packages/cua-driver/examples/agent-sdks/codex_agent.ts +++ b/packages/cua-driver/examples/agent-sdks/codex_agent.ts @@ -1,16 +1,9 @@ /** Run a Codex SDK desktop task through Cua Driver's MCP server. */ -import { execFile } from 'node:child_process'; import process from 'node:process'; -import { promisify } from 'node:util'; -import { randomUUID } from 'node:crypto'; import { Codex } from '@openai/codex-sdk'; -type CaptureScope = 'auto' | 'window' | 'desktop'; - -const execFileAsync = promisify(execFile); - function taskFromArgs(): string { const task = process.argv.slice(2).join(' ').trim(); if (!task) { @@ -19,37 +12,19 @@ function taskFromArgs(): string { return task; } -function captureScope(): CaptureScope { - const value = process.env.CUA_CAPTURE_SCOPE ?? 'auto'; - if (value !== 'auto' && value !== 'window' && value !== 'desktop') { - throw new Error('CUA_CAPTURE_SCOPE must be auto, window, or desktop'); - } - return value; -} - -async function driverCall( - binary: string, - tool: string, - args: Record -): Promise { - await execFileAsync(binary, ['call', tool, JSON.stringify(args)], { - timeout: 30_000, - }); -} - -function agentPrompt(task: string, session: string, scope: CaptureScope): string { +function agentPrompt(task: string): string { return `Complete this desktop task through the cua_driver MCP server: ${task} -The host has already started session ${JSON.stringify(session)} with capture -scope ${JSON.stringify(scope)}. Use only cua_driver MCP tools for desktop +This MCP transport owns one implicit lifecycle session. Omit the session field +for ordinary calls so the runtime creates and reuses it. Select an exact window +or desktop target on every action; session identity never selects capture +modality or permission authority. Use only cua_driver MCP tools for desktop observation and interaction. Do not substitute shell, filesystem, web, or code -execution. Pass session ${JSON.stringify(session)} to every Cua tool that -accepts a session. Inspect state before each action and verify state after it. -Do not call start_session or end_session; the host owns lifecycle cleanup. Do -not blindly retry a mutation after a timeout or disconnect; observe first. Do -not perform purchases, send messages, delete data, expose credentials, or take +execution. Inspect state before each action and verify state after it. Do not +blindly retry a mutation after a timeout or disconnect; observe first. Do not +perform purchases, send messages, delete data, expose credentials, or take another irreversible action unless the task explicitly requests that exact action. Return a concise result and mention any step you could not verify.`; } @@ -57,44 +32,28 @@ action. Return a concise result and mention any step you could not verify.`; async function main(): Promise { const task = taskFromArgs(); const binary = process.env.CUA_DRIVER_BIN ?? 'qwen-cua-driver'; - const scope = captureScope(); - const session = `codex-agent-${randomUUID().slice(0, 12)}`; - let started = false; - - try { - await driverCall(binary, 'start_session', { - session, - capture_scope: scope, - }); - started = true; - - const codex = new Codex({ - config: { - mcp_servers: { - cua_driver: { - command: binary, - args: ['mcp'], - startup_timeout_sec: 30, - required: true, - }, + const codex = new Codex({ + config: { + mcp_servers: { + cua_driver: { + command: binary, + args: ['mcp'], + startup_timeout_sec: 30, + required: true, }, }, - }); - const model = process.env.CODEX_MODEL; - const thread = codex.startThread({ - ...(model ? { model } : {}), - sandboxMode: 'read-only', - approvalPolicy: 'never', - skipGitRepoCheck: true, - }); + }, + }); + const model = process.env.CODEX_MODEL; + const thread = codex.startThread({ + ...(model ? { model } : {}), + sandboxMode: 'read-only', + approvalPolicy: 'never', + skipGitRepoCheck: true, + }); - const result = await thread.run(agentPrompt(task, session, scope)); - console.log(result.finalResponse); - } finally { - if (started) { - await driverCall(binary, 'end_session', { session }); - } - } + const result = await thread.run(agentPrompt(task)); + console.log(result.finalResponse); } await main(); diff --git a/packages/cua-driver/examples/agent-sdks/native-tools.ts b/packages/cua-driver/examples/agent-sdks/native-tools.ts index 2b2470a80e..74fb1eca73 100644 --- a/packages/cua-driver/examples/agent-sdks/native-tools.ts +++ b/packages/cua-driver/examples/agent-sdks/native-tools.ts @@ -1,20 +1,15 @@ /** Narrow agent-tool adapter over the same-process Cua Driver TypeScript SDK. */ -import { randomUUID } from 'node:crypto'; - import { - CaptureScope, + ActionTarget, ClickButton, ClickInput, CuaDriver, - DesktopScope, - EndSessionInput, GetDesktopStateInput, PressKeyInput, - StartSessionInput, ToolResult, TypeTextInput, -} from '@trycua/cua-driver'; +} from '@qwen-code/cua-sdk'; type McpContent = | { type: 'text'; text: string } @@ -27,47 +22,23 @@ export type NativeToolResult = { export class NativeDesktopTools { readonly driver = CuaDriver.create(undefined); - readonly session = `claude-native-${randomUUID().slice(0, 12)}`; - private started = false; + readonly desktopTarget = new ActionTarget.Desktop({ displayId: 'primary' }); constructor(private readonly timeoutMs = 30_000) {} - async start(): Promise { - await this.bounded( - this.driver.startSession( - StartSessionInput.new({ - session: this.session, - captureScope: CaptureScope.Desktop, - }) - ), - 'start_session' - ); - this.started = true; - } - async close(): Promise { try { - if (this.started) { - await this.bounded( - this.driver.endSession(EndSessionInput.new({ session: this.session })), - 'end_session' - ); - this.started = false; - } + await this.driver.shutdown(); } finally { - try { - await this.driver.shutdown(); - } finally { - // Generated UniFFI bindings expose deterministic handle release - // separately from asynchronous runtime shutdown. - (this.driver as unknown as { uniffiDestroy(): void }).uniffiDestroy(); - } + // Generated UniFFI bindings expose deterministic handle release + // separately from asynchronous runtime shutdown. + (this.driver as unknown as { uniffiDestroy(): void }).uniffiDestroy(); } } async observe(): Promise { const result = await this.bounded( - this.driver.getDesktopState(GetDesktopStateInput.new({ session: this.session })), + this.driver.getDesktopState(GetDesktopStateInput.new({})), 'get_desktop_state' ); return this.content(result); @@ -79,8 +50,7 @@ export class NativeDesktopTools { ClickInput.new({ x, y, - scope: DesktopScope.Desktop, - session: this.session, + target: this.desktopTarget, button: ClickButton.Left, count: 1, }) @@ -93,8 +63,7 @@ export class NativeDesktopTools { this.driver.typeText( TypeTextInput.new({ text, - scope: DesktopScope.Desktop, - session: this.session, + target: this.desktopTarget, }) ) ); @@ -105,8 +74,7 @@ export class NativeDesktopTools { this.driver.pressKey( PressKeyInput.new({ key, - scope: DesktopScope.Desktop, - session: this.session, + target: this.desktopTarget, }) ) ); diff --git a/packages/cua-driver/examples/agent-sdks/native_driver.py b/packages/cua-driver/examples/agent-sdks/native_driver.py index a2a7bf8b7b..205e4df2de 100644 --- a/packages/cua-driver/examples/agent-sdks/native_driver.py +++ b/packages/cua-driver/examples/agent-sdks/native_driver.py @@ -9,13 +9,10 @@ from urllib.request import Request, urlopen from uuid import uuid4 from cua_driver import ( - CaptureScope, + ActionTarget, CuaDriver, - DesktopScope, - EndSessionInput, GetDesktopStateInput, PressKeyInput, - StartSessionInput, TypeTextInput, ) @@ -47,25 +44,13 @@ def parse_args() -> argparse.Namespace: async def main() -> None: args = parse_args() token = f"cua-{uuid4().hex[:10]}" - session = f"native-python-{uuid4().hex[:10]}" await asyncio.to_thread(fixture_request, args.fixture, "/reset", method="POST") driver = CuaDriver.create() - started = False mutation_outcome_unknown = False try: - await asyncio.wait_for( - driver.start_session( - StartSessionInput(session=session, capture_scope=CaptureScope.DESKTOP) - ), - args.timeout, - ) - started = True - before = await asyncio.wait_for( - driver.get_desktop_state( - GetDesktopStateInput(session=session, screenshot_out_file=None) - ), + driver.get_desktop_state(GetDesktopStateInput(session=None, screenshot_out_file=None)), args.timeout, ) if before.is_error or not before.images: @@ -77,8 +62,9 @@ async def main() -> None: driver.type_text( TypeTextInput( text=token, - scope=DesktopScope.DESKTOP, - session=session, + target=ActionTarget.DESKTOP(display_id="primary"), + scope=None, + session=None, ) ), args.timeout, @@ -90,8 +76,9 @@ async def main() -> None: PressKeyInput( key="ENTER", modifiers=None, - scope=DesktopScope.DESKTOP, - session=session, + target=ActionTarget.DESKTOP(display_id="primary"), + scope=None, + session=None, ) ), args.timeout, @@ -105,9 +92,7 @@ async def main() -> None: # the independent fixture first; it may have consumed the action. await wait_for_submission(args.fixture, token, args.timeout) after = await asyncio.wait_for( - driver.get_desktop_state( - GetDesktopStateInput(session=session, screenshot_out_file=None) - ), + driver.get_desktop_state(GetDesktopStateInput(session=None, screenshot_out_file=None)), args.timeout, ) if after.is_error or not after.images: @@ -116,11 +101,7 @@ async def main() -> None: if mutation_outcome_unknown: print("the driver response was uncertain; the external postcondition resolved it") finally: - try: - if started: - await driver.end_session(EndSessionInput(session=session)) - finally: - await driver.shutdown() + await driver.shutdown() if __name__ == "__main__": diff --git a/packages/cua-driver/examples/agent-sdks/native_driver.ts b/packages/cua-driver/examples/agent-sdks/native_driver.ts index 67e41b294b..015dd94e49 100644 --- a/packages/cua-driver/examples/agent-sdks/native_driver.ts +++ b/packages/cua-driver/examples/agent-sdks/native_driver.ts @@ -3,18 +3,16 @@ import { randomUUID } from 'node:crypto'; import { - CaptureScope, + ActionTarget, CuaDriver, - DesktopScope, - EndSessionInput, GetDesktopStateInput, PressKeyInput, - StartSessionInput, TypeTextInput, -} from '@trycua/cua-driver'; +} from '@qwen-code/cua-sdk'; const fixture = process.env.CUA_FIXTURE_URL ?? 'http://127.0.0.1:8765'; const timeoutMs = 10_000; +const desktopTarget = new ActionTarget.Desktop({ displayId: 'primary' }); async function bounded(promise: Promise, label: string): Promise { let timer: ReturnType | undefined; @@ -47,27 +45,14 @@ async function waitForSubmission(token: string): Promise { } const token = `cua-${randomUUID().slice(0, 10)}`; -const session = `native-typescript-${randomUUID().slice(0, 10)}`; await resetFixture(); const driver = CuaDriver.create(undefined); -let started = false; let mutationOutcomeUnknown = false; try { - await bounded( - driver.startSession( - StartSessionInput.new({ - session, - captureScope: CaptureScope.Desktop, - }) - ), - 'start_session' - ); - started = true; - const before = await bounded( - driver.getDesktopState(GetDesktopStateInput.new({ session })), + driver.getDesktopState(GetDesktopStateInput.new({})), 'get_desktop_state' ); if (before.isError || before.images.length === 0) { @@ -80,8 +65,7 @@ try { driver.typeText( TypeTextInput.new({ text: token, - scope: DesktopScope.Desktop, - session, + target: desktopTarget, }) ), 'type_text' @@ -92,8 +76,7 @@ try { driver.pressKey( PressKeyInput.new({ key: 'ENTER', - scope: DesktopScope.Desktop, - session, + target: desktopTarget, }) ), 'press_key' @@ -107,7 +90,7 @@ try { // fixture before considering any retry. await waitForSubmission(token); const after = await bounded( - driver.getDesktopState(GetDesktopStateInput.new({ session })), + driver.getDesktopState(GetDesktopStateInput.new({})), 'post-action get_desktop_state' ); if (after.isError || after.images.length === 0) { @@ -119,16 +102,10 @@ try { } } finally { try { - if (started) { - await driver.endSession(EndSessionInput.new({ session })); - } + await driver.shutdown(); } finally { - try { - await driver.shutdown(); - } finally { - // shutdown() drains the runtime; this generated destructor releases the - // native UniFFI handle immediately instead of waiting for JavaScript GC. - (driver as unknown as { uniffiDestroy(): void }).uniffiDestroy(); - } + // shutdown() drains the runtime; this generated destructor releases the + // native UniFFI handle immediately instead of waiting for JavaScript GC. + (driver as unknown as { uniffiDestroy(): void }).uniffiDestroy(); } } diff --git a/packages/cua-driver/examples/agent-sdks/native_tools.py b/packages/cua-driver/examples/agent-sdks/native_tools.py index bb5c7f4774..78a071c82b 100644 --- a/packages/cua-driver/examples/agent-sdks/native_tools.py +++ b/packages/cua-driver/examples/agent-sdks/native_tools.py @@ -5,18 +5,14 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from typing import TypeVar -from uuid import uuid4 from cua_driver import ( - CaptureScope, + ActionTarget, ClickButton, ClickInput, CuaDriver, - DesktopScope, - EndSessionInput, GetDesktopStateInput, PressKeyInput, - StartSessionInput, ToolResult, TypeTextInput, ) @@ -26,40 +22,20 @@ T = TypeVar("T") class NativeDesktopTools: - """Own one in-process driver runtime and one desktop-scoped session.""" + """Own one in-process runtime with one implicit lifecycle session.""" def __init__(self, timeout: float = 30.0) -> None: self.driver = CuaDriver.create() - self.session = f"claude-native-{uuid4().hex[:12]}" self.timeout = timeout - self.started = False - - async def start(self) -> None: - await self._bounded( - self.driver.start_session( - StartSessionInput( - session=self.session, - capture_scope=CaptureScope.DESKTOP, - ) - ) - ) - self.started = True async def close(self) -> None: - try: - if self.started: - await self._bounded( - self.driver.end_session(EndSessionInput(session=self.session)) - ) - self.started = False - finally: - await self.driver.shutdown() + await self.driver.shutdown() async def observe(self) -> dict[str, object]: result = await self._bounded( self.driver.get_desktop_state( GetDesktopStateInput( - session=self.session, + session=None, screenshot_out_file=None, ) ) @@ -72,8 +48,9 @@ class NativeDesktopTools: ClickInput( x=x, y=y, - scope=DesktopScope.DESKTOP, - session=self.session, + target=ActionTarget.DESKTOP(display_id="primary"), + scope=None, + session=None, button=ClickButton.LEFT, count=1, ) @@ -85,8 +62,9 @@ class NativeDesktopTools: lambda: self.driver.type_text( TypeTextInput( text=text, - scope=DesktopScope.DESKTOP, - session=self.session, + target=ActionTarget.DESKTOP(display_id="primary"), + scope=None, + session=None, ) ) ) @@ -97,8 +75,9 @@ class NativeDesktopTools: PressKeyInput( key=key, modifiers=None, - scope=DesktopScope.DESKTOP, - session=self.session, + target=ActionTarget.DESKTOP(display_id="primary"), + scope=None, + session=None, ) ) ) diff --git a/packages/cua-driver/examples/agent-sdks/package-lock.json b/packages/cua-driver/examples/agent-sdks/package-lock.json new file mode 100644 index 0000000000..335780abbe --- /dev/null +++ b/packages/cua-driver/examples/agent-sdks/package-lock.json @@ -0,0 +1,2208 @@ +{ + "name": "cua-driver-agent-sdk-examples", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cua-driver-agent-sdk-examples", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.218", + "@openai/codex-sdk": "0.145.0", + "@qwen-code/cua-sdk": "file:../../typescript", + "zod": "4.1.12" + }, + "devDependencies": { + "@types/node": "22.17.0", + "tsx": "4.20.3", + "typescript": "5.9.2" + } + }, + "../../typescript": { + "name": "@qwen-code/cua-sdk", + "version": "0.20.0", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@ubjs/core": "0.31.0-3", + "tar": "7.5.22" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "electron": "43.2.0", + "typescript": "^5.7.0", + "uniffi-bindgen-react-native": "0.31.0-3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.218", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.218.tgz", + "integrity": "sha512-lbVOmgdzjBdSUCTbxElpoTxxP/u9jY8gpG/3cr5l1jSSvSY5FrANdRcze/z6gM330xo32SHH6WPGFd4+K0fMoQ==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.218", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.218", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.218", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.218", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.218", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.218", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.218", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.218" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.218", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.218.tgz", + "integrity": "sha512-foTI71ua2r5lJo7sfcw/3UafBCJYjxPmOXZ98wOz1UWIJxaP/Oq9Xp4sUavBd1efFVRxPnAcRnrZ5yrmXK1xUw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.218", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.218.tgz", + "integrity": "sha512-rAdlR2ukJd01Osfsb7nPPv31MCVJBennBDaL7ZP/bv+dednwNuY6thuPO5PMFJUM+54Gbnziqm+hqoNTgndqDg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.218", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.218.tgz", + "integrity": "sha512-Y6vFEnz6wwd+rYpVGsPqgeXZuZP7NvQg6vjkC+N6cs1+I41LdKjfs+F+WG3daIqgt+vJBz/EB5Q0PByABkwufA==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.218", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.218.tgz", + "integrity": "sha512-qfFBwOf0uh/mAtNGEkBJlLWt1XIgFqcQeeX966g2NexasCdSJGnwfc0YWA0QOeAAU/5xk6tSCW/Nn0zHGBO/3A==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.218", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.218.tgz", + "integrity": "sha512-gCq/i7yRXeuoQXidGQynuiw5AeczQXSIifxH5Vpr9OFDyHCBNotS1mQ8qutuclZeFpLOPkJ2ic/nvEgHJvfXdg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.218", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.218.tgz", + "integrity": "sha512-e47oi8dYWnS0wx2/F6FL3YhaD2HedMd2nhI/nududP4PBzytuXeaDE/sJ0eIqtUVkl6/bn5uBhtIEHHWoj37JQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.218", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.218.tgz", + "integrity": "sha512-D0UKI8jOpEsWO1A+i0DlIrgjXFKEZX3RrID2l49S4pShUlLsnSLJGMTPy4nQt6CCl5MzPqWiJDE/lCpImFTDcg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.218", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.218.tgz", + "integrity": "sha512-pgE39WnKPPSRsiRJ2pm5NETxQgtVhrquqg7UByZxne0xQw2gNS0Qy+9ZumrsNBNlfrrnQCVDU1I2ks3ZLrEa8Q==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.113.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.113.0.tgz", + "integrity": "sha512-8YKIP333ypmy8QjtSP691fIqdes3HL/rSXAT4FDZX/HRnkQaNG+iOVBfEQw4vjPNpYHeoy7FSYc2o/jb8b3AnA==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@openai/codex": { + "version": "0.145.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0.tgz", + "integrity": "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.145.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.145.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.145.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.145.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.145.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.145.0-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.145.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-darwin-arm64.tgz", + "integrity": "sha512-h6aQ0UxnaP8mIM/9/qPAH9MNkRliJo88toq1T36IxNM2L5JSU0TFamu+MZn7YkFgDsrp0RfiI+97Tm8AVVxqtA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.145.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-darwin-x64.tgz", + "integrity": "sha512-FCYzVKCa9VoLtg9gVyzKpqylonfgZrfcWZN6HsXAZPeuo8CukdMqdgTUOhDn2V6h3MbqS0z6VqQVKUllN/yKhA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.145.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-linux-arm64.tgz", + "integrity": "sha512-8OLcPXaAol/FOrRoDxWhIiHIFa73KRsM41EKocjRZOwiT4TcelzJWn3dHyiuSb7teWF25rrslvSPyvhULYRRCQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.145.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-linux-x64.tgz", + "integrity": "sha512-u8w8LLv3DvsfrDCoswLIemZ0SoNEXyi511WsfFsSiYUazk9qMsB/NtU8N9vhAfN7mZAxLFoMex4v66JjHuZWwA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-sdk": { + "version": "0.145.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.145.0.tgz", + "integrity": "sha512-lBviqBKnomEXNpdjfDzUoz/H+VZuK75m2oR8dQSmL2zUpnklfAsTneuaSg23NOoE+vw/Qhbxp8ePy9te973yfw==", + "license": "Apache-2.0", + "dependencies": { + "@openai/codex": "0.145.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.145.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-win32-arm64.tgz", + "integrity": "sha512-sub61rjEFevi1i3Zx7nAd4JM5XxoNFqMqFc5LfTo2xSI8ixHjFvEYDFDXwXOftT04n3Ht1Wh271ioUZpDiEjEg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.145.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.145.0-win32-x64.tgz", + "integrity": "sha512-u0h9lk094CaXRSqE34SBW2dRaQTPa6fASXqehczWH9QdsU62mBsiAgAdp6tCG4i+YzPmmhjD8FdXNnYGNmwuMg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@qwen-code/cua-sdk": { + "resolved": "../../typescript", + "link": true + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "22.17.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", + "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "peer": true, + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT", + "peer": true + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "peer": true + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT", + "peer": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT", + "peer": true + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC", + "peer": true + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "peer": true + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/packages/cua-driver/examples/agent-sdks/package.json b/packages/cua-driver/examples/agent-sdks/package.json index 9090e10fef..7efbc1363d 100644 --- a/packages/cua-driver/examples/agent-sdks/package.json +++ b/packages/cua-driver/examples/agent-sdks/package.json @@ -11,7 +11,7 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.218", "@openai/codex-sdk": "0.145.0", - "@trycua/cua-driver": "0.12.2", + "@qwen-code/cua-sdk": "file:../../typescript", "zod": "4.1.12" }, "devDependencies": { diff --git a/packages/cua-driver/examples/agent-sdks/requirements.txt b/packages/cua-driver/examples/agent-sdks/requirements.txt index 40fa8f33ef..28503eeea4 100644 --- a/packages/cua-driver/examples/agent-sdks/requirements.txt +++ b/packages/cua-driver/examples/agent-sdks/requirements.txt @@ -1,3 +1,3 @@ claude-agent-sdk==0.2.124 openai-codex==0.144.4 -cua-driver==0.12.2 +cua-driver==0.20.0 diff --git a/packages/cua-driver/python/README.md b/packages/cua-driver/python/README.md index cbd216912d..cebfe2919e 100644 --- a/packages/cua-driver/python/README.md +++ b/packages/cua-driver/python/README.md @@ -38,7 +38,6 @@ in the importing process and does not require the executable or daemon. import asyncio from cua_driver import ( - CaptureScope, CuaDriver, CursorReducedMotion, EndSessionInput, @@ -50,7 +49,7 @@ from cua_driver import ( async def main() -> None: driver = CuaDriver.create() await driver.start_session( - StartSessionInput(session="demo", capture_scope=CaptureScope.DESKTOP) + StartSessionInput(session="demo", capture_scope=None, cursor_theme=None) ) try: await driver.set_agent_cursor_theme( @@ -77,7 +76,13 @@ text, images, verification/error metadata, and `structured_json` / `raw_json` for platform-extensible results. Session lifecycle calls return dedicated generated records. -The agent cursor is session-owned. Its default theme and custom dotLottie +`start_session` is optional for ordinary calls. The runtime creates one +implicit session for this SDK transport and reuses it until shutdown, explicit +end, or five minutes of inactivity. Use a named session when application code +needs to configure or inspect that run explicitly. + +The agent cursor is session-owned and initializes on the first cursor-bearing +action, including `move_cursor`. Its default theme and custom dotLottie authoring workflow are documented in [`docs/cursor-themes.md`](../docs/cursor-themes.md). Custom source is compiled and installed with the local CLI; SDK and MCP tools select only an installed diff --git a/packages/cua-driver/python/pyproject.toml b/packages/cua-driver/python/pyproject.toml index ee23220a1f..54a9af6cb0 100644 --- a/packages/cua-driver/python/pyproject.toml +++ b/packages/cua-driver/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "cua-driver" -version = "0.17.0" +version = "0.20.0" description = "Rust-backed Cua Driver SDK and bundled executable for client applications" readme = "README.md" license = { text = "MIT" } diff --git a/packages/cua-driver/python/src/cua_driver/__init__.py b/packages/cua-driver/python/src/cua_driver/__init__.py index cb6dce1d8e..d00314b2e2 100644 --- a/packages/cua-driver/python/src/cua_driver/__init__.py +++ b/packages/cua-driver/python/src/cua_driver/__init__.py @@ -4,7 +4,7 @@ Agents should configure the bundled ``qwen-cua-driver mcp`` executable directly through their runtime's MCP client instead of importing a language MCP facade. """ -__version__ = "0.17.0" # x-release-please-version +__version__ = "0.20.0" # x-release-please-version from ._native import ( ActionCompletion, @@ -79,9 +79,11 @@ from ._native_contract import ( GetDesktopStateInput, GetScreenSizeInput, GetSessionStateInput, + GetWindowStateInput, HotkeyInput, InvokeMenuInput, MoveCursorInput, + ObservationRevisionInput, Platform, PredicateOutcome, PressKeyInput, @@ -227,11 +229,13 @@ __all__ = [ "GetDesktopStateInput", "GetScreenSizeInput", "GetSessionStateInput", + "GetWindowStateInput", "HotkeyInput", "InvokeMenuInput", "ImageContent", "MacOsPermissionStatus", "MoveCursorInput", + "ObservationRevisionInput", "Platform", "PredicateOutcome", "PrivateWorkerOptions", diff --git a/packages/cua-driver/python/src/cua_driver/_native.py b/packages/cua-driver/python/src/cua_driver/_native.py index 54f92889ef..04e3053296 100644 --- a/packages/cua-driver/python/src/cua_driver/_native.py +++ b/packages/cua-driver/python/src/cua_driver/_native.py @@ -524,6 +524,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_clipboard_write() != 8399: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_double_click() != 17412: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_drag() != 25742: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_end_session() != 18486: @@ -540,22 +542,38 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_screen_size() != 55616: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_session() != 17834: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_session_state() != 49966: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_window_state() != 64016: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_hotkey() != 53333: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_invoke_menu() != 13118: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_is_available() != 42961: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_apps() != 26703: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_host_sessions_json() != 13193: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_sessions() != 40131: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_tools_json() != 33039: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_windows() != 42174: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_metadata() != 55026: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_move_cursor() != 18320: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_perform_secondary_action() != 30464: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_press_key() != 63712: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_right_click() != 11321: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_runtime_scope_prefix() != 2453: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_scroll() != 52290: @@ -566,6 +584,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_agent_cursor_theme() != 53908: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_value() != 7729: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_window_frame() != 6509: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_shutdown() != 36331: @@ -578,6 +598,18 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_verify_state() != 26193: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_click() != 21969: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_drag() != 33641: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_hotkey() != 31424: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_press_key() != 11999: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_scroll() != 23737: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_type_text() != 7793: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_call_tool() != 52859: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_click() != 22303: @@ -588,6 +620,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_close() != 58108: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_double_click() != 51229: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_drag() != 5854: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_end_session() != 42785: @@ -602,16 +636,30 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_screen_size() != 9763: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_session() != 54666: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_session_state() != 55370: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_window_state() != 42608: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_hotkey() != 12820: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_invoke_menu() != 2742: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_list_apps() != 41939: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_list_sessions() != 32840: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_list_windows() != 7125: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_move_cursor() != 35768: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_perform_secondary_action() != 55329: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_press_key() != 36768: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_right_click() != 4654: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_scroll() != 8013: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_agent_cursor_enabled() != 37153: @@ -620,6 +668,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_agent_cursor_theme() != 29734: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_value() != 40056: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_window_frame() != 65413: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_start_session() != 13118: @@ -628,6 +678,18 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_verify_state() != 36472: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_click() != 8616: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_drag() != 16657: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_hotkey() != 33781: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_press_key() != 45879: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_scroll() != 49822: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_type_text() != 55318: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_driveractivityobserver_on_activity() != 9786: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_cua_driver_sdk_checksum_method_driverauthorizationhost_authorize() != 10089: @@ -1125,6 +1187,11 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_clipboard_write.argtypes = cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_clipboard_write.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_double_click.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_double_click.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_drag.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, @@ -1165,11 +1232,21 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_screen_size.argtypes = cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_screen_size.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_session.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_session.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_session_state.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_session_state.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_window_state.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_window_state.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_hotkey.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, @@ -1185,10 +1262,29 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_is_available.argtypes = ( ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_is_available.restype = ctypes.c_int8 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_apps.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_apps.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_host_sessions_json.argtypes = ( + ctypes.c_uint64, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_host_sessions_json.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_sessions.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_sessions.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_tools_json.argtypes = ( ctypes.c_uint64, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_tools_json.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_windows.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_windows.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_metadata.argtypes = ( ctypes.c_uint64, ) @@ -1198,11 +1294,21 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_move_cursor.argtypes = ( cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_move_cursor.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_perform_secondary_action.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_perform_secondary_action.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_press_key.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_press_key.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_right_click.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_right_click.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_runtime_scope_prefix.argtypes = ( ctypes.c_uint64, ctypes.POINTER(_UniffiRustCallStatus), @@ -1228,6 +1334,11 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_set_agent_cursor_theme.argt cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_set_agent_cursor_theme.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_set_value.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_set_value.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_set_window_frame.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, @@ -1257,6 +1368,36 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_verify_state.argtypes = ( cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_verify_state.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_click.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_click.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_drag.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_drag.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_hotkey.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_hotkey.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_press_key.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_press_key.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_scroll.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_scroll.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_type_text.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_type_text.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_call_tool.argtypes = ( ctypes.c_uint64, _UniffiRustBuffer, @@ -1283,6 +1424,11 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_close.argtypes = ( ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_close.restype = None +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_double_click.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_double_click.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_drag.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, @@ -1318,11 +1464,21 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_screen_size.argt cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_screen_size.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_session.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_session.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_session_state.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_session_state.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_window_state.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_window_state.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_hotkey.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, @@ -1333,16 +1489,41 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_invoke_menu.argtypes cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_invoke_menu.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_list_apps.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_list_apps.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_list_sessions.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_list_sessions.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_list_windows.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_list_windows.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_move_cursor.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_move_cursor.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_perform_secondary_action.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_perform_secondary_action.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_press_key.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_press_key.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_right_click.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_right_click.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_scroll.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, @@ -1363,6 +1544,11 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_set_agent_cursor_the cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_set_agent_cursor_theme.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_set_value.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_set_value.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_set_window_frame.argtypes = ( ctypes.c_uint64, cua_driver._native_contract._UniffiRustBuffer, @@ -1383,6 +1569,36 @@ _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_verify_state.argtype cua_driver._native_contract._UniffiRustBuffer, ) _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_verify_state.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_click.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_click.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_drag.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_drag.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_hotkey.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_hotkey.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_press_key.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_press_key.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_scroll.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_scroll.restype = ctypes.c_uint64 +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_type_text.argtypes = ( + ctypes.c_uint64, + cua_driver._native_contract._UniffiRustBuffer, +) +_UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_type_text.restype = ctypes.c_uint64 _UniffiLib.uniffi_cua_driver_sdk_fn_method_driveractivityobserver_on_activity.argtypes = ( ctypes.c_uint64, _UniffiRustBuffer, @@ -1501,6 +1717,9 @@ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_clipboard_read.restyp _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_clipboard_write.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_clipboard_write.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_double_click.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_double_click.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_drag.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_drag.restype = ctypes.c_uint16 @@ -1525,9 +1744,15 @@ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_desktop_state.res _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_screen_size.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_screen_size.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_session.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_session.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_session_state.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_session_state.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_window_state.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_get_window_state.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_hotkey.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_hotkey.restype = ctypes.c_uint16 @@ -1537,18 +1762,36 @@ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_invoke_menu.restype = _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_is_available.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_is_available.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_apps.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_apps.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_host_sessions_json.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_host_sessions_json.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_sessions.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_sessions.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_tools_json.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_tools_json.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_windows.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_list_windows.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_metadata.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_metadata.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_move_cursor.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_move_cursor.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_perform_secondary_action.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_perform_secondary_action.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_press_key.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_press_key.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_right_click.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_right_click.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_runtime_scope_prefix.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_runtime_scope_prefix.restype = ctypes.c_uint16 @@ -1564,6 +1807,9 @@ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_agent_cursor_moti _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_agent_cursor_theme.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_agent_cursor_theme.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_value.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_value.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_window_frame.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_set_window_frame.restype = ctypes.c_uint16 @@ -1582,6 +1828,24 @@ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_type_text.restype = c _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_verify_state.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_verify_state.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_click.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_click.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_drag.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_drag.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_hotkey.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_hotkey.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_press_key.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_press_key.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_scroll.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_scroll.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_type_text.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriver_window_type_text.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_call_tool.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_call_tool.restype = ctypes.c_uint16 @@ -1597,6 +1861,9 @@ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_clipboard_writ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_close.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_close.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_double_click.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_double_click.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_drag.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_drag.restype = ctypes.c_uint16 @@ -1618,21 +1885,42 @@ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_desktop_st _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_screen_size.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_screen_size.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_session.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_session.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_session_state.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_session_state.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_window_state.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_get_window_state.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_hotkey.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_hotkey.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_invoke_menu.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_invoke_menu.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_list_apps.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_list_apps.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_list_sessions.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_list_sessions.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_list_windows.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_list_windows.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_move_cursor.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_move_cursor.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_perform_secondary_action.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_perform_secondary_action.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_press_key.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_press_key.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_right_click.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_right_click.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_scroll.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_scroll.restype = ctypes.c_uint16 @@ -1645,6 +1933,9 @@ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_agent_curs _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_agent_cursor_theme.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_agent_cursor_theme.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_value.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_value.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_window_frame.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_set_window_frame.restype = ctypes.c_uint16 @@ -1657,6 +1948,24 @@ _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_type_text.rest _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_verify_state.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_verify_state.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_click.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_click.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_drag.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_drag.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_hotkey.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_hotkey.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_press_key.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_press_key.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_scroll.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_scroll.restype = ctypes.c_uint16 +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_type_text.argtypes = ( +) +_UniffiLib.uniffi_cua_driver_sdk_checksum_method_cuadriversession_window_type_text.restype = ctypes.c_uint16 _UniffiLib.uniffi_cua_driver_sdk_checksum_method_driveractivityobserver_on_activity.argtypes = ( ) _UniffiLib.uniffi_cua_driver_sdk_checksum_method_driveractivityobserver_on_activity.restype = ctypes.c_uint16 @@ -1990,9 +2299,13 @@ class RuntimeAuthorizationOptions: """ Immutable authorization ceiling supplied before a runtime accepts actions. """ - def __init__(self, *, allowed_modes:typing.List[SessionPermissionMode], compatibility_mode:SessionPermissionMode, compatibility_bounded_manifest_path:typing.Optional[str], unrestricted_acknowledged:bool, max_session_ttl_seconds:int, max_idle_ttl_seconds:int): + def __init__(self, *, allowed_modes:typing.List[SessionPermissionMode], compatibility_mode:SessionPermissionMode, compatibility_capability_manifest_path:typing.Optional[str] = _DEFAULT, compatibility_bounded_manifest_path:typing.Optional[str], unrestricted_acknowledged:bool, max_session_ttl_seconds:int, max_idle_ttl_seconds:int): self.allowed_modes = allowed_modes self.compatibility_mode = compatibility_mode + if compatibility_capability_manifest_path is _DEFAULT: + self.compatibility_capability_manifest_path = None + else: + self.compatibility_capability_manifest_path = compatibility_capability_manifest_path self.compatibility_bounded_manifest_path = compatibility_bounded_manifest_path self.unrestricted_acknowledged = unrestricted_acknowledged self.max_session_ttl_seconds = max_session_ttl_seconds @@ -2002,12 +2315,14 @@ class RuntimeAuthorizationOptions: def __str__(self): - return "RuntimeAuthorizationOptions(allowed_modes={}, compatibility_mode={}, compatibility_bounded_manifest_path={}, unrestricted_acknowledged={}, max_session_ttl_seconds={}, max_idle_ttl_seconds={})".format(self.allowed_modes, self.compatibility_mode, self.compatibility_bounded_manifest_path, self.unrestricted_acknowledged, self.max_session_ttl_seconds, self.max_idle_ttl_seconds) + return "RuntimeAuthorizationOptions(allowed_modes={}, compatibility_mode={}, compatibility_capability_manifest_path={}, compatibility_bounded_manifest_path={}, unrestricted_acknowledged={}, max_session_ttl_seconds={}, max_idle_ttl_seconds={})".format(self.allowed_modes, self.compatibility_mode, self.compatibility_capability_manifest_path, self.compatibility_bounded_manifest_path, self.unrestricted_acknowledged, self.max_session_ttl_seconds, self.max_idle_ttl_seconds) def __eq__(self, other): if self.allowed_modes != other.allowed_modes: return False if self.compatibility_mode != other.compatibility_mode: return False + if self.compatibility_capability_manifest_path != other.compatibility_capability_manifest_path: + return False if self.compatibility_bounded_manifest_path != other.compatibility_bounded_manifest_path: return False if self.unrestricted_acknowledged != other.unrestricted_acknowledged: @@ -2024,6 +2339,7 @@ class _UniffiFfiConverterTypeRuntimeAuthorizationOptions(_UniffiConverterRustBuf return RuntimeAuthorizationOptions( allowed_modes=_UniffiFfiConverterSequenceTypeSessionPermissionMode.read(buf), compatibility_mode=_UniffiFfiConverterTypeSessionPermissionMode.read(buf), + compatibility_capability_manifest_path=_UniffiFfiConverterOptionalString.read(buf), compatibility_bounded_manifest_path=_UniffiFfiConverterOptionalString.read(buf), unrestricted_acknowledged=_UniffiFfiConverterBoolean.read(buf), max_session_ttl_seconds=_UniffiFfiConverterUInt64.read(buf), @@ -2034,6 +2350,7 @@ class _UniffiFfiConverterTypeRuntimeAuthorizationOptions(_UniffiConverterRustBuf def check_lower(value): _UniffiFfiConverterSequenceTypeSessionPermissionMode.check_lower(value.allowed_modes) _UniffiFfiConverterTypeSessionPermissionMode.check_lower(value.compatibility_mode) + _UniffiFfiConverterOptionalString.check_lower(value.compatibility_capability_manifest_path) _UniffiFfiConverterOptionalString.check_lower(value.compatibility_bounded_manifest_path) _UniffiFfiConverterBoolean.check_lower(value.unrestricted_acknowledged) _UniffiFfiConverterUInt64.check_lower(value.max_session_ttl_seconds) @@ -2043,6 +2360,7 @@ class _UniffiFfiConverterTypeRuntimeAuthorizationOptions(_UniffiConverterRustBuf def write(value, buf): _UniffiFfiConverterSequenceTypeSessionPermissionMode.write(value.allowed_modes, buf) _UniffiFfiConverterTypeSessionPermissionMode.write(value.compatibility_mode, buf) + _UniffiFfiConverterOptionalString.write(value.compatibility_capability_manifest_path, buf) _UniffiFfiConverterOptionalString.write(value.compatibility_bounded_manifest_path, buf) _UniffiFfiConverterBoolean.write(value.unrestricted_acknowledged, buf) _UniffiFfiConverterUInt64.write(value.max_session_ttl_seconds, buf) @@ -2935,13 +3253,18 @@ class _UniffiFfiConverterOptionalTypeEmbeddedPermissionMode(_UniffiConverterRust @dataclass class EmbeddedDriverHostOptions: - def __init__(self, *, binary_path:str, host_bundle_id:str, socket_path:typing.Optional[str], startup_timeout_ms:typing.Optional[int], shutdown_timeout_ms:typing.Optional[int], permission_mode:typing.Optional[EmbeddedPermissionMode], session_policy_path:typing.Optional[str], approve_session_policy:bool, dangerously_bypass_approvals:bool, environment:typing.List[EmbeddedEnvironmentVariable], inherit_stderr:bool): + def __init__(self, *, binary_path:str, host_bundle_id:str, socket_path:typing.Optional[str], startup_timeout_ms:typing.Optional[int], shutdown_timeout_ms:typing.Optional[int], permission_mode:typing.Optional[EmbeddedPermissionMode], capability_manifest_path:typing.Optional[str] = _DEFAULT, approve_capability_manifest:bool = False, session_policy_path:typing.Optional[str], approve_session_policy:bool, dangerously_bypass_approvals:bool, environment:typing.List[EmbeddedEnvironmentVariable], inherit_stderr:bool): self.binary_path = binary_path self.host_bundle_id = host_bundle_id self.socket_path = socket_path self.startup_timeout_ms = startup_timeout_ms self.shutdown_timeout_ms = shutdown_timeout_ms self.permission_mode = permission_mode + if capability_manifest_path is _DEFAULT: + self.capability_manifest_path = None + else: + self.capability_manifest_path = capability_manifest_path + self.approve_capability_manifest = approve_capability_manifest self.session_policy_path = session_policy_path self.approve_session_policy = approve_session_policy self.dangerously_bypass_approvals = dangerously_bypass_approvals @@ -2952,7 +3275,7 @@ class EmbeddedDriverHostOptions: def __str__(self): - return "EmbeddedDriverHostOptions(binary_path={}, host_bundle_id={}, socket_path={}, startup_timeout_ms={}, shutdown_timeout_ms={}, permission_mode={}, session_policy_path={}, approve_session_policy={}, dangerously_bypass_approvals={}, environment={}, inherit_stderr={})".format(self.binary_path, self.host_bundle_id, self.socket_path, self.startup_timeout_ms, self.shutdown_timeout_ms, self.permission_mode, self.session_policy_path, self.approve_session_policy, self.dangerously_bypass_approvals, self.environment, self.inherit_stderr) + return "EmbeddedDriverHostOptions(binary_path={}, host_bundle_id={}, socket_path={}, startup_timeout_ms={}, shutdown_timeout_ms={}, permission_mode={}, capability_manifest_path={}, approve_capability_manifest={}, session_policy_path={}, approve_session_policy={}, dangerously_bypass_approvals={}, environment={}, inherit_stderr={})".format(self.binary_path, self.host_bundle_id, self.socket_path, self.startup_timeout_ms, self.shutdown_timeout_ms, self.permission_mode, self.capability_manifest_path, self.approve_capability_manifest, self.session_policy_path, self.approve_session_policy, self.dangerously_bypass_approvals, self.environment, self.inherit_stderr) def __eq__(self, other): if self.binary_path != other.binary_path: return False @@ -2966,6 +3289,10 @@ class EmbeddedDriverHostOptions: return False if self.permission_mode != other.permission_mode: return False + if self.capability_manifest_path != other.capability_manifest_path: + return False + if self.approve_capability_manifest != other.approve_capability_manifest: + return False if self.session_policy_path != other.session_policy_path: return False if self.approve_session_policy != other.approve_session_policy: @@ -2988,6 +3315,8 @@ class _UniffiFfiConverterTypeEmbeddedDriverHostOptions(_UniffiConverterRustBuffe startup_timeout_ms=_UniffiFfiConverterOptionalUInt64.read(buf), shutdown_timeout_ms=_UniffiFfiConverterOptionalUInt64.read(buf), permission_mode=_UniffiFfiConverterOptionalTypeEmbeddedPermissionMode.read(buf), + capability_manifest_path=_UniffiFfiConverterOptionalString.read(buf), + approve_capability_manifest=_UniffiFfiConverterBoolean.read(buf), session_policy_path=_UniffiFfiConverterOptionalString.read(buf), approve_session_policy=_UniffiFfiConverterBoolean.read(buf), dangerously_bypass_approvals=_UniffiFfiConverterBoolean.read(buf), @@ -3003,6 +3332,8 @@ class _UniffiFfiConverterTypeEmbeddedDriverHostOptions(_UniffiConverterRustBuffe _UniffiFfiConverterOptionalUInt64.check_lower(value.startup_timeout_ms) _UniffiFfiConverterOptionalUInt64.check_lower(value.shutdown_timeout_ms) _UniffiFfiConverterOptionalTypeEmbeddedPermissionMode.check_lower(value.permission_mode) + _UniffiFfiConverterOptionalString.check_lower(value.capability_manifest_path) + _UniffiFfiConverterBoolean.check_lower(value.approve_capability_manifest) _UniffiFfiConverterOptionalString.check_lower(value.session_policy_path) _UniffiFfiConverterBoolean.check_lower(value.approve_session_policy) _UniffiFfiConverterBoolean.check_lower(value.dangerously_bypass_approvals) @@ -3017,6 +3348,8 @@ class _UniffiFfiConverterTypeEmbeddedDriverHostOptions(_UniffiConverterRustBuffe _UniffiFfiConverterOptionalUInt64.write(value.startup_timeout_ms, buf) _UniffiFfiConverterOptionalUInt64.write(value.shutdown_timeout_ms, buf) _UniffiFfiConverterOptionalTypeEmbeddedPermissionMode.write(value.permission_mode, buf) + _UniffiFfiConverterOptionalString.write(value.capability_manifest_path, buf) + _UniffiFfiConverterBoolean.write(value.approve_capability_manifest, buf) _UniffiFfiConverterOptionalString.write(value.session_policy_path, buf) _UniffiFfiConverterBoolean.write(value.approve_session_policy, buf) _UniffiFfiConverterBoolean.write(value.dangerously_bypass_approvals, buf) @@ -3336,18 +3669,22 @@ class TrustedSessionOptions: """ Trusted host request for one immutable, connection-bound action surface. """ - def __init__(self, *, public_session:str, mode:SessionPermissionMode, ttl_seconds:int, idle_ttl_seconds:int, bounded_manifest_path:typing.Optional[str]): + def __init__(self, *, public_session:str, mode:SessionPermissionMode, ttl_seconds:int, idle_ttl_seconds:int, capability_manifest_path:typing.Optional[str] = _DEFAULT, bounded_manifest_path:typing.Optional[str]): self.public_session = public_session self.mode = mode self.ttl_seconds = ttl_seconds self.idle_ttl_seconds = idle_ttl_seconds + if capability_manifest_path is _DEFAULT: + self.capability_manifest_path = None + else: + self.capability_manifest_path = capability_manifest_path self.bounded_manifest_path = bounded_manifest_path def __str__(self): - return "TrustedSessionOptions(public_session={}, mode={}, ttl_seconds={}, idle_ttl_seconds={}, bounded_manifest_path={})".format(self.public_session, self.mode, self.ttl_seconds, self.idle_ttl_seconds, self.bounded_manifest_path) + return "TrustedSessionOptions(public_session={}, mode={}, ttl_seconds={}, idle_ttl_seconds={}, capability_manifest_path={}, bounded_manifest_path={})".format(self.public_session, self.mode, self.ttl_seconds, self.idle_ttl_seconds, self.capability_manifest_path, self.bounded_manifest_path) def __eq__(self, other): if self.public_session != other.public_session: return False @@ -3357,6 +3694,8 @@ class TrustedSessionOptions: return False if self.idle_ttl_seconds != other.idle_ttl_seconds: return False + if self.capability_manifest_path != other.capability_manifest_path: + return False if self.bounded_manifest_path != other.bounded_manifest_path: return False return True @@ -3369,6 +3708,7 @@ class _UniffiFfiConverterTypeTrustedSessionOptions(_UniffiConverterRustBuffer): mode=_UniffiFfiConverterTypeSessionPermissionMode.read(buf), ttl_seconds=_UniffiFfiConverterUInt64.read(buf), idle_ttl_seconds=_UniffiFfiConverterUInt64.read(buf), + capability_manifest_path=_UniffiFfiConverterOptionalString.read(buf), bounded_manifest_path=_UniffiFfiConverterOptionalString.read(buf), ) @@ -3378,6 +3718,7 @@ class _UniffiFfiConverterTypeTrustedSessionOptions(_UniffiConverterRustBuffer): _UniffiFfiConverterTypeSessionPermissionMode.check_lower(value.mode) _UniffiFfiConverterUInt64.check_lower(value.ttl_seconds) _UniffiFfiConverterUInt64.check_lower(value.idle_ttl_seconds) + _UniffiFfiConverterOptionalString.check_lower(value.capability_manifest_path) _UniffiFfiConverterOptionalString.check_lower(value.bounded_manifest_path) @staticmethod @@ -3386,6 +3727,7 @@ class _UniffiFfiConverterTypeTrustedSessionOptions(_UniffiConverterRustBuffer): _UniffiFfiConverterTypeSessionPermissionMode.write(value.mode, buf) _UniffiFfiConverterUInt64.write(value.ttl_seconds, buf) _UniffiFfiConverterUInt64.write(value.idle_ttl_seconds, buf) + _UniffiFfiConverterOptionalString.write(value.capability_manifest_path, buf) _UniffiFfiConverterOptionalString.write(value.bounded_manifest_path, buf) @@ -4140,6 +4482,40 @@ class _UniffiFfiConverterTypeSdkClientKind(_UniffiConverterRustBuffer): + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4176,6 +4552,8 @@ class CuaDriverProtocol(typing.Protocol): raise NotImplementedError async def clipboard_write(self, input: cua_driver._native_contract.ClipboardWriteInput) -> ToolResult: raise NotImplementedError + async def double_click(self, input: cua_driver._native_contract.DoubleClickInput) -> ToolResult: + raise NotImplementedError async def drag(self, input: cua_driver._native_contract.DragInput) -> ToolResult: raise NotImplementedError async def end_session(self, input: cua_driver._native_contract.EndSessionInput) -> cua_driver._native_contract.EndSessionOutput: @@ -4192,25 +4570,46 @@ class CuaDriverProtocol(typing.Protocol): raise NotImplementedError async def get_screen_size(self, input: cua_driver._native_contract.GetScreenSizeInput) -> ToolResult: raise NotImplementedError + async def get_session(self, input: cua_driver._native_contract.GetSessionInput) -> cua_driver._native_contract.SessionOutput: + raise NotImplementedError async def get_session_state(self, input: cua_driver._native_contract.GetSessionStateInput) -> cua_driver._native_contract.SessionStateOutput: raise NotImplementedError + async def get_window_state(self, input: cua_driver._native_contract.GetWindowStateInput) -> ToolResult: + raise NotImplementedError async def hotkey(self, input: cua_driver._native_contract.HotkeyInput) -> ToolResult: raise NotImplementedError async def invoke_menu(self, input: cua_driver._native_contract.InvokeMenuInput) -> ToolResult: raise NotImplementedError def is_available(self, ) -> bool: raise NotImplementedError + async def list_apps(self, input: cua_driver._native_contract.ListAppsInput) -> ToolResult: + raise NotImplementedError + async def list_host_sessions_json(self, ) -> str: + """ + Content-free lifecycle summaries for the trusted host's runtime or + host-lease namespace. This is deliberately separate from the + agent-facing `list_sessions` tool, whose view is transport scoped. +""" + raise NotImplementedError + async def list_sessions(self, input: cua_driver._native_contract.ListSessionsInput) -> cua_driver._native_contract.ListSessionsOutput: + raise NotImplementedError async def list_tools_json(self, ) -> str: """ Canonical tool inventory for MCP and other protocol adapters. """ raise NotImplementedError + async def list_windows(self, input: cua_driver._native_contract.ListWindowsInput) -> ToolResult: + raise NotImplementedError async def metadata(self, ) -> DriverMetadata: raise NotImplementedError async def move_cursor(self, input: cua_driver._native_contract.MoveCursorInput) -> ToolResult: raise NotImplementedError + async def perform_secondary_action(self, input: cua_driver._native_contract.PerformSecondaryActionInput) -> ToolResult: + raise NotImplementedError async def press_key(self, input: cua_driver._native_contract.PressKeyInput) -> ToolResult: raise NotImplementedError + async def right_click(self, input: cua_driver._native_contract.RightClickInput) -> ToolResult: + raise NotImplementedError def runtime_scope_prefix(self, ) -> typing.Optional[str]: raise NotImplementedError async def scroll(self, input: cua_driver._native_contract.ScrollInput) -> ToolResult: @@ -4221,6 +4620,8 @@ class CuaDriverProtocol(typing.Protocol): raise NotImplementedError async def set_agent_cursor_theme(self, input: cua_driver._native_contract.SetAgentCursorThemeInput) -> ToolResult: raise NotImplementedError + async def set_value(self, input: cua_driver._native_contract.SetValueInput) -> ToolResult: + raise NotImplementedError async def set_window_frame(self, input: cua_driver._native_contract.SetWindowFrameInput) -> ToolResult: raise NotImplementedError async def shutdown(self, ) -> None: @@ -4241,6 +4642,18 @@ class CuaDriverProtocol(typing.Protocol): raise NotImplementedError async def verify_state(self, input: cua_driver._native_contract.VerifyStateInput) -> ToolResult: raise NotImplementedError + async def window_click(self, input: cua_driver._native_contract.WindowClickInput) -> ToolResult: + raise NotImplementedError + async def window_drag(self, input: cua_driver._native_contract.WindowDragInput) -> ToolResult: + raise NotImplementedError + async def window_hotkey(self, input: cua_driver._native_contract.WindowHotkeyInput) -> ToolResult: + raise NotImplementedError + async def window_press_key(self, input: cua_driver._native_contract.WindowPressKeyInput) -> ToolResult: + raise NotImplementedError + async def window_scroll(self, input: cua_driver._native_contract.WindowScrollInput) -> ToolResult: + raise NotImplementedError + async def window_type_text(self, input: cua_driver._native_contract.WindowTypeTextInput) -> ToolResult: + raise NotImplementedError class CuaDriver(CuaDriverProtocol): @@ -4657,6 +5070,23 @@ class CuaDriver(CuaDriverProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def double_click(self, input: cua_driver._native_contract.DoubleClickInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeDoubleClickInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeDoubleClickInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_double_click(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def drag(self, input: cua_driver._native_contract.DragInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypeDragInput.check_lower(input) @@ -4788,6 +5218,23 @@ class CuaDriver(CuaDriverProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def get_session(self, input: cua_driver._native_contract.GetSessionInput) -> cua_driver._native_contract.SessionOutput: + + cua_driver._native_contract._UniffiFfiConverterTypeGetSessionInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeGetSessionInput.lower(input), + ) + _uniffi_lift_return = cua_driver._native_contract._UniffiFfiConverterTypeSessionOutput.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_session(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def get_session_state(self, input: cua_driver._native_contract.GetSessionStateInput) -> cua_driver._native_contract.SessionStateOutput: cua_driver._native_contract._UniffiFfiConverterTypeGetSessionStateInput.check_lower(input) @@ -4805,6 +5252,23 @@ class CuaDriver(CuaDriverProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def get_window_state(self, input: cua_driver._native_contract.GetWindowStateInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeGetWindowStateInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeGetWindowStateInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_get_window_state(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def hotkey(self, input: cua_driver._native_contract.HotkeyInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypeHotkeyInput.check_lower(input) @@ -4851,6 +5315,59 @@ class CuaDriver(CuaDriverProtocol): *_uniffi_lowered_args, ) return _uniffi_lift_return(_uniffi_ffi_result) + async def list_apps(self, input: cua_driver._native_contract.ListAppsInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeListAppsInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeListAppsInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_apps(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def list_host_sessions_json(self, ) -> str: + """ + Content-free lifecycle summaries for the trusted host's runtime or + host-lease namespace. This is deliberately separate from the + agent-facing `list_sessions` tool, whose view is transport scoped. +""" + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + ) + _uniffi_lift_return = _UniffiFfiConverterString.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_host_sessions_json(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def list_sessions(self, input: cua_driver._native_contract.ListSessionsInput) -> cua_driver._native_contract.ListSessionsOutput: + + cua_driver._native_contract._UniffiFfiConverterTypeListSessionsInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeListSessionsInput.lower(input), + ) + _uniffi_lift_return = cua_driver._native_contract._UniffiFfiConverterTypeListSessionsOutput.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_sessions(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def list_tools_json(self, ) -> str: """ Canonical tool inventory for MCP and other protocol adapters. @@ -4868,6 +5385,23 @@ class CuaDriver(CuaDriverProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def list_windows(self, input: cua_driver._native_contract.ListWindowsInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeListWindowsInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeListWindowsInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_list_windows(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def metadata(self, ) -> DriverMetadata: _uniffi_lowered_args = ( self._uniffi_clone_handle(), @@ -4899,6 +5433,23 @@ class CuaDriver(CuaDriverProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def perform_secondary_action(self, input: cua_driver._native_contract.PerformSecondaryActionInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypePerformSecondaryActionInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypePerformSecondaryActionInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_perform_secondary_action(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def press_key(self, input: cua_driver._native_contract.PressKeyInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypePressKeyInput.check_lower(input) @@ -4916,6 +5467,23 @@ class CuaDriver(CuaDriverProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def right_click(self, input: cua_driver._native_contract.RightClickInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeRightClickInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeRightClickInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_right_click(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) def runtime_scope_prefix(self, ) -> typing.Optional[str]: _uniffi_lowered_args = ( self._uniffi_clone_handle(), @@ -4996,6 +5564,23 @@ class CuaDriver(CuaDriverProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def set_value(self, input: cua_driver._native_contract.SetValueInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeSetValueInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeSetValueInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_set_value(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def set_window_frame(self, input: cua_driver._native_contract.SetWindowFrameInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypeSetWindowFrameInput.check_lower(input) @@ -5098,6 +5683,108 @@ class CuaDriver(CuaDriverProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def window_click(self, input: cua_driver._native_contract.WindowClickInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowClickInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowClickInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_click(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_drag(self, input: cua_driver._native_contract.WindowDragInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowDragInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowDragInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_drag(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_hotkey(self, input: cua_driver._native_contract.WindowHotkeyInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowHotkeyInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowHotkeyInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_hotkey(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_press_key(self, input: cua_driver._native_contract.WindowPressKeyInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowPressKeyInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowPressKeyInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_press_key(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_scroll(self, input: cua_driver._native_contract.WindowScrollInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowScrollInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowScrollInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_scroll(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_type_text(self, input: cua_driver._native_contract.WindowTypeTextInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowTypeTextInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowTypeTextInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriver_window_type_text(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) @@ -5147,6 +5834,8 @@ class CuaDriverSessionProtocol(typing.Protocol): cleanup, but trusted hosts should call it at their lifecycle boundary. """ raise NotImplementedError + async def double_click(self, input: cua_driver._native_contract.DoubleClickInput) -> ToolResult: + raise NotImplementedError async def drag(self, input: cua_driver._native_contract.DragInput) -> ToolResult: raise NotImplementedError async def end_session(self, input: cua_driver._native_contract.EndSessionInput) -> cua_driver._native_contract.EndSessionOutput: @@ -5161,16 +5850,30 @@ class CuaDriverSessionProtocol(typing.Protocol): raise NotImplementedError async def get_screen_size(self, input: cua_driver._native_contract.GetScreenSizeInput) -> ToolResult: raise NotImplementedError + async def get_session(self, input: cua_driver._native_contract.GetSessionInput) -> cua_driver._native_contract.SessionOutput: + raise NotImplementedError async def get_session_state(self, input: cua_driver._native_contract.GetSessionStateInput) -> cua_driver._native_contract.SessionStateOutput: raise NotImplementedError + async def get_window_state(self, input: cua_driver._native_contract.GetWindowStateInput) -> ToolResult: + raise NotImplementedError async def hotkey(self, input: cua_driver._native_contract.HotkeyInput) -> ToolResult: raise NotImplementedError async def invoke_menu(self, input: cua_driver._native_contract.InvokeMenuInput) -> ToolResult: raise NotImplementedError + async def list_apps(self, input: cua_driver._native_contract.ListAppsInput) -> ToolResult: + raise NotImplementedError + async def list_sessions(self, input: cua_driver._native_contract.ListSessionsInput) -> cua_driver._native_contract.ListSessionsOutput: + raise NotImplementedError + async def list_windows(self, input: cua_driver._native_contract.ListWindowsInput) -> ToolResult: + raise NotImplementedError async def move_cursor(self, input: cua_driver._native_contract.MoveCursorInput) -> ToolResult: raise NotImplementedError + async def perform_secondary_action(self, input: cua_driver._native_contract.PerformSecondaryActionInput) -> ToolResult: + raise NotImplementedError async def press_key(self, input: cua_driver._native_contract.PressKeyInput) -> ToolResult: raise NotImplementedError + async def right_click(self, input: cua_driver._native_contract.RightClickInput) -> ToolResult: + raise NotImplementedError async def scroll(self, input: cua_driver._native_contract.ScrollInput) -> ToolResult: raise NotImplementedError async def set_agent_cursor_enabled(self, input: cua_driver._native_contract.SetAgentCursorEnabledInput) -> ToolResult: @@ -5179,6 +5882,8 @@ class CuaDriverSessionProtocol(typing.Protocol): raise NotImplementedError async def set_agent_cursor_theme(self, input: cua_driver._native_contract.SetAgentCursorThemeInput) -> ToolResult: raise NotImplementedError + async def set_value(self, input: cua_driver._native_contract.SetValueInput) -> ToolResult: + raise NotImplementedError async def set_window_frame(self, input: cua_driver._native_contract.SetWindowFrameInput) -> ToolResult: raise NotImplementedError async def start_session(self, input: cua_driver._native_contract.StartSessionInput) -> cua_driver._native_contract.StartSessionOutput: @@ -5187,6 +5892,18 @@ class CuaDriverSessionProtocol(typing.Protocol): raise NotImplementedError async def verify_state(self, input: cua_driver._native_contract.VerifyStateInput) -> ToolResult: raise NotImplementedError + async def window_click(self, input: cua_driver._native_contract.WindowClickInput) -> ToolResult: + raise NotImplementedError + async def window_drag(self, input: cua_driver._native_contract.WindowDragInput) -> ToolResult: + raise NotImplementedError + async def window_hotkey(self, input: cua_driver._native_contract.WindowHotkeyInput) -> ToolResult: + raise NotImplementedError + async def window_press_key(self, input: cua_driver._native_contract.WindowPressKeyInput) -> ToolResult: + raise NotImplementedError + async def window_scroll(self, input: cua_driver._native_contract.WindowScrollInput) -> ToolResult: + raise NotImplementedError + async def window_type_text(self, input: cua_driver._native_contract.WindowTypeTextInput) -> ToolResult: + raise NotImplementedError class CuaDriverSession(CuaDriverSessionProtocol): @@ -5301,6 +6018,23 @@ class CuaDriverSession(CuaDriverSessionProtocol): *_uniffi_lowered_args, ) return _uniffi_lift_return(_uniffi_ffi_result) + async def double_click(self, input: cua_driver._native_contract.DoubleClickInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeDoubleClickInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeDoubleClickInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_double_click(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def drag(self, input: cua_driver._native_contract.DragInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypeDragInput.check_lower(input) @@ -5420,6 +6154,23 @@ class CuaDriverSession(CuaDriverSessionProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def get_session(self, input: cua_driver._native_contract.GetSessionInput) -> cua_driver._native_contract.SessionOutput: + + cua_driver._native_contract._UniffiFfiConverterTypeGetSessionInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeGetSessionInput.lower(input), + ) + _uniffi_lift_return = cua_driver._native_contract._UniffiFfiConverterTypeSessionOutput.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_session(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def get_session_state(self, input: cua_driver._native_contract.GetSessionStateInput) -> cua_driver._native_contract.SessionStateOutput: cua_driver._native_contract._UniffiFfiConverterTypeGetSessionStateInput.check_lower(input) @@ -5437,6 +6188,23 @@ class CuaDriverSession(CuaDriverSessionProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def get_window_state(self, input: cua_driver._native_contract.GetWindowStateInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeGetWindowStateInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeGetWindowStateInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_get_window_state(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def hotkey(self, input: cua_driver._native_contract.HotkeyInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypeHotkeyInput.check_lower(input) @@ -5471,6 +6239,57 @@ class CuaDriverSession(CuaDriverSessionProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def list_apps(self, input: cua_driver._native_contract.ListAppsInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeListAppsInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeListAppsInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_list_apps(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def list_sessions(self, input: cua_driver._native_contract.ListSessionsInput) -> cua_driver._native_contract.ListSessionsOutput: + + cua_driver._native_contract._UniffiFfiConverterTypeListSessionsInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeListSessionsInput.lower(input), + ) + _uniffi_lift_return = cua_driver._native_contract._UniffiFfiConverterTypeListSessionsOutput.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_list_sessions(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def list_windows(self, input: cua_driver._native_contract.ListWindowsInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeListWindowsInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeListWindowsInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_list_windows(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def move_cursor(self, input: cua_driver._native_contract.MoveCursorInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypeMoveCursorInput.check_lower(input) @@ -5488,6 +6307,23 @@ class CuaDriverSession(CuaDriverSessionProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def perform_secondary_action(self, input: cua_driver._native_contract.PerformSecondaryActionInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypePerformSecondaryActionInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypePerformSecondaryActionInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_perform_secondary_action(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def press_key(self, input: cua_driver._native_contract.PressKeyInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypePressKeyInput.check_lower(input) @@ -5505,6 +6341,23 @@ class CuaDriverSession(CuaDriverSessionProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def right_click(self, input: cua_driver._native_contract.RightClickInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeRightClickInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeRightClickInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_right_click(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def scroll(self, input: cua_driver._native_contract.ScrollInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypeScrollInput.check_lower(input) @@ -5573,6 +6426,23 @@ class CuaDriverSession(CuaDriverSessionProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def set_value(self, input: cua_driver._native_contract.SetValueInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeSetValueInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeSetValueInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_set_value(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) async def set_window_frame(self, input: cua_driver._native_contract.SetWindowFrameInput) -> ToolResult: cua_driver._native_contract._UniffiFfiConverterTypeSetWindowFrameInput.check_lower(input) @@ -5641,6 +6511,108 @@ class CuaDriverSession(CuaDriverSessionProtocol): _uniffi_lift_return, _uniffi_error_converter, ) + async def window_click(self, input: cua_driver._native_contract.WindowClickInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowClickInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowClickInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_click(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_drag(self, input: cua_driver._native_contract.WindowDragInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowDragInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowDragInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_drag(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_hotkey(self, input: cua_driver._native_contract.WindowHotkeyInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowHotkeyInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowHotkeyInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_hotkey(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_press_key(self, input: cua_driver._native_contract.WindowPressKeyInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowPressKeyInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowPressKeyInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_press_key(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_scroll(self, input: cua_driver._native_contract.WindowScrollInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowScrollInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowScrollInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_scroll(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) + async def window_type_text(self, input: cua_driver._native_contract.WindowTypeTextInput) -> ToolResult: + + cua_driver._native_contract._UniffiFfiConverterTypeWindowTypeTextInput.check_lower(input) + _uniffi_lowered_args = ( + self._uniffi_clone_handle(), + cua_driver._native_contract._UniffiFfiConverterTypeWindowTypeTextInput.lower(input), + ) + _uniffi_lift_return = _UniffiFfiConverterTypeToolResult.lift + _uniffi_error_converter = _UniffiFfiConverterTypeDriverError + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_cua_driver_sdk_fn_method_cuadriversession_window_type_text(*_uniffi_lowered_args), + _UniffiLib.ffi_cua_driver_sdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_cua_driver_sdk_rust_future_free_rust_buffer, + _uniffi_lift_return, + _uniffi_error_converter, + ) diff --git a/packages/cua-driver/python/src/cua_driver/_native_contract.py b/packages/cua-driver/python/src/cua_driver/_native_contract.py index 2a2c4c3d0f..0b5d528ad1 100644 --- a/packages/cua-driver/python/src/cua_driver/_native_contract.py +++ b/packages/cua-driver/python/src/cua_driver/_native_contract.py @@ -1486,37 +1486,18 @@ class _UniffiFfiConverterTypeBoundsExpectation(_UniffiConverterRustBuffer): _UniffiFfiConverterFloat64.write(value.height, buf) _UniffiFfiConverterOptionalFloat64.write(value.tolerance_px, buf) +class _UniffiFfiConverterUInt64(_UniffiConverterPrimitiveInt): + CLASS_NAME = "u64" + VALUE_MIN = 0 + VALUE_MAX = 2**64 - - - - -class DesktopScope(enum.Enum): - - DESKTOP = 0 - - - -class _UniffiFfiConverterTypeDesktopScope(_UniffiConverterRustBuffer): @staticmethod def read(buf): - variant = buf.read_i32() - if variant == 1: - return DesktopScope.DESKTOP - raise InternalError("Raw enum value doesn't match any cases") - - @staticmethod - def check_lower(value): - if value == DesktopScope.DESKTOP: - return - raise ValueError(value) + return buf.read_u64() @staticmethod def write(value, buf): - if value == DesktopScope.DESKTOP: - buf.write_i32(1) - - + buf.write_u64(value) class _UniffiFfiConverterString: @staticmethod @@ -1550,6 +1531,219 @@ class _UniffiFfiConverterString: builder.write(value.encode("utf-8")) return builder.finalize() + + + + + +class ActionTarget: + """ + Exact capture/input target selected independently for each action. + + `display_id="primary"` is the portable desktop target in this release. + Platforms that cannot address another display reject it explicitly rather + than silently changing coordinate spaces. +""" + def __init__(self): + raise RuntimeError("ActionTarget cannot be instantiated directly") + + # Each enum variant is a nested class of the enum itself. + @dataclass + class WINDOW: + + def __init__(self, pid:int, window_id:int): + self.pid = pid + + + self.window_id = window_id + + + pass + + + + + + def __str__(self): + return "ActionTarget.WINDOW(pid={}, window_id={})".format(self.pid, self.window_id) + def __eq__(self, other): + if not isinstance(other, ActionTarget): + return NotImplemented + if not other.is_WINDOW(): + return False + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + return True + + @dataclass + class DESKTOP: + + def __init__(self, display_id:str): + self.display_id = display_id + + + pass + + + + + + def __str__(self): + return "ActionTarget.DESKTOP(display_id={})".format(self.display_id) + def __eq__(self, other): + if not isinstance(other, ActionTarget): + return NotImplemented + if not other.is_DESKTOP(): + return False + if self.display_id != other.display_id: + return False + return True + + + + # For each variant, we have `is_NAME` and `is_name` methods for easily checking + # whether an instance is that variant. + def is_WINDOW(self) -> bool: + return isinstance(self, ActionTarget.WINDOW) + def is_window(self) -> bool: + return isinstance(self, ActionTarget.WINDOW) + def is_DESKTOP(self) -> bool: + return isinstance(self, ActionTarget.DESKTOP) + def is_desktop(self) -> bool: + return isinstance(self, ActionTarget.DESKTOP) + + +# Now, a little trick - we make each nested variant class be a subclass of the main +# enum class, so that method calls and instance checks etc will work intuitively. +# We might be able to do this a little more neatly with a metaclass, but this'll do. +ActionTarget.WINDOW = type("ActionTarget.WINDOW", (ActionTarget.WINDOW, ActionTarget,), {}) # type: ignore +ActionTarget.DESKTOP = type("ActionTarget.DESKTOP", (ActionTarget.DESKTOP, ActionTarget,), {}) # type: ignore + + + + +class _UniffiFfiConverterTypeActionTarget(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return ActionTarget.WINDOW( + _UniffiFfiConverterUInt32.read(buf), + _UniffiFfiConverterUInt64.read(buf), + ) + if variant == 2: + return ActionTarget.DESKTOP( + _UniffiFfiConverterString.read(buf), + ) + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value.is_WINDOW(): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterUInt64.check_lower(value.window_id) + return + if value.is_DESKTOP(): + _UniffiFfiConverterString.check_lower(value.display_id) + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value.is_WINDOW(): + buf.write_i32(1) + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterUInt64.write(value.window_id, buf) + if value.is_DESKTOP(): + buf.write_i32(2) + _UniffiFfiConverterString.write(value.display_id, buf) + + + +class _UniffiFfiConverterOptionalTypeActionTarget(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + if value is not None: + _UniffiFfiConverterTypeActionTarget.check_lower(value) + + @classmethod + def write(cls, value, buf): + if value is None: + buf.write_u8(0) + return + + buf.write_u8(1) + _UniffiFfiConverterTypeActionTarget.write(value, buf) + + @classmethod + def read(cls, buf): + flag = buf.read_u8() + if flag == 0: + return None + elif flag == 1: + return _UniffiFfiConverterTypeActionTarget.read(buf) + else: + raise InternalError("Unexpected flag byte for optional type") + + + + + + +class DesktopScope(enum.Enum): + + DESKTOP = 0 + + + +class _UniffiFfiConverterTypeDesktopScope(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return DesktopScope.DESKTOP + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value == DesktopScope.DESKTOP: + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value == DesktopScope.DESKTOP: + buf.write_i32(1) + + + +class _UniffiFfiConverterOptionalTypeDesktopScope(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + if value is not None: + _UniffiFfiConverterTypeDesktopScope.check_lower(value) + + @classmethod + def write(cls, value, buf): + if value is None: + buf.write_u8(0) + return + + buf.write_u8(1) + _UniffiFfiConverterTypeDesktopScope.write(value, buf) + + @classmethod + def read(cls, buf): + flag = buf.read_u8() + if flag == 0: + return None + elif flag == 1: + return _UniffiFfiConverterTypeDesktopScope.read(buf) + else: + raise InternalError("Unexpected flag byte for optional type") + class _UniffiFfiConverterOptionalString(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -1650,9 +1844,10 @@ class _UniffiFfiConverterOptionalTypeClickButton(_UniffiConverterRustBuffer): @dataclass class ClickInput: - def __init__(self, *, x:float, y:float, scope:DesktopScope, session:typing.Optional[str], button:typing.Optional[ClickButton], count:typing.Optional[int]): + def __init__(self, *, x:float, y:float, target:typing.Optional[ActionTarget], scope:typing.Optional[DesktopScope], session:typing.Optional[str], button:typing.Optional[ClickButton], count:typing.Optional[int]): self.x = x self.y = y + self.target = target self.scope = scope self.session = session self.button = button @@ -1662,12 +1857,14 @@ class ClickInput: def __str__(self): - return "ClickInput(x={}, y={}, scope={}, session={}, button={}, count={})".format(self.x, self.y, self.scope, self.session, self.button, self.count) + return "ClickInput(x={}, y={}, target={}, scope={}, session={}, button={}, count={})".format(self.x, self.y, self.target, self.scope, self.session, self.button, self.count) def __eq__(self, other): if self.x != other.x: return False if self.y != other.y: return False + if self.target != other.target: + return False if self.scope != other.scope: return False if self.session != other.session: @@ -1684,7 +1881,8 @@ class _UniffiFfiConverterTypeClickInput(_UniffiConverterRustBuffer): return ClickInput( x=_UniffiFfiConverterFloat64.read(buf), y=_UniffiFfiConverterFloat64.read(buf), - scope=_UniffiFfiConverterTypeDesktopScope.read(buf), + target=_UniffiFfiConverterOptionalTypeActionTarget.read(buf), + scope=_UniffiFfiConverterOptionalTypeDesktopScope.read(buf), session=_UniffiFfiConverterOptionalString.read(buf), button=_UniffiFfiConverterOptionalTypeClickButton.read(buf), count=_UniffiFfiConverterOptionalUInt32.read(buf), @@ -1694,7 +1892,8 @@ class _UniffiFfiConverterTypeClickInput(_UniffiConverterRustBuffer): def check_lower(value): _UniffiFfiConverterFloat64.check_lower(value.x) _UniffiFfiConverterFloat64.check_lower(value.y) - _UniffiFfiConverterTypeDesktopScope.check_lower(value.scope) + _UniffiFfiConverterOptionalTypeActionTarget.check_lower(value.target) + _UniffiFfiConverterOptionalTypeDesktopScope.check_lower(value.scope) _UniffiFfiConverterOptionalString.check_lower(value.session) _UniffiFfiConverterOptionalTypeClickButton.check_lower(value.button) _UniffiFfiConverterOptionalUInt32.check_lower(value.count) @@ -1703,7 +1902,8 @@ class _UniffiFfiConverterTypeClickInput(_UniffiConverterRustBuffer): def write(value, buf): _UniffiFfiConverterFloat64.write(value.x, buf) _UniffiFfiConverterFloat64.write(value.y, buf) - _UniffiFfiConverterTypeDesktopScope.write(value.scope, buf) + _UniffiFfiConverterOptionalTypeActionTarget.write(value.target, buf) + _UniffiFfiConverterOptionalTypeDesktopScope.write(value.scope, buf) _UniffiFfiConverterOptionalString.write(value.session, buf) _UniffiFfiConverterOptionalTypeClickButton.write(value.button, buf) _UniffiFfiConverterOptionalUInt32.write(value.count, buf) @@ -2316,19 +2516,6 @@ class _UniffiFfiConverterTypeCursorAction(_UniffiConverterRustBuffer): -class _UniffiFfiConverterUInt64(_UniffiConverterPrimitiveInt): - CLASS_NAME = "u64" - VALUE_MIN = 0 - VALUE_MAX = 2**64 - - @staticmethod - def read(buf): - return buf.read_u64() - - @staticmethod - def write(value, buf): - buf.write_u64(value) - @dataclass class CursorVisualOutput: def __init__(self, *, requested_action:CursorAction, resolved_action:CursorAction, modifiers:typing.List[str], phase:str, frame:int, preempted_count:int): @@ -2414,6 +2601,60 @@ class _UniffiFfiConverterOptionalUInt64(_UniffiConverterRustBuffer): else: raise InternalError("Unexpected flag byte for optional type") +@dataclass +class DoubleClickInput: + def __init__(self, *, pid:int, window_id:typing.Optional[int], element_token:typing.Optional[str], x:typing.Optional[float], y:typing.Optional[float]): + self.pid = pid + self.window_id = window_id + self.element_token = element_token + self.x = x + self.y = y + + + + + def __str__(self): + return "DoubleClickInput(pid={}, window_id={}, element_token={}, x={}, y={})".format(self.pid, self.window_id, self.element_token, self.x, self.y) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.element_token != other.element_token: + return False + if self.x != other.x: + return False + if self.y != other.y: + return False + return True + +class _UniffiFfiConverterTypeDoubleClickInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return DoubleClickInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterOptionalUInt64.read(buf), + element_token=_UniffiFfiConverterOptionalString.read(buf), + x=_UniffiFfiConverterOptionalFloat64.read(buf), + y=_UniffiFfiConverterOptionalFloat64.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalUInt64.check_lower(value.window_id) + _UniffiFfiConverterOptionalString.check_lower(value.element_token) + _UniffiFfiConverterOptionalFloat64.check_lower(value.x) + _UniffiFfiConverterOptionalFloat64.check_lower(value.y) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalUInt64.write(value.window_id, buf) + _UniffiFfiConverterOptionalString.write(value.element_token, buf) + _UniffiFfiConverterOptionalFloat64.write(value.x, buf) + _UniffiFfiConverterOptionalFloat64.write(value.y, buf) + class _UniffiFfiConverterOptionalSequenceString(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -2441,11 +2682,12 @@ class _UniffiFfiConverterOptionalSequenceString(_UniffiConverterRustBuffer): @dataclass class DragInput: - def __init__(self, *, from_x:float, from_y:float, to_x:float, to_y:float, scope:DesktopScope, session:typing.Optional[str], duration_ms:typing.Optional[int], steps:typing.Optional[int], button:typing.Optional[ClickButton], modifier:typing.Optional[typing.List[str]]): + def __init__(self, *, from_x:float, from_y:float, to_x:float, to_y:float, target:typing.Optional[ActionTarget], scope:typing.Optional[DesktopScope], session:typing.Optional[str], duration_ms:typing.Optional[int], steps:typing.Optional[int], button:typing.Optional[ClickButton], modifier:typing.Optional[typing.List[str]]): self.from_x = from_x self.from_y = from_y self.to_x = to_x self.to_y = to_y + self.target = target self.scope = scope self.session = session self.duration_ms = duration_ms @@ -2457,7 +2699,7 @@ class DragInput: def __str__(self): - return "DragInput(from_x={}, from_y={}, to_x={}, to_y={}, scope={}, session={}, duration_ms={}, steps={}, button={}, modifier={})".format(self.from_x, self.from_y, self.to_x, self.to_y, self.scope, self.session, self.duration_ms, self.steps, self.button, self.modifier) + return "DragInput(from_x={}, from_y={}, to_x={}, to_y={}, target={}, scope={}, session={}, duration_ms={}, steps={}, button={}, modifier={})".format(self.from_x, self.from_y, self.to_x, self.to_y, self.target, self.scope, self.session, self.duration_ms, self.steps, self.button, self.modifier) def __eq__(self, other): if self.from_x != other.from_x: return False @@ -2467,6 +2709,8 @@ class DragInput: return False if self.to_y != other.to_y: return False + if self.target != other.target: + return False if self.scope != other.scope: return False if self.session != other.session: @@ -2489,7 +2733,8 @@ class _UniffiFfiConverterTypeDragInput(_UniffiConverterRustBuffer): from_y=_UniffiFfiConverterFloat64.read(buf), to_x=_UniffiFfiConverterFloat64.read(buf), to_y=_UniffiFfiConverterFloat64.read(buf), - scope=_UniffiFfiConverterTypeDesktopScope.read(buf), + target=_UniffiFfiConverterOptionalTypeActionTarget.read(buf), + scope=_UniffiFfiConverterOptionalTypeDesktopScope.read(buf), session=_UniffiFfiConverterOptionalString.read(buf), duration_ms=_UniffiFfiConverterOptionalUInt64.read(buf), steps=_UniffiFfiConverterOptionalUInt64.read(buf), @@ -2503,7 +2748,8 @@ class _UniffiFfiConverterTypeDragInput(_UniffiConverterRustBuffer): _UniffiFfiConverterFloat64.check_lower(value.from_y) _UniffiFfiConverterFloat64.check_lower(value.to_x) _UniffiFfiConverterFloat64.check_lower(value.to_y) - _UniffiFfiConverterTypeDesktopScope.check_lower(value.scope) + _UniffiFfiConverterOptionalTypeActionTarget.check_lower(value.target) + _UniffiFfiConverterOptionalTypeDesktopScope.check_lower(value.scope) _UniffiFfiConverterOptionalString.check_lower(value.session) _UniffiFfiConverterOptionalUInt64.check_lower(value.duration_ms) _UniffiFfiConverterOptionalUInt64.check_lower(value.steps) @@ -2516,7 +2762,8 @@ class _UniffiFfiConverterTypeDragInput(_UniffiConverterRustBuffer): _UniffiFfiConverterFloat64.write(value.from_y, buf) _UniffiFfiConverterFloat64.write(value.to_x, buf) _UniffiFfiConverterFloat64.write(value.to_y, buf) - _UniffiFfiConverterTypeDesktopScope.write(value.scope, buf) + _UniffiFfiConverterOptionalTypeActionTarget.write(value.target, buf) + _UniffiFfiConverterOptionalTypeDesktopScope.write(value.scope, buf) _UniffiFfiConverterOptionalString.write(value.session, buf) _UniffiFfiConverterOptionalUInt64.write(value.duration_ms, buf) _UniffiFfiConverterOptionalUInt64.write(value.steps, buf) @@ -2640,7 +2887,7 @@ class _UniffiFfiConverterTypeElementPredicate(_UniffiConverterRustBuffer): @dataclass class EndSessionInput: - def __init__(self, *, session:str): + def __init__(self, *, session:typing.Optional[str]): self.session = session @@ -2657,16 +2904,16 @@ class _UniffiFfiConverterTypeEndSessionInput(_UniffiConverterRustBuffer): @staticmethod def read(buf): return EndSessionInput( - session=_UniffiFfiConverterString.read(buf), + session=_UniffiFfiConverterOptionalString.read(buf), ) @staticmethod def check_lower(value): - _UniffiFfiConverterString.check_lower(value.session) + _UniffiFfiConverterOptionalString.check_lower(value.session) @staticmethod def write(value, buf): - _UniffiFfiConverterString.write(value.session, buf) + _UniffiFfiConverterOptionalString.write(value.session, buf) @dataclass class EndSessionOutput: @@ -3024,9 +3271,39 @@ class _UniffiFfiConverterTypeGetScreenSizeInput(_UniffiConverterRustBuffer): def write(value, buf): _UniffiFfiConverterOptionalString.write(value.session, buf) +@dataclass +class GetSessionInput: + def __init__(self, *, session:typing.Optional[str]): + self.session = session + + + + + def __str__(self): + return "GetSessionInput(session={})".format(self.session) + def __eq__(self, other): + if self.session != other.session: + return False + return True + +class _UniffiFfiConverterTypeGetSessionInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return GetSessionInput( + session=_UniffiFfiConverterOptionalString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterOptionalString.check_lower(value.session) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterOptionalString.write(value.session, buf) + @dataclass class GetSessionStateInput: - def __init__(self, *, session:str): + def __init__(self, *, session:typing.Optional[str]): self.session = session @@ -3043,21 +3320,184 @@ class _UniffiFfiConverterTypeGetSessionStateInput(_UniffiConverterRustBuffer): @staticmethod def read(buf): return GetSessionStateInput( - session=_UniffiFfiConverterString.read(buf), + session=_UniffiFfiConverterOptionalString.read(buf), ) @staticmethod def check_lower(value): - _UniffiFfiConverterString.check_lower(value.session) + _UniffiFfiConverterOptionalString.check_lower(value.session) @staticmethod def write(value, buf): - _UniffiFfiConverterString.write(value.session, buf) + _UniffiFfiConverterOptionalString.write(value.session, buf) + +@dataclass +class ObservationRevisionInput: + """ + Opt in to the versioned `accessibility.observation_revision.v1` protocol on + `get_window_state`. Omitting the whole record preserves the legacy + full-snapshot contract byte for byte. +""" + def __init__(self, *, version:int, serializer_version:str, projection_version:str, base_revision_id:typing.Optional[str], force_full:typing.Optional[bool]): + self.version = version + self.serializer_version = serializer_version + self.projection_version = projection_version + self.base_revision_id = base_revision_id + self.force_full = force_full + + + + + def __str__(self): + return "ObservationRevisionInput(version={}, serializer_version={}, projection_version={}, base_revision_id={}, force_full={})".format(self.version, self.serializer_version, self.projection_version, self.base_revision_id, self.force_full) + def __eq__(self, other): + if self.version != other.version: + return False + if self.serializer_version != other.serializer_version: + return False + if self.projection_version != other.projection_version: + return False + if self.base_revision_id != other.base_revision_id: + return False + if self.force_full != other.force_full: + return False + return True + +class _UniffiFfiConverterTypeObservationRevisionInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return ObservationRevisionInput( + version=_UniffiFfiConverterUInt32.read(buf), + serializer_version=_UniffiFfiConverterString.read(buf), + projection_version=_UniffiFfiConverterString.read(buf), + base_revision_id=_UniffiFfiConverterOptionalString.read(buf), + force_full=_UniffiFfiConverterOptionalBoolean.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.version) + _UniffiFfiConverterString.check_lower(value.serializer_version) + _UniffiFfiConverterString.check_lower(value.projection_version) + _UniffiFfiConverterOptionalString.check_lower(value.base_revision_id) + _UniffiFfiConverterOptionalBoolean.check_lower(value.force_full) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.version, buf) + _UniffiFfiConverterString.write(value.serializer_version, buf) + _UniffiFfiConverterString.write(value.projection_version, buf) + _UniffiFfiConverterOptionalString.write(value.base_revision_id, buf) + _UniffiFfiConverterOptionalBoolean.write(value.force_full, buf) + +class _UniffiFfiConverterOptionalTypeObservationRevisionInput(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + if value is not None: + _UniffiFfiConverterTypeObservationRevisionInput.check_lower(value) + + @classmethod + def write(cls, value, buf): + if value is None: + buf.write_u8(0) + return + + buf.write_u8(1) + _UniffiFfiConverterTypeObservationRevisionInput.write(value, buf) + + @classmethod + def read(cls, buf): + flag = buf.read_u8() + if flag == 0: + return None + elif flag == 1: + return _UniffiFfiConverterTypeObservationRevisionInput.read(buf) + else: + raise InternalError("Unexpected flag byte for optional type") + +@dataclass +class GetWindowStateInput: + def __init__(self, *, pid:int, window_id:int, session:typing.Optional[str], query:typing.Optional[str], include_screenshot:typing.Optional[bool], screenshot_out_file:typing.Optional[str], max_elements:typing.Optional[int], max_depth:typing.Optional[int], observation_revision:typing.Optional[ObservationRevisionInput]): + self.pid = pid + self.window_id = window_id + self.session = session + self.query = query + self.include_screenshot = include_screenshot + self.screenshot_out_file = screenshot_out_file + self.max_elements = max_elements + self.max_depth = max_depth + self.observation_revision = observation_revision + + + + + def __str__(self): + return "GetWindowStateInput(pid={}, window_id={}, session={}, query={}, include_screenshot={}, screenshot_out_file={}, max_elements={}, max_depth={}, observation_revision={})".format(self.pid, self.window_id, self.session, self.query, self.include_screenshot, self.screenshot_out_file, self.max_elements, self.max_depth, self.observation_revision) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.session != other.session: + return False + if self.query != other.query: + return False + if self.include_screenshot != other.include_screenshot: + return False + if self.screenshot_out_file != other.screenshot_out_file: + return False + if self.max_elements != other.max_elements: + return False + if self.max_depth != other.max_depth: + return False + if self.observation_revision != other.observation_revision: + return False + return True + +class _UniffiFfiConverterTypeGetWindowStateInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return GetWindowStateInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterUInt64.read(buf), + session=_UniffiFfiConverterOptionalString.read(buf), + query=_UniffiFfiConverterOptionalString.read(buf), + include_screenshot=_UniffiFfiConverterOptionalBoolean.read(buf), + screenshot_out_file=_UniffiFfiConverterOptionalString.read(buf), + max_elements=_UniffiFfiConverterOptionalUInt32.read(buf), + max_depth=_UniffiFfiConverterOptionalUInt32.read(buf), + observation_revision=_UniffiFfiConverterOptionalTypeObservationRevisionInput.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterUInt64.check_lower(value.window_id) + _UniffiFfiConverterOptionalString.check_lower(value.session) + _UniffiFfiConverterOptionalString.check_lower(value.query) + _UniffiFfiConverterOptionalBoolean.check_lower(value.include_screenshot) + _UniffiFfiConverterOptionalString.check_lower(value.screenshot_out_file) + _UniffiFfiConverterOptionalUInt32.check_lower(value.max_elements) + _UniffiFfiConverterOptionalUInt32.check_lower(value.max_depth) + _UniffiFfiConverterOptionalTypeObservationRevisionInput.check_lower(value.observation_revision) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterUInt64.write(value.window_id, buf) + _UniffiFfiConverterOptionalString.write(value.session, buf) + _UniffiFfiConverterOptionalString.write(value.query, buf) + _UniffiFfiConverterOptionalBoolean.write(value.include_screenshot, buf) + _UniffiFfiConverterOptionalString.write(value.screenshot_out_file, buf) + _UniffiFfiConverterOptionalUInt32.write(value.max_elements, buf) + _UniffiFfiConverterOptionalUInt32.write(value.max_depth, buf) + _UniffiFfiConverterOptionalTypeObservationRevisionInput.write(value.observation_revision, buf) @dataclass class HotkeyInput: - def __init__(self, *, keys:typing.List[str], scope:DesktopScope, session:typing.Optional[str]): + def __init__(self, *, keys:typing.List[str], target:typing.Optional[ActionTarget], scope:typing.Optional[DesktopScope], session:typing.Optional[str]): self.keys = keys + self.target = target self.scope = scope self.session = session @@ -3065,10 +3505,12 @@ class HotkeyInput: def __str__(self): - return "HotkeyInput(keys={}, scope={}, session={})".format(self.keys, self.scope, self.session) + return "HotkeyInput(keys={}, target={}, scope={}, session={})".format(self.keys, self.target, self.scope, self.session) def __eq__(self, other): if self.keys != other.keys: return False + if self.target != other.target: + return False if self.scope != other.scope: return False if self.session != other.session: @@ -3080,20 +3522,23 @@ class _UniffiFfiConverterTypeHotkeyInput(_UniffiConverterRustBuffer): def read(buf): return HotkeyInput( keys=_UniffiFfiConverterSequenceString.read(buf), - scope=_UniffiFfiConverterTypeDesktopScope.read(buf), + target=_UniffiFfiConverterOptionalTypeActionTarget.read(buf), + scope=_UniffiFfiConverterOptionalTypeDesktopScope.read(buf), session=_UniffiFfiConverterOptionalString.read(buf), ) @staticmethod def check_lower(value): _UniffiFfiConverterSequenceString.check_lower(value.keys) - _UniffiFfiConverterTypeDesktopScope.check_lower(value.scope) + _UniffiFfiConverterOptionalTypeActionTarget.check_lower(value.target) + _UniffiFfiConverterOptionalTypeDesktopScope.check_lower(value.scope) _UniffiFfiConverterOptionalString.check_lower(value.session) @staticmethod def write(value, buf): _UniffiFfiConverterSequenceString.write(value.keys, buf) - _UniffiFfiConverterTypeDesktopScope.write(value.scope, buf) + _UniffiFfiConverterOptionalTypeActionTarget.write(value.target, buf) + _UniffiFfiConverterOptionalTypeDesktopScope.write(value.scope, buf) _UniffiFfiConverterOptionalString.write(value.session, buf) @dataclass @@ -3149,11 +3594,410 @@ class _UniffiFfiConverterTypeInvokeMenuInput(_UniffiConverterRustBuffer): _UniffiFfiConverterSequenceString.write(value.path, buf) _UniffiFfiConverterOptionalString.write(value.session, buf) +@dataclass +class ListAppsInput: + + + + + + def __str__(self): + return "ListAppsInput()".format() + def __eq__(self, other): + return True + +class _UniffiFfiConverterTypeListAppsInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return ListAppsInput( + ) + + @staticmethod + def check_lower(value): + pass + + @staticmethod + def write(value, buf): + pass + +@dataclass +class ListSessionsInput: + def __init__(self, *, limit:typing.Optional[int], cursor:typing.Optional[str]): + self.limit = limit + self.cursor = cursor + + + + + def __str__(self): + return "ListSessionsInput(limit={}, cursor={})".format(self.limit, self.cursor) + def __eq__(self, other): + if self.limit != other.limit: + return False + if self.cursor != other.cursor: + return False + return True + +class _UniffiFfiConverterTypeListSessionsInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return ListSessionsInput( + limit=_UniffiFfiConverterOptionalUInt32.read(buf), + cursor=_UniffiFfiConverterOptionalString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterOptionalUInt32.check_lower(value.limit) + _UniffiFfiConverterOptionalString.check_lower(value.cursor) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterOptionalUInt32.write(value.limit, buf) + _UniffiFfiConverterOptionalString.write(value.cursor, buf) + + + + + + +class SessionLifecycleState(enum.Enum): + + ACTIVE = 0 + + ENDING = 1 + + + +class _UniffiFfiConverterTypeSessionLifecycleState(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return SessionLifecycleState.ACTIVE + if variant == 2: + return SessionLifecycleState.ENDING + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value == SessionLifecycleState.ACTIVE: + return + if value == SessionLifecycleState.ENDING: + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value == SessionLifecycleState.ACTIVE: + buf.write_i32(1) + if value == SessionLifecycleState.ENDING: + buf.write_i32(2) + + + + + + + + +class SessionClientKindOutput(enum.Enum): + + CLI = 0 + + DIRECT = 1 + + MCP = 2 + + PYTHON_SDK = 3 + + TYPESCRIPT_SDK = 4 + + + +class _UniffiFfiConverterTypeSessionClientKindOutput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return SessionClientKindOutput.CLI + if variant == 2: + return SessionClientKindOutput.DIRECT + if variant == 3: + return SessionClientKindOutput.MCP + if variant == 4: + return SessionClientKindOutput.PYTHON_SDK + if variant == 5: + return SessionClientKindOutput.TYPESCRIPT_SDK + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value == SessionClientKindOutput.CLI: + return + if value == SessionClientKindOutput.DIRECT: + return + if value == SessionClientKindOutput.MCP: + return + if value == SessionClientKindOutput.PYTHON_SDK: + return + if value == SessionClientKindOutput.TYPESCRIPT_SDK: + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value == SessionClientKindOutput.CLI: + buf.write_i32(1) + if value == SessionClientKindOutput.DIRECT: + buf.write_i32(2) + if value == SessionClientKindOutput.MCP: + buf.write_i32(3) + if value == SessionClientKindOutput.PYTHON_SDK: + buf.write_i32(4) + if value == SessionClientKindOutput.TYPESCRIPT_SDK: + buf.write_i32(5) + + + + + + + + +class SessionTransportOutput(enum.Enum): + + CLI = 0 + + DAEMON = 1 + + MCP_STDIO = 2 + + MCP_HTTP = 3 + + + +class _UniffiFfiConverterTypeSessionTransportOutput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return SessionTransportOutput.CLI + if variant == 2: + return SessionTransportOutput.DAEMON + if variant == 3: + return SessionTransportOutput.MCP_STDIO + if variant == 4: + return SessionTransportOutput.MCP_HTTP + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value == SessionTransportOutput.CLI: + return + if value == SessionTransportOutput.DAEMON: + return + if value == SessionTransportOutput.MCP_STDIO: + return + if value == SessionTransportOutput.MCP_HTTP: + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value == SessionTransportOutput.CLI: + buf.write_i32(1) + if value == SessionTransportOutput.DAEMON: + buf.write_i32(2) + if value == SessionTransportOutput.MCP_STDIO: + buf.write_i32(3) + if value == SessionTransportOutput.MCP_HTTP: + buf.write_i32(4) + + + +@dataclass +class SessionOutput: + """ + Content-free lifecycle state safe for an ordinary agent transport. +""" + def __init__(self, *, session:typing.Optional[str], implicit:bool, state:SessionLifecycleState, client_kind:SessionClientKindOutput, transport:SessionTransportOutput, cursor_visible:bool, recording_active:bool, idle_seconds:int, expires_in_seconds:int): + self.session = session + self.implicit = implicit + self.state = state + self.client_kind = client_kind + self.transport = transport + self.cursor_visible = cursor_visible + self.recording_active = recording_active + self.idle_seconds = idle_seconds + self.expires_in_seconds = expires_in_seconds + + + + + def __str__(self): + return "SessionOutput(session={}, implicit={}, state={}, client_kind={}, transport={}, cursor_visible={}, recording_active={}, idle_seconds={}, expires_in_seconds={})".format(self.session, self.implicit, self.state, self.client_kind, self.transport, self.cursor_visible, self.recording_active, self.idle_seconds, self.expires_in_seconds) + def __eq__(self, other): + if self.session != other.session: + return False + if self.implicit != other.implicit: + return False + if self.state != other.state: + return False + if self.client_kind != other.client_kind: + return False + if self.transport != other.transport: + return False + if self.cursor_visible != other.cursor_visible: + return False + if self.recording_active != other.recording_active: + return False + if self.idle_seconds != other.idle_seconds: + return False + if self.expires_in_seconds != other.expires_in_seconds: + return False + return True + +class _UniffiFfiConverterTypeSessionOutput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return SessionOutput( + session=_UniffiFfiConverterOptionalString.read(buf), + implicit=_UniffiFfiConverterBoolean.read(buf), + state=_UniffiFfiConverterTypeSessionLifecycleState.read(buf), + client_kind=_UniffiFfiConverterTypeSessionClientKindOutput.read(buf), + transport=_UniffiFfiConverterTypeSessionTransportOutput.read(buf), + cursor_visible=_UniffiFfiConverterBoolean.read(buf), + recording_active=_UniffiFfiConverterBoolean.read(buf), + idle_seconds=_UniffiFfiConverterUInt64.read(buf), + expires_in_seconds=_UniffiFfiConverterUInt64.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterOptionalString.check_lower(value.session) + _UniffiFfiConverterBoolean.check_lower(value.implicit) + _UniffiFfiConverterTypeSessionLifecycleState.check_lower(value.state) + _UniffiFfiConverterTypeSessionClientKindOutput.check_lower(value.client_kind) + _UniffiFfiConverterTypeSessionTransportOutput.check_lower(value.transport) + _UniffiFfiConverterBoolean.check_lower(value.cursor_visible) + _UniffiFfiConverterBoolean.check_lower(value.recording_active) + _UniffiFfiConverterUInt64.check_lower(value.idle_seconds) + _UniffiFfiConverterUInt64.check_lower(value.expires_in_seconds) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterOptionalString.write(value.session, buf) + _UniffiFfiConverterBoolean.write(value.implicit, buf) + _UniffiFfiConverterTypeSessionLifecycleState.write(value.state, buf) + _UniffiFfiConverterTypeSessionClientKindOutput.write(value.client_kind, buf) + _UniffiFfiConverterTypeSessionTransportOutput.write(value.transport, buf) + _UniffiFfiConverterBoolean.write(value.cursor_visible, buf) + _UniffiFfiConverterBoolean.write(value.recording_active, buf) + _UniffiFfiConverterUInt64.write(value.idle_seconds, buf) + _UniffiFfiConverterUInt64.write(value.expires_in_seconds, buf) + +class _UniffiFfiConverterSequenceTypeSessionOutput(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + for item in value: + _UniffiFfiConverterTypeSessionOutput.check_lower(item) + + @classmethod + def write(cls, value, buf): + items = len(value) + buf.write_i32(items) + for item in value: + _UniffiFfiConverterTypeSessionOutput.write(item, buf) + + @classmethod + def read(cls, buf): + count = buf.read_i32() + if count < 0: + raise InternalError("Unexpected negative sequence length") + + return [ + _UniffiFfiConverterTypeSessionOutput.read(buf) for i in range(count) + ] + +@dataclass +class ListSessionsOutput: + def __init__(self, *, sessions:typing.List[SessionOutput], next_cursor:typing.Optional[str]): + self.sessions = sessions + self.next_cursor = next_cursor + + + + + def __str__(self): + return "ListSessionsOutput(sessions={}, next_cursor={})".format(self.sessions, self.next_cursor) + def __eq__(self, other): + if self.sessions != other.sessions: + return False + if self.next_cursor != other.next_cursor: + return False + return True + +class _UniffiFfiConverterTypeListSessionsOutput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return ListSessionsOutput( + sessions=_UniffiFfiConverterSequenceTypeSessionOutput.read(buf), + next_cursor=_UniffiFfiConverterOptionalString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterSequenceTypeSessionOutput.check_lower(value.sessions) + _UniffiFfiConverterOptionalString.check_lower(value.next_cursor) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterSequenceTypeSessionOutput.write(value.sessions, buf) + _UniffiFfiConverterOptionalString.write(value.next_cursor, buf) + +@dataclass +class ListWindowsInput: + def __init__(self, *, pid:typing.Optional[int], on_screen_only:typing.Optional[bool]): + self.pid = pid + self.on_screen_only = on_screen_only + + + + + def __str__(self): + return "ListWindowsInput(pid={}, on_screen_only={})".format(self.pid, self.on_screen_only) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.on_screen_only != other.on_screen_only: + return False + return True + +class _UniffiFfiConverterTypeListWindowsInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return ListWindowsInput( + pid=_UniffiFfiConverterOptionalUInt32.read(buf), + on_screen_only=_UniffiFfiConverterOptionalBoolean.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterOptionalUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalBoolean.check_lower(value.on_screen_only) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterOptionalUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalBoolean.write(value.on_screen_only, buf) + @dataclass class MoveCursorInput: - def __init__(self, *, x:float, y:float, scope:DesktopScope, session:typing.Optional[str]): + def __init__(self, *, x:float, y:float, target:typing.Optional[ActionTarget], scope:typing.Optional[DesktopScope], session:typing.Optional[str]): self.x = x self.y = y + self.target = target self.scope = scope self.session = session @@ -3161,12 +4005,14 @@ class MoveCursorInput: def __str__(self): - return "MoveCursorInput(x={}, y={}, scope={}, session={})".format(self.x, self.y, self.scope, self.session) + return "MoveCursorInput(x={}, y={}, target={}, scope={}, session={})".format(self.x, self.y, self.target, self.scope, self.session) def __eq__(self, other): if self.x != other.x: return False if self.y != other.y: return False + if self.target != other.target: + return False if self.scope != other.scope: return False if self.session != other.session: @@ -3179,7 +4025,8 @@ class _UniffiFfiConverterTypeMoveCursorInput(_UniffiConverterRustBuffer): return MoveCursorInput( x=_UniffiFfiConverterFloat64.read(buf), y=_UniffiFfiConverterFloat64.read(buf), - scope=_UniffiFfiConverterTypeDesktopScope.read(buf), + target=_UniffiFfiConverterOptionalTypeActionTarget.read(buf), + scope=_UniffiFfiConverterOptionalTypeDesktopScope.read(buf), session=_UniffiFfiConverterOptionalString.read(buf), ) @@ -3187,16 +4034,66 @@ class _UniffiFfiConverterTypeMoveCursorInput(_UniffiConverterRustBuffer): def check_lower(value): _UniffiFfiConverterFloat64.check_lower(value.x) _UniffiFfiConverterFloat64.check_lower(value.y) - _UniffiFfiConverterTypeDesktopScope.check_lower(value.scope) + _UniffiFfiConverterOptionalTypeActionTarget.check_lower(value.target) + _UniffiFfiConverterOptionalTypeDesktopScope.check_lower(value.scope) _UniffiFfiConverterOptionalString.check_lower(value.session) @staticmethod def write(value, buf): _UniffiFfiConverterFloat64.write(value.x, buf) _UniffiFfiConverterFloat64.write(value.y, buf) - _UniffiFfiConverterTypeDesktopScope.write(value.scope, buf) + _UniffiFfiConverterOptionalTypeActionTarget.write(value.target, buf) + _UniffiFfiConverterOptionalTypeDesktopScope.write(value.scope, buf) _UniffiFfiConverterOptionalString.write(value.session, buf) +@dataclass +class PerformSecondaryActionInput: + def __init__(self, *, pid:int, window_id:typing.Optional[int], element_token:str, action:str): + self.pid = pid + self.window_id = window_id + self.element_token = element_token + self.action = action + + + + + def __str__(self): + return "PerformSecondaryActionInput(pid={}, window_id={}, element_token={}, action={})".format(self.pid, self.window_id, self.element_token, self.action) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.element_token != other.element_token: + return False + if self.action != other.action: + return False + return True + +class _UniffiFfiConverterTypePerformSecondaryActionInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return PerformSecondaryActionInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterOptionalUInt64.read(buf), + element_token=_UniffiFfiConverterString.read(buf), + action=_UniffiFfiConverterString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalUInt64.check_lower(value.window_id) + _UniffiFfiConverterString.check_lower(value.element_token) + _UniffiFfiConverterString.check_lower(value.action) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalUInt64.write(value.window_id, buf) + _UniffiFfiConverterString.write(value.element_token, buf) + _UniffiFfiConverterString.write(value.action, buf) + @@ -3400,8 +4297,9 @@ class _UniffiFfiConverterTypePredicateOutcome(_UniffiConverterRustBuffer): @dataclass class PressKeyInput: - def __init__(self, *, key:str, scope:DesktopScope, session:typing.Optional[str], modifiers:typing.Optional[typing.List[str]]): + def __init__(self, *, key:str, target:typing.Optional[ActionTarget], scope:typing.Optional[DesktopScope], session:typing.Optional[str], modifiers:typing.Optional[typing.List[str]]): self.key = key + self.target = target self.scope = scope self.session = session self.modifiers = modifiers @@ -3410,10 +4308,12 @@ class PressKeyInput: def __str__(self): - return "PressKeyInput(key={}, scope={}, session={}, modifiers={})".format(self.key, self.scope, self.session, self.modifiers) + return "PressKeyInput(key={}, target={}, scope={}, session={}, modifiers={})".format(self.key, self.target, self.scope, self.session, self.modifiers) def __eq__(self, other): if self.key != other.key: return False + if self.target != other.target: + return False if self.scope != other.scope: return False if self.session != other.session: @@ -3427,7 +4327,8 @@ class _UniffiFfiConverterTypePressKeyInput(_UniffiConverterRustBuffer): def read(buf): return PressKeyInput( key=_UniffiFfiConverterString.read(buf), - scope=_UniffiFfiConverterTypeDesktopScope.read(buf), + target=_UniffiFfiConverterOptionalTypeActionTarget.read(buf), + scope=_UniffiFfiConverterOptionalTypeDesktopScope.read(buf), session=_UniffiFfiConverterOptionalString.read(buf), modifiers=_UniffiFfiConverterOptionalSequenceString.read(buf), ) @@ -3435,17 +4336,79 @@ class _UniffiFfiConverterTypePressKeyInput(_UniffiConverterRustBuffer): @staticmethod def check_lower(value): _UniffiFfiConverterString.check_lower(value.key) - _UniffiFfiConverterTypeDesktopScope.check_lower(value.scope) + _UniffiFfiConverterOptionalTypeActionTarget.check_lower(value.target) + _UniffiFfiConverterOptionalTypeDesktopScope.check_lower(value.scope) _UniffiFfiConverterOptionalString.check_lower(value.session) _UniffiFfiConverterOptionalSequenceString.check_lower(value.modifiers) @staticmethod def write(value, buf): _UniffiFfiConverterString.write(value.key, buf) - _UniffiFfiConverterTypeDesktopScope.write(value.scope, buf) + _UniffiFfiConverterOptionalTypeActionTarget.write(value.target, buf) + _UniffiFfiConverterOptionalTypeDesktopScope.write(value.scope, buf) _UniffiFfiConverterOptionalString.write(value.session, buf) _UniffiFfiConverterOptionalSequenceString.write(value.modifiers, buf) +@dataclass +class RightClickInput: + def __init__(self, *, pid:int, window_id:typing.Optional[int], element_token:typing.Optional[str], x:typing.Optional[float], y:typing.Optional[float], modifier:typing.Optional[typing.List[str]]): + self.pid = pid + self.window_id = window_id + self.element_token = element_token + self.x = x + self.y = y + self.modifier = modifier + + + + + def __str__(self): + return "RightClickInput(pid={}, window_id={}, element_token={}, x={}, y={}, modifier={})".format(self.pid, self.window_id, self.element_token, self.x, self.y, self.modifier) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.element_token != other.element_token: + return False + if self.x != other.x: + return False + if self.y != other.y: + return False + if self.modifier != other.modifier: + return False + return True + +class _UniffiFfiConverterTypeRightClickInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return RightClickInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterOptionalUInt64.read(buf), + element_token=_UniffiFfiConverterOptionalString.read(buf), + x=_UniffiFfiConverterOptionalFloat64.read(buf), + y=_UniffiFfiConverterOptionalFloat64.read(buf), + modifier=_UniffiFfiConverterOptionalSequenceString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalUInt64.check_lower(value.window_id) + _UniffiFfiConverterOptionalString.check_lower(value.element_token) + _UniffiFfiConverterOptionalFloat64.check_lower(value.x) + _UniffiFfiConverterOptionalFloat64.check_lower(value.y) + _UniffiFfiConverterOptionalSequenceString.check_lower(value.modifier) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalUInt64.write(value.window_id, buf) + _UniffiFfiConverterOptionalString.write(value.element_token, buf) + _UniffiFfiConverterOptionalFloat64.write(value.x, buf) + _UniffiFfiConverterOptionalFloat64.write(value.y, buf) + _UniffiFfiConverterOptionalSequenceString.write(value.modifier, buf) + @@ -3569,10 +4532,11 @@ class _UniffiFfiConverterOptionalTypeScrollBy(_UniffiConverterRustBuffer): @dataclass class ScrollInput: - def __init__(self, *, x:float, y:float, direction:ScrollDirection, scope:DesktopScope, session:typing.Optional[str], by:typing.Optional[ScrollBy], amount:typing.Optional[int]): + def __init__(self, *, x:float, y:float, direction:ScrollDirection, target:typing.Optional[ActionTarget], scope:typing.Optional[DesktopScope], session:typing.Optional[str], by:typing.Optional[ScrollBy], amount:typing.Optional[int]): self.x = x self.y = y self.direction = direction + self.target = target self.scope = scope self.session = session self.by = by @@ -3582,7 +4546,7 @@ class ScrollInput: def __str__(self): - return "ScrollInput(x={}, y={}, direction={}, scope={}, session={}, by={}, amount={})".format(self.x, self.y, self.direction, self.scope, self.session, self.by, self.amount) + return "ScrollInput(x={}, y={}, direction={}, target={}, scope={}, session={}, by={}, amount={})".format(self.x, self.y, self.direction, self.target, self.scope, self.session, self.by, self.amount) def __eq__(self, other): if self.x != other.x: return False @@ -3590,6 +4554,8 @@ class ScrollInput: return False if self.direction != other.direction: return False + if self.target != other.target: + return False if self.scope != other.scope: return False if self.session != other.session: @@ -3607,7 +4573,8 @@ class _UniffiFfiConverterTypeScrollInput(_UniffiConverterRustBuffer): x=_UniffiFfiConverterFloat64.read(buf), y=_UniffiFfiConverterFloat64.read(buf), direction=_UniffiFfiConverterTypeScrollDirection.read(buf), - scope=_UniffiFfiConverterTypeDesktopScope.read(buf), + target=_UniffiFfiConverterOptionalTypeActionTarget.read(buf), + scope=_UniffiFfiConverterOptionalTypeDesktopScope.read(buf), session=_UniffiFfiConverterOptionalString.read(buf), by=_UniffiFfiConverterOptionalTypeScrollBy.read(buf), amount=_UniffiFfiConverterOptionalUInt64.read(buf), @@ -3618,7 +4585,8 @@ class _UniffiFfiConverterTypeScrollInput(_UniffiConverterRustBuffer): _UniffiFfiConverterFloat64.check_lower(value.x) _UniffiFfiConverterFloat64.check_lower(value.y) _UniffiFfiConverterTypeScrollDirection.check_lower(value.direction) - _UniffiFfiConverterTypeDesktopScope.check_lower(value.scope) + _UniffiFfiConverterOptionalTypeActionTarget.check_lower(value.target) + _UniffiFfiConverterOptionalTypeDesktopScope.check_lower(value.scope) _UniffiFfiConverterOptionalString.check_lower(value.session) _UniffiFfiConverterOptionalTypeScrollBy.check_lower(value.by) _UniffiFfiConverterOptionalUInt64.check_lower(value.amount) @@ -3628,7 +4596,8 @@ class _UniffiFfiConverterTypeScrollInput(_UniffiConverterRustBuffer): _UniffiFfiConverterFloat64.write(value.x, buf) _UniffiFfiConverterFloat64.write(value.y, buf) _UniffiFfiConverterTypeScrollDirection.write(value.direction, buf) - _UniffiFfiConverterTypeDesktopScope.write(value.scope, buf) + _UniffiFfiConverterOptionalTypeActionTarget.write(value.target, buf) + _UniffiFfiConverterOptionalTypeDesktopScope.write(value.scope, buf) _UniffiFfiConverterOptionalString.write(value.session, buf) _UniffiFfiConverterOptionalTypeScrollBy.write(value.by, buf) _UniffiFfiConverterOptionalUInt64.write(value.amount, buf) @@ -4079,6 +5048,54 @@ class _UniffiFfiConverterTypeSetAgentCursorThemeOutput(_UniffiConverterRustBuffe _UniffiFfiConverterString.write(value.session, buf) _UniffiFfiConverterTypeCursorThemeOutput.write(value.theme, buf) +@dataclass +class SetValueInput: + def __init__(self, *, pid:int, window_id:typing.Optional[int], element_token:str, value:str): + self.pid = pid + self.window_id = window_id + self.element_token = element_token + self.value = value + + + + + def __str__(self): + return "SetValueInput(pid={}, window_id={}, element_token={}, value={})".format(self.pid, self.window_id, self.element_token, self.value) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.element_token != other.element_token: + return False + if self.value != other.value: + return False + return True + +class _UniffiFfiConverterTypeSetValueInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return SetValueInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterOptionalUInt64.read(buf), + element_token=_UniffiFfiConverterString.read(buf), + value=_UniffiFfiConverterString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalUInt64.check_lower(value.window_id) + _UniffiFfiConverterString.check_lower(value.element_token) + _UniffiFfiConverterString.check_lower(value.value) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalUInt64.write(value.window_id, buf) + _UniffiFfiConverterString.write(value.element_token, buf) + _UniffiFfiConverterString.write(value.value, buf) + @dataclass class SetWindowFrameInput: def __init__(self, *, pid:int, window_id:int, x:float, y:float, width:float, height:float, session:typing.Optional[str]): @@ -4197,7 +5214,7 @@ class _UniffiFfiConverterOptionalTypeCursorThemeSelection(_UniffiConverterRustBu @dataclass class StartSessionInput: - def __init__(self, *, session:str, capture_scope:typing.Optional[CaptureScope], cursor_theme:typing.Optional[CursorThemeSelection]): + def __init__(self, *, session:typing.Optional[str], capture_scope:typing.Optional[CaptureScope], cursor_theme:typing.Optional[CursorThemeSelection]): self.session = session self.capture_scope = capture_scope self.cursor_theme = cursor_theme @@ -4220,20 +5237,20 @@ class _UniffiFfiConverterTypeStartSessionInput(_UniffiConverterRustBuffer): @staticmethod def read(buf): return StartSessionInput( - session=_UniffiFfiConverterString.read(buf), + session=_UniffiFfiConverterOptionalString.read(buf), capture_scope=_UniffiFfiConverterOptionalTypeCaptureScope.read(buf), cursor_theme=_UniffiFfiConverterOptionalTypeCursorThemeSelection.read(buf), ) @staticmethod def check_lower(value): - _UniffiFfiConverterString.check_lower(value.session) + _UniffiFfiConverterOptionalString.check_lower(value.session) _UniffiFfiConverterOptionalTypeCaptureScope.check_lower(value.capture_scope) _UniffiFfiConverterOptionalTypeCursorThemeSelection.check_lower(value.cursor_theme) @staticmethod def write(value, buf): - _UniffiFfiConverterString.write(value.session, buf) + _UniffiFfiConverterOptionalString.write(value.session, buf) _UniffiFfiConverterOptionalTypeCaptureScope.write(value.capture_scope, buf) _UniffiFfiConverterOptionalTypeCursorThemeSelection.write(value.cursor_theme, buf) @@ -4431,8 +5448,9 @@ class _UniffiFfiConverterTypeStatePredicate(_UniffiConverterRustBuffer): @dataclass class TypeTextInput: - def __init__(self, *, text:str, scope:DesktopScope, session:typing.Optional[str]): + def __init__(self, *, text:str, target:typing.Optional[ActionTarget], scope:typing.Optional[DesktopScope], session:typing.Optional[str]): self.text = text + self.target = target self.scope = scope self.session = session @@ -4440,10 +5458,12 @@ class TypeTextInput: def __str__(self): - return "TypeTextInput(text={}, scope={}, session={})".format(self.text, self.scope, self.session) + return "TypeTextInput(text={}, target={}, scope={}, session={})".format(self.text, self.target, self.scope, self.session) def __eq__(self, other): if self.text != other.text: return False + if self.target != other.target: + return False if self.scope != other.scope: return False if self.session != other.session: @@ -4455,20 +5475,23 @@ class _UniffiFfiConverterTypeTypeTextInput(_UniffiConverterRustBuffer): def read(buf): return TypeTextInput( text=_UniffiFfiConverterString.read(buf), - scope=_UniffiFfiConverterTypeDesktopScope.read(buf), + target=_UniffiFfiConverterOptionalTypeActionTarget.read(buf), + scope=_UniffiFfiConverterOptionalTypeDesktopScope.read(buf), session=_UniffiFfiConverterOptionalString.read(buf), ) @staticmethod def check_lower(value): _UniffiFfiConverterString.check_lower(value.text) - _UniffiFfiConverterTypeDesktopScope.check_lower(value.scope) + _UniffiFfiConverterOptionalTypeActionTarget.check_lower(value.target) + _UniffiFfiConverterOptionalTypeDesktopScope.check_lower(value.scope) _UniffiFfiConverterOptionalString.check_lower(value.session) @staticmethod def write(value, buf): _UniffiFfiConverterString.write(value.text, buf) - _UniffiFfiConverterTypeDesktopScope.write(value.scope, buf) + _UniffiFfiConverterOptionalTypeActionTarget.write(value.target, buf) + _UniffiFfiConverterOptionalTypeDesktopScope.write(value.scope, buf) _UniffiFfiConverterOptionalString.write(value.session, buf) class _UniffiFfiConverterInt64(_UniffiConverterPrimitiveInt): @@ -4650,6 +5673,463 @@ class _UniffiFfiConverterTypeVerifyStateOutput(_UniffiConverterRustBuffer): _UniffiFfiConverterUInt64.write(value.samples, buf) _UniffiFfiConverterSequenceTypePredicateOutcome.write(value.predicates, buf) +@dataclass +class WindowClickInput: + """ + Exact-window click input for the generated SDK. The existing [`ClickInput`] + remains the portable desktop-coordinate form. +""" + def __init__(self, *, pid:int, window_id:typing.Optional[int], element_token:typing.Optional[str], x:typing.Optional[float], y:typing.Optional[float], button:typing.Optional[ClickButton], count:typing.Optional[int]): + self.pid = pid + self.window_id = window_id + self.element_token = element_token + self.x = x + self.y = y + self.button = button + self.count = count + + + + + def __str__(self): + return "WindowClickInput(pid={}, window_id={}, element_token={}, x={}, y={}, button={}, count={})".format(self.pid, self.window_id, self.element_token, self.x, self.y, self.button, self.count) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.element_token != other.element_token: + return False + if self.x != other.x: + return False + if self.y != other.y: + return False + if self.button != other.button: + return False + if self.count != other.count: + return False + return True + +class _UniffiFfiConverterTypeWindowClickInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return WindowClickInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterOptionalUInt64.read(buf), + element_token=_UniffiFfiConverterOptionalString.read(buf), + x=_UniffiFfiConverterOptionalFloat64.read(buf), + y=_UniffiFfiConverterOptionalFloat64.read(buf), + button=_UniffiFfiConverterOptionalTypeClickButton.read(buf), + count=_UniffiFfiConverterOptionalUInt32.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalUInt64.check_lower(value.window_id) + _UniffiFfiConverterOptionalString.check_lower(value.element_token) + _UniffiFfiConverterOptionalFloat64.check_lower(value.x) + _UniffiFfiConverterOptionalFloat64.check_lower(value.y) + _UniffiFfiConverterOptionalTypeClickButton.check_lower(value.button) + _UniffiFfiConverterOptionalUInt32.check_lower(value.count) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalUInt64.write(value.window_id, buf) + _UniffiFfiConverterOptionalString.write(value.element_token, buf) + _UniffiFfiConverterOptionalFloat64.write(value.x, buf) + _UniffiFfiConverterOptionalFloat64.write(value.y, buf) + _UniffiFfiConverterOptionalTypeClickButton.write(value.button, buf) + _UniffiFfiConverterOptionalUInt32.write(value.count, buf) + + + + + + +class DeliveryMode(enum.Enum): + + BACKGROUND = 0 + + FOREGROUND = 1 + + + +class _UniffiFfiConverterTypeDeliveryMode(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return DeliveryMode.BACKGROUND + if variant == 2: + return DeliveryMode.FOREGROUND + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value == DeliveryMode.BACKGROUND: + return + if value == DeliveryMode.FOREGROUND: + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value == DeliveryMode.BACKGROUND: + buf.write_i32(1) + if value == DeliveryMode.FOREGROUND: + buf.write_i32(2) + + + +class _UniffiFfiConverterOptionalTypeDeliveryMode(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + if value is not None: + _UniffiFfiConverterTypeDeliveryMode.check_lower(value) + + @classmethod + def write(cls, value, buf): + if value is None: + buf.write_u8(0) + return + + buf.write_u8(1) + _UniffiFfiConverterTypeDeliveryMode.write(value, buf) + + @classmethod + def read(cls, buf): + flag = buf.read_u8() + if flag == 0: + return None + elif flag == 1: + return _UniffiFfiConverterTypeDeliveryMode.read(buf) + else: + raise InternalError("Unexpected flag byte for optional type") + +@dataclass +class WindowDragInput: + """ + Exact-window drag input for the generated SDK. The existing [`DragInput`] + keeps its established UniFFI record layout. +""" + def __init__(self, *, pid:int, window_id:int, from_x:float, from_y:float, to_x:float, to_y:float, duration_ms:typing.Optional[int], steps:typing.Optional[int], delivery_mode:typing.Optional[DeliveryMode], button:typing.Optional[ClickButton], modifier:typing.Optional[typing.List[str]]): + self.pid = pid + self.window_id = window_id + self.from_x = from_x + self.from_y = from_y + self.to_x = to_x + self.to_y = to_y + self.duration_ms = duration_ms + self.steps = steps + self.delivery_mode = delivery_mode + self.button = button + self.modifier = modifier + + + + + def __str__(self): + return "WindowDragInput(pid={}, window_id={}, from_x={}, from_y={}, to_x={}, to_y={}, duration_ms={}, steps={}, delivery_mode={}, button={}, modifier={})".format(self.pid, self.window_id, self.from_x, self.from_y, self.to_x, self.to_y, self.duration_ms, self.steps, self.delivery_mode, self.button, self.modifier) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.from_x != other.from_x: + return False + if self.from_y != other.from_y: + return False + if self.to_x != other.to_x: + return False + if self.to_y != other.to_y: + return False + if self.duration_ms != other.duration_ms: + return False + if self.steps != other.steps: + return False + if self.delivery_mode != other.delivery_mode: + return False + if self.button != other.button: + return False + if self.modifier != other.modifier: + return False + return True + +class _UniffiFfiConverterTypeWindowDragInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return WindowDragInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterUInt64.read(buf), + from_x=_UniffiFfiConverterFloat64.read(buf), + from_y=_UniffiFfiConverterFloat64.read(buf), + to_x=_UniffiFfiConverterFloat64.read(buf), + to_y=_UniffiFfiConverterFloat64.read(buf), + duration_ms=_UniffiFfiConverterOptionalUInt64.read(buf), + steps=_UniffiFfiConverterOptionalUInt64.read(buf), + delivery_mode=_UniffiFfiConverterOptionalTypeDeliveryMode.read(buf), + button=_UniffiFfiConverterOptionalTypeClickButton.read(buf), + modifier=_UniffiFfiConverterOptionalSequenceString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterUInt64.check_lower(value.window_id) + _UniffiFfiConverterFloat64.check_lower(value.from_x) + _UniffiFfiConverterFloat64.check_lower(value.from_y) + _UniffiFfiConverterFloat64.check_lower(value.to_x) + _UniffiFfiConverterFloat64.check_lower(value.to_y) + _UniffiFfiConverterOptionalUInt64.check_lower(value.duration_ms) + _UniffiFfiConverterOptionalUInt64.check_lower(value.steps) + _UniffiFfiConverterOptionalTypeDeliveryMode.check_lower(value.delivery_mode) + _UniffiFfiConverterOptionalTypeClickButton.check_lower(value.button) + _UniffiFfiConverterOptionalSequenceString.check_lower(value.modifier) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterUInt64.write(value.window_id, buf) + _UniffiFfiConverterFloat64.write(value.from_x, buf) + _UniffiFfiConverterFloat64.write(value.from_y, buf) + _UniffiFfiConverterFloat64.write(value.to_x, buf) + _UniffiFfiConverterFloat64.write(value.to_y, buf) + _UniffiFfiConverterOptionalUInt64.write(value.duration_ms, buf) + _UniffiFfiConverterOptionalUInt64.write(value.steps, buf) + _UniffiFfiConverterOptionalTypeDeliveryMode.write(value.delivery_mode, buf) + _UniffiFfiConverterOptionalTypeClickButton.write(value.button, buf) + _UniffiFfiConverterOptionalSequenceString.write(value.modifier, buf) + +@dataclass +class WindowHotkeyInput: + def __init__(self, *, pid:int, window_id:typing.Optional[int], element_token:typing.Optional[str], keys:typing.List[str]): + self.pid = pid + self.window_id = window_id + self.element_token = element_token + self.keys = keys + + + + + def __str__(self): + return "WindowHotkeyInput(pid={}, window_id={}, element_token={}, keys={})".format(self.pid, self.window_id, self.element_token, self.keys) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.element_token != other.element_token: + return False + if self.keys != other.keys: + return False + return True + +class _UniffiFfiConverterTypeWindowHotkeyInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return WindowHotkeyInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterOptionalUInt64.read(buf), + element_token=_UniffiFfiConverterOptionalString.read(buf), + keys=_UniffiFfiConverterSequenceString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalUInt64.check_lower(value.window_id) + _UniffiFfiConverterOptionalString.check_lower(value.element_token) + _UniffiFfiConverterSequenceString.check_lower(value.keys) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalUInt64.write(value.window_id, buf) + _UniffiFfiConverterOptionalString.write(value.element_token, buf) + _UniffiFfiConverterSequenceString.write(value.keys, buf) + +@dataclass +class WindowPressKeyInput: + def __init__(self, *, pid:int, window_id:typing.Optional[int], element_token:typing.Optional[str], key:str, modifiers:typing.Optional[typing.List[str]]): + self.pid = pid + self.window_id = window_id + self.element_token = element_token + self.key = key + self.modifiers = modifiers + + + + + def __str__(self): + return "WindowPressKeyInput(pid={}, window_id={}, element_token={}, key={}, modifiers={})".format(self.pid, self.window_id, self.element_token, self.key, self.modifiers) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.element_token != other.element_token: + return False + if self.key != other.key: + return False + if self.modifiers != other.modifiers: + return False + return True + +class _UniffiFfiConverterTypeWindowPressKeyInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return WindowPressKeyInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterOptionalUInt64.read(buf), + element_token=_UniffiFfiConverterOptionalString.read(buf), + key=_UniffiFfiConverterString.read(buf), + modifiers=_UniffiFfiConverterOptionalSequenceString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalUInt64.check_lower(value.window_id) + _UniffiFfiConverterOptionalString.check_lower(value.element_token) + _UniffiFfiConverterString.check_lower(value.key) + _UniffiFfiConverterOptionalSequenceString.check_lower(value.modifiers) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalUInt64.write(value.window_id, buf) + _UniffiFfiConverterOptionalString.write(value.element_token, buf) + _UniffiFfiConverterString.write(value.key, buf) + _UniffiFfiConverterOptionalSequenceString.write(value.modifiers, buf) + +@dataclass +class WindowScrollInput: + def __init__(self, *, pid:int, window_id:typing.Optional[int], element_token:typing.Optional[str], x:typing.Optional[float], y:typing.Optional[float], direction:ScrollDirection, by:typing.Optional[ScrollBy], amount:typing.Optional[int]): + self.pid = pid + self.window_id = window_id + self.element_token = element_token + self.x = x + self.y = y + self.direction = direction + self.by = by + self.amount = amount + + + + + def __str__(self): + return "WindowScrollInput(pid={}, window_id={}, element_token={}, x={}, y={}, direction={}, by={}, amount={})".format(self.pid, self.window_id, self.element_token, self.x, self.y, self.direction, self.by, self.amount) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.element_token != other.element_token: + return False + if self.x != other.x: + return False + if self.y != other.y: + return False + if self.direction != other.direction: + return False + if self.by != other.by: + return False + if self.amount != other.amount: + return False + return True + +class _UniffiFfiConverterTypeWindowScrollInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return WindowScrollInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterOptionalUInt64.read(buf), + element_token=_UniffiFfiConverterOptionalString.read(buf), + x=_UniffiFfiConverterOptionalFloat64.read(buf), + y=_UniffiFfiConverterOptionalFloat64.read(buf), + direction=_UniffiFfiConverterTypeScrollDirection.read(buf), + by=_UniffiFfiConverterOptionalTypeScrollBy.read(buf), + amount=_UniffiFfiConverterOptionalUInt64.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalUInt64.check_lower(value.window_id) + _UniffiFfiConverterOptionalString.check_lower(value.element_token) + _UniffiFfiConverterOptionalFloat64.check_lower(value.x) + _UniffiFfiConverterOptionalFloat64.check_lower(value.y) + _UniffiFfiConverterTypeScrollDirection.check_lower(value.direction) + _UniffiFfiConverterOptionalTypeScrollBy.check_lower(value.by) + _UniffiFfiConverterOptionalUInt64.check_lower(value.amount) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalUInt64.write(value.window_id, buf) + _UniffiFfiConverterOptionalString.write(value.element_token, buf) + _UniffiFfiConverterOptionalFloat64.write(value.x, buf) + _UniffiFfiConverterOptionalFloat64.write(value.y, buf) + _UniffiFfiConverterTypeScrollDirection.write(value.direction, buf) + _UniffiFfiConverterOptionalTypeScrollBy.write(value.by, buf) + _UniffiFfiConverterOptionalUInt64.write(value.amount, buf) + +@dataclass +class WindowTypeTextInput: + def __init__(self, *, pid:int, window_id:typing.Optional[int], element_token:typing.Optional[str], text:str, delay_ms:typing.Optional[int]): + self.pid = pid + self.window_id = window_id + self.element_token = element_token + self.text = text + self.delay_ms = delay_ms + + + + + def __str__(self): + return "WindowTypeTextInput(pid={}, window_id={}, element_token={}, text={}, delay_ms={})".format(self.pid, self.window_id, self.element_token, self.text, self.delay_ms) + def __eq__(self, other): + if self.pid != other.pid: + return False + if self.window_id != other.window_id: + return False + if self.element_token != other.element_token: + return False + if self.text != other.text: + return False + if self.delay_ms != other.delay_ms: + return False + return True + +class _UniffiFfiConverterTypeWindowTypeTextInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return WindowTypeTextInput( + pid=_UniffiFfiConverterUInt32.read(buf), + window_id=_UniffiFfiConverterOptionalUInt64.read(buf), + element_token=_UniffiFfiConverterOptionalString.read(buf), + text=_UniffiFfiConverterString.read(buf), + delay_ms=_UniffiFfiConverterOptionalUInt64.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiFfiConverterUInt32.check_lower(value.pid) + _UniffiFfiConverterOptionalUInt64.check_lower(value.window_id) + _UniffiFfiConverterOptionalString.check_lower(value.element_token) + _UniffiFfiConverterString.check_lower(value.text) + _UniffiFfiConverterOptionalUInt64.check_lower(value.delay_ms) + + @staticmethod + def write(value, buf): + _UniffiFfiConverterUInt32.write(value.pid, buf) + _UniffiFfiConverterOptionalUInt64.write(value.window_id, buf) + _UniffiFfiConverterOptionalString.write(value.element_token, buf) + _UniffiFfiConverterString.write(value.text, buf) + _UniffiFfiConverterOptionalUInt64.write(value.delay_ms, buf) + @@ -4719,17 +6199,22 @@ __all__ = [ "ActionEvidenceKind", "ActionEffect", "ActionRoute", + "ActionTarget", "DesktopScope", "ClickButton", "CursorReducedMotion", "CursorAction", "EscalationReason", + "SessionLifecycleState", + "SessionClientKindOutput", + "SessionTransportOutput", "VerificationStatus", "UnknownReason", "ScrollDirection", "ScrollBy", "CaptureScope", "EffectiveScope", + "DeliveryMode", "Platform", "ActionDelivery", "ActionEscalation", @@ -4746,6 +6231,7 @@ __all__ = [ "CursorThemeOutput", "CursorThemeSelection", "CursorVisualOutput", + "DoubleClickInput", "DragInput", "ElementSelector", "ElementPredicate", @@ -4757,12 +6243,22 @@ __all__ = [ "GetCursorPositionInput", "GetDesktopStateInput", "GetScreenSizeInput", + "GetSessionInput", "GetSessionStateInput", + "ObservationRevisionInput", + "GetWindowStateInput", "HotkeyInput", "InvokeMenuInput", + "ListAppsInput", + "ListSessionsInput", + "SessionOutput", + "ListSessionsOutput", + "ListWindowsInput", "MoveCursorInput", + "PerformSecondaryActionInput", "PredicateOutcome", "PressKeyInput", + "RightClickInput", "ScrollInput", "SessionStateOutput", "SetAgentCursorEnabledInput", @@ -4771,6 +6267,7 @@ __all__ = [ "SetAgentCursorMotionOutput", "SetAgentCursorThemeInput", "SetAgentCursorThemeOutput", + "SetValueInput", "SetWindowFrameInput", "StartSessionInput", "StartSessionOutput", @@ -4779,4 +6276,10 @@ __all__ = [ "TypeTextInput", "VerifyStateInput", "VerifyStateOutput", + "WindowClickInput", + "WindowDragInput", + "WindowHotkeyInput", + "WindowPressKeyInput", + "WindowScrollInput", + "WindowTypeTextInput", ] diff --git a/packages/cua-driver/python/tests/test_uniffi_loader.py b/packages/cua-driver/python/tests/test_uniffi_loader.py index 7bb925be58..6441166b5f 100644 --- a/packages/cua-driver/python/tests/test_uniffi_loader.py +++ b/packages/cua-driver/python/tests/test_uniffi_loader.py @@ -58,7 +58,7 @@ while True: if request["method"] == "metadata": result = { "driver_version": "0.10.0", - "contract_version": "0.6.0", + "contract_version": "0.7.0", "tools_list_schema_version": "1", "capability_version": "1", "mcp_protocol_version": "2025-06-18", @@ -142,7 +142,7 @@ except FileNotFoundError: if request["method"] == "metadata": result = { "driver_version": "0.12.6", - "contract_version": "0.6.0", + "contract_version": "0.7.0", "tools_list_schema_version": "1", "capability_version": "1", "mcp_protocol_version": "2025-06-18", @@ -186,6 +186,8 @@ except FileNotFoundError: expected_methods = { "start_session", "escalate_session", + "get_session", + "list_sessions", "get_session_state", "end_session", "get_desktop_state", @@ -224,6 +226,7 @@ except FileNotFoundError: ClickInput( x=12.0, y=34.0, + target=None, scope=DesktopScope.DESKTOP, session="python-run", button=ClickButton.LEFT, diff --git a/packages/cua-driver/python/uv.lock b/packages/cua-driver/python/uv.lock index 69716b8cef..3156b59e4e 100644 --- a/packages/cua-driver/python/uv.lock +++ b/packages/cua-driver/python/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "cua-driver" -version = "0.17.0" +version = "0.20.0" source = { editable = "." } [package.optional-dependencies] diff --git a/packages/cua-driver/rust/CHANGELOG.md b/packages/cua-driver/rust/CHANGELOG.md index 54cd669098..d44d287670 100644 --- a/packages/cua-driver/rust/CHANGELOG.md +++ b/packages/cua-driver/rust/CHANGELOG.md @@ -1,5 +1,119 @@ # Changelog +## [0.20.0](https://github.com/trycua/cua/compare/cua-driver-rs-v0.19.3...cua-driver-rs-v0.20.0) (2026-08-15) + + +### ⚠ BREAKING CHANGES + +* **cua-driver:** remove browser approval tokens ([#3185](https://github.com/trycua/cua/issues/3185)) + +### Features + +* add immutable Driver and Lume nightly releases ([8cef90b](https://github.com/trycua/cua/commit/8cef90b7ce6f85793a0ca3a3ddc050eac01b6b83)) +* add persistent Driver and Lume release channels ([0813659](https://github.com/trycua/cua/commit/0813659ac237cd46da21c2a51f9a95f27a3e7845)) +* **cua-driver:** add implicit lifecycle sessions ([#3013](https://github.com/trycua/cua/issues/3013)) ([858ef13](https://github.com/trycua/cua/commit/858ef13a5b1f2630b074d8f588a25cb0b19cab25)) +* **cua-driver:** apply capability manifests across permission profiles ([#3015](https://github.com/trycua/cua/issues/3015)) ([3a53e53](https://github.com/trycua/cua/commit/3a53e53f99dcf8d5ed761c5cd0b5dd039c916121)) +* **cua-driver:** remove browser approval tokens ([#3185](https://github.com/trycua/cua/issues/3185)) ([0d7c011](https://github.com/trycua/cua/commit/0d7c011eb31526d9c2b500806f35523dc2bdfb0e)) + + +### Bug Fixes + +* **cua-driver:** align agent guidance with lifecycle sessions ([#3041](https://github.com/trycua/cua/issues/3041)) ([dbde09e](https://github.com/trycua/cua/commit/dbde09e0bfd39abbc374e2e9aade86086e773bb8)) +* **cua-driver:** hide policy-disabled MCP tools ([#3132](https://github.com/trycua/cua/issues/3132)) ([1532830](https://github.com/trycua/cua/commit/1532830d3d5c6e5db22198130d82a4741a7b4a2f)) +* **cua-driver:** preserve direct MCP session ownership ([#3079](https://github.com/trycua/cua/issues/3079)) ([d509687](https://github.com/trycua/cua/commit/d5096871d088c2e5b5942d678f2c127840061d7d)) +* **cua-driver:** preserve named CLI sessions ([#3144](https://github.com/trycua/cua/issues/3144)) ([1803e52](https://github.com/trycua/cua/commit/1803e52d0c4a94d88787706cfe89eccb6dc723db)) +* **cua-driver:** reject misplaced MCP permission flags ([#3085](https://github.com/trycua/cua/issues/3085)) ([a5bc265](https://github.com/trycua/cua/commit/a5bc2651c10dc64f4779f2c9fd99bfddb9905ffb)) +* **cua-driver:** stage current skill pack on Windows local installs ([#3083](https://github.com/trycua/cua/issues/3083)) ([b4bc58a](https://github.com/trycua/cua/commit/b4bc58a3a262f30fcd9d6772851cde0f3429deb6)) +* **cua-driver:** verify foreground focus before input ([#3068](https://github.com/trycua/cua/issues/3068)) ([f9192dd](https://github.com/trycua/cua/commit/f9192dde69dd85ff6c5ad0bf4731d33ebc7d20d3)) +* **cua-driver:** write the update-check cache to the canonical home ([#3032](https://github.com/trycua/cua/issues/3032)) ([69e5774](https://github.com/trycua/cua/commit/69e57742751407258f2189ec2c4bb21fba78da2e)) + +## [0.19.3](https://github.com/trycua/cua/compare/cua-driver-rs-v0.19.2...cua-driver-rs-v0.19.3) (2026-08-10) + + +### Bug Fixes + +* **cua-driver:** remove Windows npm VC runtime prerequisite ([#3038](https://github.com/trycua/cua/issues/3038)) ([c167125](https://github.com/trycua/cua/commit/c167125e1bb0c4fe515beddc0d480f573f3c6077)) + +## [0.19.2](https://github.com/trycua/cua/compare/cua-driver-rs-v0.19.1...cua-driver-rs-v0.19.2) (2026-08-07) + + +### Bug Fixes + +* **cua-driver:** constrain incompatible zune-core resolution ([#2984](https://github.com/trycua/cua/issues/2984)) ([1979ab7](https://github.com/trycua/cua/commit/1979ab73fdcdba130a54467ca7072c447011f7ab)) +* **cua-driver:** make MCP output schemas object-rooted ([96d87ad](https://github.com/trycua/cua/commit/96d87adb248d746aed2a5427b1a256dbd3f1ae1a)) +* **cua-driver:** reap Linux launch_app children instead of leaking zombies ([#2974](https://github.com/trycua/cua/issues/2974)) ([f91d78e](https://github.com/trycua/cua/commit/f91d78e49a74fd7a690100957886e93e185655e2)) + +## [0.19.1](https://github.com/trycua/cua/compare/cua-driver-rs-v0.19.0...cua-driver-rs-v0.19.1) (2026-08-07) + + +### Bug Fixes + +* **cua-driver:** advertise refusals in the MCP outputSchema ([#2968](https://github.com/trycua/cua/issues/2968)) ([dc6f32c](https://github.com/trycua/cua/commit/dc6f32cd4d32bb0a60e18674086f443f9d9d8288)) +* **cua-driver:** record launch provenance for sessionless launch_app so kill_app can reprove it ([#2966](https://github.com/trycua/cua/issues/2966)) ([b065550](https://github.com/trycua/cua/commit/b06555075fc0ccc14df7e17d33b7eb27d97c9c53)), closes [#2965](https://github.com/trycua/cua/issues/2965) +* **cua-driver:** resolve Linux launch_app names via .desktop entries and surface xdg-open failures ([#2954](https://github.com/trycua/cua/issues/2954)) ([fb4b492](https://github.com/trycua/cua/commit/fb4b492cd997dd3e8659298adfd98825974ebb7e)) +* **cua-driver:** survive z-order BadMatch under reparenting WMs so the Linux agent cursor can paint ([#2957](https://github.com/trycua/cua/issues/2957)) ([9522358](https://github.com/trycua/cua/commit/9522358294f34aba138221e87d3dbb79486b199a)), closes [#2955](https://github.com/trycua/cua/issues/2955) + +## [0.19.0](https://github.com/trycua/cua/compare/cua-driver-rs-v0.18.0...cua-driver-rs-v0.19.0) (2026-08-06) + + +### Features + +* **cua-driver:** support Prime Agent skill onboarding ([#2944](https://github.com/trycua/cua/issues/2944)) ([0f796d0](https://github.com/trycua/cua/commit/0f796d0659877bee3404d11eef566cfbcbf8edf1)) + + +### Bug Fixes + +* **cua-driver:** Linux cursor overlay compositing, xrdp-family servers, and resting float ([#2940](https://github.com/trycua/cua/issues/2940)) ([997d6d8](https://github.com/trycua/cua/commit/997d6d86433254ccf12e5dbf1edcb46bf05eb5ac)) +* **cua-driver:** resolve SDK release from run artifacts ([#2926](https://github.com/trycua/cua/issues/2926)) ([3cd9de9](https://github.com/trycua/cua/commit/3cd9de9a32bf6b4a98a2186c1535054bd740bb21)) + +## [0.18.0](https://github.com/trycua/cua/compare/cua-driver-rs-v0.17.0...cua-driver-rs-v0.18.0) (2026-08-05) + + +### Features + +* **cua-driver:** add macOS Spaces awareness to list_windows ([#2850](https://github.com/trycua/cua/issues/2850)) ([f7404b4](https://github.com/trycua/cua/commit/f7404b40287c128b15afa30b6c6f9024cbf081b8)) +* **cua-driver:** drop the session orb from the cursor badge ([#2842](https://github.com/trycua/cua/issues/2842)) ([59f280e](https://github.com/trycua/cua/commit/59f280e485be4f58811bda6a9e2dbe7632b9b63c)) +* **cua-driver:** exact-target macOS background input v1 ([#2837](https://github.com/trycua/cua/issues/2837)) ([1b2cb5a](https://github.com/trycua/cua/commit/1b2cb5a706c3e5d636b683ab15336dbf35e579e0)) + + +### Bug Fixes + +* **cua-driver:** avoid accessibility advertise on Cinnamon ([#2784](https://github.com/trycua/cua/issues/2784)) ([b3ba121](https://github.com/trycua/cua/commit/b3ba12126188e1540b93d7c51d55926299fd36ab)) +* **cua-driver:** avoid retrying deferred text writes ([7e0d6bc](https://github.com/trycua/cua/commit/7e0d6bcb5d7ca55d9da1ff3e8acc1bdddf6fa422)) +* **cua-driver:** bound large macOS text synthesis ([#2863](https://github.com/trycua/cua/issues/2863)) ([a6b87e0](https://github.com/trycua/cua/commit/a6b87e01af9c3f291a40a9d7f4e6301418b2a8ab)) +* **cua-driver:** bound Linux AT-SPI listener startup ([#2827](https://github.com/trycua/cua/issues/2827)) ([ed3c365](https://github.com/trycua/cua/commit/ed3c365c954446c774c422e8018541e56155dd6a)) +* **cua-driver:** bound Linux snapshots after app exit ([#2822](https://github.com/trycua/cua/issues/2822)) ([d1c5a7a](https://github.com/trycua/cua/commit/d1c5a7a90b23a37a18838d9c2122d003d00e7ecf)) +* **cua-driver:** bound Windows app-name lookup ([#2858](https://github.com/trycua/cua/issues/2858)) ([2be7aab](https://github.com/trycua/cua/commit/2be7aab3de35bdffa9d8ad767471c0699d36d6e5)) +* **cua-driver:** bound Windows installed-app discovery ([#2855](https://github.com/trycua/cua/issues/2855)) ([ea971ef](https://github.com/trycua/cua/commit/ea971ef06d4ebeda16c6947cb2bca2dd875d2ddc)) +* **cua-driver:** clarify synthetic browser click outcomes ([#2866](https://github.com/trycua/cua/issues/2866)) ([197c518](https://github.com/trycua/cua/commit/197c518c9c364bd30da8bc076748ea82ed093e27)) +* **cua-driver:** explain permanent scope recovery ([#2857](https://github.com/trycua/cua/issues/2857)) ([9110d9f](https://github.com/trycua/cua/commit/9110d9fd69c7fda41caab3aaac9df80c87bdab2f)) +* **cua-driver:** fail closed on X11 overlay shape errors ([#1818](https://github.com/trycua/cua/issues/1818)) ([0462954](https://github.com/trycua/cua/commit/0462954cfb9843ca0cc43f49262acf0e69e3517d)) +* **cua-driver:** hard-bound Windows UIA provider calls ([#2117](https://github.com/trycua/cua/issues/2117)) ([c9c6607](https://github.com/trycua/cua/commit/c9c660770a26c2f0024b9be1079fac0d4c9effff)) +* **cua-driver:** harden Windows discovery and foreground input ([#2812](https://github.com/trycua/cua/issues/2812)) ([441cae0](https://github.com/trycua/cua/commit/441cae0d4d4048524ce2f7eeabfb63a6c9a422d3)) +* **cua-driver:** ignore macOS compositor sibling surfaces ([#2908](https://github.com/trycua/cua/issues/2908)) ([df57e61](https://github.com/trycua/cua/commit/df57e610d3f2e9c07aac59d4896d48656490e1cc)) +* **cua-driver:** keep Windows autostart on the junction path ([#2809](https://github.com/trycua/cua/issues/2809)) ([06f0c04](https://github.com/trycua/cua/commit/06f0c048fb11b1780e61252eb41a2b968865a8a7)) +* **cua-driver:** keep Windows cursor visible without focus theft ([#2864](https://github.com/trycua/cua/issues/2864)) ([2e7a412](https://github.com/trycua/cua/commit/2e7a41215ec9e3594567029aad07450c3d731721)) +* **cua-driver:** keep remote debugging enabled when a restart is needed ([#2911](https://github.com/trycua/cua/issues/2911)) ([f5b15cc](https://github.com/trycua/cua/commit/f5b15cccf6a9f0424dd2d83f730b523c4be21df0)) +* **cua-driver:** launch desktop AppsFolder apps on Windows ([#2862](https://github.com/trycua/cua/issues/2862)) ([fb2a6f6](https://github.com/trycua/cua/commit/fb2a6f60522ebf132270a3377c9a0496d2c0ba98)) +* **cua-driver:** prevent Linux uinput pointer panics ([#2736](https://github.com/trycua/cua/issues/2736)) ([9fde53e](https://github.com/trycua/cua/commit/9fde53e1fb190834054eb36e7574bc0b523831e2)) +* **cua-driver:** quiesce idle Wayland overlay ([#2828](https://github.com/trycua/cua/issues/2828)) ([35b71a0](https://github.com/trycua/cua/commit/35b71a0ebd081c333bf53af6b386ef4798dd05b1)) +* **cua-driver:** refuse minimized Windows element actions ([8a9b7ba](https://github.com/trycua/cua/commit/8a9b7baa32bb5b7ea48a1ee6ffc1d87a433405e4)) +* **cua-driver:** repair X11 overlay after display changes ([#2354](https://github.com/trycua/cua/issues/2354)) ([81019b5](https://github.com/trycua/cua/commit/81019b54b89d577ccb5d7758eba0fd33861fc9d9)) +* **cua-driver:** report honest macOS browser chrome coverage ([#2919](https://github.com/trycua/cua/issues/2919)) ([7c52b84](https://github.com/trycua/cua/commit/7c52b8449fab36cdda9fe4310e6afd3be202c955)) +* **cua-driver:** report macOS key delivery truthfully ([#2830](https://github.com/trycua/cua/issues/2830)) ([3dc0fc0](https://github.com/trycua/cua/commit/3dc0fc0e345f6d8107899a18c74245615e294c03)) +* **cua-driver:** report PostMessage pixel fallback truthfully ([4dc537e](https://github.com/trycua/cua/commit/4dc537ef34e715c099ab30c7d45618e791da2ad1)) +* **cua-driver:** scope the Linux AT-SPI walk to the requested window ([#2895](https://github.com/trycua/cua/issues/2895)) ([8c515ca](https://github.com/trycua/cua/commit/8c515ca74d7f9dad2b3ae9d74b59294511824fa1)) +* **cua-driver:** select the correct Linux AT-SPI application tree ([#2740](https://github.com/trycua/cua/issues/2740)) ([d86e49b](https://github.com/trycua/cua/commit/d86e49ba4bf1c456fd16eca220184a38600ed927)) +* **cua-driver:** stage local installs over a running driver ([#2889](https://github.com/trycua/cua/issues/2889)) ([157816e](https://github.com/trycua/cua/commit/157816e6144a79162253762b79d0abdfffa8e486)) +* **cua-driver:** stop Windows update --apply from killing itself ([#2805](https://github.com/trycua/cua/issues/2805)) ([aebd996](https://github.com/trycua/cua/commit/aebd9962d2686e75ff9e17e0a3735e303ff96981)) +* **cua-driver:** verify exact macOS window activation ([#2829](https://github.com/trycua/cua/issues/2829)) ([cd5c6f3](https://github.com/trycua/cua/commit/cd5c6f3ec5c2f71a8fbc64563d42933d381008ba)) + + +### Performance Improvements + +* **cua-driver:** accelerate Linux X11 capture with MIT-SHM ([#2796](https://github.com/trycua/cua/issues/2796)) ([5448c44](https://github.com/trycua/cua/commit/5448c449c81c81a7e48a418f8276d257f0e4c563)) +* **cua-driver:** accelerate macOS window capture with ScreenCaptureKit ([#2795](https://github.com/trycua/cua/issues/2795)) ([bc90373](https://github.com/trycua/cua/commit/bc90373362cb7c521b1ff03f94457d5de618095c)) + ## [0.17.0](https://github.com/trycua/cua/compare/cua-driver-rs-v0.16.0...cua-driver-rs-v0.17.0) (2026-08-02) diff --git a/packages/cua-driver/rust/Cargo.lock b/packages/cua-driver/rust/Cargo.lock index 9ccddd4a1e..84b8c341df 100644 --- a/packages/cua-driver/rust/Cargo.lock +++ b/packages/cua-driver/rust/Cargo.lock @@ -981,11 +981,12 @@ dependencies = [ [[package]] name = "cua-driver" -version = "0.17.0" +version = "0.20.0" dependencies = [ "anyhow", "async-trait", "base64", + "core-foundation", "cua-driver-contract", "cua-driver-core", "cua-driver-sdk", @@ -1016,7 +1017,7 @@ dependencies = [ [[package]] name = "cua-driver-bindgen" -version = "0.17.0" +version = "0.20.0" dependencies = [ "cbindgen", "uniffi", @@ -1024,7 +1025,7 @@ dependencies = [ [[package]] name = "cua-driver-contract" -version = "0.17.0" +version = "0.20.0" dependencies = [ "schemars", "serde", @@ -1034,7 +1035,7 @@ dependencies = [ [[package]] name = "cua-driver-core" -version = "0.17.0" +version = "0.20.0" dependencies = [ "anyhow", "async-trait", @@ -1042,6 +1043,7 @@ dependencies = [ "cua-driver-contract", "futures-util", "image", + "jsonschema", "libc", "regex", "regorus", @@ -1057,11 +1059,12 @@ dependencies = [ "url", "uuid", "windows 0.58.0", + "zune-core", ] [[package]] name = "cua-driver-sdk" -version = "0.17.0" +version = "0.20.0" dependencies = [ "async-trait", "core-foundation", @@ -1083,7 +1086,7 @@ dependencies = [ [[package]] name = "cua-driver-testkit" -version = "0.17.0" +version = "0.20.0" dependencies = [ "core-foundation", "libc", @@ -1097,7 +1100,7 @@ dependencies = [ [[package]] name = "cua-driver-uia" -version = "0.17.0" +version = "0.20.0" dependencies = [ "anyhow", "cua-driver-core", @@ -1112,7 +1115,7 @@ dependencies = [ [[package]] name = "cursor-overlay" -version = "0.17.0" +version = "0.20.0" dependencies = [ "anyhow", "cua-driver-contract", @@ -1129,7 +1132,7 @@ dependencies = [ [[package]] name = "cursor-theme-cli" -version = "0.17.0" +version = "0.20.0" dependencies = [ "anyhow", "cursor-overlay", @@ -2897,7 +2900,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pip-preview" -version = "0.17.0" +version = "0.20.0" dependencies = [ "anyhow", "serde_json", @@ -2957,7 +2960,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-linux" -version = "0.17.0" +version = "0.20.0" dependencies = [ "anyhow", "ashpd", @@ -2976,6 +2979,7 @@ dependencies = [ "image", "libc", "libspa", + "memmap2", "meval", "pip-preview", "pipewire", @@ -2988,6 +2992,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "uuid", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3001,7 +3006,7 @@ dependencies = [ [[package]] name = "platform-macos" -version = "0.17.0" +version = "0.20.0" dependencies = [ "anyhow", "async-trait", @@ -3036,7 +3041,7 @@ dependencies = [ [[package]] name = "platform-windows" -version = "0.17.0" +version = "0.20.0" dependencies = [ "anyhow", "async-trait", @@ -3054,6 +3059,7 @@ dependencies = [ "tiny-skia", "tokio", "tracing", + "uuid", "windows 0.58.0", ] diff --git a/packages/cua-driver/rust/Cargo.toml b/packages/cua-driver/rust/Cargo.toml index 471ccef462..3ac5628c6f 100644 --- a/packages/cua-driver/rust/Cargo.toml +++ b/packages/cua-driver/rust/Cargo.toml @@ -17,7 +17,7 @@ members = [ ] [workspace.package] -version = "0.17.0" +version = "0.20.0" edition = "2021" authors = ["trycua"] license = "MIT" @@ -34,6 +34,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } base64 = "0.22" uuid = { version = "1", features = ["v4"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +# zune-jpeg 0.5.15 accepts zune-core ^0.5, but zune-core 0.5.2 changed its +# disabled logging macros in a source-incompatible way. Keep fresh downstream +# resolution on the last compatible pair until zune-jpeg publishes its fix. +zune-core = "=0.5.1" uniffi = "=0.31.0" postcard = { version = "1", features = ["alloc"] } sha2 = "0.10" diff --git a/packages/cua-driver/rust/Skills/cua-driver/BROWSER.md b/packages/cua-driver/rust/Skills/cua-driver/BROWSER.md index fb44403527..c7c9e57161 100644 --- a/packages/cua-driver/rust/Skills/cua-driver/BROWSER.md +++ b/packages/cua-driver/rust/Skills/cua-driver/BROWSER.md @@ -15,21 +15,40 @@ mints session-scoped tab and element capabilities. The canonical loop is: ```text -start_session +start_session(session?) # optional; can name before acting list_windows or launch_app -get_browser_state(pid, window_id, session) # bind -get_browser_state(target_id, tab_id, session, - snapshot_format=semantic_v2) # snapshot +get_browser_state(pid, window_id, session?) # bind +get_browser_state(target_id, tab_id, session?, + snapshot_format=semantic_v2) # snapshot browser_navigate / browser_click / browser_type / browser_pointer browser_dialog / browser_set_input_files / browser_download -get_browser_state(target_id, tab_id, session, - snapshot_format=semantic_v2) # verify and refresh refs -end_session +get_browser_state(target_id, tab_id, session?, + snapshot_format=semantic_v2) # verify and refresh refs +end_session(session?) # optional cleanup ``` -Use one explicit `session` value throughout. Never substitute a raw CDP -target id, tab ordinal, URL match, or remembered ref for a capability returned -by `get_browser_state`. +For a multi-call browser workflow, prefer a short `session` label and pass the +same value on every call that accepts it. Passing it once is not sticky; a later +omitted value uses the transport's implicit session. One long-lived MCP or SDK +transport may omit `session` for one-off or deliberately unlabeled work; its +first admitted call creates one implicit session and later unnamed calls reuse +it. Direct one-shot CLI calls use disposable transports. Never substitute a raw +CDP target id, tab ordinal, URL match, or remembered ref for a capability +returned by `get_browser_state`. + +### Copy page content to the system clipboard + +If the requested outcome is exact page content on the system clipboard—not a +literal text-selection gesture—read the content from a fresh semantic browser +snapshot, call `clipboard_write` with the exact observed value, and verify it +with `clipboard_read`. This path is background-safe and does not require a +clickable ref: passive headings and text nodes are evidence sources, not +controls that must be clicked before their value can be copied. + +Fall back to visual text selection and the platform copy hotkey only when the +user explicitly requires that gesture or clipboard tools are unavailable. +That fallback is native input, not a typed page mutation, and may require the +foreground escalation rules in `SKILL.md`. ### Browser recording feedback @@ -88,15 +107,17 @@ Prefer an isolated profile when the task does not need the user's existing cookies or login state: ```bash -# Direct CLI/raw clients mint this token interactively. MCP hosts can use their -# destructive-tool approval flow instead. -qwen-cua-driver browser-approve --pid 4242 --profile-mode isolated_new - qwen-cua-driver browser_prepare \ '{"pid":4242,"session":"browser-run-1","allow_launch":true, - "profile":{"mode":"isolated_new"},"approval_token":""}' + "profile":{"mode":"isolated_new"}}' ``` +Isolated preparation follows the runtime permission mode and optional +capability manifest. Standard mode treats it as routine, bounded mode requires +a matching manifest, and unrestricted mode requires the launcher's dangerous +acknowledgement. `allow_launch: true` states that this call may create the +separate process; it does not widen runtime authorization. + Use `isolated_named` with a path-safe `name` for a reusable driver-managed profile. Preparation launches a separate browser and never copies, modifies, or terminates the requested personal profile. The result returns a @@ -282,9 +303,11 @@ qwen-cua-driver browser_click \ `dom_event` calls the page element's click behavior without pretending that a trusted pointer event occurred. It requires a ref and is the full-background -alternative where supported. Never silently change trust class after a -refusal. Coordinate clicks accept viewport CSS `x` and `y`, but only on the -trusted route; prefer refs. +alternative where supported. Dispatch is not proof that the control activated: +trust-gated controls can ignore synthetic events, so refresh page state and +verify the expected postcondition. Never silently change trust class or +foreground the browser after a refusal. Coordinate clicks accept viewport CSS +`x` and `y`, but only on the trusted route; prefer refs. ### Type @@ -409,8 +432,9 @@ result from the current host, process, window, session, and tab. - `browser_requires_setup`: obtain explicit approval and call `browser_prepare`; never make setup a hidden read side effect. - `browser_consent_required`: restart standard mode with the trusted launch - grant, use a matching bounded manifest, or let the embedding host decide the - attested request. Do not automate a generic approval dialog. + grant, use a capability manifest that admits the exact resource while the + selected profile remains independently binding, or let the embedding host + decide the attested request. Do not automate a generic approval dialog. - `browser_binding_ambiguous` or heuristic binding: resolve the native-window ambiguity and bind again; do not mutate. - `browser_ref_stale`: snapshot again and use a new ref. diff --git a/packages/cua-driver/rust/Skills/cua-driver/EMBEDDING.md b/packages/cua-driver/rust/Skills/cua-driver/EMBEDDING.md index 13ade7b9a7..4c48ab16b4 100644 --- a/packages/cua-driver/rust/Skills/cua-driver/EMBEDDING.md +++ b/packages/cua-driver/rust/Skills/cua-driver/EMBEDDING.md @@ -47,7 +47,7 @@ create `CuaDriver` directly. This path does not start an executable or open a socket, and TCC checks execute as the importing application: ```ts -import { CuaDriver } from '@trycua/cua-driver'; +import { CuaDriver } from '@qwen-code/cua-sdk'; const driver = CuaDriver.create(undefined); try { @@ -140,13 +140,13 @@ migration. ### Node and Electron daemon hosts -Use the embedded host in `@trycua/cua-driver` instead of implementing process +Use the embedded host in `@qwen-code/cua-sdk` instead of implementing process and socket management in every host. It starts a private daemon directly, waits until its socket accepts connections, returns SDK and MCP connection details, and owns restart and cleanup: ```ts -import { CuaDriver, EmbeddedCuaDriverHost } from '@trycua/cua-driver'; +import { CuaDriver, EmbeddedCuaDriverHost } from '@qwen-code/cua-sdk'; const embedded = new EmbeddedCuaDriverHost( '/path/inside/YourApp.app/Contents/Resources/qwen-cua-driver', @@ -194,8 +194,9 @@ daemon child. completion is unknown. - The Rust owner holds a parent-liveness pipe, so host death closes the daemon; orderly shutdown should still await `stop()`. -- Capture scope belongs to each session. One embedded daemon can concurrently - serve `auto`, strict `window`, and strict `desktop` sessions. +- Capture modality belongs to each observation or action target, not to the + lifecycle session. One embedded daemon can concurrently serve exact window + and desktop calls without changing session state. - Permission changes require destroying clients, restarting the daemon, and reconnecting. A connection from the old generation is never reusable. @@ -272,7 +273,7 @@ a dialog (the `prompt` argument is ignored) and returns: "embedded": true, "pid": 12345, "responsible_ppid": 12300, - "executable": "/path/to/cua-driver", + "executable": "/path/to/qwen-cua-driver", "disclaim_env": false, "note": "Embedded mode: these booleans reflect the HOST app's TCC grant…" } diff --git a/packages/cua-driver/rust/Skills/cua-driver/LINUX.md b/packages/cua-driver/rust/Skills/cua-driver/LINUX.md index 7d964d585f..df2cc2df33 100644 --- a/packages/cua-driver/rust/Skills/cua-driver/LINUX.md +++ b/packages/cua-driver/rust/Skills/cua-driver/LINUX.md @@ -127,11 +127,13 @@ CI so the three surfaces can't drift. Linux-relevant notes: Linux builds rejected it via `additionalProperties:false` (it was effectively macOS-only); it is now uniformly schema-accepted — Linux glides a per-session cursor on X11 where the overlay is available. -- **Windowless screen-absolute actions** pass `scope:"desktop"` with no - pid/window_id, uniformly with macOS and Windows. The session must have - effective desktop scope (`start_session(..., capture_scope:"desktop")`, or - an explicitly escalated `auto` session). Strict window sessions receive - `desktop_scope_disabled`; no persistent config is read or written. +- **Windowless screen-absolute actions** use + `target:{"kind":"desktop","display_id":"primary"}`, uniformly with macOS + and Windows. Exact window actions use + `target:{"kind":"window","pid":PID,"window_id":WINDOW_ID}`. Legacy flat + `scope`, `pid`, and `window_id` fields remain compatibility inputs, but do + not combine them with `target`; capture modality no longer changes lifecycle + session state. ## Native application menus diff --git a/packages/cua-driver/rust/Skills/cua-driver/MACOS.md b/packages/cua-driver/rust/Skills/cua-driver/MACOS.md index 52cd7cc9a0..154ea2376e 100644 --- a/packages/cua-driver/rust/Skills/cua-driver/MACOS.md +++ b/packages/cua-driver/rust/Skills/cua-driver/MACOS.md @@ -324,11 +324,12 @@ _Cross-platform parameter contract_): - **`session` always worked on macOS;** the cross-platform change is that Windows/Linux stopped _rejecting_ it. No macOS-side change to how you pass it. -- **`scope`** (`window` / `desktop`) selects the action form uniformly on all - platforms. Pass `scope:"desktop"` with no pid/window_id for screen-absolute - pointer actions or foreground keyboard actions. The session's immutable - `capture_scope` policy must permit that form; set it with `start_session`, - never persistent config. +- **`target`** selects the action coordinate space uniformly on all platforms. + Use `target:{"kind":"desktop","display_id":"primary"}` for + screen-absolute pointer actions or foreground keyboard actions, and + `target:{"kind":"window","pid":PID,"window_id":WINDOW_ID}` for exact + window coordinates. Legacy flat `scope`, `pid`, and `window_id` fields remain + compatibility inputs, but do not combine them with `target`. ### Canvases, viewports, games (Blender, Unity, GHOST, Qt, wxWidgets) diff --git a/packages/cua-driver/rust/Skills/cua-driver/SKILL.md b/packages/cua-driver/rust/Skills/cua-driver/SKILL.md index 7551317f52..2427fd18f3 100644 --- a/packages/cua-driver/rust/Skills/cua-driver/SKILL.md +++ b/packages/cua-driver/rust/Skills/cua-driver/SKILL.md @@ -1,7 +1,7 @@ --- name: cua-driver description: Drive a native GUI app (macOS, Windows, Linux) via the Qwen Cua Driver CLI (default) or MCP server; snapshot its accessibility tree, act through snapshot-bound element tokens, native menu paths, exact window geometry, or pixel coordinates, and verify from fresh state. Use when the user asks you to operate, drive, automate, or perform a GUI task in a real application on the host. -version: 0.17.0 # x-release-please-version +version: 0.20.0 # x-release-please-version metadata: openclaw: requires: @@ -86,7 +86,8 @@ before stopping or advancing: 3. **Background pixel action.** Use the pixels from the same state snapshot. 4. **Foreground delivery.** Retry only the action that evidence says could not land in the background. -5. **Desktop fallback.** Enter this explicit, one-way session phase last. +5. **Desktop fallback.** Select an exact desktop target for that call only. + Later calls may return to an exact window target in the same session. Use Cua Driver when the outcome lives in an application's UI or window state, or when the user explicitly asks to operate that GUI. Once the task crosses @@ -123,10 +124,27 @@ and keep each claim narrow: partial result, stop that GUI path and surface the unresolved state instead of retrying blindly. +### Clipboard outcomes and GUI fallbacks + +When the requested postcondition is an exact value on the system clipboard, +rather than the literal gesture of selecting and copying it, keep the operation +semantic. Read the value from the narrowest typed source, call +`clipboard_write`, then prove the real clipboard state with `clipboard_read`. +For browser content, this means reading the page with `get_browser_state` and +writing the exact observed text; a passive page-text ref does not need to be +clicked first. + +Use visual selection followed by the platform copy hotkey only when the user +explicitly asks for that gesture, the source cannot expose the value +semantically, or direct clipboard tools are unavailable. Treat that as a GUI +fallback: re-snapshot before acting, verify the selected range when the +application exposes it, and escalate only the delivery step that cannot land +in the background. + ## The no-foreground principle (window phase) -In a strict `window` session, and during the initial window phase of an -`auto` session, **the user's frontmost app MUST NOT change.** Every platform +During window-targeted background actions, **the user's frontmost app MUST NOT +change.** Every platform has its own list of forbidden commands: - macOS: any `open` invocation, any `osascript` that mutates GUI @@ -140,11 +158,11 @@ If you reach for a command that says "activate", "foreground", "raise", or "make key", stop and translate to the cua-driver tool that does the same intent without focus-stealing. -A strict `desktop` session is an explicit user choice to operate the visible -desktop and therefore uses foreground/system input. An `auto` session may enter -that phase only after the complete window ladder below has been attempted and -verified, followed by `escalate_session`. Never infer desktop permission from a -failed action or a proxy/transport session id. +A desktop target is an explicit per-call choice to operate the visible desktop +and therefore uses foreground/system input. Use it only after the narrower +window ladder has been attempted and verified. Permission policy must still +admit the display resource. Never infer desktop permission from a failed action +or a public session label. ## GUI transport defaults — prefer cua-driver over GUI shell shims @@ -215,7 +233,7 @@ qwen-cua-driver stop ``` For Chromium page content, keep the same native window selection but switch to -the browser capability loop: `start_session`, bind `(pid, window_id)` with +the browser capability loop: use one lifecycle session, bind `(pid, window_id)` with `get_browser_state`, snapshot the returned tab, then use `browser_click`, `browser_type`, or `browser_navigate`. Read `BROWSER.md` before using this route. Browser target ids, tab ids, and refs are session-scoped and stale refs @@ -223,8 +241,9 @@ must be replaced by a fresh snapshot. ## Agent cursor overlay -Visual cursor overlay for demos and screen recordings. It is enabled by -default for declared sessions; anonymous actions remain cursor-less. Toggle with +Visual cursor overlay for demos and screen recordings. It initializes on the +first cursor-bearing action, including `move_cursor`, and follows the +transport's implicit or named lifecycle session. Toggle a named cursor with `set_agent_cursor_enabled` to hide or re-show it. The embedded `cua.default` theme uses a session-colored pointer over a larger, cursor-shaped glow in the same session color. The glow fades to transparent @@ -266,6 +285,11 @@ recording, do a pixel click (`click({pid,x,y})`) or a `move_cursor` first to put the cursor on-screen; subsequent AX actions then glide the full path normally. +Pixel `click` already glides the overlay. Do not call `move_cursor` +immediately before `click` on the same target; that plays two glides. +Use `move_cursor` to place the overlay without clicking, or as the +one-time seed above before AX actions. + Requires a suitable UI event loop. Service and private-worker runtimes provide one. On macOS, a same-process SDK runtime or `qwen-cua-driver mcp --direct` without a certified host main-thread adapter returns a structured @@ -328,9 +352,11 @@ The route vocabulary is intentionally cross-platform: An optional escalation is a harness instruction, never an automatic retry: - `pixel`: refresh visual state and choose an exact pixel target; -- `foreground`: explicitly select foreground delivery if session policy allows; +- `foreground`: explicitly select foreground delivery if the authorization + stack admits the tool and exact target; - `page`: bind the native window to a supported browser page route; -- `session`: prepare or explicitly widen the session only when policy permits. +- `session`: a legacy compatibility signal from an older capture-scope daemon; + current callers choose a desktop target on the specific action instead. Branch on the closed reason vocabulary: `route_unavailable`, `delivery_failed`, `effect_unconfirmed`, @@ -340,36 +366,67 @@ After any action, keep using `verify_state` or a fresh state snapshot for the actual task postcondition. The multimodal harness owns visual reading and the decision to stop, retry, or advance the ladder. -## Choose capture scope when the session starts +## Choose the target on each action -`capture_scope` is a per-session policy, not persistent configuration. Declare -it with `start_session`; it is immutable until that session ends. Concurrent -sessions may choose different policies safely. +A session owns lifecycle, cursor, recording, cleanup, and telemetry state. It +does not store the current capture modality. Select an exact target on each +action: -- `auto` (default): begins with effective scope `window`. Desktop perception - and actions are locked until the window ladder is exhausted, each attempted - action is verified, and the caller explicitly invokes `escalate_session`. - Escalation is one-way for the live session. -- `window`: strict window-only perception and actions. Desktop tools are always - rejected with `desktop_scope_disabled`. -- `desktop`: strict full-desktop perception and foreground/system actions. - Window-scoped perception and actions are rejected with - `window_scope_disabled`. - -```bash -qwen-cua-driver start_session '{"session":"research-1","capture_scope":"auto"}' -qwen-cua-driver get_session_state '{"session":"research-1"}' +```jsonc +{"target":{"kind":"window","pid":844,"window_id":10725}} +{"target":{"kind":"desktop","display_id":"primary"}} ``` -Do not use `config set capture_scope` or `set_config`; that key is retired and -stale values on disk are ignored. Always pass the public `session` field on -state and action calls. Reserved fields such as `_session_id` are transport -metadata and cannot create or change policy. +The window target uses window-local coordinates and the background/foreground +delivery ladder. The desktop target uses screen coordinates and foreground +delivery. A desktop action does not disable window tools for later calls. -During a mixed-version rollout, require `tools/list` to advertise -`session.capture_scope` (and `session.capture_scope.escalate` for `auto`). If an -older daemon does not advertise them, fail closed and ask for an upgrade; never -fall back to the retired global config key. +`start_session` is optional. For a multi-call run, prefer a short public +`session` label and pass the same label on every call that accepts it. The label +is call-scoped: if a later call omits it, that call uses the authenticated +transport's implicit session instead. Unnamed calls on one transport reuse that +implicit identity. The default idle TTL is five minutes. Call +`start_session(session)` to name or configure a run before acting, or to revive +an ended name. + +Do not use `config set capture_scope` or `set_config`; that key is retired and +stale values on disk are ignored. `start_session.capture_scope`, +`get_session_state`, and `escalate_session` are deprecated compatibility +surfaces. There is no `deescalate_session`. Reserved fields such as +`_session_id` are transport metadata and cannot create authority. + +## Keep authorization separate from sessions + +The trusted host selects one permission profile at startup. `standard` keeps +the normal profile behavior and residual approval requirements, `bounded` +requires a reviewed capability manifest and has no runtime approval path, and +`unrestricted` bypasses Cua approval prompts after explicit risk acceptance. +Hard invariants plus managed and user policy remain binding in every profile. + +An optional capability manifest is a deny-by-default ceiling in `standard` and +`unrestricted`; `bounded` requires one. It can remove tools or typed resources +from the selected profile, but it cannot grant a tool, resource, or approval +bypass that another authorization layer denies. Approval is considered only +after the tool and every adapter-attested resource are inside manifest scope. + +Use the canonical startup pair together: + +```bash +qwen-cua-driver mcp \ + --permission-mode standard \ + --capability-manifest ./capabilities.yaml \ + --approve-capability-manifest +``` + +Capability manifest v3 omits file-level `mode` and `ask.tools`. Its +`allow.tools` list is nonempty. Lifetime fields are optional in `standard` and +`unrestricted`; `bounded` requires both `expires_after` and `idle_timeout`. +The older `--session-policy` names remain compatibility aliases and must not be +used in new configurations. + +Starting, ending, naming, reconnecting, or omitting a session never changes +permission authority. A public session label is lifecycle metadata, never a +grant, caller identity, or bearer credential. ### Why window selection is the caller's job now @@ -483,6 +540,13 @@ success” above. The old `verified`, `path`, coordinates, scope, and The full wire contract and 0.14 migration notes are in `../../../docs/action-result-contract.md`. +A successful accessibility value write can still return +`effect:"unverifiable"` when the provider publishes its new value only after +the action call unwinds. Take a fresh snapshot before retrying; an immediate +retry can duplicate text. An explicit pixel escalation is reserved for a web +surface whose accessibility layer echoed the write without proving that the +renderer observed it. + `get_window_state` itself, when the AX tree comes back empty (a non-AX surface like Electron/Chromium/canvas), returns `degraded: true` plus an observation-specific escalation hint — normally pointing at pixels (you @@ -547,15 +611,12 @@ if resp.escalation.target == "foreground" # unfocused window there; see LINUX.md verify again -# Route 5 — desktop fallback (auto sessions only, explicit and one-way) +# Route 5 — per-call desktop fallback # Reach this only after semantic, AX, window-pixel, and foreground-window # delivery have all been exhausted and verified ineffective. -escalate_session(session, - reason="foreground_ineffective", # or another advertised reason - detail="bounded non-sensitive summary") -get_desktop_state(session) # full primary display -desktop_action(session, scope="desktop", ...) # no pid/window_id -get_desktop_state(session) # verify in the same coordinate frame +get_desktop_state() # full primary display +desktop_action(target={kind:"desktop", display_id:"primary"}, ...) +get_desktop_state() # verify in the same coordinate frame ``` The two ideas to hold onto: (1) the AX tree **lies** on canvas / web / @@ -586,44 +647,45 @@ last resort. ## The canonical loop ``` -start_session(session, capture_scope="auto") # once per run; policy is immutable -launch_app(target) +# for multi-call work, repeat the same session label on every call that accepts it +launch_app(target, session) → pick window_id from the returned `windows` array (or call list_windows(pid) separately) → get_window_state(pid, window_id) - → [act] # every action also takes (pid, window_id) + your `session` + → [act] # pass target={kind:"window", pid, window_id} → verify_state(pid, window_id, expect) # structured check; optional image -end_session(session) # when the run finishes +end_session(session?) # optional explicit cleanup ``` -For strict desktop sessions, replace the window portion with -`get_desktop_state(session) → action(session, scope="desktop", ...) → -get_desktop_state(session)`. Desktop actions use screen-absolute coordinates -from that exact full-display image and omit `pid`/`window_id`. The global -`get_screen_size` and `get_cursor_position` helpers are desktop-scoped too. +For screen-absolute work, replace the window portion with +`get_desktop_state() → action(target={kind:"desktop",display_id:"primary"}, ...) +→ get_desktop_state()`. Desktop actions use coordinates from that exact +full-display image. `launch_app` now returns a `windows` array alongside the pid, so the common case collapses to two calls (`launch_app` → `get_window_state`) without a separate `list_windows` hop. -**Declare a session.** A session is _your run's_ identity — a stable id -you choose (`"research-1"`), declared with `start_session` and passed as -`session` on every action. It owns your agent cursor and capture policy (a -distinct colour and one immutable policy per id), follows the run across any -apps/windows, and is the same whether -you drive over MCP, the CLI, or the socket. Declaring the session creates the -cursor; anonymous actions remain cursor-less. -End with `end_session` (or the idle-TTL reclaims it). +**Prefer a named session for multi-call work.** Choose a short label (for +example, `session: "research-1"`) and pass the same value on every call that +accepts it. Passing it once is not sticky: a later call that omits `session` +uses the transport's implicit session. Call `start_session(session)` when you +need to name or configure the run before acting, or to revive a name after +`end_session`. For one-off or deliberately unlabeled work, omission is valid +and the transport still gets one private lifecycle identity and visible agent +cursor. A public label makes inspection and cleanup easier, but it is not a +credential. End with `end_session` when useful; transport close or the +five-minute idle TTL also reclaims it. -**Concurrent runs/subagents:** each run may independently choose `auto`, -`window`, or `desktop`; one session's escalation never changes another. Also, +**Concurrent runs/subagents:** each transport gets its own implicit session. +Also, `launch_app` is idempotent — two runs that launch the same app get the **same** instance (and on single-instance apps like Calculator, the same window), so they clobber each other. Give each run -its **own `session`** (→ its own cursor) AND pass +its **own connection** (for independent lifecycle/cursor ownership) AND pass `creates_new_application_instance: true` to `launch_app` (→ its own window). -The element cache is keyed on `(pid, window_id)` and the cursor on `session`, -so distinct instances + distinct sessions keep the runs fully separated. +The element cache is keyed on `(pid, window_id)` and the cursor on the private +lifecycle session, so distinct instances and transports keep the runs isolated. **Parallelism vs. ordering.** Distinct sessions give distinct _cursors_, not distinct _connections_. Subagents that share one `qwen-cua-driver mcp` (stdio) diff --git a/packages/cua-driver/rust/Skills/cua-driver/WINDOWS.md b/packages/cua-driver/rust/Skills/cua-driver/WINDOWS.md index 68ced610d7..716513566a 100644 --- a/packages/cua-driver/rust/Skills/cua-driver/WINDOWS.md +++ b/packages/cua-driver/rust/Skills/cua-driver/WINDOWS.md @@ -412,15 +412,18 @@ When a qwen-cua-driver call surprises you, diagnose qwen-cua-driver first: `capture_mode` param is **deprecated and ignored** — it's still accepted so old callers don't error, but both the tree and the image come back regardless of what you pass. -- **`get_desktop_state` returns `desktop_scope_disabled`?** That's - intended: full-display capture is a **desktop-scope** operation, gated - by the caller-declared session policy. To verify a specific window use - `get_window_state(pid, window_id)` (works backgrounded). Use - `get_desktop_state` only in a strict desktop session or after explicitly - escalating an `auto` session once the window ladder is exhausted. Desktop - actions pass `scope:"desktop"` with no pid/window_id. Don't reach for - `get_desktop_state` as a casual screenshot — it's the capture surface for - desktop-scope coordinate loops, not window inspection. +- **`list_windows` returns Win32 windows but misses UWP / WebView2 + windows?** UIA desktop enumeration may be degraded because a provider + is unresponsive. `list_windows` falls back to Win32-only output instead + of hanging; run `cua-driver doctor` and retry after the provider + recovers. +- **Need full-display capture?** Use `get_desktop_state` only for a desktop + coordinate loop. To verify a specific window, use + `get_window_state(pid, window_id)` instead; it works while backgrounded. + Desktop actions use + `target:{"kind":"desktop","display_id":"primary"}`. Don't reach for + `get_desktop_state` as a casual screenshot—it is the desktop capture + surface, not window inspection. - **`Calc display stuck at 0 after my clicks`?** Almost always means UWP and you're on the PostMessage path. UWP processes pointer input via `Windows.UI.Input`, NOT through HWND message @@ -482,8 +485,9 @@ your prior tool calls earned. schtasks /Run /TN qwen-cua-driver-serve ``` 3. **Run `qwen-cua-driver doctor`** — reports session ID, COM apartment - status, UIA reachability, install paths, version. If anything reads - `false` / `error`, fix that before tool-calling. + status, UIA desktop-enumeration reachability, install paths, + version. If anything reads `false` / `error`, fix that before + tool-calling. 4. **Permissions** — Windows has no TCC equivalent. cua-driver-rs needs: - No admin elevation for normal use (UIA, PostMessage, UWP @@ -781,21 +785,26 @@ typed browser tools yet. - **`hotkey` / `press_key`** (keystroke + key-combo): `delivery_mode:"background"` surfaces a `background_unavailable` error for VCL. - **`type_text`** does a **UIA read-back** and returns the shared - `ActionResult`: `effect:"confirmed"` with `evidence:[{"kind": - "value_readback"}]` when the value reflects the text, and - `effect:"unverifiable"` when the value is unchanged or unreadable. Use the - optional `escalation` to choose the next rung. **Pass an - `element_index`** for reliable verification: the read-back then reads - _that specific element_ by handle (ValuePattern → TextPattern), which is - **focus-independent** — it can confirm or disprove a change whether or not - the target is foreground. (Verified live against the WPF harness: typed - via element_index, read back confirmed, value independently present in - the next snapshot — app never fronted.) **Without** an element_index it - falls back to system-wide `GetFocusedElement`, which on Windows only - resolves when the target is the **foreground** app (no per-app - `AXFocusedUIElement` like macOS); a backgrounded target then reads - an unverifiable result even when the text actually landed — so it is NOT a - failure signal; call `verify_state` or inspect a fresh screenshot. + `ActionResult`. With an `element_index`, the ValuePattern path returns + `effect:"confirmed"` with `evidence:[{"kind":"value_readback"}]` only + when the complete expected value is synchronously visible and differs from + the prior value. If SetValue succeeded but the provider still exposes the + old or no value, the result is `effect:"unverifiable"` with no escalation. + This is common for deferred providers such as AccessKit: take a fresh + snapshot before retrying because the value may publish only after + `type_text` returns and an immediate retry can duplicate text. A pixel + escalation is reserved for an Electron/web accessibility echo that does + not prove the renderer observed the write. The PostMessage fallback keeps + its separate delivery/read-back behavior. Even when the value changes and + contains the requested text, the result stays `effect:"unverifiable"` because + WM_CHAR does not expose the insertion point. Take a fresh snapshot before + retrying. It may recommend foreground when a background insert appears + dropped. Passing an `element_index` makes the + read-back target that exact element by handle (ValuePattern → TextPattern), + independent of foreground focus. Without one, PostMessage verification + falls back to system-wide `GetFocusedElement`, which normally resolves only + for the foreground app; an unreadable result is therefore not proof of + failure. Escalate to `delivery_mode:"foreground"` for both (SendInput Unicode / accelerator). **But** foreground needs the swap to actually land — if the daemon lacks UIAccess and `bring_to_front` returns `landed_on_target:false` @@ -828,7 +837,7 @@ typed browser tools yet. - Daemon version and install paths - Current session ID (must be ≥1) - COM apartment status (STA / MTA / uninitialized) -- UIA reachability (can we connect to `CUIAutomation`?) +- UIA reachability (can we create `CUIAutomation` and enumerate desktop children?) - AppX broker reachability (for packaged-app activation) - PATH state (is `qwen-cua-driver` actually on PATH?) - Autostart Scheduled Task status diff --git a/packages/cua-driver/rust/VERSION b/packages/cua-driver/rust/VERSION index c5523bd09b..5a03fb737b 100644 --- a/packages/cua-driver/rust/VERSION +++ b/packages/cua-driver/rust/VERSION @@ -1 +1 @@ -0.17.0 +0.20.0 diff --git a/packages/cua-driver/rust/crates/cua-driver-contract/src/compatibility.rs b/packages/cua-driver/rust/crates/cua-driver-contract/src/compatibility.rs index 44f7aeba05..2471f3a178 100644 --- a/packages/cua-driver/rust/crates/cua-driver-contract/src/compatibility.rs +++ b/packages/cua-driver/rust/crates/cua-driver-contract/src/compatibility.rs @@ -43,6 +43,14 @@ pub fn schema_subset_violations(portable: &Value, live: &Value) -> Vec { } fn compare_schema(path: &str, portable: &Value, live: &Value, violations: &mut Vec) { + // Exact schema equality is itself a proof of subset compatibility, even + // when the shared schema contains a construct (for example a generated + // tagged-union `anyOf`) that this intentionally small implication engine + // does not otherwise interpret. + if portable == live { + return; + } + let Some(portable_object) = portable.as_object() else { violations.push(format!("{path}: portable schema must be an object")); return; @@ -370,6 +378,17 @@ mod tests { assert!(violations[0].contains("unsupported portable schema keyword `pattern`")); } + #[test] + fn identical_unknown_schema_is_a_proven_subset() { + let schema = json!({ + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + }); + assert!(schema_subset_violations(&schema, &schema).is_empty()); + } + #[test] fn additional_property_domain_must_be_accepted_live() { let portable = json!({ "type": "object" }); diff --git a/packages/cua-driver/rust/crates/cua-driver-contract/src/desktop.rs b/packages/cua-driver/rust/crates/cua-driver-contract/src/desktop.rs index 93a3406591..aa43b8b4dc 100644 --- a/packages/cua-driver/rust/crates/cua-driver-contract/src/desktop.rs +++ b/packages/cua-driver/rust/crates/cua-driver-contract/src/desktop.rs @@ -10,10 +10,11 @@ use crate::{ ActionResult, ClickInput, ClipboardReadInput, ClipboardReadOutput, ClipboardWriteInput, ClipboardWriteOutput, CursorAction, CursorPositionOutput, CursorSemantics, DesktopStateOutput, - DragInput, GetCursorPositionInput, GetDesktopStateInput, GetScreenSizeInput, HotkeyInput, - InvokeMenuInput, MoveCursorInput, Platform, PressKeyInput, SchemaMode, ScreenSizeOutput, - ScrollInput, SetWindowFrameInput, ToolAnnotations, ToolContract, ToolInput, ToolOutput, - TypeTextInput, + DragInput, GetCursorPositionInput, GetDesktopStateInput, GetScreenSizeInput, + GetWindowStateInput, HotkeyInput, InvokeMenuInput, MoveCursorInput, + PerformSecondaryActionInput, Platform, PressKeyInput, SchemaMode, ScreenSizeOutput, + ScrollInput, SetValueInput, SetWindowFrameInput, ToolAnnotations, ToolContract, ToolInput, + ToolOutput, TypeTextInput, WindowStateOutput, }; const ALL_PLATFORMS: [Platform; 3] = [Platform::Macos, Platform::Windows, Platform::Linux]; @@ -21,6 +22,7 @@ const ALL_PLATFORMS: [Platform; 3] = [Platform::Macos, Platform::Windows, Platfo pub fn contracts() -> Vec { vec![ get_desktop_state(), + get_window_state(), get_screen_size(), get_cursor_position(), move_cursor(), @@ -29,6 +31,8 @@ pub fn contracts() -> Vec { click(), drag(), scroll(), + set_value(), + perform_secondary_action(), clipboard_read(), clipboard_write(), type_text(), @@ -157,6 +161,35 @@ fn get_desktop_state() -> ToolContract { ) } +// The window snapshot payload stays platform-owned (markdown + structured +// elements shapes are still converging), so this contract commits only the +// portable input projection and the versioned observation-revision envelope; +// the remaining output fields stay additive extensions — the same stance +// `list_windows` takes above. +fn get_window_state() -> ToolContract { + contract::( + "get_window_state", + "Walk one exact window's accessibility tree and return the actionable element snapshot, optionally as a versioned observation revision (`accessibility.observation_revision.v1`) diffed against a caller-supplied base.", + &[ + "accessibility.window_state", + "accessibility.tree", + "accessibility.tree.structured", + "accessibility.tree.bounded", + "accessibility.element_tokens", + "accessibility.observation_revision.v1", + "screen.capture", + "screen.capture.window", + ], + ToolAnnotations { + read_only: true, + destructive: false, + idempotent: false, + open_world: false, + }, + CursorAction::Observe, + ) +} + fn get_screen_size() -> ToolContract { contract::( "get_screen_size", @@ -281,6 +314,39 @@ fn scroll() -> ToolContract { ) } +fn set_value() -> ToolContract { + contract::( + "set_value", + "Set the value of the exact current accessibility element named by an opaque element token.", + &["accessibility.value.set", "accessibility.element_tokens"], + ToolAnnotations { + read_only: false, + destructive: true, + idempotent: true, + open_world: true, + }, + CursorAction::Text, + ) +} + +fn perform_secondary_action() -> ToolContract { + contract::( + "perform_secondary_action", + "Perform one explicitly named action advertised by the exact current accessibility element. Missing, ambiguous, disabled, or stale actions fail closed without a pixel fallback.", + &[ + "accessibility.action.secondary", + "accessibility.element_tokens", + ], + ToolAnnotations { + read_only: false, + destructive: true, + idempotent: false, + open_world: true, + }, + CursorAction::App, + ) +} + fn type_text() -> ToolContract { contract::( "type_text", @@ -319,7 +385,7 @@ fn hotkey() -> ToolContract { contract::( "hotkey", "Press a key chord in the foreground desktop application.", - &["input.keyboard.hotkey"], + &["input.keyboard.hotkey", "accessibility.element_tokens"], ToolAnnotations { read_only: false, destructive: true, diff --git a/packages/cua-driver/rust/crates/cua-driver-contract/src/inputs.rs b/packages/cua-driver/rust/crates/cua-driver-contract/src/inputs.rs index 3d0c8bf810..4e1c3bb951 100644 --- a/packages/cua-driver/rust/crates/cua-driver-contract/src/inputs.rs +++ b/packages/cua-driver/rust/crates/cua-driver-contract/src/inputs.rs @@ -58,6 +58,14 @@ fn string_schema(generator: &mut SchemaGenerator) -> Schema { String::json_schema(generator) } +fn boolean_schema(generator: &mut SchemaGenerator) -> Schema { + bool::json_schema(generator) +} + +pub const MULTI_CALL_SESSION_DESCRIPTION: &str = + "For multi-call work, prefer a short public session label and repeat it on every call that \ + accepts it. Omit it to use the authenticated transport's implicit lifecycle session."; + fn string_list_schema(generator: &mut SchemaGenerator) -> Schema { Vec::::json_schema(generator) } @@ -83,6 +91,23 @@ fn positive_integer_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({ "type": "integer", "minimum": 1 }) } +fn observation_revision_schema(_: &mut SchemaGenerator) -> Schema { + // Mirrors the live platform subschema so the portable projection stays a + // provable subset of every runtime `get_window_state` schema. + json_schema!({ + "type": "object", + "required": ["version", "serializer_version", "projection_version"], + "properties": { + "version": { "type": "integer", "const": 1 }, + "serializer_version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "projection_version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "base_revision_id": { "type": "string", "minLength": 1, "maxLength": 256 }, + "force_full": { "type": "boolean", "default": false } + }, + "additionalProperties": false + }) +} + fn click_button_schema(generator: &mut SchemaGenerator) -> Schema { ClickButton::json_schema(generator) } @@ -103,6 +128,10 @@ fn drag_steps_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({ "type": "integer", "minimum": 1, "maximum": 200 }) } +fn delivery_mode_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "type": "string", "enum": ["background", "foreground"] }) +} + fn scroll_amount_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({ "type": "integer", "minimum": 1, "maximum": 50 }) } @@ -111,6 +140,10 @@ fn cursor_theme_id_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({ "type": "string", "minLength": 1, "maxLength": 200 }) } +fn element_token_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ "type": "string", "minLength": 1, "maxLength": 512 }) +} + fn escalation_detail_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({ "type": "string", "maxLength": 200 }) } @@ -118,8 +151,7 @@ fn escalation_detail_schema(_: &mut SchemaGenerator) -> Schema { fn capture_scope_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({ "type": "string", - "enum": ["auto", "window", "desktop"], - "default": "auto" + "enum": ["auto", "window", "desktop"] }) } @@ -198,6 +230,42 @@ pub enum DesktopScope { Desktop, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, uniffi::Enum)] +#[serde(rename_all = "snake_case")] +pub enum DeliveryMode { + Background, + Foreground, +} + +fn desktop_scope_schema(generator: &mut SchemaGenerator) -> Schema { + DesktopScope::json_schema(generator) +} + +/// Exact capture/input target selected independently for each action. +/// +/// `display_id="primary"` is the portable desktop target in this release. +/// Platforms that cannot address another display reject it explicitly rather +/// than silently changing coordinate spaces. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, uniffi::Enum)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ActionTarget { + Window { pid: u32, window_id: u64 }, + Desktop { display_id: String }, +} + +pub fn action_target_schema() -> Value { + let settings = schemars::generate::SchemaSettings::draft2020_12().with(|settings| { + settings.meta_schema = None; + settings.inline_subschemas = true; + }); + let schema = settings + .into_generator() + .into_root_schema_for::(); + let mut value = serde_json::to_value(schema).expect("action target schema serializes"); + normalize_schema(&mut value); + value +} + impl JsonSchema for DesktopScope { fn schema_name() -> std::borrow::Cow<'static, str> { "DesktopScope".into() @@ -264,9 +332,14 @@ impl ScrollBy { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] pub struct StartSessionInput { - /// Stable session id for this run (e.g. "research-run-1"). - pub session: String, - /// Per-session perception/action modality. auto starts window-only and requires explicit escalation before desktop tools; window and desktop are strict. Immutable for the live session. + /// Optional stable public label for this run (e.g. "research-run-1"). + /// When omitted, the authenticated transport lease's implicit session is + /// created or returned. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub session: Option, + /// Deprecated compatibility policy. New callers select window or desktop + /// modality on each action instead of storing it on the session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "capture_scope_schema")] pub capture_scope: Option, @@ -296,17 +369,53 @@ impl ToolInput for EscalateSessionInput { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] pub struct GetSessionStateInput { - pub session: String, + /// Optional public label. When omitted, inspect the caller's attached + /// implicit session. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub session: Option, } impl ToolInput for GetSessionStateInput { const TOOL_NAME: &'static str = "get_session_state"; } +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +pub struct GetSessionInput { + /// Optional public label. When omitted, inspect the caller's attached + /// implicit session. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub session: Option, +} + +impl ToolInput for GetSessionInput { + const TOOL_NAME: &'static str = "get_session"; +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +pub struct ListSessionsInput { + /// Maximum number of content-free summaries to return (default 50, max + /// 100). Ordinary agent transports are scoped to their own lease. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Opaque continuation cursor returned by a previous call. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub cursor: Option, +} + +impl ToolInput for ListSessionsInput { + const TOOL_NAME: &'static str = "list_sessions"; +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] pub struct EndSessionInput { - /// The session id to end. - pub session: String, + /// Optional public label to end. When omitted, end the caller's attached + /// implicit session. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub session: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] @@ -370,7 +479,8 @@ impl ToolInput for EndSessionInput { #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] #[serde(deny_unknown_fields)] pub struct GetDesktopStateInput { - /// Optional session id. + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -384,10 +494,103 @@ impl ToolInput for GetDesktopStateInput { const TOOL_NAME: &'static str = "get_desktop_state"; } +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct ListAppsInput {} + +impl ToolInput for ListAppsInput { + const TOOL_NAME: &'static str = "list_apps"; +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct ListWindowsInput { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub pid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_screen_only: Option, +} + +impl ToolInput for ListWindowsInput { + const TOOL_NAME: &'static str = "list_windows"; +} + +/// Opt in to the versioned `accessibility.observation_revision.v1` protocol on +/// `get_window_state`. Omitting the whole record preserves the legacy +/// full-snapshot contract byte for byte. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct ObservationRevisionInput { + /// Protocol version. Only `1` is defined; any other value is rejected + /// with a closed `invalid_observation_revision` error. + pub version: u32, + /// Canonical accessibility serializer expected by the caller. A mismatch + /// returns a full response with `serializer_changed`. + pub serializer_version: String, + /// Canonical tree projection expected by the caller. A mismatch returns a + /// full response with `projection_changed`. + pub projection_version: String, + /// Revision the caller wants to diff against. The caller — never the + /// driver — decides which revision was actually delivered downstream. + /// Missing, expired, foreign, or incompatible bases yield a full response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_revision_id: Option, + /// Force a full resynchronization even when a compatible base exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub force_full: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct GetWindowStateInput { + /// Target process ID. + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + /// Exact window to observe. + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: u64, + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub session: Option, + /// Case-insensitive projection filter. Incompatible with `observation_revision`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub query: Option, + /// Default true. Set false to skip the screenshot and return the tree only. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "boolean_schema")] + pub include_screenshot: Option, + /// Write the PNG here instead of returning base64. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub screenshot_out_file: Option, + /// Cap on the total number of accessibility nodes walked. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub max_elements: Option, + /// Cap on the accessibility-tree walk depth. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub max_depth: Option, + /// Opt in to `accessibility.observation_revision.v1`. Requires a bound + /// driver session. Omit to preserve the legacy full-snapshot contract. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "observation_revision_schema")] + pub observation_revision: Option, +} + +impl ToolInput for GetWindowStateInput { + const TOOL_NAME: &'static str = "get_window_state"; +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] #[serde(deny_unknown_fields)] pub struct GetScreenSizeInput { - /// Optional session id. + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -400,7 +603,8 @@ impl ToolInput for GetScreenSizeInput { #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] #[serde(deny_unknown_fields)] pub struct GetCursorPositionInput { - /// Optional session id. + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -417,8 +621,15 @@ pub struct MoveCursorInput { pub x: f64, #[schemars(schema_with = "number_schema")] pub y: f64, - pub scope: DesktopScope, - /// Optional session id. + /// Preferred per-call target. New callers should set this field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + /// Deprecated flat desktop target retained for wire compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "desktop_scope_schema")] + pub scope: Option, + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -439,7 +650,8 @@ pub struct SetWindowFrameInput { pub width: f64, #[schemars(schema_with = "positive_number_schema")] pub height: f64, - /// Optional session id. + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -461,7 +673,8 @@ pub struct InvokeMenuInput { pub window_id: u64, #[schemars(schema_with = "menu_path_schema")] pub path: Vec, - /// Optional session id. + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -482,8 +695,14 @@ pub struct ClickInput { pub x: f64, #[schemars(schema_with = "number_schema")] pub y: f64, - pub scope: DesktopScope, - /// Optional session id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + /// Deprecated flat desktop target retained for wire compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "desktop_scope_schema")] + pub scope: Option, + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -499,6 +718,86 @@ impl ToolInput for ClickInput { const TOOL_NAME: &'static str = "click"; } +/// Exact-window click input for the generated SDK. The existing [`ClickInput`] +/// remains the portable desktop-coordinate form. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct WindowClickInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "element_token_schema")] + pub element_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "number_schema")] + pub x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "number_schema")] + pub y: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "click_button_schema")] + pub button: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "click_count_schema")] + pub count: Option, +} + +impl ToolInput for WindowClickInput { + const TOOL_NAME: &'static str = "click"; +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct DoubleClickInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "element_token_schema")] + pub element_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "number_schema")] + pub x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "number_schema")] + pub y: Option, +} + +impl ToolInput for DoubleClickInput { + const TOOL_NAME: &'static str = "double_click"; +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct RightClickInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "element_token_schema")] + pub element_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "number_schema")] + pub x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "number_schema")] + pub y: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_list_schema")] + pub modifier: Option>, +} + +impl ToolInput for RightClickInput { + const TOOL_NAME: &'static str = "right_click"; +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] #[serde(deny_unknown_fields)] pub struct DragInput { @@ -510,8 +809,14 @@ pub struct DragInput { pub to_x: f64, #[schemars(schema_with = "number_schema")] pub to_y: f64, - pub scope: DesktopScope, - /// Optional session id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + /// Deprecated flat desktop target retained for wire compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "desktop_scope_schema")] + pub scope: Option, + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -533,6 +838,44 @@ impl ToolInput for DragInput { const TOOL_NAME: &'static str = "drag"; } +/// Exact-window drag input for the generated SDK. The existing [`DragInput`] +/// keeps its established UniFFI record layout. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct WindowDragInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: u64, + #[schemars(schema_with = "number_schema")] + pub from_x: f64, + #[schemars(schema_with = "number_schema")] + pub from_y: f64, + #[schemars(schema_with = "number_schema")] + pub to_x: f64, + #[schemars(schema_with = "number_schema")] + pub to_y: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "drag_duration_schema")] + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "drag_steps_schema")] + pub steps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "delivery_mode_schema")] + pub delivery_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "click_button_schema")] + pub button: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_list_schema")] + pub modifier: Option>, +} + +impl ToolInput for WindowDragInput { + const TOOL_NAME: &'static str = "drag"; +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] #[serde(deny_unknown_fields)] pub struct ScrollInput { @@ -541,8 +884,14 @@ pub struct ScrollInput { #[schemars(schema_with = "number_schema")] pub y: f64, pub direction: ScrollDirection, - pub scope: DesktopScope, - /// Optional session id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + /// Deprecated flat desktop target retained for wire compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "desktop_scope_schema")] + pub scope: Option, + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -558,12 +907,48 @@ impl ToolInput for ScrollInput { const TOOL_NAME: &'static str = "scroll"; } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct WindowScrollInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "element_token_schema")] + pub element_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "number_schema")] + pub x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "number_schema")] + pub y: Option, + pub direction: ScrollDirection, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "scroll_by_schema")] + pub by: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "scroll_amount_schema")] + pub amount: Option, +} + +impl ToolInput for WindowScrollInput { + const TOOL_NAME: &'static str = "scroll"; +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] #[serde(deny_unknown_fields)] pub struct TypeTextInput { pub text: String, - pub scope: DesktopScope, - /// Optional session id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + /// Deprecated flat desktop target retained for wire compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "desktop_scope_schema")] + pub scope: Option, + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -573,6 +958,43 @@ impl ToolInput for TypeTextInput { const TOOL_NAME: &'static str = "type_text"; } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct WindowTypeTextInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "element_token_schema")] + pub element_token: Option, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delay_ms: Option, +} + +impl ToolInput for WindowTypeTextInput { + const TOOL_NAME: &'static str = "type_text"; +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct SetValueInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: Option, + #[schemars(schema_with = "element_token_schema")] + pub element_token: String, + pub value: String, +} + +impl ToolInput for SetValueInput { + const TOOL_NAME: &'static str = "set_value"; +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] #[serde(deny_unknown_fields)] pub struct ClipboardReadInput { @@ -580,7 +1002,8 @@ pub struct ClipboardReadInput { /// Clipboard content is privacy-sensitive and is never retained in telemetry. #[serde(default)] pub include_text: bool, - /// Optional session id. + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -605,7 +1028,8 @@ pub struct ClipboardWriteInput { #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub file_path: Option, - /// Optional session id. + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -619,8 +1043,14 @@ impl ToolInput for ClipboardWriteInput { #[serde(deny_unknown_fields)] pub struct PressKeyInput { pub key: String, - pub scope: DesktopScope, - /// Optional session id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + /// Deprecated flat desktop target retained for wire compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "desktop_scope_schema")] + pub scope: Option, + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -633,13 +1063,40 @@ impl ToolInput for PressKeyInput { const TOOL_NAME: &'static str = "press_key"; } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct WindowPressKeyInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "element_token_schema")] + pub element_token: Option, + pub key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_list_schema")] + pub modifiers: Option>, +} + +impl ToolInput for WindowPressKeyInput { + const TOOL_NAME: &'static str = "press_key"; +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] #[serde(deny_unknown_fields)] pub struct HotkeyInput { #[schemars(length(min = 2))] pub keys: Vec, - pub scope: DesktopScope, - /// Optional session id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + /// Deprecated flat desktop target retained for wire compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "desktop_scope_schema")] + pub scope: Option, + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, @@ -649,6 +1106,42 @@ impl ToolInput for HotkeyInput { const TOOL_NAME: &'static str = "hotkey"; } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct WindowHotkeyInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "element_token_schema")] + pub element_token: Option, + #[schemars(length(min = 2))] + pub keys: Vec, +} + +impl ToolInput for WindowHotkeyInput { + const TOOL_NAME: &'static str = "hotkey"; +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, uniffi::Record)] +#[serde(deny_unknown_fields)] +pub struct PerformSecondaryActionInput { + #[schemars(schema_with = "positive_integer_schema")] + pub pid: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "positive_integer_schema")] + pub window_id: Option, + #[schemars(schema_with = "element_token_schema")] + pub element_token: String, + pub action: String, +} + +impl ToolInput for PerformSecondaryActionInput { + const TOOL_NAME: &'static str = "perform_secondary_action"; +} + #[cfg(test)] mod tests { use serde_json::json; @@ -660,8 +1153,9 @@ mod tests { let schema = ClickInput::input_schema(); assert_eq!(schema["type"], "object"); assert_eq!(schema["additionalProperties"], false); - assert_eq!(schema["required"], json!(["x", "y", "scope"])); - assert_eq!(schema["properties"]["scope"], json!({ "const": "desktop" })); + assert_eq!(schema["required"], json!(["x", "y"])); + assert_eq!(schema["properties"]["scope"]["const"], "desktop"); + assert!(schema["properties"]["target"]["anyOf"].is_array()); assert_eq!( schema["properties"]["button"], json!({ "type": "string", "enum": ["left", "right", "middle"] }) @@ -673,11 +1167,22 @@ mod tests { } #[test] - fn generated_session_schema_preserves_default_and_open_input() { + fn generated_drag_schema_exposes_the_runtime_delivery_ladder() { + let schema = WindowDragInput::input_schema(); + assert_eq!( + schema["properties"]["delivery_mode"], + json!({ "type": "string", "enum": ["background", "foreground"] }) + ); + } + + #[test] + fn generated_session_schema_makes_name_and_legacy_capture_optional() { let schema = StartSessionInput::input_schema(); assert_eq!(schema["additionalProperties"], true); - assert_eq!(schema["required"], json!(["session"])); - assert_eq!(schema["properties"]["capture_scope"]["default"], "auto"); + assert_eq!(schema["required"], json!([])); + assert!(schema["properties"]["capture_scope"] + .get("default") + .is_none()); assert_eq!( schema["properties"]["capture_scope"]["enum"], json!(["auto", "window", "desktop"]) @@ -689,7 +1194,6 @@ mod tests { let error = serde_json::from_value::(json!({ "x": 1, "y": 2, - "scope": "desktop", "pid": 3 })) .expect_err("portable input must reject runtime-only fields"); diff --git a/packages/cua-driver/rust/crates/cua-driver-contract/src/lib.rs b/packages/cua-driver/rust/crates/cua-driver-contract/src/lib.rs index 2e723bb95f..dcd86e78a5 100644 --- a/packages/cua-driver/rust/crates/cua-driver-contract/src/lib.rs +++ b/packages/cua-driver/rust/crates/cua-driver-contract/src/lib.rs @@ -27,21 +27,29 @@ pub use cursor::{ CursorSemantics, CursorTarget, CursorThemeSelection, }; pub use inputs::{ - CaptureScope, ClickButton, ClickInput, ClipboardReadInput, ClipboardWriteInput, DesktopScope, - DragInput, EndSessionInput, EscalateSessionInput, EscalationReason, GetAgentCursorStateInput, - GetCursorPositionInput, GetDesktopStateInput, GetScreenSizeInput, GetSessionStateInput, - HotkeyInput, InvokeMenuInput, MoveCursorInput, PressKeyInput, ScrollBy, ScrollDirection, - ScrollInput, SetAgentCursorEnabledInput, SetAgentCursorMotionInput, SetAgentCursorThemeInput, - SetWindowFrameInput, StartSessionInput, ToolInput, TypeTextInput, + action_target_schema, ActionTarget, CaptureScope, ClickButton, ClickInput, ClipboardReadInput, + ClipboardWriteInput, DeliveryMode, DesktopScope, DoubleClickInput, DragInput, EndSessionInput, + EscalateSessionInput, EscalationReason, GetAgentCursorStateInput, GetCursorPositionInput, + GetDesktopStateInput, GetScreenSizeInput, GetSessionInput, GetSessionStateInput, + GetWindowStateInput, HotkeyInput, InvokeMenuInput, ListAppsInput, ListSessionsInput, + ListWindowsInput, MoveCursorInput, ObservationRevisionInput, PerformSecondaryActionInput, + PressKeyInput, RightClickInput, ScrollBy, ScrollDirection, ScrollInput, + SetAgentCursorEnabledInput, SetAgentCursorMotionInput, SetAgentCursorThemeInput, SetValueInput, + SetWindowFrameInput, StartSessionInput, ToolInput, TypeTextInput, WindowClickInput, + WindowDragInput, WindowHotkeyInput, WindowPressKeyInput, WindowScrollInput, + WindowTypeTextInput, MULTI_CALL_SESSION_DESCRIPTION, }; pub use outputs::{ - ActionDelivery, ActionDeliveryMode, ActionEffect, ActionEscalation, ActionEscalationReason, - ActionEscalationTarget, ActionEvidence, ActionEvidenceKind, ActionResult, - ActionResultValidationError, ActionRoute, ClipboardReadOutput, ClipboardWriteOutput, - CursorMotionOutput, CursorPointOutput, CursorPositionOutput, CursorThemeOutput, - CursorVisualOutput, DesktopStateOutput, EffectiveScope, EndSessionOutput, - GetAgentCursorStateOutput, ScreenSizeOutput, SessionStateOutput, SetAgentCursorEnabledOutput, + advertised_output_schema, refusal_envelope_schema, ActionDelivery, ActionDeliveryMode, + ActionEffect, ActionEscalation, ActionEscalationReason, ActionEscalationTarget, ActionEvidence, + ActionEvidenceKind, ActionResult, ActionResultValidationError, ActionRoute, + ClipboardReadOutput, ClipboardWriteOutput, CursorMotionOutput, CursorPointOutput, + CursorPositionOutput, CursorThemeOutput, CursorVisualOutput, DesktopStateOutput, + EffectiveScope, EndSessionOutput, GetAgentCursorStateOutput, ListSessionsOutput, + ObservationRevisionOutput, ScreenSizeOutput, SessionClientKindOutput, SessionLifecycleState, + SessionOutput, SessionStateOutput, SessionTransportOutput, SetAgentCursorEnabledOutput, SetAgentCursorMotionOutput, SetAgentCursorThemeOutput, StartSessionOutput, ToolOutput, + WindowStateOutput, }; pub use verification::{ BoundsExpectation, ElementPredicate, ElementSelector, PredicateOutcome, StatePredicate, @@ -56,7 +64,7 @@ pub const TOOLS_LIST_SCHEMA_VERSION: &str = "1"; pub const CAPABILITY_VERSION: &str = "1"; /// Shape version for the checked-in generated client contract. -pub const CONTRACT_VERSION: &str = "0.6.0"; +pub const CONTRACT_VERSION: &str = "0.7.0"; /// MCP protocol version used by current cua-driver clients. pub const MCP_PROTOCOL_VERSION: &str = "2025-06-18"; @@ -81,6 +89,7 @@ pub const ACTION_RESULT_TOOLS: &[&str] = &[ "press_key", "hotkey", "set_value", + "perform_secondary_action", "set_window_frame", "invoke_menu", "browser_click", @@ -282,6 +291,57 @@ pub fn validate_success_output(name: &str, value: Value) -> Result mod tests { use super::*; + #[test] + fn get_window_state_input_serializes_to_the_live_wire_shape() { + let minimal = serde_json::to_value(GetWindowStateInput { + pid: 42, + window_id: 7, + session: None, + query: None, + include_screenshot: None, + screenshot_out_file: None, + max_elements: None, + max_depth: None, + observation_revision: None, + }) + .unwrap(); + assert_eq!(minimal, serde_json::json!({ "pid": 42, "window_id": 7 })); + + let revision = serde_json::to_value(GetWindowStateInput { + pid: 42, + window_id: 7, + session: Some("s1".into()), + query: None, + include_screenshot: Some(false), + screenshot_out_file: None, + max_elements: None, + max_depth: None, + observation_revision: Some(ObservationRevisionInput { + version: 1, + serializer_version: "accessibility-render-v1".into(), + projection_version: "full-tree-v1".into(), + base_revision_id: Some("r_1".into()), + force_full: None, + }), + }) + .unwrap(); + assert_eq!( + revision, + serde_json::json!({ + "pid": 42, + "window_id": 7, + "session": "s1", + "include_screenshot": false, + "observation_revision": { + "version": 1, + "serializer_version": "accessibility-render-v1", + "projection_version": "full-tree-v1", + "base_revision_id": "r_1" + } + }) + ); + } + #[test] fn manifest_is_sorted_and_versioned() { let manifest = manifest(); @@ -293,7 +353,7 @@ mod tests { let mut sorted = names.clone(); sorted.sort_unstable(); assert_eq!(names, sorted); - assert_eq!(manifest.contract_version, "0.6.0"); + assert_eq!(manifest.contract_version, "0.7.0"); assert!(manifest.experimental); } @@ -342,6 +402,45 @@ mod tests { ); } + #[test] + fn portable_action_sessions_explain_named_multi_call_runs() { + for name in [ + "click", + "clipboard_read", + "clipboard_write", + "drag", + "get_cursor_position", + "get_desktop_state", + "get_screen_size", + "hotkey", + "invoke_menu", + "move_cursor", + "press_key", + "scroll", + "set_window_frame", + "type_text", + ] { + let contract = tool_contract(name).expect("portable action contract"); + let description = contract.input_schema["properties"]["session"]["description"] + .as_str() + .expect("portable session description") + .split_whitespace() + .collect::>() + .join(" "); + assert_eq!(description, MULTI_CALL_SESSION_DESCRIPTION, "{name}"); + } + + let verify = tool_contract("verify_state").expect("verify_state contract"); + let description = verify.input_schema["properties"]["session"]["description"] + .as_str() + .expect("verify_state session description") + .split_whitespace() + .collect::>() + .join(" "); + assert!(description.starts_with(MULTI_CALL_SESSION_DESCRIPTION)); + assert!(description.contains("never selects capture modality or authorization")); + } + #[test] fn every_contract_has_success_schema_and_platforms() { for tool in manifest().tools { diff --git a/packages/cua-driver/rust/crates/cua-driver-contract/src/outputs.rs b/packages/cua-driver/rust/crates/cua-driver-contract/src/outputs.rs index eaea4cea46..bc559b5c71 100644 --- a/packages/cua-driver/rust/crates/cua-driver-contract/src/outputs.rs +++ b/packages/cua-driver/rust/crates/cua-driver-contract/src/outputs.rs @@ -18,6 +18,43 @@ pub trait ToolOutput: Serialize + DeserializeOwned + JsonSchema { } } +/// Schema for the refusal payload a tool emits alongside `isError: true`. +/// +/// Refusals answer with a diagnostic shape rather than the success shape, and +/// two are in service: `{"status":"refused","refusal":{code,message,detail}}` +/// on the element-token and daemon paths, and `{"code":…,"effect":"refused",…}` +/// on the window-target paths. Both carry tool-specific diagnostic keys, so the +/// envelope stays open — the marker keys are what make it recognisably a +/// refusal rather than a malformed success. +pub fn refusal_envelope_schema() -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": true, + "anyOf": [ + {"required": ["refusal"]}, + {"required": ["status"]}, + {"required": ["code"]}, + ], + }) +} + +/// Wrap a success schema into the shape advertised as the MCP `outputSchema`. +/// +/// MCP requires every `structuredContent` a tool emits to validate against its +/// advertised `outputSchema` — including payloads that accompany +/// `isError: true`. Advertising the success shape alone made strict clients +/// reject refusals outright, replacing the actionable message the driver had +/// already placed in `content` ("element_token is stale; call get_window_state +/// again to refresh") with an opaque schema-validation error. Agents then had +/// no signal to re-snapshot and fell back to blind pixel clicking. +/// +/// The success variant is kept exactly as generated — still closed, so success +/// payloads stay strictly checked — and the refusal envelope joins it as a +/// sibling variant. +pub fn advertised_output_schema(success: Value) -> Value { + serde_json::json!({ "type": "object", "anyOf": [success, refusal_envelope_schema()] }) +} + fn output_schema_with_additional_properties(additional_properties: bool) -> Value { let mut settings = SchemaSettings::draft2020_12(); settings.inline_subschemas = true; @@ -82,6 +119,59 @@ pub struct SessionStateOutput { impl ToolOutput for SessionStateOutput {} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, uniffi::Enum)] +#[serde(rename_all = "snake_case")] +pub enum SessionLifecycleState { + Active, + Ending, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, uniffi::Enum)] +#[serde(rename_all = "snake_case")] +pub enum SessionClientKindOutput { + Cli, + Direct, + Mcp, + PythonSdk, + TypescriptSdk, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, uniffi::Enum)] +#[serde(rename_all = "snake_case")] +pub enum SessionTransportOutput { + Cli, + Daemon, + McpStdio, + McpHttp, +} + +/// Content-free lifecycle state safe for an ordinary agent transport. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, uniffi::Record)] +pub struct SessionOutput { + /// Sanitized public label, or null for an unnamed implicit session. + #[schemars(required, schema_with = "nullable_string_schema")] + pub session: Option, + pub implicit: bool, + pub state: SessionLifecycleState, + pub client_kind: SessionClientKindOutput, + pub transport: SessionTransportOutput, + pub cursor_visible: bool, + pub recording_active: bool, + pub idle_seconds: u64, + pub expires_in_seconds: u64, +} + +impl ToolOutput for SessionOutput {} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, uniffi::Record)] +pub struct ListSessionsOutput { + pub sessions: Vec, + #[schemars(required, schema_with = "nullable_string_schema")] + pub next_cursor: Option, +} + +impl ToolOutput for ListSessionsOutput {} + /// Successful structured result returned by `start_session`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, uniffi::Record)] pub struct StartSessionOutput { @@ -229,6 +319,109 @@ fn png_mime_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { schemars::json_schema!({ "const": "image/png" }) } +/// Versioned `accessibility.observation_revision.v1` envelope attached to a +/// `get_window_state` success when the caller opted in. Platform runtimes add +/// diagnostic fields beyond this committed core; they flow into `extensions`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct ObservationRevisionOutput { + #[schemars(schema_with = "observation_revision_capability_schema")] + pub capability: String, + #[schemars(schema_with = "integer_schema")] + pub version: u64, + #[schemars(schema_with = "string_schema_required")] + pub serializer_version: String, + #[schemars(schema_with = "string_schema_required")] + pub projection_version: String, + #[schemars(schema_with = "observation_revision_mode_schema")] + pub mode: String, + #[schemars(schema_with = "string_schema_required")] + pub lineage_id: String, + #[schemars(schema_with = "string_schema_required")] + pub revision_id: String, + /// Base the driver actually diffed against. Absent on full responses + /// without a usable base. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub base_revision_id: Option, + /// Whether emitted element tokens are stable revision tokens that survive + /// compatible diffs. False means the response is a non-retained full. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stable_element_ids: Option, + /// Closed reason naming why a full resynchronization was required. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "string_schema")] + pub resync_reason: Option, + #[serde(flatten)] + pub extensions: BTreeMap, +} + +impl ObservationRevisionOutput { + fn validate(&self) -> Result<(), String> { + if self.capability != "accessibility.observation_revision.v1" { + return Err( + "observation_revision.capability must be accessibility.observation_revision.v1" + .into(), + ); + } + if self.version != 1 { + return Err("observation_revision.version must be 1".into()); + } + if self.serializer_version.is_empty() || self.projection_version.is_empty() { + return Err( + "observation_revision serializer_version and projection_version must be non-empty" + .into(), + ); + } + if !matches!(self.mode.as_str(), "full" | "diff" | "no_change") { + return Err("observation_revision.mode must be full, diff, or no_change".into()); + } + if self.lineage_id.is_empty() || self.revision_id.is_empty() { + return Err("observation_revision lineage_id and revision_id must be non-empty".into()); + } + if matches!(self.mode.as_str(), "diff" | "no_change") && self.base_revision_id.is_none() { + return Err( + "observation_revision diff/no_change responses must name their base_revision_id" + .into(), + ); + } + Ok(()) + } +} + +fn observation_revision_capability_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ "const": "accessibility.observation_revision.v1" }) +} + +fn observation_revision_mode_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ "type": "string", "enum": ["full", "diff", "no_change"] }) +} + +fn string_schema_required(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ "type": "string", "minLength": 1 }) +} + +/// Portable projection of a `get_window_state` success. The snapshot payload +/// (markdown rendering, structured elements, screenshot fields) remains +/// platform-owned and still converging, so only the versioned observation +/// revision envelope is committed here; everything else flows through +/// `extensions` unvalidated. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct WindowStateOutput { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observation_revision: Option, + #[serde(flatten)] + pub extensions: BTreeMap, +} + +impl ToolOutput for WindowStateOutput { + fn validate(&self) -> Result<(), String> { + match &self.observation_revision { + Some(revision) => revision.validate(), + None => Ok(()), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct ScreenSizeOutput { #[schemars(schema_with = "number_schema")] diff --git a/packages/cua-driver/rust/crates/cua-driver-contract/src/session.rs b/packages/cua-driver/rust/crates/cua-driver-contract/src/session.rs index 74a9ae3210..d887050f06 100644 --- a/packages/cua-driver/rust/crates/cua-driver-contract/src/session.rs +++ b/packages/cua-driver/rust/crates/cua-driver-contract/src/session.rs @@ -3,14 +3,15 @@ use crate::{ CursorAction, CursorSemantics, EndSessionInput, EndSessionOutput, EscalateSessionInput, - GetSessionStateInput, Platform, SchemaMode, SessionStateOutput, StartSessionInput, - StartSessionOutput, ToolAnnotations, ToolContract, ToolInput, ToolOutput, + GetSessionInput, GetSessionStateInput, ListSessionsInput, ListSessionsOutput, Platform, + SchemaMode, SessionOutput, SessionStateOutput, StartSessionInput, StartSessionOutput, + ToolAnnotations, ToolContract, ToolInput, ToolOutput, }; const ALL_PLATFORMS: [Platform; 3] = [Platform::Macos, Platform::Windows, Platform::Linux]; pub fn contracts() -> Vec { - vec![start(), escalate(), get_state(), end()] + vec![start(), escalate(), get(), list(), get_state(), end()] } fn contract( @@ -38,7 +39,7 @@ fn contract( fn start() -> ToolContract { contract::( "start_session", - "Declare a session — a named, color-coded identity for THIS agent run. Pass a stable `session` id and choose capture_scope=auto|window|desktop; the agent cursor, capture policy, per-session config, and recording all key on it, and it follows the run across any apps/windows. The cursor's color is derived from the id, so distinct runs are visually distinct. A cursor is shown only for a declared session — call this (or pass `session` on your first action) to opt in. Idempotent: re-calling with the same id just refreshes its idle-TTL. End it with `end_session` (or let the idle-TTL reclaim it). Concurrent runs/subagents each pass their own `session` to get their own cursor.", + "Optionally create or return a lifecycle session before acting. For multi-call work, prefer a short public `session` label and repeat it on every call that accepts it; an omitted value uses the authenticated transport lease's implicit session instead. This tool is optional because an ordinary action can create or reuse a named run directly. Use it to set the initial cursor theme before acting or to revive a public name after it has ended; ordinary actions never revive ended names. `capture_scope` is deprecated compatibility input; new callers select window or desktop modality per action. Idempotent.", &["session.lifecycle.start", "session.capture_scope"], ToolAnnotations { read_only: false, @@ -52,7 +53,7 @@ fn start() -> ToolContract { fn escalate() -> ToolContract { contract::( "escalate_session", - "Unlock the desktop phase of an auto capture-scope session after the window action ladder has been exhausted and verified. This is a one-way transition for the live session and records a bounded reason.", + "Deprecated compatibility tool for legacy capture-scope sessions. New callers select window or desktop modality on each action. No deescalate_session tool exists.", &["session.capture_scope.escalate"], ToolAnnotations { read_only: false, @@ -66,7 +67,7 @@ fn escalate() -> ToolContract { fn get_state() -> ToolContract { contract::( "get_session_state", - "Read the live session's capture policy and effective scope.", + "Deprecated compatibility alias that reads a live legacy session's capture policy. Use get_session for lifecycle state.", &["session.capture_scope.read"], ToolAnnotations { read_only: true, @@ -77,10 +78,38 @@ fn get_state() -> ToolContract { ) } +fn get() -> ToolContract { + contract::( + "get_session", + "Read content-free lifecycle, cursor, recording, and idle status for one session visible to this authenticated transport. Omit `session` to inspect its implicit session.", + &["session.lifecycle.read"], + ToolAnnotations { + read_only: true, + destructive: false, + idempotent: true, + open_world: false, + }, + ) +} + +fn list() -> ToolContract { + contract::( + "list_sessions", + "List content-free lifecycle summaries attached to this authenticated transport lease. It does not enumerate other callers' sessions.", + &["session.lifecycle.list"], + ToolAnnotations { + read_only: true, + destructive: false, + idempotent: true, + open_world: false, + }, + ) +} + fn end() -> ToolContract { contract::( "end_session", - "End a session declared with `start_session`: removes its agent cursor, stops any recording it owns, and clears its per-session config. Call this when a run finishes so its cursor doesn't linger (otherwise the idle-TTL reclaims it after a period of inactivity). Idempotent.", + "End one visible lifecycle session and run its cursor, recording, configuration, and other cleanup hooks exactly once. Omit `session` to end the authenticated transport's implicit session. Idempotent.", &["session.lifecycle.end"], ToolAnnotations { read_only: false, @@ -90,3 +119,18 @@ fn end() -> ToolContract { }, ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_description_explains_direct_naming_and_explicit_revival() { + let description = start().description; + assert!(description.contains("prefer a short public `session` label")); + assert!(description.contains("repeat it on every call that accepts it")); + assert!(description.contains("omitted value uses the authenticated transport")); + assert!(description.contains("revive a public name after it has ended")); + assert!(description.contains("ordinary actions never revive ended names")); + } +} diff --git a/packages/cua-driver/rust/crates/cua-driver-contract/src/verification.rs b/packages/cua-driver/rust/crates/cua-driver-contract/src/verification.rs index bab727b38b..fb4c068baf 100644 --- a/packages/cua-driver/rust/crates/cua-driver-contract/src/verification.rs +++ b/packages/cua-driver/rust/crates/cua-driver-contract/src/verification.rs @@ -156,7 +156,9 @@ pub struct VerifyStateInput { /// One to eight predicates, combined with logical AND. #[schemars(length(min = 1, max = 8))] pub expect: Vec, - /// Optional session id for capture-scope and authorization continuity. + /// For multi-call work, prefer a short public session label and repeat it on every call that + /// accepts it. Omit it to use the authenticated transport's implicit lifecycle session. This + /// field never selects capture modality or authorization. #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(schema_with = "string_schema")] pub session: Option, diff --git a/packages/cua-driver/rust/crates/cua-driver-core/Cargo.toml b/packages/cua-driver/rust/crates/cua-driver-core/Cargo.toml index 4fddd8f175..8171b241c8 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/Cargo.toml +++ b/packages/cua-driver/rust/crates/cua-driver-core/Cargo.toml @@ -25,6 +25,10 @@ regorus = { version = "0.10.1", optional = true } # helpers extracted from `platform-{macos,windows,linux}/src/capture.rs` so # all three platforms call the same code path. image = { workspace = true } +# Compatibility constraint for image's zune-jpeg decoder. This dependency is +# intentionally direct so SDK consumers without this workspace lockfile cannot +# resolve the incompatible zune-core 0.5.2 release. +zune-core = { workspace = true } # Used by the `browser` module's pooled CDP browser-endpoint WebSocket. # Same versions platform-macos already depends on, to avoid duplicate # crate versions in the workspace graph. No TLS features: the endpoint @@ -44,6 +48,13 @@ libc = "0.2" # a duplicate crate version. [dev-dependencies] tempfile = "3" +# Test-only. Asserts the advertised `outputSchema` the way a strict MCP client +# does — by validating real payloads against it. The runtime itself validates +# success output by deserialising into the Rust type, so this stays out of the +# shipped dependency graph. +# Version matches the one regorus already pulls into this crate's graph, so +# the test dependency reuses that build instead of adding a second copy. +jsonschema = { version = "0.46", default-features = false } [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] } diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/action_record.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/action_record.rs index f73762857b..4b21971a6a 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/action_record.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/action_record.rs @@ -1820,4 +1820,45 @@ mod tests { ); } } + + #[test] + fn every_legacy_text_only_action_is_conservatively_unverifiable() { + let tools = [ + "click", + "double_click", + "right_click", + "scroll", + "drag", + "mouse_drag", + "parallel_mouse_drag", + "move_cursor", + "mouse_button_down", + "mouse_button_up", + "type_text", + "type_text_chars", + "press_key", + "hotkey", + "set_value", + "browser_click", + "browser_pointer", + "browser_type", + ]; + + for tool in tools { + let record = ActionExecutionRecord::from_legacy( + tool, + &serde_json::json!({}), + &serde_json::Value::Null, + ) + .unwrap_or_else(|| panic!("legacy text-only action {tool} must normalize")); + assert_eq!( + record.effect, + ActionEffect::Unverifiable, + "legacy text-only action {tool} must never imply confirmation" + ); + record + .public_result() + .unwrap_or_else(|error| panic!("{tool} must publish: {error:?}")); + } + } } diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/action_target.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/action_target.rs new file mode 100644 index 0000000000..24c80c3785 --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/action_target.rs @@ -0,0 +1,146 @@ +//! Per-call target normalization for lifecycle-independent capture modality. + +use crate::protocol::ToolResult; +use serde_json::{json, Value}; + +const TARGETED_TOOLS: &[&str] = &[ + "move_cursor", + "click", + "drag", + "scroll", + "type_text", + "press_key", + "hotkey", +]; + +pub fn supports_typed_target(tool_name: &str) -> bool { + TARGETED_TOOLS.contains(&tool_name) +} + +fn invalid_target(message: impl Into) -> ToolResult { + ToolResult::error(message.into()).with_structured(json!({ + "code": "invalid_action_target", + })) +} + +/// Normalize the generated tagged union into the legacy flat fields consumed +/// by the current thin platform adapters. This runs once at the canonical +/// dispatch boundary before authorization, so policy/resource checks and the +/// platform worker see the same exact target. +pub fn normalize_action_target(tool_name: &str, args: &mut Value) -> Result<(), ToolResult> { + let Some(object) = args.as_object_mut() else { + return Ok(()); + }; + let Some(target) = object.remove("target") else { + if object.get("scope").and_then(Value::as_str) == Some("desktop") + && (object.contains_key("pid") || object.contains_key("window_id")) + { + return Err(invalid_target( + "desktop scope cannot be combined with pid or window_id", + )); + } + return Ok(()); + }; + if !supports_typed_target(tool_name) { + return Err(invalid_target(format!( + "{tool_name} does not accept a per-call target" + ))); + } + if object.contains_key("scope") + || object.contains_key("pid") + || object.contains_key("window_id") + { + return Err(invalid_target( + "target cannot be combined with legacy scope, pid, or window_id fields", + )); + } + let Some(target) = target.as_object() else { + return Err(invalid_target("target must be an object")); + }; + match target.get("kind").and_then(Value::as_str) { + Some("window") => { + let pid = target + .get("pid") + .and_then(Value::as_u64) + .filter(|pid| *pid > 0 && *pid <= u32::MAX as u64) + .ok_or_else(|| invalid_target("window target requires a positive 32-bit pid"))?; + let window_id = target + .get("window_id") + .and_then(Value::as_u64) + .filter(|window_id| *window_id > 0) + .ok_or_else(|| invalid_target("window target requires a positive window_id"))?; + if target.len() != 3 { + return Err(invalid_target( + "window target accepts only kind, pid, and window_id", + )); + } + object.insert("scope".into(), Value::String("window".into())); + object.insert("pid".into(), Value::Number(pid.into())); + object.insert("window_id".into(), Value::Number(window_id.into())); + } + Some("desktop") => { + let display_id = target + .get("display_id") + .and_then(Value::as_str) + .filter(|display_id| !display_id.is_empty()) + .ok_or_else(|| invalid_target("desktop target requires display_id"))?; + if target.len() != 2 { + return Err(invalid_target( + "desktop target accepts only kind and display_id", + )); + } + if display_id != "primary" { + return Err(invalid_target( + "this release supports only display_id='primary'", + )); + } + object.insert("scope".into(), Value::String("desktop".into())); + } + Some(_) => return Err(invalid_target("target.kind must be window or desktop")), + None => return Err(invalid_target("target.kind is required")), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_targets_normalize_to_one_unambiguous_legacy_shape() { + let mut window = json!({ + "x": 1, + "y": 2, + "target": {"kind": "window", "pid": 7, "window_id": 9} + }); + normalize_action_target("click", &mut window).unwrap(); + assert_eq!(window["scope"], "window"); + assert_eq!(window["pid"], 7); + assert_eq!(window["window_id"], 9); + assert!(window.get("target").is_none()); + + let mut desktop = json!({ + "x": 1, + "y": 2, + "target": {"kind": "desktop", "display_id": "primary"} + }); + normalize_action_target("click", &mut desktop).unwrap(); + assert_eq!(desktop["scope"], "desktop"); + assert!(desktop.get("pid").is_none()); + } + + #[test] + fn ambiguous_or_unsupported_targets_fail_closed() { + for mut args in [ + json!({ + "scope": "desktop", + "target": {"kind": "desktop", "display_id": "primary"} + }), + json!({"scope": "desktop", "pid": 7}), + json!({"target": {"kind": "desktop", "display_id": "secondary"}}), + json!({"target": {"kind": "window", "pid": 7, "window_id": 0}}), + ] { + assert!(normalize_action_target("click", &mut args).is_err()); + } + } +} diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/authorization.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/authorization.rs index 351e098347..cf686f4384 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/authorization.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/authorization.rs @@ -14,8 +14,6 @@ use serde_json::Value; pub const PERMISSION_MODE_ENV: &str = "CUA_DRIVER_PERMISSION_MODE"; pub const DANGEROUS_BYPASS_ENV: &str = "CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS"; pub const DISABLE_UNRESTRICTED_ENV: &str = "CUA_DRIVER_DISABLE_UNRESTRICTED"; -pub const LEGACY_EXISTING_PROFILE_APPROVAL_ENV: &str = - "CUA_DRIVER_ALLOW_LEGACY_EXISTING_PROFILE_APPROVAL"; pub const RISK_METADATA_VERSION: &str = "1"; static LAUNCH_GRANTS: OnceLock> = OnceLock::new(); @@ -116,8 +114,10 @@ pub struct EnforcementAdapterDescriptor { /// Mode-specific truth. `state` remains the standard-mode value for /// backward-compatible inventory consumers. pub enforcement_by_mode: AdapterEnforcement, - /// Runtime behavior selected after hard invariants and policy admission. - pub behavior_by_mode: AdapterModeBehavior, + /// Factored profile behavior selected after hard invariants, configured + /// policy, and optional capability-manifest scope admission. + #[serde(rename = "profile_behavior_class")] + pub profile_behavior: AdapterProfileBehavior, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -151,39 +151,58 @@ pub enum ModeBehavior { Allow, Deny, RequireGrant, - Manifest, + /// Bounded-profile compatibility spelling on the public inventory. The + /// factored profile class explains that the manifest is a separate scope + /// ceiling rather than the approval behavior itself. + #[serde(rename = "manifest")] + AllowWithoutGrant, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct AdapterModeBehavior { - pub standard: ModeBehavior, - pub bounded: ModeBehavior, - pub unrestricted: ModeBehavior, +#[serde(rename_all = "snake_case")] +pub enum AdapterProfileBehavior { + Routine, + GrantInStandard, + BoundedOrUnrestricted, + UnrestrictedOnly, + Denied, } -impl AdapterModeBehavior { - pub const fn new( - standard: ModeBehavior, - bounded: ModeBehavior, - unrestricted: ModeBehavior, - ) -> Self { - Self { - standard, - bounded, - unrestricted, - } - } - +impl AdapterProfileBehavior { pub const fn for_mode(self, mode: PermissionMode) -> ModeBehavior { - match mode { - PermissionMode::Standard => self.standard, - PermissionMode::Bounded => self.bounded, - PermissionMode::Unrestricted => self.unrestricted, + match (self, mode) { + (Self::Routine, PermissionMode::Standard | PermissionMode::Unrestricted) => { + ModeBehavior::Allow + } + (Self::Routine, PermissionMode::Bounded) => ModeBehavior::AllowWithoutGrant, + (Self::GrantInStandard, PermissionMode::Standard) => ModeBehavior::RequireGrant, + (Self::GrantInStandard, PermissionMode::Bounded) => ModeBehavior::AllowWithoutGrant, + (Self::GrantInStandard, PermissionMode::Unrestricted) => ModeBehavior::Allow, + (Self::BoundedOrUnrestricted, PermissionMode::Standard) => ModeBehavior::Deny, + (Self::BoundedOrUnrestricted, PermissionMode::Bounded) => { + ModeBehavior::AllowWithoutGrant + } + (Self::BoundedOrUnrestricted, PermissionMode::Unrestricted) => ModeBehavior::Allow, + (Self::UnrestrictedOnly, PermissionMode::Unrestricted) => ModeBehavior::Allow, + (Self::UnrestrictedOnly, PermissionMode::Standard | PermissionMode::Bounded) + | (Self::Denied, _) => ModeBehavior::Deny, } } } const EXISTING_PROFILE_OPERATIONS: &[&str] = &["browser_prepare[strategy.kind=existing_profile]"]; +const ISOLATED_BROWSER_OPERATIONS: &[&str] = &["browser_prepare[profile.mode=isolated]"]; +const ISOLATED_BROWSER_SCOPE_KEYS: &[&str] = &[ + "daemon_generation", + "public_session", + "transport_session", + "pid", + "profile_mode", + "profile_name", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", +]; const EXISTING_PROFILE_SCOPE_KEYS: &[&str] = &[ "daemon_generation", "public_session", @@ -253,6 +272,7 @@ const DESKTOP_INPUT_OPERATIONS: &[&str] = &[ "press_key", "hotkey", "set_value", + "perform_secondary_action", "bring_to_front", "set_window_frame", ]; @@ -357,6 +377,23 @@ const SESSION_REVOCATION: &[&str] = &[ ]; pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ + EnforcementAdapterDescriptor { + id: "browser_prepare.isolated", + operations: ISOLATED_BROWSER_OPERATIONS, + state: RiskEnforcement::Active, + risk_class: RiskClass::R1, + resource_kind: "driver_owned_browser_profile", + scope_keys: ISOLATED_BROWSER_SCOPE_KEYS, + grant_type: None, + idle_ttl_seconds: None, + absolute_ttl_seconds: None, + authorization_requirement: "profile_behavior_and_optional_capability_manifest", + revocation_triggers: SESSION_REVOCATION, + refusal_code: Some("authorization_required"), + authorization_source: "built_in_standard; unattended_bounded_profile; trusted_unrestricted_mode; optional_capability_manifest_ceiling", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), + profile_behavior: AdapterProfileBehavior::Routine, + }, EnforcementAdapterDescriptor { id: "browser_prepare.existing_profile", operations: EXISTING_PROFILE_OPERATIONS, @@ -367,17 +404,13 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ grant_type: Some("existing_profile_session_grant"), idle_ttl_seconds: Some(30 * 60), absolute_ttl_seconds: Some(8 * 60 * 60), - authorization_requirement: "launch_grant_or_host_in_standard; manifest_in_bounded", + authorization_requirement: "profile_approval_behavior_and_optional_capability_manifest", revocation_triggers: EXISTING_PROFILE_REVOCATION, refusal_code: Some("browser_consent_required"), authorization_source: - "launch_grant_or_authorization_host_in_standard; manifest_in_bounded; trusted_launch_acknowledgement_in_unrestricted", + "launch_grant_or_authorization_host_in_standard; unattended_in_bounded; trusted_launch_acknowledgement_in_unrestricted; optional_capability_manifest_ceiling", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::RequireGrant, - ModeBehavior::Manifest, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::GrantInStandard, }, EnforcementAdapterDescriptor { id: "private_observation", @@ -389,16 +422,12 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ grant_type: Some("protected_resource_grant"), idle_ttl_seconds: Some(30 * 60), absolute_ttl_seconds: Some(8 * 60 * 60), - authorization_requirement: "manifest_only_in_bounded", + authorization_requirement: "profile_behavior_and_optional_capability_manifest", revocation_triggers: SESSION_REVOCATION, refusal_code: Some("authorization_required"), - authorization_source: "built_in_standard; manifest_in_bounded; trusted_unrestricted_mode", + authorization_source: "built_in_standard; unattended_bounded_profile; trusted_unrestricted_mode; optional_capability_manifest_ceiling", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Allow, - ModeBehavior::Manifest, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::Routine, }, EnforcementAdapterDescriptor { id: "desktop_input", @@ -410,16 +439,12 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ grant_type: Some("protected_resource_grant"), idle_ttl_seconds: Some(30 * 60), absolute_ttl_seconds: Some(8 * 60 * 60), - authorization_requirement: "manifest_only_in_bounded", + authorization_requirement: "profile_behavior_and_optional_capability_manifest", revocation_triggers: SESSION_REVOCATION, refusal_code: Some("authorization_required"), - authorization_source: "built_in_standard; manifest_in_bounded; trusted_unrestricted_mode", + authorization_source: "built_in_standard; unattended_bounded_profile; trusted_unrestricted_mode; optional_capability_manifest_ceiling", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Allow, - ModeBehavior::Manifest, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::Routine, }, EnforcementAdapterDescriptor { id: "file_transfer_and_output", @@ -431,16 +456,12 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ grant_type: Some("protected_resource_grant"), idle_ttl_seconds: Some(30 * 60), absolute_ttl_seconds: Some(8 * 60 * 60), - authorization_requirement: "manifest_only_in_bounded", + authorization_requirement: "profile_behavior_and_optional_capability_manifest", revocation_triggers: SESSION_REVOCATION, refusal_code: Some("authorization_required"), - authorization_source: "built_in_standard; manifest_in_bounded; trusted_unrestricted_mode", + authorization_source: "built_in_standard; unattended_bounded_profile; trusted_unrestricted_mode; optional_capability_manifest_ceiling", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Allow, - ModeBehavior::Manifest, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::Routine, }, EnforcementAdapterDescriptor { id: "browser_consequential_action", @@ -452,16 +473,12 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ grant_type: Some("protected_resource_grant"), idle_ttl_seconds: Some(5 * 60), absolute_ttl_seconds: Some(30 * 60), - authorization_requirement: "manifest_only_in_bounded", + authorization_requirement: "profile_behavior_and_optional_capability_manifest", revocation_triggers: SESSION_REVOCATION, refusal_code: Some("authorization_required"), - authorization_source: "built_in_standard; manifest_in_bounded; trusted_unrestricted_mode", + authorization_source: "built_in_standard; unattended_bounded_profile; trusted_unrestricted_mode; optional_capability_manifest_ceiling", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Allow, - ModeBehavior::Manifest, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::Routine, }, EnforcementAdapterDescriptor { id: "browser_unbounded_script", @@ -478,11 +495,7 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ refusal_code: Some("unbounded_operation_requires_unrestricted"), authorization_source: "unrestricted_mode_with_trusted_launch_time_risk_acknowledgement", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Deny, - ModeBehavior::Deny, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::UnrestrictedOnly, }, EnforcementAdapterDescriptor { id: "browser_bound_input", @@ -494,16 +507,12 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ grant_type: Some("protected_resource_grant"), idle_ttl_seconds: Some(30 * 60), absolute_ttl_seconds: Some(8 * 60 * 60), - authorization_requirement: "manifest_only_in_bounded", + authorization_requirement: "profile_behavior_and_optional_capability_manifest", revocation_triggers: SESSION_REVOCATION, refusal_code: Some("authorization_required"), - authorization_source: "built_in_standard; manifest_in_bounded; trusted_unrestricted_mode", + authorization_source: "built_in_standard; unattended_bounded_profile; trusted_unrestricted_mode; optional_capability_manifest_ceiling", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Allow, - ModeBehavior::Manifest, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::Routine, }, EnforcementAdapterDescriptor { id: "process_control", @@ -515,17 +524,13 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ grant_type: Some("protected_resource_grant"), idle_ttl_seconds: Some(2 * 60), absolute_ttl_seconds: Some(10 * 60), - authorization_requirement: "driver_ownership_in_standard; manifest_in_bounded", + authorization_requirement: "driver_ownership_in_standard; unattended_in_bounded; optional_capability_manifest", revocation_triggers: SESSION_REVOCATION, refusal_code: Some("authorization_required"), authorization_source: - "driver_ownership_in_standard; manifest_in_bounded; trusted_unrestricted_mode", + "driver_ownership_in_standard; unattended_bounded_profile; trusted_unrestricted_mode; optional_capability_manifest_ceiling", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Deny, - ModeBehavior::Manifest, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::BoundedOrUnrestricted, }, EnforcementAdapterDescriptor { id: "os_permission_prompt", @@ -542,11 +547,7 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ refusal_code: Some("os_permission_prompt_requires_trusted_host"), authorization_source: "trusted_host_setup_outside_the_agent_tool_path", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Deny, - ModeBehavior::Deny, - ModeBehavior::Deny, - ), + profile_behavior: AdapterProfileBehavior::Denied, }, EnforcementAdapterDescriptor { id: "driver_configuration", @@ -563,16 +564,12 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ grant_type: Some("protected_resource_grant"), idle_ttl_seconds: Some(5 * 60), absolute_ttl_seconds: Some(30 * 60), - authorization_requirement: "manifest_only_in_bounded", + authorization_requirement: "profile_behavior_and_optional_capability_manifest", revocation_triggers: SESSION_REVOCATION, refusal_code: Some("authorization_required"), - authorization_source: "built_in_standard; manifest_in_bounded; trusted_unrestricted_mode", + authorization_source: "built_in_standard; unattended_bounded_profile; trusted_unrestricted_mode; optional_capability_manifest_ceiling", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Allow, - ModeBehavior::Manifest, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::Routine, }, EnforcementAdapterDescriptor { id: "clipboard", @@ -592,16 +589,12 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ grant_type: Some("protected_resource_grant"), idle_ttl_seconds: Some(30 * 60), absolute_ttl_seconds: Some(8 * 60 * 60), - authorization_requirement: "manifest_only_in_bounded", + authorization_requirement: "profile_behavior_and_optional_capability_manifest", revocation_triggers: SESSION_REVOCATION, refusal_code: Some("authorization_required"), - authorization_source: "built_in_standard; manifest_in_bounded; trusted_unrestricted_mode", + authorization_source: "built_in_standard; unattended_bounded_profile; trusted_unrestricted_mode; optional_capability_manifest_ceiling", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Allow, - ModeBehavior::Manifest, - ModeBehavior::Allow, - ), + profile_behavior: AdapterProfileBehavior::Routine, }, EnforcementAdapterDescriptor { id: "devices", @@ -618,11 +611,7 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ refusal_code: None, authorization_source: "capability_not_exposed", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::NotExposed), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Deny, - ModeBehavior::Deny, - ModeBehavior::Deny, - ), + profile_behavior: AdapterProfileBehavior::Denied, }, EnforcementAdapterDescriptor { id: "shell_and_network", @@ -639,16 +628,32 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ refusal_code: None, authorization_source: "capability_not_exposed", enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::NotExposed), - behavior_by_mode: AdapterModeBehavior::new( - ModeBehavior::Deny, - ModeBehavior::Deny, - ModeBehavior::Deny, - ), + profile_behavior: AdapterProfileBehavior::Denied, }, ]; pub fn enforcement_adapter_inventory_json() -> Value { - serde_json::to_value(ENFORCEMENT_ADAPTERS).expect("static adapter inventory serializes") + Value::Array( + ENFORCEMENT_ADAPTERS + .iter() + .map(|adapter| { + let mut value = + serde_json::to_value(adapter).expect("static adapter inventory serializes"); + value + .as_object_mut() + .expect("adapter descriptor serializes as an object") + .insert( + "behavior_by_mode".to_owned(), + serde_json::json!({ + "standard": adapter.profile_behavior.for_mode(PermissionMode::Standard), + "bounded": adapter.profile_behavior.for_mode(PermissionMode::Bounded), + "unrestricted": adapter.profile_behavior.for_mode(PermissionMode::Unrestricted), + }), + ); + value + }) + .collect(), + ) } pub fn adapter_ids_with_state(state: RiskEnforcement) -> Vec<&'static str> { @@ -692,6 +697,13 @@ pub fn enforcement_adapters_for_call( && args.pointer("/strategy/kind").and_then(Value::as_str) == Some("existing_profile") { add("browser_prepare.existing_profile"); + } else if tool == "browser_prepare" + && matches!( + args.pointer("/profile/mode").and_then(Value::as_str), + Some("isolated_new" | "isolated_named") + ) + { + add("browser_prepare.isolated"); } if matches!( @@ -822,6 +834,8 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { "get_screen_size" | "get_cursor_position" | "get_config" + | "get_session" + | "list_sessions" | "get_session_state" | "get_agent_cursor_state" | "get_recording_state" @@ -845,6 +859,7 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { | "press_key" | "hotkey" | "set_value" + | "perform_secondary_action" | "invoke_menu" | "launch_app" | "bring_to_front" @@ -919,7 +934,7 @@ pub fn classify_tool_call(tool: &str, args: &Value) -> RiskAssessment { } else { RiskAssessment { class: RiskClass::R1, - enforcement: RiskEnforcement::MetadataOnly, + enforcement: RiskEnforcement::Active, operation_sensitive: true, } } @@ -1086,46 +1101,32 @@ pub fn authorize_tool_call_with_context( ))); } let mode = context.mode(); - if mode == PermissionMode::Bounded { - let manifest = context.bounded_manifest().ok_or_else(|| { - crate::policy::AuthorizationError::Loading( - "bounded session policy is unavailable".to_owned(), - ) - })?; - if manifest.is_expired() { - return Err(crate::policy::AuthorizationError::Denied( - "bounded session policy expired".to_owned(), - )); - } + if mode == PermissionMode::Bounded && context.capability_manifest().is_none() { + return Err(crate::policy::AuthorizationError::Loading( + "bounded mode capability manifest is unavailable".to_owned(), + )); + } + if let Some(manifest) = context.capability_manifest() { + manifest + .validate_for_mode(mode) + .map_err(crate::policy::AuthorizationError::Denied)?; match manifest.decision(tool) { - crate::session_manifest::ManifestDecision::Allow => { - manifest - .authorize_call(tool, args) - .map_err(crate::policy::AuthorizationError::Denied)?; - manifest - .authorize_dispatch() - .map_err(crate::policy::AuthorizationError::Denied)?; - } - crate::session_manifest::ManifestDecision::Deny => { + crate::session_manifest::ManifestDecision::Allow => manifest + .authorize_call(tool, args) + .map_err(crate::policy::AuthorizationError::Denied)?, + crate::session_manifest::ManifestDecision::Deny + | crate::session_manifest::ManifestDecision::Ask => { return Err(crate::policy::AuthorizationError::Denied(format!( - "bounded session policy denies tool '{tool}'" - ))) - } - crate::session_manifest::ManifestDecision::Ask => { - return Err(crate::policy::AuthorizationError::Denied(format!( - "bounded session policy requires protected approval for tool '{tool}'; unattended dispatch cannot auto-accept" + "capability manifest denies tool '{tool}'" ))) } crate::session_manifest::ManifestDecision::Undeclared => { return Err(crate::policy::AuthorizationError::Denied(format!( - "tool '{tool}' is outside the bounded session policy" + "tool '{tool}' is outside the capability manifest" ))) } } } - context - .authorize_dispatch() - .map_err(crate::policy::AuthorizationError::Denied)?; Ok(risk) } @@ -1150,6 +1151,7 @@ fn enforce_hard_invariants( | "press_key" | "hotkey" | "set_value" + | "perform_secondary_action" | "kill_app" | "bring_to_front" | "get_accessibility_tree" @@ -1198,20 +1200,16 @@ pub enum PermissionModeError { MissingDangerAcknowledgement, #[error("--dangerously-bypass-approvals cannot be combined with a non-unrestricted --permission-mode")] UnexpectedDangerAcknowledgement, - #[error("permission mode bounded requires --session-policy at trusted daemon startup")] - MissingSessionPolicy, - #[error("permission mode bounded requires --approve-session-policy at trusted daemon startup")] - MissingSessionPolicyApproval, #[error( - "--session-policy/--approve-session-policy are valid only with --permission-mode bounded" + "permission mode bounded requires --capability-manifest at trusted daemon startup" )] + MissingSessionPolicy, + #[error("a configured capability manifest requires --approve-capability-manifest at trusted daemon startup")] + MissingSessionPolicyApproval, + #[error("--approve-capability-manifest requires --capability-manifest ")] UnexpectedSessionPolicy, #[error("permission mode unrestricted is disabled by managed startup configuration")] UnrestrictedDisabled, - #[error( - "--allow-legacy-existing-profile-approval is valid only with --permission-mode standard" - )] - UnexpectedLegacyApproval, } fn parse_permission_mode( @@ -1244,21 +1242,6 @@ fn env_flag(name: &str) -> bool { }) } -/// Temporary migration escape hatch for the same-user-writable browser -/// approval artifact. It is deliberately outside the public tool protocol and -/// must never be described as protected human consent. -pub fn legacy_existing_profile_approval_enabled() -> bool { - // Core's browser unit fixtures exercise the legacy artifact's binding, - // setup, reconnect, and cleanup mechanics directly. Integration tests - // compile this crate without `cfg(test)` and own the production-default - // refusal contract. - #[cfg(test)] - return true; - - #[cfg(not(test))] - env_flag(LEGACY_EXISTING_PROFILE_APPROVAL_ENV) -} - static CONFIGURED_PERMISSION_MODE: OnceLock> = OnceLock::new(); /// Return the immutable process permission mode. An unset mode is `standard`. @@ -1281,32 +1264,23 @@ pub fn validate_startup_authorization() -> anyhow::Result<()> { if mode == PermissionMode::Unrestricted && env_flag(DISABLE_UNRESTRICTED_ENV) { return Err(PermissionModeError::UnrestrictedDisabled.into()); } - if mode != PermissionMode::Standard && env_flag(LEGACY_EXISTING_PROFILE_APPROVAL_ENV) { - return Err(PermissionModeError::UnexpectedLegacyApproval.into()); + let manifest_configured = crate::session_manifest::capability_manifest_configured(); + let manifest_approved = crate::session_manifest::capability_manifest_approved(); + if mode == PermissionMode::Bounded && !manifest_configured { + return Err(PermissionModeError::MissingSessionPolicy.into()); } - let session_policy_configured = - std::env::var_os(crate::session_manifest::SESSION_POLICY_FILE_ENV).is_some(); - let session_policy_approved = env_flag(crate::session_manifest::SESSION_POLICY_APPROVED_ENV); - match mode { - PermissionMode::Bounded => { - if !session_policy_configured { - return Err(PermissionModeError::MissingSessionPolicy.into()); - } - if !session_policy_approved { - return Err(PermissionModeError::MissingSessionPolicyApproval.into()); - } - let manifest = crate::session_manifest::configured_session_manifest() - .map_err(anyhow::Error::msg)? - .ok_or(PermissionModeError::MissingSessionPolicy)?; - if manifest.is_expired() { - anyhow::bail!("bounded session policy is already expired"); - } - } - PermissionMode::Standard | PermissionMode::Unrestricted => { - if session_policy_configured || session_policy_approved { - return Err(PermissionModeError::UnexpectedSessionPolicy.into()); - } - } + if manifest_configured && !manifest_approved { + return Err(PermissionModeError::MissingSessionPolicyApproval.into()); + } + if manifest_approved && !manifest_configured { + return Err(PermissionModeError::UnexpectedSessionPolicy.into()); + } + if let Some(manifest) = + crate::session_manifest::configured_capability_manifest().map_err(anyhow::Error::msg)? + { + manifest + .validate_for_mode(mode) + .map_err(anyhow::Error::msg)?; } Ok(()) } @@ -1342,8 +1316,8 @@ pub fn status_json_with_provider(authorization_host: Option<&'static str>) -> se .as_ref() .map(|mode| adapter_ids_with_state_for_mode(*mode, RiskEnforcement::NotExposed)) .unwrap_or_default(); - let session_policy = crate::session_manifest::configured_session_manifest(); - let session_policy_status = session_policy + let capability_manifest = crate::session_manifest::configured_capability_manifest(); + let capability_manifest_status = capability_manifest .as_ref() .ok() .and_then(|manifest| *manifest) @@ -1353,7 +1327,9 @@ pub fn status_json_with_provider(authorization_host: Option<&'static str>) -> se "version": manifest.version(), "sha256": manifest.sha256(), "expires_unix_ms": manifest.expires_unix_ms(), - "idle_timeout_seconds": manifest.idle_timeout().as_secs(), + "idle_timeout_seconds": manifest.idle_timeout().map(|duration| duration.as_secs()), + "expired": manifest.is_expired(), + "idle_expired": manifest.is_idle_expired(), "allow_count": allow, "deny_count": deny, "ask_count": ask, @@ -1377,7 +1353,6 @@ pub fn status_json_with_provider(authorization_host: Option<&'static str>) -> se "managed_policy_valid": managed_policy.is_ok(), "managed_policy_sha256": managed_policy_sha256, "built_in_ceiling": "reviewed_tool_and_risk_map_v1", - "legacy_existing_profile_approval": legacy_existing_profile_approval_enabled(), "risk_metadata_version": RISK_METADATA_VERSION, "active_risk_enforcement": adapter_ids_with_state(RiskEnforcement::Active), "metadata_only_risk_enforcement": adapter_ids_with_state(RiskEnforcement::MetadataOnly), @@ -1388,10 +1363,15 @@ pub fn status_json_with_provider(authorization_host: Option<&'static str>) -> se "enforcement_adapters": enforcement_adapter_inventory_json(), "authorization_host": authorization_host, "authorization_host_assurance": authorization_host_assurance, - "session_policy_configured": std::env::var_os(crate::session_manifest::SESSION_POLICY_FILE_ENV).is_some(), - "session_policy_approved_at_startup": env_flag(crate::session_manifest::SESSION_POLICY_APPROVED_ENV), - "session_policy_valid": session_policy.is_ok(), - "session_policy": session_policy_status, + "capability_manifest_configured": crate::session_manifest::capability_manifest_configured(), + "capability_manifest_approved_at_startup": crate::session_manifest::capability_manifest_approved(), + "capability_manifest_valid": capability_manifest.is_ok(), + "capability_manifest": capability_manifest_status, + // Deprecated status aliases retained for one compatibility window. + "session_policy_configured": crate::session_manifest::capability_manifest_configured(), + "session_policy_approved_at_startup": crate::session_manifest::capability_manifest_approved(), + "session_policy_valid": capability_manifest.is_ok(), + "session_policy": capability_manifest_status, }); let session_status = crate::session_authorization::status_json(); if let (Some(target), Some(session_status)) = @@ -1467,7 +1447,7 @@ mod tests { } #[test] - fn existing_profile_is_the_first_actively_enforced_risk_operation() { + fn browser_prepare_operations_are_actively_enforced() { assert_eq!( advertised_risk_for("browser_prepare").enforcement, RiskEnforcement::Active, @@ -1485,7 +1465,7 @@ mod tests { &serde_json::json!({"profile": {"mode": "isolated_new"}}), ); assert_eq!(isolated.class, RiskClass::R1); - assert_eq!(isolated.enforcement, RiskEnforcement::MetadataOnly); + assert_eq!(isolated.enforcement, RiskEnforcement::Active); } #[test] @@ -1618,6 +1598,7 @@ mod tests { assert_eq!( adapter_ids_with_state(RiskEnforcement::Active), vec![ + "browser_prepare.isolated", "browser_prepare.existing_profile", "private_observation", "desktop_input", @@ -1642,6 +1623,7 @@ mod tests { assert_eq!( adapter_ids_with_state_for_mode(PermissionMode::Standard, RiskEnforcement::Active), vec![ + "browser_prepare.isolated", "browser_prepare.existing_profile", "private_observation", "desktop_input", @@ -1672,11 +1654,12 @@ mod tests { .iter() .find(|adapter| adapter.id == id) .unwrap() - .behavior_by_mode + .profile_behavior .for_mode(mode) }; for id in [ + "browser_prepare.isolated", "private_observation", "desktop_input", "file_transfer_and_output", @@ -1688,7 +1671,7 @@ mod tests { assert_eq!(behavior(id, PermissionMode::Standard), ModeBehavior::Allow); assert_eq!( behavior(id, PermissionMode::Bounded), - ModeBehavior::Manifest + ModeBehavior::AllowWithoutGrant ); assert_eq!( behavior(id, PermissionMode::Unrestricted), @@ -1713,6 +1696,87 @@ mod tests { ); } + #[test] + fn adding_a_manifest_is_monotonic_for_standard_and_unrestricted_profile_behavior() { + let authority = |behavior| match behavior { + ModeBehavior::Deny => 0, + ModeBehavior::RequireGrant => 1, + ModeBehavior::Allow | ModeBehavior::AllowWithoutGrant => 2, + }; + + // Manifest admission is evaluated before the unchanged profile + // behavior. Exhaust every adapter and every manifest decision: an + // in-scope allow preserves the profile result, while deny, legacy ask, + // and undeclared scope all reduce it to deny. + for mode in [PermissionMode::Standard, PermissionMode::Unrestricted] { + for adapter in ENFORCEMENT_ADAPTERS { + let baseline = adapter.profile_behavior.for_mode(mode); + for manifest_decision in [ + crate::session_manifest::ManifestDecision::Allow, + crate::session_manifest::ManifestDecision::Deny, + crate::session_manifest::ManifestDecision::Ask, + crate::session_manifest::ManifestDecision::Undeclared, + ] { + let with_manifest = match manifest_decision { + crate::session_manifest::ManifestDecision::Allow => baseline, + crate::session_manifest::ManifestDecision::Deny + | crate::session_manifest::ManifestDecision::Ask + | crate::session_manifest::ManifestDecision::Undeclared => { + ModeBehavior::Deny + } + }; + assert!( + authority(with_manifest) <= authority(baseline), + "adding {manifest_decision:?} scope widened {} in {mode:?} from {baseline:?} to {with_manifest:?}", + adapter.id, + ); + } + } + } + } + + #[test] + fn standard_manifest_scope_is_checked_before_existing_profile_approval() { + use std::io::Write as _; + use std::sync::Arc; + + let mut file = tempfile::NamedTempFile::new().unwrap(); + writeln!( + file, + "version: 3\nallow:\n tools: [browser_prepare]\nresources:\n browser:\n existing_profiles:\n - pid: 42\n window_id: 7" + ) + .unwrap(); + let manifest = Arc::new(crate::session_manifest::load_manifest(file.path()).unwrap()); + let ceiling = crate::session_authorization::SessionModeCeiling::for_trusted_sessions( + [PermissionMode::Standard], + false, + std::time::Duration::from_secs(60), + std::time::Duration::from_secs(30), + ) + .unwrap(); + let context = + crate::session_authorization::SessionAuthorizationRegistry::with_ceiling(ceiling) + .compatibility_context(PermissionMode::Standard, Some(manifest)) + .unwrap(); + + let call = |window_id| { + authorize_tool_call_with_context( + "browser_prepare", + &serde_json::json!({ + "pid": 42, + "window_id": window_id, + "strategy": {"kind": "existing_profile"}, + }), + &context, + ) + }; + assert!(call(7).is_ok()); + assert!(call(8) + .unwrap_err() + .to_string() + .contains("outside the capability manifest")); + } + #[test] fn exact_call_inventory_composes_overlapping_resource_boundaries() { let ids = |tool, args: Value| { @@ -1735,6 +1799,20 @@ mod tests { ); assert!(ids("page", serde_json::json!({})).is_empty()); assert!(ids("page", serde_json::json!({"action": "unknown"})).is_empty()); + assert_eq!( + ids( + "browser_prepare", + serde_json::json!({"profile": {"mode": "isolated_new"}}) + ), + vec!["browser_prepare.isolated"] + ); + assert_eq!( + ids( + "browser_prepare", + serde_json::json!({"strategy": {"kind": "existing_profile"}}) + ), + vec!["browser_prepare.existing_profile"] + ); assert_eq!( ids("browser_dialog", serde_json::json!({"action": "accept"})), vec!["browser_consequential_action"] @@ -1820,6 +1898,7 @@ mod tests { assert_eq!( status["active_risk_enforcement"], serde_json::json!([ + "browser_prepare.isolated", "browser_prepare.existing_profile", "private_observation", "desktop_input", @@ -1844,6 +1923,7 @@ mod tests { assert_eq!( status["effective_active_risk_enforcement"], serde_json::json!([ + "browser_prepare.isolated", "browser_prepare.existing_profile", "private_observation", "desktop_input", @@ -1865,6 +1945,24 @@ mod tests { status["enforcement_adapters"], enforcement_adapter_inventory_json() ); + let existing_profile = status["enforcement_adapters"] + .as_array() + .unwrap() + .iter() + .find(|adapter| adapter["id"] == "browser_prepare.existing_profile") + .unwrap(); + assert_eq!( + existing_profile["profile_behavior_class"], + "grant_in_standard" + ); + assert_eq!( + existing_profile["behavior_by_mode"], + serde_json::json!({ + "standard": "require_grant", + "bounded": "manifest", + "unrestricted": "allow", + }) + ); assert_eq!(status["authorization_host_assurance"], "unavailable"); assert_eq!( status_json_with_provider(Some("cua_local_desktop_confirmation_v1")) diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/background_input.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/background_input.rs new file mode 100644 index 0000000000..cfd35d2647 --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/background_input.rs @@ -0,0 +1,698 @@ +//! Pure exact-target decisions for macOS-style background input (v1). +//! +//! Background mutation must carry one exact `(pid, CGWindowID)` target from +//! request to postcondition. The platform shell gathers fresh facts about that +//! target immediately before dispatch and asks this module for exactly one +//! decision: execute the requested route, or refuse with a stable +//! machine-readable reason. The shell never improvises a second actuator after +//! a refusal, and a sibling window's state can never confirm the requested +//! action. +//! +//! This module owns no platform objects and performs no I/O, so the complete +//! state-policy matrix is testable in ordinary CI. See +//! `docs/macos-background-input-v1-plan.md` for the design contract. + +use serde_json::{json, Value}; + +/// The canonical target key: the requested `(pid, CGWindowID)`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ExactWindowTarget { + pub pid: i32, + pub window_id: u32, +} + +/// WindowServer's attribution of the requested CGWindowID, resolved +/// immediately before the decision. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WindowServerOwnership { + /// The window exists and the requested pid owns it. + SamePid, + /// WindowServer has no record of the id — closed, stale, or fabricated. + NotFound, + /// The window exists but another process owns it (out-of-process panels). + ForeignPid { owner_pid: i32 }, +} + +/// Whether an explicitly addressed element was proven to belong to the +/// requested window. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ElementAncestry { + /// The action does not address a retained element. + NotAddressed, + /// The live element ascends to an AX window whose CGWindowID is the + /// requested one. + ProvenDescendant, + /// The live element ascends to a different window of the same process. + OutsideTargetWindow, + /// Ancestry could not be resolved (dead element, SPI failure). An address + /// the shell cannot re-prove is not an exact target. + Unproven, +} + +/// Fresh facts about one exact target, gathered by the platform shell. +/// +/// Unknown facts must stay unknown: gatherers must not guess a value merely to +/// unlock a route. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BackgroundTargetFacts { + pub window_server: WindowServerOwnership, + /// The requested CGWindowID is claimed by one of the application's fresh + /// `AXWindows` (mapped through `_AXUIElementGetWindow`). Absent means + /// off-Space or AX-unresolved: observation-only. + pub ax_window_present: bool, + /// `AXMinimized` on the exact target window. `None` means the attribute + /// could not be read — an unproven fact, which fails closed for pointer + /// and keyboard routes (it never unlocks them). + pub target_minimized: Option, + /// `AXHidden` on the owning application. `None` fails closed like + /// `target_minimized`. + pub app_hidden: Option, + /// Same-pid top-level windows, other than the target, that could receive + /// process-scoped keyboard input (enumerated from WindowServer so an + /// off-Space sibling that AX cannot see still counts; proven-minimized + /// siblings are excluded because they cannot be the key window). + pub competing_keyboard_destinations: usize, + /// Ancestry proof for an explicitly addressed element, when one exists. + pub element: ElementAncestry, +} + +/// The background action classes v1 distinguishes. Route order and trust are +/// documented in the plan's routing ladder. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BackgroundAction { + /// Semantic AX action or value mutation on an exact element + /// (`AXPress`, `AXConfirm`, selection, `AXValue`). + AxSemantic, + /// Window-local routed pointer (stamped CGWindowID; real pointer unmoved). + WindowPointer, + /// Process-scoped text insertion (AX selected-text write or CGEvent + /// keystrokes) with a target-bound value readback. + InsertText, + /// Process-scoped key/hotkey without an action-specific verifier. + GenericKey, +} + +impl BackgroundAction { + fn is_pid_keyboard(self) -> bool { + matches!( + self, + BackgroundAction::InsertText | BackgroundAction::GenericKey + ) + } +} + +/// Stable machine-readable refusal codes. These appear in structured tool +/// results; do not rename them without a compatibility review. +pub mod refusal_codes { + pub const WINDOW_NOT_FOUND: &str = "window_not_found"; + pub const OWNER_PID_MISMATCH: &str = "owner_pid_mismatch"; + pub const OFF_SPACE_OR_AX_UNRESOLVED: &str = "off_space_or_ax_unresolved"; + pub const MINIMIZED_OR_HIDDEN: &str = "minimized_or_hidden_window"; + pub const SAME_PID_KEYBOARD_AMBIGUITY: &str = "same_pid_keyboard_ambiguity"; + pub const ELEMENT_OUTSIDE_TARGET_WINDOW: &str = "element_outside_target_window"; +} + +/// A typed refusal: no actuator ran and none may run for this decision. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BackgroundRefusal { + pub code: &'static str, + pub reason: String, + /// The safe next route when one actually exists (advice for an explicit + /// caller decision, never an implicit fallback). + pub advice: Option<&'static str>, +} + +/// Verification binding for an executed route: evidence is acceptable only +/// when it belongs to the exact requested window. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TargetBoundVerification { + pub window_id: u32, +} + +impl TargetBoundVerification { + /// Whether a readback observed on an element whose resolved window is + /// `evidence_window_id` may confirm the requested action. Unresolvable + /// evidence (`None`) is never accepted — it is unverifiable, not proof — + /// and a sibling window's state can never confirm the requested target. + pub fn accepts_evidence_from(&self, evidence_window_id: Option) -> bool { + evidence_window_id == Some(self.window_id) + } +} + +/// One decision for one gathered-facts snapshot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BackgroundInputDecision { + Execute { + /// The only evidence source that may report `confirmed`. + verification: TargetBoundVerification, + }, + Refuse(BackgroundRefusal), +} + +impl BackgroundInputDecision { + pub fn is_execute(&self) -> bool { + matches!(self, BackgroundInputDecision::Execute { .. }) + } +} + +fn refuse( + code: &'static str, + reason: String, + advice: Option<&'static str>, +) -> BackgroundInputDecision { + BackgroundInputDecision::Refuse(BackgroundRefusal { + code, + reason, + advice, + }) +} + +/// Decide whether one background action may run against one exact target. +/// +/// The rules encode the v1 state-policy matrix: +/// - a stale or foreign CGWindowID refuses every mutation; +/// - a target absent from fresh `AXWindows` (off-Space or AX-unresolved) is +/// observation-only; +/// - an addressed element must prove ancestry to the requested window for +/// every route, including semantic AX; +/// - semantic AX actions remain available for minimized/hidden targets; +/// - the routed pointer requires a visible (possibly occluded) target; and +/// - process-scoped keyboard additionally requires that the target is the +/// only eligible same-pid keyboard destination, because the transport +/// addresses a process, not a window. +pub fn decide_background_input( + target: ExactWindowTarget, + facts: &BackgroundTargetFacts, + action: BackgroundAction, +) -> BackgroundInputDecision { + match &facts.window_server { + WindowServerOwnership::NotFound => { + return refuse( + refusal_codes::WINDOW_NOT_FOUND, + format!( + "WindowServer has no record of window {} (closed or stale); \ + re-enumerate windows and re-target", + target.window_id + ), + None, + ); + } + WindowServerOwnership::ForeignPid { owner_pid } => { + return refuse( + refusal_codes::OWNER_PID_MISMATCH, + format!( + "window {} is owned by pid {owner_pid}, not requested pid {}; \ + re-target the actual owner", + target.window_id, target.pid + ), + None, + ); + } + WindowServerOwnership::SamePid => {} + } + + match facts.element { + ElementAncestry::NotAddressed | ElementAncestry::ProvenDescendant => {} + ElementAncestry::OutsideTargetWindow | ElementAncestry::Unproven => { + return refuse( + refusal_codes::ELEMENT_OUTSIDE_TARGET_WINDOW, + format!( + "the addressed element could not be proven to belong to window {}; \ + take a fresh get_window_state snapshot and re-address it", + target.window_id + ), + Some("get_window_state"), + ); + } + } + + if !facts.ax_window_present { + return refuse( + refusal_codes::OFF_SPACE_OR_AX_UNRESOLVED, + format!( + "window {} is not among the process's current AXWindows (another Space, \ + or its AX surface is unresolved); background input is refused so a \ + sibling window cannot receive it. Observation (capture/list_windows) \ + remains available", + target.window_id + ), + Some("foreground"), + ); + } + + // Pointer/keyboard require PROVEN not-minimized and not-hidden: an + // unreadable attribute (`None`) is an unknown fact and unknown facts + // never unlock a route. Semantic AX stays available either way. + let visibility_disproven = + !(facts.target_minimized == Some(false) && facts.app_hidden == Some(false)); + match action { + BackgroundAction::AxSemantic => BackgroundInputDecision::Execute { + verification: TargetBoundVerification { + window_id: target.window_id, + }, + }, + BackgroundAction::WindowPointer => { + if visibility_disproven { + return refuse( + refusal_codes::MINIMIZED_OR_HIDDEN, + format!( + "window {} is minimized or its application is hidden (or that \ + state could not be proven); the routed pointer is refused in \ + v1. Use an exact element action (click/set_value by element) \ + instead", + target.window_id + ), + Some("accessibility"), + ); + } + BackgroundInputDecision::Execute { + verification: TargetBoundVerification { + window_id: target.window_id, + }, + } + } + BackgroundAction::InsertText | BackgroundAction::GenericKey => { + if visibility_disproven { + return refuse( + refusal_codes::MINIMIZED_OR_HIDDEN, + format!( + "window {} is minimized or its application is hidden (or that \ + state could not be proven); raw key input is refused in v1. \ + Use an exact element action (set_value, click \ + action:\"confirm\"/\"press\") instead", + target.window_id + ), + Some("accessibility"), + ); + } + debug_assert!(action.is_pid_keyboard()); + if facts.competing_keyboard_destinations > 0 { + return refuse( + refusal_codes::SAME_PID_KEYBOARD_AMBIGUITY, + format!( + "pid {} owns {} other eligible top-level window(s); process-scoped \ + key events cannot be proven to reach window {} and could mutate a \ + sibling window. Use an exact element action, the page tool for \ + browser content, or delivery_mode:\"foreground\"", + target.pid, facts.competing_keyboard_destinations, target.window_id + ), + Some("accessibility"), + ); + } + BackgroundInputDecision::Execute { + verification: TargetBoundVerification { + window_id: target.window_id, + }, + } + } + } +} + +/// The exact-window resolution string reported by the read-only capability +/// section. +fn exact_window_status(facts: &BackgroundTargetFacts) -> &'static str { + match &facts.window_server { + WindowServerOwnership::NotFound => "not_found", + WindowServerOwnership::ForeignPid { .. } => "owner_mismatch", + WindowServerOwnership::SamePid if facts.ax_window_present => "matched", + WindowServerOwnership::SamePid => "ax_unresolved", + } +} + +/// Build the additive read-only `background_input` capability report from the +/// same pure facts that gate every action. +/// +/// Semantics: `available` means the route's prerequisites are currently +/// proven, not that a future call is guaranteed to succeed; every action +/// revalidates instead of trusting this report. The report contains no window +/// titles, control values, or private symbol addresses. +pub fn background_input_capability_report( + target: ExactWindowTarget, + facts: &BackgroundTargetFacts, + one_shot_capture_available: Option, +) -> Value { + let route_entry = |route: &str, action: BackgroundAction| -> Value { + match decide_background_input(target, facts, action) { + BackgroundInputDecision::Execute { .. } => { + json!({ "route": route, "status": "available" }) + } + BackgroundInputDecision::Refuse(refusal) => { + json!({ "route": route, "status": "refused", "reason": refusal.code }) + } + } + }; + json!({ + "exact_window": { + "status": exact_window_status(facts), + "pid": target.pid, + "window_id": target.window_id, + }, + "routes": [ + route_entry("accessibility", BackgroundAction::AxSemantic), + route_entry("window_pointer", BackgroundAction::WindowPointer), + route_entry("pid_keyboard", BackgroundAction::GenericKey), + ], + "observation": { + "one_shot_capture": match one_shot_capture_available { + Some(true) => "available", + Some(false) => "unavailable", + None => "unknown", + }, + "frame_freshness": "unknown", + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TARGET: ExactWindowTarget = ExactWindowTarget { + pid: 42, + window_id: 700, + }; + + fn matched_facts() -> BackgroundTargetFacts { + BackgroundTargetFacts { + window_server: WindowServerOwnership::SamePid, + ax_window_present: true, + target_minimized: Some(false), + app_hidden: Some(false), + competing_keyboard_destinations: 0, + element: ElementAncestry::NotAddressed, + } + } + + const ALL_ACTIONS: [BackgroundAction; 4] = [ + BackgroundAction::AxSemantic, + BackgroundAction::WindowPointer, + BackgroundAction::InsertText, + BackgroundAction::GenericKey, + ]; + + fn code_of(decision: BackgroundInputDecision) -> &'static str { + match decision { + BackgroundInputDecision::Refuse(refusal) => refusal.code, + BackgroundInputDecision::Execute { .. } => panic!("expected a refusal"), + } + } + + /// Regression for the proven same-PID wrong-target case: target window A + /// requested while sibling window B is the process's focused/eligible + /// window. Process-scoped keyboard must refuse before dispatch — an + /// explicit `window_id` is an address, not delivery proof. + #[test] + fn two_window_process_refuses_background_keyboard_for_both_text_and_keys() { + let facts = BackgroundTargetFacts { + competing_keyboard_destinations: 1, + ..matched_facts() + }; + for action in [BackgroundAction::InsertText, BackgroundAction::GenericKey] { + assert_eq!( + code_of(decide_background_input(TARGET, &facts, action)), + refusal_codes::SAME_PID_KEYBOARD_AMBIGUITY, + "{action:?} must refuse while a sibling keyboard destination exists" + ); + } + // Semantic AX and the stamped window-local pointer remain available: + // they are window-addressed, not process-addressed. + for action in [ + BackgroundAction::AxSemantic, + BackgroundAction::WindowPointer, + ] { + assert!( + decide_background_input(TARGET, &facts, action).is_execute(), + "{action:?} is window-addressed and stays available" + ); + } + } + + /// A sibling window's state can never satisfy the requested target's + /// verification plan, and unresolvable evidence is not proof. + #[test] + fn sibling_readback_never_confirms_the_requested_window() { + let verification = TargetBoundVerification { window_id: 700 }; + assert!(verification.accepts_evidence_from(Some(700))); + assert!(!verification.accepts_evidence_from(Some(701)), "sibling"); + assert!(!verification.accepts_evidence_from(None), "unresolvable"); + } + + #[test] + fn stale_window_id_refuses_every_mutation() { + let facts = BackgroundTargetFacts { + window_server: WindowServerOwnership::NotFound, + ax_window_present: false, + ..matched_facts() + }; + for action in ALL_ACTIONS { + assert_eq!( + code_of(decide_background_input(TARGET, &facts, action)), + refusal_codes::WINDOW_NOT_FOUND, + "{action:?}" + ); + } + } + + #[test] + fn foreign_owner_refuses_every_mutation() { + let facts = BackgroundTargetFacts { + window_server: WindowServerOwnership::ForeignPid { owner_pid: 900 }, + ..matched_facts() + }; + for action in ALL_ACTIONS { + assert_eq!( + code_of(decide_background_input(TARGET, &facts, action)), + refusal_codes::OWNER_PID_MISMATCH, + "{action:?}" + ); + } + } + + /// Off-Space / AX-unresolved targets are observation-only: no route may + /// send input, and no sibling window may be used instead. + #[test] + fn ax_unresolved_target_is_observation_only() { + let facts = BackgroundTargetFacts { + ax_window_present: false, + ..matched_facts() + }; + for action in ALL_ACTIONS { + assert_eq!( + code_of(decide_background_input(TARGET, &facts, action)), + refusal_codes::OFF_SPACE_OR_AX_UNRESOLVED, + "{action:?}" + ); + } + } + + /// Minimized/hidden targets retain exact semantic AX actions; raw keys and + /// the routed pointer refuse rather than restoring the window. + #[test] + fn minimized_or_hidden_keeps_semantic_ax_and_refuses_keys_and_pointer() { + for facts in [ + BackgroundTargetFacts { + target_minimized: Some(true), + ..matched_facts() + }, + BackgroundTargetFacts { + app_hidden: Some(true), + ..matched_facts() + }, + ] { + assert!( + decide_background_input(TARGET, &facts, BackgroundAction::AxSemantic).is_execute() + ); + for action in [ + BackgroundAction::WindowPointer, + BackgroundAction::InsertText, + BackgroundAction::GenericKey, + ] { + assert_eq!( + code_of(decide_background_input(TARGET, &facts, action)), + refusal_codes::MINIMIZED_OR_HIDDEN, + "{action:?}" + ); + } + } + } + + /// Unknown visibility fails closed: when minimized/hidden state could not + /// be read, the routed pointer and raw keys refuse exactly as if the + /// window were minimized, while exact semantic AX actions remain allowed. + #[test] + fn unknown_visibility_fails_closed_for_pointer_and_keyboard() { + for facts in [ + BackgroundTargetFacts { + target_minimized: None, + ..matched_facts() + }, + BackgroundTargetFacts { + app_hidden: None, + ..matched_facts() + }, + ] { + assert!( + decide_background_input(TARGET, &facts, BackgroundAction::AxSemantic).is_execute() + ); + for action in [ + BackgroundAction::WindowPointer, + BackgroundAction::InsertText, + BackgroundAction::GenericKey, + ] { + assert_eq!( + code_of(decide_background_input(TARGET, &facts, action)), + refusal_codes::MINIMIZED_OR_HIDDEN, + "{action:?}" + ); + } + } + } + + /// The singleton rule: with no competing keyboard destination, exact + /// background text/keys are permitted and verification is target-bound. + #[test] + fn singleton_window_permits_background_keyboard_with_target_bound_verification() { + let facts = matched_facts(); + for action in [BackgroundAction::InsertText, BackgroundAction::GenericKey] { + match decide_background_input(TARGET, &facts, action) { + BackgroundInputDecision::Execute { verification } => { + assert_eq!(verification.window_id, TARGET.window_id); + } + other => panic!("{action:?} expected Execute, got {other:?}"), + } + } + } + + /// An addressed element that cannot be proven to descend from the + /// requested window refuses every route — a cached element under the right + /// cache key is not sufficient after a lifecycle or Space transition. + #[test] + fn unproven_element_ancestry_refuses_all_routes() { + for element in [ + ElementAncestry::OutsideTargetWindow, + ElementAncestry::Unproven, + ] { + let facts = BackgroundTargetFacts { + element, + ..matched_facts() + }; + for action in ALL_ACTIONS { + assert_eq!( + code_of(decide_background_input(TARGET, &facts, action)), + refusal_codes::ELEMENT_OUTSIDE_TARGET_WINDOW, + "{action:?} with {element:?}" + ); + } + } + } + + #[test] + fn proven_element_ancestry_executes_semantic_ax_even_when_minimized() { + let facts = BackgroundTargetFacts { + element: ElementAncestry::ProvenDescendant, + target_minimized: Some(true), + ..matched_facts() + }; + assert!(decide_background_input(TARGET, &facts, BackgroundAction::AxSemantic).is_execute()); + } + + /// Refusal precedence: exactness failures are reported before state or + /// cardinality failures so the caller fixes the most fundamental fact. + #[test] + fn refusal_precedence_owner_before_element_before_scope_before_state() { + let worst = BackgroundTargetFacts { + window_server: WindowServerOwnership::ForeignPid { owner_pid: 9 }, + ax_window_present: false, + target_minimized: Some(true), + app_hidden: Some(true), + competing_keyboard_destinations: 3, + element: ElementAncestry::OutsideTargetWindow, + }; + assert_eq!( + code_of(decide_background_input( + TARGET, + &worst, + BackgroundAction::InsertText + )), + refusal_codes::OWNER_PID_MISMATCH + ); + let bad_element = BackgroundTargetFacts { + window_server: WindowServerOwnership::SamePid, + ..worst.clone() + }; + assert_eq!( + code_of(decide_background_input( + TARGET, + &bad_element, + BackgroundAction::InsertText + )), + refusal_codes::ELEMENT_OUTSIDE_TARGET_WINDOW + ); + let unresolved = BackgroundTargetFacts { + element: ElementAncestry::NotAddressed, + ..bad_element + }; + assert_eq!( + code_of(decide_background_input( + TARGET, + &unresolved, + BackgroundAction::InsertText + )), + refusal_codes::OFF_SPACE_OR_AX_UNRESOLVED + ); + let minimized = BackgroundTargetFacts { + ax_window_present: true, + ..unresolved + }; + assert_eq!( + code_of(decide_background_input( + TARGET, + &minimized, + BackgroundAction::InsertText + )), + refusal_codes::MINIMIZED_OR_HIDDEN + ); + } + + #[test] + fn capability_report_shape_is_stable() { + let report = background_input_capability_report(TARGET, &matched_facts(), Some(true)); + assert_eq!(report["exact_window"]["status"], "matched"); + assert_eq!(report["exact_window"]["pid"], 42); + assert_eq!(report["exact_window"]["window_id"], 700); + assert_eq!(report["routes"][0]["route"], "accessibility"); + assert_eq!(report["routes"][0]["status"], "available"); + assert_eq!(report["routes"][2]["route"], "pid_keyboard"); + assert_eq!(report["routes"][2]["status"], "available"); + assert_eq!(report["observation"]["one_shot_capture"], "available"); + assert_eq!(report["observation"]["frame_freshness"], "unknown"); + } + + #[test] + fn capability_report_refusals_carry_stable_reason_codes() { + let facts = BackgroundTargetFacts { + ax_window_present: false, + ..matched_facts() + }; + let report = background_input_capability_report(TARGET, &facts, Some(false)); + assert_eq!(report["exact_window"]["status"], "ax_unresolved"); + for route in report["routes"].as_array().unwrap() { + assert_eq!(route["status"], "refused"); + assert_eq!(route["reason"], refusal_codes::OFF_SPACE_OR_AX_UNRESOLVED); + } + assert_eq!(report["observation"]["one_shot_capture"], "unavailable"); + + let two_windows = BackgroundTargetFacts { + competing_keyboard_destinations: 1, + ..matched_facts() + }; + let report = background_input_capability_report(TARGET, &two_windows, None); + assert_eq!(report["routes"][0]["status"], "available"); + assert_eq!(report["routes"][2]["status"], "refused"); + assert_eq!( + report["routes"][2]["reason"], + refusal_codes::SAME_PID_KEYBOARD_AMBIGUITY + ); + assert_eq!(report["observation"]["one_shot_capture"], "unknown"); + } +} diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/approval.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/approval.rs deleted file mode 100644 index 87669abf59..0000000000 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/approval.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Explicit approval evidence for mutating browser preparation. -//! -//! MCP-host approval is carried on an internal transport marker. Direct CLI -//! and raw callers instead use a short-lived, single-use artifact minted by -//! the interactive `browser-approve` command. Public tool arguments never -//! treat a Boolean as consent. - -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::PathBuf; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use super::platform::{PrepareProfile, PrepareProfileMode}; -use super::refusal::{BrowserRefusal, BrowserRefusalCode}; - -pub const MCP_HOST_APPROVAL_ARG: &str = "_cua_browser_prepare_mcp_host_approved"; -pub const MAX_APPROVAL_TTL: Duration = Duration::from_secs(5 * 60); - -#[derive(Debug, Serialize, Deserialize)] -struct IsolatedApprovalArtifact { - schema: String, - token: String, - pid: i64, - profile: PrepareProfile, - expires_unix_ms: u128, -} - -/// Exact public operation authorized by an interactive existing-profile -/// approval. Transport identity is intentionally absent: the daemon binds it -/// when consuming the artifact and minting its in-memory grant. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ExistingProfileApprovalScope { - pub pid: i64, - pub window_id: u64, - pub session: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct ExistingProfileApprovalArtifact { - schema: String, - token: String, - scope: ExistingProfileApprovalScope, - expires_unix_ms: u128, -} - -fn refusal(message: impl Into) -> BrowserRefusal { - BrowserRefusal::new(BrowserRefusalCode::BrowserConsentRequired, message) -} - -fn now_unix_ms() -> Result { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .map_err(|_| refusal("the system clock cannot validate browser preparation approval")) -} - -fn approval_root() -> PathBuf { - std::env::temp_dir().join("cua-driver-browser-approvals-v1") -} - -fn artifact_path(token: &str) -> Result { - let parsed = Uuid::parse_str(token) - .map_err(|_| refusal("browser preparation approval token is malformed or expired"))?; - Ok(approval_root().join(format!("{parsed}.json"))) -} - -pub fn validate_profile(profile: &PrepareProfile) -> Result<(), BrowserRefusal> { - match profile.mode { - PrepareProfileMode::IsolatedNew => { - if profile.name.is_some() { - return Err(refusal("profile.name is not valid with mode=isolated_new")); - } - } - PrepareProfileMode::IsolatedNamed => { - let Some(name) = profile.name.as_deref() else { - return Err(refusal("profile.name is required with mode=isolated_named")); - }; - if name.is_empty() - || name.len() > 64 - || !name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return Err(refusal( - "profile.name must be 1-64 ASCII letters, digits, '-' or '_'", - )); - } - } - } - Ok(()) -} - -/// Mint approval evidence after an interactive caller has confirmed the exact -/// operation. The returned token is the only public value passed to the tool. -pub fn mint_prepare_approval(pid: i64, profile: PrepareProfile) -> Result { - validate_profile(&profile)?; - let token = Uuid::new_v4().to_string(); - let root = approval_root(); - fs::create_dir_all(&root) - .map_err(|error| refusal(format!("could not create approval directory: {error}")))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) - .map_err(|error| refusal(format!("could not restrict approval directory: {error}")))?; - } - - let artifact = IsolatedApprovalArtifact { - schema: "cua-browser-prepare-approval-v1".to_owned(), - token: token.clone(), - pid, - profile, - expires_unix_ms: now_unix_ms()? + MAX_APPROVAL_TTL.as_millis(), - }; - let path = artifact_path(&token)?; - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options - .open(path) - .map_err(|error| refusal(format!("could not create approval artifact: {error}")))?; - let bytes = serde_json::to_vec(&artifact) - .map_err(|error| refusal(format!("could not encode approval artifact: {error}")))?; - file.write_all(&bytes) - .and_then(|_| file.sync_all()) - .map_err(|error| refusal(format!("could not persist approval artifact: {error}")))?; - Ok(token) -} - -fn persist_artifact(token: &str, artifact: &T) -> Result<(), BrowserRefusal> { - let root = approval_root(); - fs::create_dir_all(&root) - .map_err(|error| refusal(format!("could not create approval directory: {error}")))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) - .map_err(|error| refusal(format!("could not restrict approval directory: {error}")))?; - } - let path = artifact_path(token)?; - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options - .open(path) - .map_err(|error| refusal(format!("could not create approval artifact: {error}")))?; - let bytes = serde_json::to_vec(artifact) - .map_err(|error| refusal(format!("could not encode approval artifact: {error}")))?; - file.write_all(&bytes) - .and_then(|_| file.sync_all()) - .map_err(|error| refusal(format!("could not persist approval artifact: {error}"))) -} - -/// Mint a single-use artifact for the exact existing-profile request shown to -/// the person at the interactive approval boundary. -pub fn mint_existing_profile_approval( - scope: ExistingProfileApprovalScope, -) -> Result { - if scope.pid <= 0 || scope.window_id == 0 || scope.session.trim().is_empty() { - return Err(refusal( - "existing-profile approval requires a positive pid/window_id and explicit session", - )); - } - let token = Uuid::new_v4().to_string(); - let artifact = ExistingProfileApprovalArtifact { - schema: "cua-browser-existing-profile-approval-v1".to_owned(), - token: token.clone(), - scope, - expires_unix_ms: now_unix_ms()? + MAX_APPROVAL_TTL.as_millis(), - }; - persist_artifact(&token, &artifact)?; - Ok(token) -} - -/// Consume approval evidence. The artifact is removed before any semantic -/// validation so malformed, mismatched, and expired attempts are single-use. -pub fn consume_prepare_approval( - token: &str, - pid: i64, - profile: &PrepareProfile, -) -> Result<(), BrowserRefusal> { - validate_profile(profile)?; - let path = artifact_path(token)?; - let bytes = fs::read(&path) - .map_err(|_| refusal("browser preparation approval token is missing or expired"))?; - fs::remove_file(&path) - .map_err(|error| refusal(format!("could not consume approval artifact: {error}")))?; - let artifact: IsolatedApprovalArtifact = serde_json::from_slice(&bytes) - .map_err(|_| refusal("browser preparation approval artifact is invalid"))?; - if artifact.schema != "cua-browser-prepare-approval-v1" - || artifact.token != token - || artifact.pid != pid - || artifact.profile != *profile - || artifact.expires_unix_ms < now_unix_ms()? - { - return Err(refusal( - "browser preparation approval does not match this pid/profile request or has expired", - )); - } - Ok(()) -} - -/// Consume an existing-profile artifact before validating it. A mismatch is -/// deliberately destructive so an artifact can never become a probing oracle. -pub fn consume_existing_profile_approval( - token: &str, - scope: &ExistingProfileApprovalScope, -) -> Result<(), BrowserRefusal> { - let path = artifact_path(token)?; - let bytes = fs::read(&path) - .map_err(|_| refusal("browser preparation approval token is missing or expired"))?; - fs::remove_file(&path) - .map_err(|error| refusal(format!("could not consume approval artifact: {error}")))?; - let artifact: ExistingProfileApprovalArtifact = serde_json::from_slice(&bytes) - .map_err(|_| refusal("browser preparation approval artifact is invalid"))?; - if artifact.schema != "cua-browser-existing-profile-approval-v1" - || artifact.token != token - || artifact.scope != *scope - || artifact.expires_unix_ms < now_unix_ms()? - { - return Err(refusal( - "browser preparation approval does not match this pid/window/session request or has expired", - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn named(name: &str) -> PrepareProfile { - PrepareProfile { - mode: PrepareProfileMode::IsolatedNamed, - name: Some(name.to_owned()), - } - } - - #[test] - fn approval_is_bound_and_single_use() { - let profile = named("approval-test"); - let token = mint_prepare_approval(42, profile.clone()).unwrap(); - consume_prepare_approval(&token, 42, &profile).unwrap(); - assert!(consume_prepare_approval(&token, 42, &profile).is_err()); - } - - #[test] - fn mismatch_consumes_the_artifact() { - let profile = named("approval-mismatch"); - let token = mint_prepare_approval(42, profile.clone()).unwrap(); - assert!(consume_prepare_approval(&token, 7, &profile).is_err()); - assert!(consume_prepare_approval(&token, 42, &profile).is_err()); - } - - #[test] - fn named_profile_is_path_safe() { - for name in ["", "../Default", "a/b", "with space", "."] { - assert!(validate_profile(&named(name)).is_err(), "accepted {name:?}"); - } - validate_profile(&named("research_2026-07")).unwrap(); - } - - #[test] - fn existing_profile_approval_is_exact_and_single_use() { - let scope = ExistingProfileApprovalScope { - pid: 42, - window_id: 7, - session: "approval-existing".to_owned(), - }; - let token = mint_existing_profile_approval(scope.clone()).unwrap(); - consume_existing_profile_approval(&token, &scope).unwrap(); - assert!(consume_existing_profile_approval(&token, &scope).is_err()); - } - - #[test] - fn existing_profile_mismatch_consumes_artifact() { - let scope = ExistingProfileApprovalScope { - pid: 42, - window_id: 7, - session: "approval-existing-mismatch".to_owned(), - }; - let token = mint_existing_profile_approval(scope.clone()).unwrap(); - let mut wrong = scope.clone(); - wrong.window_id = 8; - assert!(consume_existing_profile_approval(&token, &wrong).is_err()); - assert!(consume_existing_profile_approval(&token, &scope).is_err()); - } -} diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/download.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/download.rs index 46871f5218..3a2a467705 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/download.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/download.rs @@ -19,6 +19,7 @@ use crate::tool_args::ArgsExt; use super::cdp_ws::CdpConnection; use super::engine::BrowserEngine; use super::refusal::{BrowserRefusal, BrowserRefusalCode}; +use super::required_session_schema; use super::store::BrowserActionKind; /// Injected by an MCP host only after its destructive-tool approval flow. @@ -46,10 +47,7 @@ impl BrowserDownloadTool { input_schema: json!({ "type": "object", "properties": { - "session": { - "type": "string", - "description": "Explicit caller session owning the browser capabilities." - }, + "session": required_session_schema(), "target_id": { "type": "string", "description": "Opaque exact browser target id from get_browser_state." diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/engine.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/engine.rs index dbc5720356..2b599b5a68 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/engine.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/engine.rs @@ -96,7 +96,7 @@ fn authorize_live_browser_origin( let manifest = manifest.ok_or_else(|| { refuse( BrowserRefusalCode::BrowserOriginOutsideScope, - "the bounded session policy is unavailable", + "the capability manifest is unavailable", ) })?; manifest @@ -1425,15 +1425,13 @@ impl BrowserEngine { let cdp_session = self.attach(&conn, &tab.cdp_target_id).await?; let dispatch_context = crate::tool::current_dispatch_authorization_context(); - let dispatch_mode = dispatch_context - .as_ref() - .map(|context| context.mode()) - .map(Ok) - .unwrap_or_else(crate::authorization::configured_permission_mode); - if dispatch_mode.is_ok_and(|mode| mode == crate::authorization::PermissionMode::Bounded) { + if dispatch_context + .as_deref() + .is_some_and(|context| context.capability_manifest().is_some()) + { let live_url = self.live_top_level_url(&conn, &cdp_session).await?; - // A browser mutation admitted for a delegated bounded session - // must use that exact session's manifest. Falling back to the + // A browser mutation admitted for a delegated session must use + // that exact session's capability manifest. Falling back to the // process compatibility manifest would let a missing task-local // context borrow unrelated authority. let manifest = dispatch_context @@ -1441,10 +1439,10 @@ impl BrowserEngine { .ok_or_else(|| { refuse( BrowserRefusalCode::BrowserOriginOutsideScope, - "the bounded browser authorization context is unavailable", + "the browser authorization context is unavailable", ) })? - .bounded_manifest(); + .capability_manifest(); authorize_live_browser_origin(manifest, &live_url)?; } Ok(ValidatedTab { diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/mod.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/mod.rs index 7bcbe2ef1b..c401844f02 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/mod.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/mod.rs @@ -35,7 +35,6 @@ //! is re-proven before any mutation; frames whose identity or //! capability cannot be proven are omitted or refused, never guessed. -pub mod approval; pub mod binding; pub mod cdp_ws; pub mod download; @@ -61,8 +60,8 @@ pub use engine::BrowserEngine; pub use platform::{ BrowserConsentOutcome, BrowserConsentRequest, BrowserPlatform, BrowserVisualAction, BrowserVisualActionKind, ExistingProfileSetupOutcome, ExistingProfileSetupRequest, - PrepareAction, PrepareAttachment, PrepareAttachmentKind, PrepareAuthorization, PrepareOutcome, - PrepareProfile, PrepareProfileMode, PrepareRequest, PrepareSideEffects, PrepareStrategy, + PrepareAction, PrepareAttachment, PrepareAttachmentKind, PrepareOutcome, PrepareProfile, + PrepareProfileMode, PrepareRequest, PrepareSideEffects, PrepareStrategy, }; pub use refusal::{BrowserRefusal, BrowserRefusalCode}; pub use setup_descriptor::{ @@ -74,3 +73,48 @@ pub use types::{ EndpointOwnershipMethod, EndpointOwnershipProof, NativeOwnershipMethod, NativeOwnershipProof, NativeWindowInfo, OwnedEndpoint, ProcessFingerprint, Rect, }; + +pub(crate) fn session_schema() -> serde_json::Value { + serde_json::json!({ + "type": "string", + "description": format!( + "{} Browser targets, tabs, and refs belong to the resolved lifecycle session.", + cua_driver_contract::MULTI_CALL_SESSION_DESCRIPTION + ) + }) +} + +pub(crate) fn required_session_schema() -> serde_json::Value { + serde_json::json!({ + "type": "string", + "description": "For multi-call work, prefer a short public session label and repeat it on every call that accepts it. This tool requires the label that owns its browser target, tab, and refs." + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_session_schema_keeps_naming_and_capability_guidance_together() { + let schema = session_schema(); + let description = schema["description"] + .as_str() + .expect("browser session description"); + assert!(description.contains("prefer a short public session label")); + assert!(description.contains("repeat it on every call that accepts it")); + assert!(description.contains("Browser targets, tabs, and refs")); + } + + #[test] + fn required_browser_session_schema_does_not_suggest_omission() { + let schema = required_session_schema(); + let description = schema["description"] + .as_str() + .expect("required browser session description"); + assert!(description.contains("prefer a short public session label")); + assert!(description.contains("repeat it on every call that accepts it")); + assert!(description.contains("requires the label")); + assert!(!description.contains("Omit it")); + } +} diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs index 1a42d9c720..8e071f8ae7 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs @@ -38,12 +38,6 @@ pub enum PrepareStrategy { ExistingProfile, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PrepareAuthorization { - McpHost, - ApprovalArtifact(String), -} - /// Caller context for an explicit `browser_prepare` call. Prepare is never /// implicit: `get_browser_state` must not trigger it. #[derive(Debug, Clone)] @@ -57,7 +51,6 @@ pub struct PrepareRequest { /// this independently from the public capability session so either proxy /// disconnect or explicit `end_session` can reap a spawned browser. pub transport_session: Option, - pub authorization: Option, /// Omitted for the legacy isolated-profile compatibility form. pub strategy: Option, pub profile: Option, diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/pointer.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/pointer.rs index 1ae2bf5de1..ad80763bb4 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/pointer.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/pointer.rs @@ -20,6 +20,7 @@ use super::cdp_ws::CdpConnection; use super::engine::{BrowserEngine, ValidatedTab}; use super::platform::BrowserVisualActionKind; use super::refusal::{BrowserRefusal, BrowserRefusalCode}; +use super::required_session_schema; use super::store::{BrowserActionKind, FrameKind, FrameRef}; use super::tools::{browser_protected_resource_scope, browser_resource_ownership}; @@ -299,7 +300,7 @@ impl BrowserPointerTool { "properties": { "target_id": { "type": "string", "description": "Opaque target id minted by get_browser_state." }, "tab_id": { "type": "string", "description": "Opaque tab id minted by get_browser_state." }, - "session": { "type": "string", "description": "Explicit caller session owning the browser capabilities." }, + "session": required_session_schema(), "action": { "type": "string", "enum": ["hover", "right_click", "double_click", "scroll", "drag"] }, "input_route": { "type": "string", "enum": ["trusted", "dom_event"], "default": "trusted" }, "ref": { "type": "string", "description": "Origin page ref. Alternative to x/y." }, diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/prepare.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/prepare.rs index 0a864dd510..903188a110 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/prepare.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/prepare.rs @@ -11,16 +11,12 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; -use super::approval::{ - consume_existing_profile_approval, consume_prepare_approval, validate_profile, - ExistingProfileApprovalScope, -}; use super::engine::unsupported_engine_refusal; use super::platform::{ BrowserConsentOutcome, BrowserConsentRequest, ExistingProfileSetupOutcome, ExistingProfileSetupRequest, PrepareAction, PrepareAttachment, PrepareAttachmentKind, - PrepareAuthorization, PrepareOutcome, PrepareProfile, PrepareProfileMode, PrepareRequest, - PrepareSideEffects, PrepareStrategy, + PrepareOutcome, PrepareProfile, PrepareProfileMode, PrepareRequest, PrepareSideEffects, + PrepareStrategy, }; use super::refusal::{BrowserRefusal, BrowserRefusalCode}; use super::types::{ @@ -31,6 +27,39 @@ use super::BrowserEngine; const PROFILE_MARKER: &str = ".qwen-cua-driver-owned-profile.json"; const PROFILE_SCHEMA: &str = "qwen-cua-driver-browser-profile-v1"; +fn validate_profile(profile: &PrepareProfile) -> Result<(), BrowserRefusal> { + match profile.mode { + PrepareProfileMode::IsolatedNew => { + if profile.name.is_some() { + return Err(refusal( + BrowserRefusalCode::BrowserConsentRequired, + "profile.name is not valid with mode=isolated_new", + )); + } + } + PrepareProfileMode::IsolatedNamed => { + let Some(name) = profile.name.as_deref() else { + return Err(refusal( + BrowserRefusalCode::BrowserConsentRequired, + "profile.name is required with mode=isolated_named", + )); + }; + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(refusal( + BrowserRefusalCode::BrowserConsentRequired, + "profile.name must be 1-64 ASCII letters, digits, '-' or '_'", + )); + } + } + } + Ok(()) +} + async fn claim_with_optional_consent( claim: &mut Pin>, consent: Consent, @@ -670,19 +699,6 @@ impl BrowserEngine { ) })?; validate_profile(profile_request)?; - match request.authorization.as_ref() { - Some(PrepareAuthorization::McpHost) => {} - Some(PrepareAuthorization::ApprovalArtifact(token)) => { - consume_prepare_approval(token, request.pid, profile_request)?; - } - None => { - return Err(refusal( - BrowserRefusalCode::BrowserConsentRequired, - "browser preparation needs MCP host approval or a fresh browser-approve token", - )) - } - } - let classification = self.platform.classify_browser(request.pid).await?; if !classification.supports_cdp || classification.engine != BrowserEngineFamily::Chromium { return Err(unsupported_engine_refusal( @@ -769,7 +785,6 @@ impl BrowserEngine { Protected, BoundedManifest, LaunchGrant, - LegacyArtifact, Unrestricted, } @@ -779,11 +794,6 @@ impl BrowserEngine { "strategy=existing_profile requires an exact window_id approval anchor", ) })?; - let scope = ExistingProfileApprovalScope { - pid: request.pid, - window_id, - session: request.session.clone(), - }; let mode = crate::tool::current_dispatch_authorization_context() .map(|context| context.mode()) .map(Ok) @@ -803,10 +813,10 @@ impl BrowserEngine { "bounded existing-profile attachment requires a live session authorization context", ) })?; - let manifest = context.bounded_manifest().ok_or_else(|| { + let manifest = context.capability_manifest().ok_or_else(|| { refusal( BrowserRefusalCode::BrowserConsentRequired, - "bounded existing-profile attachment requires an approved session manifest", + "bounded existing-profile attachment requires an approved capability manifest", ) })?; manifest @@ -824,27 +834,6 @@ impl BrowserEngine { ConsentPath::LaunchGrant } else if self.approval_broker.provider_id().is_some() { ConsentPath::Protected - } else if crate::authorization::legacy_existing_profile_approval_enabled() { - match request.authorization.as_ref() { - Some(PrepareAuthorization::ApprovalArtifact(token)) => { - consume_existing_profile_approval(token, &scope)?; - ConsentPath::LegacyArtifact - } - // The ordinary MCP destructive-tool marker proves transport - // provenance, not a person's approval of their authenticated - // profile. It is deliberately insufficient here. - Some(PrepareAuthorization::McpHost) | None => { - return Err(refusal( - BrowserRefusalCode::BrowserConsentRequired, - "legacy existing-profile compatibility requires a fresh operation-bound browser-approve artifact", - ) - .with_detail(serde_json::json!({ - "approval_request_id": uuid::Uuid::new_v4().to_string(), - "approval_command": "qwen-cua-driver browser-approve --strategy existing_profile --pid --window-id --session ", - "legacy_approval_enabled": true, - }))); - } - } } else { return Err(refusal( BrowserRefusalCode::BrowserConsentRequired, @@ -853,7 +842,6 @@ impl BrowserEngine { .with_detail(serde_json::json!({ "permission_mode": mode.as_str(), "authorization_required": true, - "legacy_approval_enabled": false, "authorization_host": self.approval_broker.provider_id(), }))); }; @@ -985,7 +973,7 @@ impl BrowserEngine { // Host authorization is requested only after the exact process, // native window, browser product, and endpoint owner have all been - // proven. Bounded manifests, launch grants, and unrestricted mode + // proven. Bounded capability manifests, launch grants, and unrestricted mode // never enter this callback path. let protected_consent = if matches!(consent_path, ConsentPath::Protected) { let transport_session = request diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/refusal.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/refusal.rs index 51863b4486..74bc3e2b66 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/refusal.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/refusal.rs @@ -62,7 +62,7 @@ pub enum BrowserRefusalCode { /// browser action. BrowserActionUnavailable, /// The live top-level document left the origin set approved in the - /// bounded session manifest. Further browser input is paused. + /// capability manifest. Further browser input is paused. BrowserOriginOutsideScope, } diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/tools.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/tools.rs index fce9fc8561..b787a24d76 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/tools.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/tools.rs @@ -14,15 +14,13 @@ use crate::protocol::{Content, ToolResult}; use crate::tool::{ProtectedResourceOwnership, Tool, ToolDef, ToolRegistry}; use crate::tool_args::ArgsExt; -use super::approval::MCP_HOST_APPROVAL_ARG; use super::cdp_ws::CdpConnection; use super::download::BrowserDownloadTool; use super::engine::{BrowserEngine, BrowserTabScreenshot}; -use super::platform::{ - BrowserVisualActionKind, PrepareAuthorization, PrepareProfile, PrepareRequest, PrepareStrategy, -}; +use super::platform::{BrowserVisualActionKind, PrepareProfile, PrepareRequest, PrepareStrategy}; use super::pointer::BrowserPointerTool; use super::refusal::{BrowserRefusal, BrowserRefusalCode}; +use super::session_schema as schema_session; use super::store::BrowserActionKind; use super::types::BindingQuality; @@ -87,13 +85,6 @@ fn schema_ref() -> Value { }) } -fn schema_session() -> Value { - json!({ - "type": "string", - "description": "Stable caller-declared session id. Browser targets, tabs, and refs are scoped to this session." - }) -} - pub(crate) fn browser_resource_ownership( engine: &BrowserEngine, args: &Value, @@ -592,8 +583,8 @@ impl BrowserPrepareTool { name: "browser_prepare".into(), description: "Explicitly prepare an owned DevTools endpoint for a browser \ pid. Existing endpoints are detected without side effects. Acting setup \ - for an isolated profile requires host approval or a short-lived setup \ - token plus allow_launch=true. It launches a separate browser and never \ + for an isolated profile follows the runtime permission mode and optional \ + capability manifest. It requires allow_launch=true, launches a separate browser, and never \ copies, modifies, or terminates the requested user profile. Existing-profile \ attachment is explicit and follows the runtime's immutable permission mode: \ standard requires an explicit --grant existing-profile launch grant or an \ @@ -611,10 +602,6 @@ impl BrowserPrepareTool { "properties": { "pid": { "type": "integer", "description": "Browser process id to prepare." }, "window_id": { "type": "integer", "description": "Exact native window approval anchor; required for strategy.kind=existing_profile." }, - "approval_token": { - "type": "string", - "description": "Legacy single-use setup token. Existing-profile use is disabled unless a trusted launcher explicitly enables the same-user-writable compatibility path." - }, "allow_launch": { "type": "boolean", "description": "Allow a separate driver-owned isolated Chromium process to be launched (default false)." @@ -685,24 +672,11 @@ impl Tool for BrowserPrepareTool { } }, }; - let approval_token = args.opt_str("approval_token"); - let authorization = if strategy == Some(PrepareStrategy::ExistingProfile) { - approval_token.map(PrepareAuthorization::ApprovalArtifact) - } else if args - .get(MCP_HOST_APPROVAL_ARG) - .and_then(Value::as_bool) - .unwrap_or(false) - { - Some(PrepareAuthorization::McpHost) - } else { - approval_token.map(PrepareAuthorization::ApprovalArtifact) - }; let request = PrepareRequest { pid, window_id: args.opt_u64("window_id"), session, transport_session: args.opt_str("_transport_session_id"), - authorization, strategy, profile, allow_launch: args.opt_bool("allow_launch").unwrap_or(false), @@ -887,7 +861,9 @@ impl BrowserClickTool { (Input.dispatchMouseEvent), and refuses where that route cannot \ preserve standalone-browser background posture. \ input_route=\"dom_event\" (synthetic \ - el.click(), ref required) is used only when explicitly requested. \ + el.click(), ref required) is used only when explicitly requested; \ + it proves dispatch, not control activation, because trust-gated \ + controls may ignore synthetic events. \ Refused for heuristic bindings." .into(), input_schema: json!({ @@ -905,7 +881,9 @@ impl BrowserClickTool { "description": "\"trusted\" (default): Input.dispatchMouseEvent. \ It refuses rather than foregrounding a standalone browser. \ \"dom_event\": synthetic full-background DOM click, only \ - when explicitly requested." + when explicitly requested. Dispatch does not prove the \ + control activated; refresh page state and verify the \ + expected postcondition." }, }, "required": ["target_id", "tab_id"], @@ -1143,16 +1121,23 @@ impl Tool for BrowserClickTool { .await { Ok(_) => ToolResult::text(format!( - "dispatched DOM click on {} in {tab_id}", + "dispatched synthetic DOM click on {} in {tab_id}; application effect not \ + verified (trust-gated controls may ignore untrusted events). Refresh page \ + state and verify the expected postcondition", ext_ref.as_deref().unwrap_or("?") )) .with_structured(json!({ "status": "ok", + "effect": "unverifiable", "route": "dom_event", "target_id": target_id, "tab_id": tab_id, "ref": ext_ref, "frame": frame_kind, + "escalation": { + "recommended": "page", + "reason": "synthetic DOM dispatch cannot prove control activation; refresh page state and verify the expected postcondition", + }, })), Err(e) => ToolResult::error(format!("DOM click failed: {e}")), }; @@ -2612,7 +2597,7 @@ mod tests { .expect("browser_prepare properties"); assert!(!prepare_properties.contains_key("consent")); assert!(!prepare_properties.contains_key("allow_restart")); - assert!(!prepare_properties.contains_key(MCP_HOST_APPROVAL_ARG)); + assert!(!prepare_properties.contains_key("approval_token")); let dialog = BrowserDialogTool::new(e.clone()); assert_eq!( @@ -2821,7 +2806,7 @@ mod tests { } #[tokio::test] - async fn prepare_requires_non_forgeable_approval_for_acting_setup() { + async fn isolated_prepare_uses_runtime_authorization_without_a_token() { let tool = BrowserPrepareTool::new(engine()); let result = tool .invoke(json!({ @@ -2833,47 +2818,38 @@ mod tests { .await; assert_eq!( structured(&result)["refusal"]["code"], - "browser_consent_required" + "browser_route_unavailable" ); } #[tokio::test] - async fn ordinary_mcp_marker_never_approves_an_existing_profile() { + async fn existing_profile_requires_runtime_authorization() { let tool = BrowserPrepareTool::new(engine()); let result = tool .invoke(json!({ "pid": 1, "window_id": 7, "strategy": { "kind": "existing_profile" }, - "session": "existing-profile-run", - MCP_HOST_APPROVAL_ARG: true + "session": "existing-profile-run" })) .await; let structured = structured(&result); assert_eq!(structured["refusal"]["code"], "browser_consent_required"); - assert!(structured["refusal"]["detail"]["approval_request_id"] - .as_str() - .is_some()); + assert_eq!( + structured["refusal"]["detail"]["authorization_required"], + true + ); } #[tokio::test] async fn existing_strategy_conflicts_fail_before_platform_setup() { let tool = BrowserPrepareTool::new(engine()); - let token = crate::browser::approval::mint_existing_profile_approval( - crate::browser::approval::ExistingProfileApprovalScope { - pid: 1, - window_id: 7, - session: "existing-conflict".to_owned(), - }, - ) - .unwrap(); let result = tool .invoke(json!({ "pid": 1, "window_id": 7, "strategy": { "kind": "existing_profile" }, "profile": { "mode": "isolated_new" }, - "approval_token": token, "session": "existing-conflict" })) .await; diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs index 7d2fb74340..032d2b6cbd 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs @@ -15,6 +15,7 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; use serde_json::{json, Value}; +use crate::action_record::ActionExecutionRecord; use crate::protocol::{Content, ToolResult}; use crate::tool::Tool; @@ -885,27 +886,6 @@ async fn fixture() -> Fixture { fixture_with(|_| {}).await } -async fn existing_profile_only_fixture() -> Fixture { - let state = Arc::new(StdMutex::new(FixtureState::default())); - let server = MockCdpServer::start(fixture_handler(state.clone())).await; - let setup_invoked = Arc::new(AtomicBool::new(false)); - let engine = BrowserEngine::new(Arc::new(FixturePlatform { - ws_url: server.ws_url(), - trusted_input_limited: false, - managed_endpoint_visible: false, - existing_endpoint_visible: Arc::new(AtomicBool::new(true)), - setup_invoked: setup_invoked.clone(), - setup_aborted: Arc::new(AtomicBool::new(false)), - stall_consent: false, - })); - Fixture { - state, - _server: server, - engine, - setup_invoked, - } -} - struct FixtureProtectedProvider { consent_seen: AtomicBool, } @@ -962,15 +942,20 @@ async fn existing_profile_setup_fixture() -> (Fixture, Arc) { let state = Arc::new(StdMutex::new(FixtureState::default())); let server = MockCdpServer::start(fixture_handler(state.clone())).await; let setup_invoked = Arc::new(AtomicBool::new(false)); - let engine = BrowserEngine::new(Arc::new(FixturePlatform { - ws_url: server.ws_url(), - trusted_input_limited: false, - managed_endpoint_visible: false, - existing_endpoint_visible: Arc::new(AtomicBool::new(false)), - setup_invoked: setup_invoked.clone(), - setup_aborted: Arc::new(AtomicBool::new(false)), - stall_consent: false, - })); + let engine = BrowserEngine::new_with_protected_consent_provider( + Arc::new(FixturePlatform { + ws_url: server.ws_url(), + trusted_input_limited: false, + managed_endpoint_visible: false, + existing_endpoint_visible: Arc::new(AtomicBool::new(false)), + setup_invoked: setup_invoked.clone(), + setup_aborted: Arc::new(AtomicBool::new(false)), + stall_consent: false, + }), + Some(Arc::new(FixtureProtectedProvider { + consent_seen: AtomicBool::new(false), + })), + ); ( Fixture { state, @@ -1008,23 +993,14 @@ async fn bind(f: &Fixture) -> (String, String) { async fn approved_existing_profile_attach_claims_then_binds_one_generation() { // Match Chrome's per-instance toggle: the endpoint is discoverable only // through the approved existing-profile route, never as driver-managed. - let f = existing_profile_only_fixture().await; - let token = super::approval::mint_existing_profile_approval( - super::approval::ExistingProfileApprovalScope { - pid: 1, - window_id: 7, - session: SESSION.to_owned(), - }, - ) - .unwrap(); + let (f, provider) = protected_existing_profile_fixture().await; let prepare = BrowserPrepareTool::new(f.engine.clone()) .invoke(json!({ "pid": 1, "window_id": 7, "session": SESSION, "_transport_session_id": "transport-v2-attach", - "strategy": { "kind": "existing_profile" }, - "approval_token": token + "strategy": { "kind": "existing_profile" } })) .await; let prepared = structured(&prepare); @@ -1033,6 +1009,7 @@ async fn approved_existing_profile_attach_claims_then_binds_one_generation() { assert_eq!(prepared["attachment"]["kind"], "existing_profile"); assert_eq!(prepared["attachment"]["capabilities_invalidated"], true); assert_eq!(prepared["side_effects"]["displayed_consent_prompt"], false); + assert!(provider.consent_seen.load(Ordering::SeqCst)); assert!(!f.setup_invoked.load(Ordering::SeqCst)); let state = GetBrowserStateTool::new(f.engine.clone()) @@ -1086,21 +1063,12 @@ async fn protected_provider_accepts_exact_attach_and_session_end_revokes_the_gra #[tokio::test] async fn approved_existing_profile_setup_reports_exact_side_effects() { let (f, setup_invoked) = existing_profile_setup_fixture().await; - let token = super::approval::mint_existing_profile_approval( - super::approval::ExistingProfileApprovalScope { - pid: 1, - window_id: 7, - session: SESSION.to_owned(), - }, - ) - .unwrap(); let prepare = BrowserPrepareTool::new(f.engine.clone()) .invoke(json!({ "pid": 1, "window_id": 7, "session": SESSION, - "strategy": { "kind": "existing_profile" }, - "approval_token": token + "strategy": { "kind": "existing_profile" } })) .await; let prepared = structured(&prepare); @@ -1133,31 +1101,27 @@ async fn refused_consent_cancels_stalled_claim_before_revoking_grant() { let (_stream, _) = listener.accept().await.unwrap(); tokio::time::sleep(std::time::Duration::from_secs(10)).await; }); - let engine = BrowserEngine::new(Arc::new(FixturePlatform { - ws_url: format!("ws://{address}/devtools/browser"), - trusted_input_limited: false, - managed_endpoint_visible: false, - existing_endpoint_visible: Arc::new(AtomicBool::new(false)), - setup_invoked: Arc::new(AtomicBool::new(false)), - setup_aborted: Arc::new(AtomicBool::new(false)), - stall_consent: false, - })); - let token = super::approval::mint_existing_profile_approval( - super::approval::ExistingProfileApprovalScope { - pid: 1, - window_id: 7, - session: SESSION.to_owned(), - }, - ) - .unwrap(); + let engine = BrowserEngine::new_with_protected_consent_provider( + Arc::new(FixturePlatform { + ws_url: format!("ws://{address}/devtools/browser"), + trusted_input_limited: false, + managed_endpoint_visible: false, + existing_endpoint_visible: Arc::new(AtomicBool::new(false)), + setup_invoked: Arc::new(AtomicBool::new(false)), + setup_aborted: Arc::new(AtomicBool::new(false)), + stall_consent: false, + }), + Some(Arc::new(FixtureProtectedProvider { + consent_seen: AtomicBool::new(false), + })), + ); let prepared = tokio::time::timeout( std::time::Duration::from_secs(3), BrowserPrepareTool::new(engine).invoke(json!({ "pid": 1, "window_id": 7, "session": SESSION, - "strategy": { "kind": "existing_profile" }, - "approval_token": token + "strategy": { "kind": "existing_profile" } })), ) .await @@ -1179,31 +1143,27 @@ async fn cancelled_prepare_aborts_the_exact_pending_setup() { tokio::time::sleep(std::time::Duration::from_secs(10)).await; }); let setup_aborted = Arc::new(AtomicBool::new(false)); - let engine = BrowserEngine::new(Arc::new(FixturePlatform { - ws_url: format!("ws://{address}/devtools/browser"), - trusted_input_limited: false, - managed_endpoint_visible: false, - existing_endpoint_visible: Arc::new(AtomicBool::new(false)), - setup_invoked: Arc::new(AtomicBool::new(false)), - setup_aborted: setup_aborted.clone(), - stall_consent: true, - })); - let token = super::approval::mint_existing_profile_approval( - super::approval::ExistingProfileApprovalScope { - pid: 1, - window_id: 7, - session: SESSION.to_owned(), - }, - ) - .unwrap(); + let engine = BrowserEngine::new_with_protected_consent_provider( + Arc::new(FixturePlatform { + ws_url: format!("ws://{address}/devtools/browser"), + trusted_input_limited: false, + managed_endpoint_visible: false, + existing_endpoint_visible: Arc::new(AtomicBool::new(false)), + setup_invoked: Arc::new(AtomicBool::new(false)), + setup_aborted: setup_aborted.clone(), + stall_consent: true, + }), + Some(Arc::new(FixtureProtectedProvider { + consent_seen: AtomicBool::new(false), + })), + ); let cancelled = tokio::time::timeout( std::time::Duration::from_millis(750), BrowserPrepareTool::new(engine).invoke(json!({ "pid": 1, "window_id": 7, "session": SESSION, - "strategy": { "kind": "existing_profile" }, - "approval_token": token + "strategy": { "kind": "existing_profile" } })), ) .await; @@ -2004,13 +1964,40 @@ async fn trusted_click_refuses_when_standalone_background_posture_is_unavailable ); assert!(recorded_calls(&f, "Input.dispatchMouseEvent").is_empty()); + let synthetic_args = json!({ + "target_id": target, "tab_id": tab, "ref": main_ref, + "input_route": "dom_event", "session": SESSION + }); let synthetic = BrowserClickTool::new(f.engine.clone()) - .invoke(json!({ - "target_id": target, "tab_id": tab, "ref": main_ref, - "input_route": "dom_event", "session": SESSION - })) + .invoke(synthetic_args.clone()) .await; assert_eq!(structured(&synthetic)["status"], "ok"); + assert_eq!(structured(&synthetic)["effect"], "unverifiable"); + assert_eq!(structured(&synthetic)["escalation"]["recommended"], "page"); + assert!(synthetic.content.iter().any(|content| matches!( + content, + crate::protocol::Content::Text { text, .. } + if text.contains("application effect not verified") + && text.contains("trust-gated controls") + ))); + let public = ActionExecutionRecord::from_legacy( + "browser_click", + &synthetic_args, + structured(&synthetic), + ) + .expect("browser click action record") + .public_result() + .expect("public browser click result"); + let public = serde_json::to_value(public).expect("serialize public result"); + assert_eq!(public["effect"], "unverifiable", "{public}"); + assert_eq!(public["route"], "dom", "{public}"); + assert_eq!(public["delivery"]["mode"], "background", "{public}"); + assert_eq!(public["escalation"]["target"], "page", "{public}"); + assert_eq!( + public["escalation"]["reason"], "effect_unconfirmed", + "{public}" + ); + assert!(public.get("status").is_none(), "{public}"); assert!(!recorded_calls(&f, "Runtime.callFunctionOn").is_empty()); assert!(recorded_calls(&f, "Page.bringToFront").is_empty()); assert!(recorded_calls(&f, "Target.activateTarget").is_empty()); diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/capture_scope.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/capture_scope.rs index 4574758947..1769dee61c 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/capture_scope.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/capture_scope.rs @@ -238,13 +238,18 @@ impl ScopeViolation { } } -/// Enforce the modality for a public session. A first non-lifecycle action -/// implicitly starts that session in `auto`, matching the existing cursor and -/// recording lifecycle behavior. +/// Enforce modality only for a session that explicitly opted into the +/// deprecated capture-scope compatibility contract. Lifecycle-only explicit +/// and implicit sessions select modality per call and carry no capture state. pub fn enforce_tool(tool_name: &str, args: &Value) -> Result<(), ScopeViolation> { if matches!( tool_name, - "start_session" | "end_session" | "escalate_session" | "get_session_state" + "start_session" + | "end_session" + | "escalate_session" + | "get_session" + | "list_sessions" + | "get_session_state" ) { return Ok(()); } @@ -255,11 +260,9 @@ pub fn enforce_tool(tool_name: &str, args: &Value) -> Result<(), ScopeViolation> else { return Ok(()); }; - let (state, _) = bind_session(session, None).map_err(|_| ScopeViolation { - code: "session_ended", - message: format!("session '{session}' has ended"), - state: SessionCaptureScope::new(CaptureScopePolicy::Auto), - })?; + let Some(state) = get_session(session) else { + return Ok(()); + }; match (state.effective_scope(), tool_scope(tool_name, args)) { (_, ToolScope::Unscoped) | (EffectiveCaptureScope::Window, ToolScope::Window) @@ -295,13 +298,22 @@ pub fn enforce_tool(tool_name: &str, args: &Value) -> Result<(), ScopeViolation> ), state, }), - (EffectiveCaptureScope::Desktop, ToolScope::Window) => Err(ScopeViolation { - code: "window_scope_disabled", - message: format!( - "window-scope tool '{tool_name}' is disabled while session '{session}' is in desktop scope" - ), - state, - }), + (EffectiveCaptureScope::Desktop, ToolScope::Window) => { + let message = if state.policy == CaptureScopePolicy::Auto { + format!( + "window-scope tool '{tool_name}' is disabled because escalation to desktop scope is permanent for session '{session}'; to recover, call end_session for session '{session}', then call start_session with a new session id" + ) + } else { + format!( + "window-scope tool '{tool_name}' is disabled while session '{session}' is in desktop scope" + ) + }; + Err(ScopeViolation { + code: "window_scope_disabled", + message, + state, + }) + } } } @@ -351,6 +363,39 @@ mod tests { ); } + #[test] + fn escalated_auto_session_reports_permanent_scope_and_recovery() { + let session = fresh("auto-recovery"); + bind_session(&session, Some(CaptureScopePolicy::Auto)).unwrap(); + escalate_session( + &session, + EscalationReason::ForegroundIneffective, + Some("window ladder exhausted"), + ) + .unwrap(); + + let violation = enforce_tool("get_window_state", &json!({"session": session})).unwrap_err(); + assert_eq!(violation.code, "window_scope_disabled"); + assert_eq!( + violation.message, + format!( + "window-scope tool 'get_window_state' is disabled because escalation to desktop scope is permanent for session '{session}'; to recover, call end_session for session '{session}', then call start_session with a new session id" + ) + ); + assert_eq!( + violation.as_json(&session), + json!({ + "session": session, + "capture_scope": "auto", + "effective_scope": "desktop", + "desktop_unlocked": true, + "escalation_reason": "foreground_ineffective", + "escalation_detail": "window ladder exhausted", + "code": "window_scope_disabled" + }) + ); + } + #[test] fn conflicting_live_rebind_is_rejected() { let session = fresh("conflict"); @@ -495,7 +540,7 @@ mod tests { } #[test] - fn end_racing_escalation_never_resurrects_scope_state() { + fn end_racing_escalation_never_resurrects_legacy_scope_state() { use std::sync::{Arc, Barrier}; let session = fresh("end-escalate-race"); @@ -523,11 +568,9 @@ mod tests { assert!(crate::session::is_session_ended(&session)); assert!(get_session(&session).is_none()); - assert_eq!( - enforce_tool("get_window_state", &json!({"session": session})) - .unwrap_err() - .code, - "session_ended" + assert!( + enforce_tool("get_window_state", &json!({"session": session})).is_ok(), + "the compatibility helper must not recreate cleared capture state; the canonical lifecycle gate rejects the ended session" ); assert!(crate::session::revive_session(&session)); diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/consent.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/consent.rs index e1d41a12d0..397b15a307 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/consent.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/consent.rs @@ -98,7 +98,7 @@ pub enum ConsentError { DigestMismatch, #[error("confirmation expired before activation")] Expired, - #[error("protected resource is outside the bounded session policy: {0}")] + #[error("protected resource is outside the capability manifest: {0}")] BoundedResource(String), } @@ -372,7 +372,27 @@ impl ProtectedResourceGrants { "protected resource adapter '{adapter_id}' is not registered" )) })?; - match descriptor.behavior_by_mode.for_mode(context.mode()) { + if let Some(manifest) = context.capability_manifest() { + manifest + .authorize_protected_resource(adapter_id, &resource) + .map_err(ConsentError::BoundedResource)?; + } + let profile_behavior = if adapter_id == "process_control" + && context.mode() == PermissionMode::Standard + && resource + .get("driver_owned") + .and_then(Value::as_bool) + .unwrap_or(false) + { + // Standard's process-control capability is ownership-dependent: + // an attested runtime-launched process remains available, while a + // foreign process is denied before approval. Manifest scope is an + // additional ceiling and never replaces that ownership proof. + ModeBehavior::Allow + } else { + descriptor.profile_behavior.for_mode(context.mode()) + }; + match profile_behavior { ModeBehavior::Allow => return Ok(None), ModeBehavior::Deny => { return Err(ConsentError::Provider(format!( @@ -380,15 +400,12 @@ impl ProtectedResourceGrants { context.mode().as_str() ))) } - ModeBehavior::Manifest => { - let manifest = context.bounded_manifest().ok_or_else(|| { + ModeBehavior::AllowWithoutGrant => { + context.capability_manifest().ok_or_else(|| { ConsentError::BoundedResource( - "bounded mode has no approved session manifest".to_owned(), + "bounded mode has no approved capability manifest".to_owned(), ) })?; - manifest - .authorize_protected_resource(adapter_id, &resource) - .map_err(ConsentError::BoundedResource)?; return Ok(None); } ModeBehavior::RequireGrant => {} @@ -595,6 +612,7 @@ fn digest_request(request: &ConsentRequest) -> String { #[cfg(test)] mod tests { use super::*; + use std::io::Write; struct FakeProvider { action: ConsentAction, @@ -659,6 +677,28 @@ mod tests { .unwrap() } + fn authorization_context_with_manifest( + mode: PermissionMode, + ) -> Arc { + let mut file = tempfile::NamedTempFile::new().unwrap(); + writeln!( + file, + "version: 1\nmode: bounded\nexpires_after: 1h\nidle_timeout: 30m\nresources:\n desktop:\n windows:\n - pid: 42\n window_id: 7\nallow:\n tools: [click]" + ) + .unwrap(); + let manifest = Arc::new(crate::session_manifest::load_manifest(file.path()).unwrap()); + let ceiling = crate::session_authorization::SessionModeCeiling::for_trusted_sessions( + [mode], + mode == PermissionMode::Unrestricted, + Duration::from_secs(60), + Duration::from_secs(30), + ) + .unwrap(); + crate::session_authorization::SessionAuthorizationRegistry::with_ceiling(ceiling) + .compatibility_context(mode, Some(manifest)) + .unwrap() + } + #[test] fn process_ownership_requires_the_same_attested_process_instance() { let store = ProtectedResourceOwnershipStore::default(); @@ -777,6 +817,44 @@ mod tests { assert_eq!(provider.requests.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn capability_manifest_resources_narrow_standard_and_unrestricted_without_prompting() { + for mode in [PermissionMode::Standard, PermissionMode::Unrestricted] { + let provider = provider(ConsentAction::Accept); + let grants = + ProtectedResourceGrants::new(Arc::new(ApprovalBroker::new(Some(provider.clone())))); + let context = authorization_context_with_manifest(mode); + + assert!(grants + .authorize( + &context, + "desktop_input", + RiskClass::R1, + Some("public-a"), + serde_json::json!({"kind": "window_input", "pid": 42, "window_id": 7}), + "Control the declared window", + Duration::from_secs(30), + Duration::from_secs(60), + ) + .await + .is_ok()); + assert!(grants + .authorize( + &context, + "desktop_input", + RiskClass::R1, + Some("public-a"), + serde_json::json!({"kind": "window_input", "pid": 42, "window_id": 8}), + "Control an undeclared window", + Duration::from_secs(30), + Duration::from_secs(60), + ) + .await + .is_err()); + assert_eq!(provider.requests.load(Ordering::SeqCst), 0); + } + } + #[tokio::test] async fn exact_accept_creates_a_revocable_grant() { let provider = provider(ConsentAction::Accept); diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/element_token.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/element_token.rs index ce5470638b..1a9174af52 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/element_token.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/element_token.rs @@ -195,6 +195,30 @@ impl TokenRegistry { before - entries.len() } + /// Invalidate every snapshot owned by a process across runtime lanes. + /// Platform target-lifecycle watchers call this only after the process is + /// confirmed exited, so PID-wide cleanup cannot revoke a live peer. + pub fn clear_pid(&self, pid: i32) -> usize { + let mut entries = self.by_runtime_and_pid.lock().unwrap(); + let before = entries.len(); + entries.retain(|(_, entry_pid), _| *entry_pid != pid); + before - entries.len() + } + + /// Invalidate snapshots for one exact process/window target across runtime + /// lanes while preserving snapshots for the process's other live windows. + pub fn clear_target(&self, pid: i32, window_id: u32) -> usize { + let mut entries = self.by_runtime_and_pid.lock().unwrap(); + let before = entries.values().map(Vec::len).sum::(); + entries.retain(|(_, entry_pid), snapshots| { + if *entry_pid == pid { + snapshots.retain(|entry| entry.window_id != window_id); + } + !snapshots.is_empty() + }); + before - entries.values().map(Vec::len).sum::() + } + /// Build the canonical token string for `snapshot_id` / `element_index`. /// Pure helper, mirrors [`format_token`] but lives on the registry so /// callers don't have to import the free function. @@ -364,6 +388,24 @@ pub fn resolve_element_args( })) }; let resolve_token = |tok: &str| { + if crate::observation_revision::is_revision_token(tok) { + let (window_id, element_index, snapshot_id) = + crate::observation_revision::revision_tokens() + .resolve_current(pid, tok) + .map_err(|error| refusal(error.code(), error.to_string()))?; + let snapshot_token = token_for(snapshot_id, element_index); + let (snapshot_window_id, snapshot_element_index) = global() + .resolve(pid, &snapshot_token) + .map_err(|message| refusal("stale_element_token", message))?; + if snapshot_window_id != window_id || snapshot_element_index != element_index { + return Err(refusal( + "stale_element_token", + "revision element_token no longer matches the current platform snapshot" + .to_owned(), + )); + } + return Ok((window_id, element_index)); + } let (wid, idx) = global().resolve(pid, tok).map_err(|message| { let code = if message.contains("another runtime generation") { "generation_mismatch" @@ -620,6 +662,32 @@ mod tests { assert_eq!(reg.snapshot_count(1), 0); } + #[test] + fn process_exit_cleanup_invalidates_only_that_pid() { + let reg = fresh_registry(); + let dead = reg.register_snapshot(41, 1, 1); + let live = reg.register_snapshot(42, 2, 1); + assert_eq!(reg.clear_pid(41), 1); + assert_eq!( + reg.resolve(41, &format_token(dead, 0)).unwrap_err(), + STALE_TOKEN_ERROR + ); + assert_eq!(reg.resolve(42, &format_token(live, 0)).unwrap().0, 2); + } + + #[test] + fn target_cleanup_preserves_other_windows_for_the_same_pid() { + let reg = fresh_registry(); + let closed = reg.register_snapshot(43, 10, 1); + let live = reg.register_snapshot(43, 11, 1); + assert_eq!(reg.clear_target(43, 10), 1); + assert_eq!( + reg.resolve(43, &format_token(closed, 0)).unwrap_err(), + STALE_TOKEN_ERROR + ); + assert_eq!(reg.resolve(43, &format_token(live, 0)).unwrap().0, 11); + } + // ── resolve_element_args precedence rule ───────────────────────── // // These cover the Surface 6 dispatch contract: diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/lib.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/lib.rs index 04e5a38b4f..929e1003a8 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/lib.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/lib.rs @@ -46,7 +46,9 @@ pub fn parent_liveness_stdin_enabled() -> bool { } pub mod action_record; +pub mod action_target; pub mod authorization; +pub mod background_input; pub mod browser; pub mod capture_mode; pub mod capture_scope; @@ -65,6 +67,7 @@ pub mod ffmpeg_install; pub mod health_report; pub mod image_utils; pub mod model_payload; +pub mod observation_revision; pub mod page; pub mod pip_hook; pub mod policy; diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/observation_revision.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/observation_revision.rs new file mode 100644 index 0000000000..a42cf2cd7c --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/observation_revision.rs @@ -0,0 +1,1759 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::hash::Hash; +use std::sync::{Mutex, OnceLock}; + +use serde::Deserialize; +use serde_json::Value; + +const REVISION_TOKEN_PREFIX: &str = "rv1:"; +pub const OBSERVATION_REVISION_VERSION: u32 = 1; +pub const ACCESSIBILITY_SERIALIZER_VERSION: &str = "accessibility-render-v1"; +pub const ACCESSIBILITY_PROJECTION_VERSION: &str = "full-tree-v1"; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ObservationRevisionRequest { + pub version: u32, + pub serializer_version: String, + pub projection_version: String, + #[serde(default)] + pub base_revision_id: Option, + #[serde(default)] + pub force_full: bool, +} + +pub fn parse_observation_revision_request( + value: Option<&Value>, +) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + let request: ObservationRevisionRequest = serde_json::from_value(value.clone()) + .map_err(|error| format!("invalid observation_revision request: {error}"))?; + if request.version != OBSERVATION_REVISION_VERSION { + return Err(format!( + "unsupported observation_revision version {}; expected {}", + request.version, OBSERVATION_REVISION_VERSION + )); + } + if request + .base_revision_id + .as_deref() + .is_some_and(|revision| revision.is_empty() || revision.len() > 256) + { + return Err("observation_revision.base_revision_id must contain 1-256 bytes".to_owned()); + } + for (field, value) in [ + ("serializer_version", request.serializer_version.as_str()), + ("projection_version", request.projection_version.as_str()), + ] { + if value.is_empty() || value.len() > 128 { + return Err(format!( + "observation_revision.{field} must contain 1-128 bytes" + )); + } + } + Ok(Some(request)) +} + +pub fn requested_format_resync_reason( + request: &ObservationRevisionRequest, +) -> Option { + if request.serializer_version != ACCESSIBILITY_SERIALIZER_VERSION { + Some(FullResyncReason::SerializerChanged) + } else if request.projection_version != ACCESSIBILITY_PROJECTION_VERSION { + Some(FullResyncReason::ProjectionChanged) + } else { + None + } +} + +/// Fresh lineage/revision identifier pair for a non-retained full response. +/// +/// Backends without an approved stable native identity (the explicit +/// full-only platforms) answer every versioned observation request with a +/// transient full snapshot; nothing is retained, so each response gets a +/// fresh lineage that can never be named as a future base. +pub fn unretained_full_ids() -> (String, String) { + let lineage_id = format!("l_{}", uuid::Uuid::new_v4().simple()); + let revision_id = format!("{lineage_id}:r0"); + (lineage_id, revision_id) +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ObservationSessionIdentity { + pub runtime_scope: String, + pub session_id: String, + pub transport_session_id: String, +} + +#[doc(hidden)] +pub fn current_observation_session_identity() -> Option { + let identity = crate::tool::current_dispatch_session_identity()?; + Some(ObservationSessionIdentity { + runtime_scope: identity.runtime_scope, + session_id: identity.session_id, + transport_session_id: identity.transport_session_id, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObservationMode { + Full, + Diff, + NoChange, +} + +impl ObservationMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Full => "full", + Self::Diff => "diff", + Self::NoChange => "no_change", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FullResyncReason { + Requested, + MissingBase, + BaseEvicted, + TargetChanged, + RuntimeChanged, + SerializerChanged, + ProjectionChanged, + CaptureIncomplete, + IdentityUnavailable, + ProviderInvalidated, + UnsupportedBackend, + ValidationFailed, + DiffNotSmaller, +} + +impl FullResyncReason { + pub fn as_str(self) -> &'static str { + match self { + Self::Requested => "requested", + Self::MissingBase => "missing_base", + Self::BaseEvicted => "base_evicted", + Self::TargetChanged => "target_changed", + Self::RuntimeChanged => "runtime_changed", + Self::SerializerChanged => "serializer_changed", + Self::ProjectionChanged => "projection_changed", + Self::CaptureIncomplete => "capture_incomplete", + Self::IdentityUnavailable => "identity_unavailable", + Self::ProviderInvalidated => "provider_invalidated", + Self::UnsupportedBackend => "unsupported_backend", + Self::ValidationFailed => "validation_failed", + Self::DiffNotSmaller => "diff_not_smaller", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapturedNode { + pub identity: I, + pub depth: usize, + pub body: String, + pub actionable_index: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RevisionNode { + pub element_id: u64, + pub order: usize, + pub depth: usize, + pub parent_id: Option, + pub body: String, + pub actionable_index: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservationRevisionResult { + pub lineage_id: String, + pub revision_id: String, + pub base_revision_id: Option, + pub mode: ObservationMode, + pub full_resync_reason: Option, + pub stable_element_ids: bool, + pub text: String, + pub full_text: String, + pub nodes: Vec, + pub serializer_duration_us: u64, + pub cache_estimate_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ObservationRevisionError { + EmptyLineageId, + InvalidRetention, + DuplicateIdentity, +} + +impl fmt::Display for ObservationRevisionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::EmptyLineageId => "observation lineage id must not be empty", + Self::InvalidRetention => "observation revision retention must be at least one", + Self::DuplicateIdentity => { + "one native identity appeared more than once in an observation" + } + }; + formatter.write_str(message) + } +} + +impl std::error::Error for ObservationRevisionError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RevisionTokenError { + InvalidFormat, + SessionUnavailable, + SessionMismatch, + RuntimeMismatch, + TargetMismatch, + StaleLineage, + StaleElement, +} + +impl RevisionTokenError { + pub fn code(self) -> &'static str { + match self { + Self::InvalidFormat => "invalid_element_token", + Self::SessionUnavailable => "session_unavailable", + Self::SessionMismatch => "session_mismatch", + Self::RuntimeMismatch => "generation_mismatch", + Self::TargetMismatch => "conflicting_element_target", + Self::StaleLineage | Self::StaleElement => "stale_element_token", + } + } +} + +impl fmt::Display for RevisionTokenError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidFormat => "revision element_token has invalid format", + Self::SessionUnavailable => { + "revision element_token requires a bound trusted driver session" + } + Self::SessionMismatch => "revision element_token belongs to another driver session", + Self::RuntimeMismatch => "revision element_token belongs to another runtime generation", + Self::TargetMismatch => "revision element_token belongs to another target", + Self::StaleLineage => "revision element_token lineage is stale", + Self::StaleElement => "revision element_token no longer names a current element", + }) + } +} + +impl std::error::Error for RevisionTokenError {} + +struct RevisionTokenEntry { + session: ObservationSessionIdentity, + pid: i32, + window_id: u32, + snapshot_id: u32, + actionable_indices: HashMap, +} + +pub struct RevisionTokenRegistry { + entries: Mutex>, +} + +impl RevisionTokenRegistry { + fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } + + pub fn register_current( + &self, + lineage_id: &str, + pid: i32, + window_id: u32, + snapshot_id: u32, + nodes: &[RevisionNode], + ) -> Result<(), RevisionTokenError> { + let session = + current_observation_session_identity().ok_or(RevisionTokenError::SessionUnavailable)?; + let actionable_indices = nodes + .iter() + .filter_map(|node| node.actionable_index.map(|index| (node.element_id, index))) + .collect(); + self.register_for_session( + session, + lineage_id, + pid, + window_id, + snapshot_id, + actionable_indices, + ) + } + + pub fn resolve_current( + &self, + pid: i32, + token: &str, + ) -> Result<(u32, usize, u32), RevisionTokenError> { + let session = + current_observation_session_identity().ok_or(RevisionTokenError::SessionUnavailable)?; + self.resolve_for_session(&session, pid, token) + } + + pub fn clear_session(&self, session_id: &str) { + self.entries + .lock() + .unwrap() + .retain(|_, entry| entry.session.session_id != session_id); + } + + pub fn clear_runtime(&self, runtime_scope: &str) { + self.entries + .lock() + .unwrap() + .retain(|_, entry| entry.session.runtime_scope != runtime_scope); + } + + pub fn clear_lineage(&self, lineage_id: &str) { + self.entries.lock().unwrap().remove(lineage_id); + } + + fn register_for_session( + &self, + session: ObservationSessionIdentity, + lineage_id: &str, + pid: i32, + window_id: u32, + snapshot_id: u32, + actionable_indices: HashMap, + ) -> Result<(), RevisionTokenError> { + if lineage_id.trim().is_empty() { + return Err(RevisionTokenError::InvalidFormat); + } + let mut entries = self.entries.lock().unwrap(); + if let Some(existing) = entries.get(lineage_id) { + if existing.session.runtime_scope != session.runtime_scope { + return Err(RevisionTokenError::RuntimeMismatch); + } + if existing.session.session_id != session.session_id + || existing.session.transport_session_id != session.transport_session_id + { + return Err(RevisionTokenError::SessionMismatch); + } + if existing.pid != pid || existing.window_id != window_id { + return Err(RevisionTokenError::TargetMismatch); + } + } + entries.insert( + lineage_id.to_owned(), + RevisionTokenEntry { + session, + pid, + window_id, + snapshot_id, + actionable_indices, + }, + ); + Ok(()) + } + + fn resolve_for_session( + &self, + session: &ObservationSessionIdentity, + pid: i32, + token: &str, + ) -> Result<(u32, usize, u32), RevisionTokenError> { + let (lineage_id, element_id) = parse_revision_token(token)?; + let entries = self.entries.lock().unwrap(); + let entry = entries + .get(lineage_id) + .ok_or(RevisionTokenError::StaleLineage)?; + if entry.session.runtime_scope != session.runtime_scope { + return Err(RevisionTokenError::RuntimeMismatch); + } + if entry.session.session_id != session.session_id + || entry.session.transport_session_id != session.transport_session_id + { + return Err(RevisionTokenError::SessionMismatch); + } + if entry.pid != pid { + return Err(RevisionTokenError::TargetMismatch); + } + let element_index = entry + .actionable_indices + .get(&element_id) + .copied() + .ok_or(RevisionTokenError::StaleElement)?; + Ok((entry.window_id, element_index, entry.snapshot_id)) + } +} + +pub fn revision_tokens() -> &'static RevisionTokenRegistry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(RevisionTokenRegistry::new) +} + +pub fn revision_token_for(lineage_id: &str, element_id: u64) -> String { + format!("{REVISION_TOKEN_PREFIX}{lineage_id}:{element_id:x}") +} + +pub fn is_revision_token(token: &str) -> bool { + token.starts_with(REVISION_TOKEN_PREFIX) +} + +fn parse_revision_token(token: &str) -> Result<(&str, u64), RevisionTokenError> { + let rest = token + .strip_prefix(REVISION_TOKEN_PREFIX) + .ok_or(RevisionTokenError::InvalidFormat)?; + let (lineage_id, element_id) = rest + .rsplit_once(':') + .ok_or(RevisionTokenError::InvalidFormat)?; + if lineage_id.is_empty() || element_id.is_empty() { + return Err(RevisionTokenError::InvalidFormat); + } + let element_id = + u64::from_str_radix(element_id, 16).map_err(|_| RevisionTokenError::InvalidFormat)?; + Ok((lineage_id, element_id)) +} + +#[derive(Clone)] +struct StoredNode { + identity: I, + rendered: RevisionNode, +} + +#[derive(Clone)] +struct StoredRevision { + revision_id: String, + nodes: Vec>, +} + +pub struct ObservationLineage { + lineage_id: String, + max_revisions: usize, + next_revision_id: u64, + next_element_id: u64, + revisions: VecDeque>, +} + +impl ObservationLineage +where + I: Clone + Eq + Hash, +{ + pub fn new( + lineage_id: impl Into, + max_revisions: usize, + ) -> Result { + let lineage_id = lineage_id.into(); + if lineage_id.trim().is_empty() { + return Err(ObservationRevisionError::EmptyLineageId); + } + if max_revisions == 0 { + return Err(ObservationRevisionError::InvalidRetention); + } + Ok(Self { + lineage_id, + max_revisions, + next_revision_id: 1, + next_element_id: 0, + revisions: VecDeque::new(), + }) + } + + pub fn observe( + &mut self, + captured: Vec>, + base_revision_id: Option<&str>, + force_full: bool, + ) -> Result { + self.observe_inner( + captured, + base_revision_id, + force_full.then_some(FullResyncReason::Requested), + true, + true, + ) + } + + pub fn observe_with_reason( + &mut self, + captured: Vec>, + base_revision_id: Option<&str>, + force_full_reason: Option, + ) -> Result { + self.observe_inner(captured, base_revision_id, force_full_reason, true, true) + } + + pub fn observe_unretained_full( + &mut self, + captured: Vec>, + reason: FullResyncReason, + ) -> Result { + self.observe_inner(captured, None, Some(reason), false, false) + } + + fn observe_inner( + &mut self, + captured: Vec>, + base_revision_id: Option<&str>, + force_full_reason: Option, + stable_element_ids: bool, + retain: bool, + ) -> Result { + let serializer_started = std::time::Instant::now(); + if stable_element_ids || retain { + ensure_unique_identities(&captured)?; + } + + let latest = self.revisions.back(); + let previous_ids = latest + .map(|revision| { + revision + .nodes + .iter() + .map(|node| (&node.identity, node.rendered.element_id)) + .collect::>() + }) + .unwrap_or_default(); + + let mut depth_stack: Vec> = Vec::new(); + let mut stored_nodes = Vec::with_capacity(captured.len()); + for (order, node) in captured.into_iter().enumerate() { + let element_id = previous_ids + .get(&node.identity) + .copied() + .unwrap_or_else(|| { + let id = self.next_element_id; + self.next_element_id += 1; + id + }); + let parent_id = depth_stack.iter().take(node.depth).rev().find_map(|id| *id); + if depth_stack.len() <= node.depth { + depth_stack.resize(node.depth + 1, None); + } else { + depth_stack.truncate(node.depth + 1); + } + depth_stack[node.depth] = Some(element_id); + stored_nodes.push(StoredNode { + identity: node.identity, + rendered: RevisionNode { + element_id, + order, + depth: node.depth, + parent_id, + body: node.body, + actionable_index: node.actionable_index, + }, + }); + } + + let revision_id = format!("{}:r{}", self.lineage_id, self.next_revision_id); + self.next_revision_id += 1; + let current = StoredRevision { + revision_id: revision_id.clone(), + nodes: stored_nodes, + }; + let full_text = render_full(¤t.nodes, &self.lineage_id, stable_element_ids); + + let requested_base = base_revision_id.and_then(|id| { + self.revisions + .iter() + .find(|revision| revision.revision_id == id) + }); + let missing_reason = if base_revision_id.is_none() { + Some(FullResyncReason::MissingBase) + } else if requested_base.is_none() { + Some(FullResyncReason::BaseEvicted) + } else { + None + }; + + let (mode, actual_base, full_resync_reason, text) = if let Some(reason) = force_full_reason + { + (ObservationMode::Full, None, Some(reason), full_text.clone()) + } else if let Some(reason) = missing_reason { + (ObservationMode::Full, None, Some(reason), full_text.clone()) + } else { + let base = requested_base.expect("checked above"); + let changes = ChangeSet::between(base, ¤t); + if changes.is_empty() { + let candidate = render_no_change(&base.revision_id); + if candidate.len() >= full_text.len() { + ( + ObservationMode::Full, + None, + Some(FullResyncReason::DiffNotSmaller), + full_text.clone(), + ) + } else { + ( + ObservationMode::NoChange, + Some(base.revision_id.clone()), + None, + candidate, + ) + } + } else { + let candidate = + changes.render(base, ¤t, &self.lineage_id, stable_element_ids); + if !changes.replays_to(base, ¤t) { + ( + ObservationMode::Full, + None, + Some(FullResyncReason::ValidationFailed), + full_text.clone(), + ) + } else if candidate.len() >= full_text.len() { + ( + ObservationMode::Full, + None, + Some(FullResyncReason::DiffNotSmaller), + full_text.clone(), + ) + } else { + ( + ObservationMode::Diff, + Some(base.revision_id.clone()), + None, + candidate, + ) + } + } + }; + + let nodes = current + .nodes + .iter() + .map(|node| node.rendered.clone()) + .collect(); + if retain { + self.revisions.push_back(current); + while self.revisions.len() > self.max_revisions { + self.revisions.pop_front(); + } + } + + let cache_estimate_bytes = if retain { + self.retained_cache_estimate_bytes() + } else { + 0 + }; + let serializer_duration_us = + u64::try_from(serializer_started.elapsed().as_micros()).unwrap_or(u64::MAX); + + Ok(ObservationRevisionResult { + lineage_id: self.lineage_id.clone(), + revision_id, + base_revision_id: actual_base, + mode, + full_resync_reason, + stable_element_ids, + text, + full_text, + nodes, + serializer_duration_us, + cache_estimate_bytes, + }) + } + + fn retained_cache_estimate_bytes(&self) -> usize { + std::mem::size_of::() + + self.lineage_id.capacity() + + self + .revisions + .iter() + .map(|revision| { + std::mem::size_of::>() + + revision.revision_id.capacity() + + revision.nodes.capacity() * std::mem::size_of::>() + + revision + .nodes + .iter() + .map(|node| node.rendered.body.capacity()) + .sum::() + }) + .sum::() + } + + pub fn retained_revision_count(&self) -> usize { + self.revisions.len() + } + + pub fn lineage_id(&self) -> &str { + &self.lineage_id + } + + pub fn resolve_current(&self, element_id: u64) -> Option<(&I, &RevisionNode)> { + self.revisions + .back()? + .nodes + .iter() + .find(|node| node.rendered.element_id == element_id) + .map(|node| (&node.identity, &node.rendered)) + } +} + +fn ensure_unique_identities(captured: &[CapturedNode]) -> Result<(), ObservationRevisionError> +where + I: Eq + Hash, +{ + let mut seen = HashSet::with_capacity(captured.len()); + if captured.iter().any(|node| !seen.insert(&node.identity)) { + return Err(ObservationRevisionError::DuplicateIdentity); + } + Ok(()) +} + +#[derive(Default)] +struct ChangeSet { + added: HashSet, + removed: HashSet, + updated: HashSet, + context: HashSet, + affected_at: HashMap, +} + +impl ChangeSet { + fn between(base: &StoredRevision, current: &StoredRevision) -> Self { + let before = node_map(&base.nodes); + let after = node_map(¤t.nodes); + let before_ids = before.keys().copied().collect::>(); + let after_ids = after.keys().copied().collect::>(); + let added: HashSet = after_ids.difference(&before_ids).copied().collect(); + let removed: HashSet = before_ids.difference(&after_ids).copied().collect(); + let mut updated = before_ids + .intersection(&after_ids) + .filter_map(|id| { + let old = before[id]; + let new = after[id]; + (old.body != new.body + || old.depth != new.depth + || old.parent_id != new.parent_id + || old.actionable_index.is_some() != new.actionable_index.is_some()) + .then_some(*id) + }) + .collect::>(); + + for id in reordered_ids(base, current, &before_ids, &after_ids) { + updated.insert(id); + } + + let mut context = HashSet::new(); + for id in added.iter().chain(updated.iter()) { + add_current_ancestors(*id, &after, &added, &updated, &mut context); + } + for id in &removed { + let mut parent = before[id].parent_id; + while let Some(parent_id) = parent { + if after.contains_key(&parent_id) + && !added.contains(&parent_id) + && !updated.contains(&parent_id) + { + context.insert(parent_id); + } + parent = before.get(&parent_id).and_then(|node| node.parent_id); + } + } + + let affected_at = current + .nodes + .iter() + .filter(|node| { + added.contains(&node.rendered.element_id) + || updated.contains(&node.rendered.element_id) + }) + .map(|node| (node.rendered.order, node.rendered.element_id)) + .collect(); + + Self { + added, + removed, + updated, + context, + affected_at, + } + } + + fn is_empty(&self) -> bool { + self.added.is_empty() && self.removed.is_empty() && self.updated.is_empty() + } + + fn replays_to(&self, base: &StoredRevision, current: &StoredRevision) -> bool { + let mut replay = node_map(&base.nodes) + .into_iter() + .map(|(id, node)| (id, node.clone())) + .collect::>(); + let current_map = node_map(¤t.nodes); + for id in &self.removed { + replay.remove(id); + } + for id in self.added.iter().chain(self.updated.iter()) { + if let Some(node) = current_map.get(id) { + replay.insert(*id, (**node).clone()); + } + } + if replay.len() != current_map.len() + || replay.iter().any(|(id, replayed)| { + current_map + .get(id) + .is_none_or(|current| !same_rendered_node(replayed, current)) + }) + { + return false; + } + + let mut unaffected = base + .nodes + .iter() + .map(|node| node.rendered.element_id) + .filter(|id| { + !self.removed.contains(id) && !self.added.contains(id) && !self.updated.contains(id) + }); + let mut replayed_order = Vec::with_capacity(current.nodes.len()); + for order in 0..current.nodes.len() { + if let Some(id) = self.affected_at.get(&order) { + replayed_order.push(*id); + } else if let Some(id) = unaffected.next() { + replayed_order.push(id); + } else { + return false; + } + } + unaffected.next().is_none() + && replayed_order + == current + .nodes + .iter() + .map(|node| node.rendered.element_id) + .collect::>() + } + + fn render( + &self, + base: &StoredRevision, + current: &StoredRevision, + lineage_id: &str, + stable_element_ids: bool, + ) -> String { + let mut sections = vec![format!( + "Accessibility diff from revision {}:", + base.revision_id + )]; + if !self.context.is_empty() { + sections.push(render_current_section( + "CONTEXT", + ' ', + ¤t.nodes, + &self.context, + lineage_id, + stable_element_ids, + )); + } + if !self.added.is_empty() { + sections.push(render_current_section( + "ADDED", + '+', + ¤t.nodes, + &self.added, + lineage_id, + stable_element_ids, + )); + } + if !self.updated.is_empty() { + sections.push(render_current_section( + "CHANGED", + '~', + ¤t.nodes, + &self.updated, + lineage_id, + stable_element_ids, + )); + } + if !self.removed.is_empty() { + sections.push(render_current_section( + "REMOVED", + '-', + &base.nodes, + &self.removed, + lineage_id, + false, + )); + } + sections.join("\n") + } +} + +fn node_map(nodes: &[StoredNode]) -> HashMap { + nodes + .iter() + .map(|node| (node.rendered.element_id, &node.rendered)) + .collect() +} + +fn same_rendered_node(left: &RevisionNode, right: &RevisionNode) -> bool { + left.element_id == right.element_id + && left.depth == right.depth + && left.parent_id == right.parent_id + && left.body == right.body + && left.actionable_index.is_some() == right.actionable_index.is_some() +} + +fn reordered_ids( + base: &StoredRevision, + current: &StoredRevision, + before_ids: &HashSet, + after_ids: &HashSet, +) -> HashSet { + let common = before_ids + .intersection(after_ids) + .copied() + .collect::>(); + let base_groups = sibling_groups(&base.nodes, &common); + let current_groups = sibling_groups(¤t.nodes, &common); + let mut reordered = HashSet::new(); + for (parent, current_ids) in current_groups { + if let Some(base_ids) = base_groups.get(&parent) { + let base_set = base_ids.iter().copied().collect::>(); + let current_set = current_ids.iter().copied().collect::>(); + let same_parent = base_set + .intersection(¤t_set) + .copied() + .collect::>(); + let base_order = base_ids + .iter() + .filter(|id| same_parent.contains(id)) + .copied() + .collect::>(); + let current_order = current_ids + .iter() + .filter(|id| same_parent.contains(id)) + .copied() + .collect::>(); + if base_order != current_order { + let retained = longest_ordered_subsequence(&base_order, ¤t_order); + reordered.extend(same_parent.difference(&retained).copied()); + } + } + } + reordered +} + +fn longest_ordered_subsequence(base: &[u64], current: &[u64]) -> HashSet { + let positions = base + .iter() + .enumerate() + .map(|(index, id)| (*id, index)) + .collect::>(); + let sequence = current + .iter() + .filter_map(|id| positions.get(id).map(|position| (*id, *position))) + .collect::>(); + if sequence.is_empty() { + return HashSet::new(); + } + + let mut lengths = vec![1usize; sequence.len()]; + let mut previous = vec![None; sequence.len()]; + for index in 0..sequence.len() { + for candidate in 0..index { + if sequence[candidate].1 < sequence[index].1 && lengths[candidate] + 1 > lengths[index] + { + lengths[index] = lengths[candidate] + 1; + previous[index] = Some(candidate); + } + } + } + let mut cursor = lengths + .iter() + .enumerate() + .max_by_key(|(index, length)| (**length, std::cmp::Reverse(*index))) + .map(|(index, _)| index); + let mut retained = HashSet::new(); + while let Some(index) = cursor { + retained.insert(sequence[index].0); + cursor = previous[index]; + } + retained +} + +fn sibling_groups( + nodes: &[StoredNode], + included: &HashSet, +) -> HashMap, Vec> { + let mut groups = HashMap::, Vec>::new(); + for node in nodes { + if included.contains(&node.rendered.element_id) { + groups + .entry(node.rendered.parent_id) + .or_default() + .push(node.rendered.element_id); + } + } + groups +} + +fn add_current_ancestors( + id: u64, + current: &HashMap, + added: &HashSet, + updated: &HashSet, + context: &mut HashSet, +) { + let mut parent = current.get(&id).and_then(|node| node.parent_id); + while let Some(parent_id) = parent { + if !added.contains(&parent_id) && !updated.contains(&parent_id) { + context.insert(parent_id); + } + parent = current.get(&parent_id).and_then(|node| node.parent_id); + } +} + +fn render_full(nodes: &[StoredNode], lineage_id: &str, stable_element_ids: bool) -> String { + if nodes.is_empty() { + return "Full accessibility state: no elements.".to_owned(); + } + let lines = nodes + .iter() + .map(|node| render_node(' ', &node.rendered, lineage_id, stable_element_ids)) + .collect::>() + .join("\n"); + format!("Full accessibility state:\n{lines}") +} + +fn render_no_change(base_revision_id: &str) -> String { + format!("Accessibility tree unchanged from revision {base_revision_id}.") +} + +fn render_current_section( + title: &str, + marker: char, + nodes: &[StoredNode], + included: &HashSet, + lineage_id: &str, + stable_element_ids: bool, +) -> String { + let lines = nodes + .iter() + .filter(|node| included.contains(&node.rendered.element_id)) + .map(|node| render_node(marker, &node.rendered, lineage_id, stable_element_ids)) + .collect::>() + .join("\n"); + format!("{title}\n{lines}") +} + +fn render_node( + marker: char, + node: &RevisionNode, + lineage_id: &str, + stable_element_ids: bool, +) -> String { + let mut rendered = format!( + "{marker}{}[{}] {}", + " ".repeat(node.depth), + node.element_id, + node.body + ); + if stable_element_ids && node.actionable_index.is_some() { + rendered.push_str(" element_token="); + rendered.push_str(&revision_token_for(lineage_id, node.element_id)); + } + rendered +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node( + identity: &'static str, + depth: usize, + body: &str, + actionable_index: Option, + ) -> CapturedNode<&'static str> { + CapturedNode { + identity, + depth, + body: body.to_owned(), + actionable_index, + } + } + + fn lineage(retention: usize) -> ObservationLineage<&'static str> { + ObservationLineage::new("lineage", retention).unwrap() + } + + #[test] + fn revision_request_is_versioned_and_closed() { + let request = parse_observation_revision_request(Some(&serde_json::json!({ + "version": 1, + "serializer_version": ACCESSIBILITY_SERIALIZER_VERSION, + "projection_version": ACCESSIBILITY_PROJECTION_VERSION, + "base_revision_id": "lineage:r1", + "force_full": true + }))) + .unwrap() + .unwrap(); + assert_eq!(request.version, 1); + assert_eq!(request.serializer_version, ACCESSIBILITY_SERIALIZER_VERSION); + assert_eq!(request.projection_version, ACCESSIBILITY_PROJECTION_VERSION); + assert_eq!(request.base_revision_id.as_deref(), Some("lineage:r1")); + assert!(request.force_full); + + assert!(parse_observation_revision_request(Some(&serde_json::json!({ + "version": 2 + }))) + .is_err()); + assert!(parse_observation_revision_request(Some(&serde_json::json!({ + "version": 1, + "serializer_version": ACCESSIBILITY_SERIALIZER_VERSION, + "projection_version": ACCESSIBILITY_PROJECTION_VERSION, + "unexpected": true + }))) + .is_err()); + + let serializer_changed = parse_observation_revision_request(Some(&serde_json::json!({ + "version": 1, + "serializer_version": "accessibility-render-v0", + "projection_version": ACCESSIBILITY_PROJECTION_VERSION + }))) + .unwrap() + .unwrap(); + assert_eq!( + requested_format_resync_reason(&serializer_changed), + Some(FullResyncReason::SerializerChanged) + ); + + let projection_changed = parse_observation_revision_request(Some(&serde_json::json!({ + "version": 1, + "serializer_version": ACCESSIBILITY_SERIALIZER_VERSION, + "projection_version": "full-tree-v0" + }))) + .unwrap() + .unwrap(); + assert_eq!( + requested_format_resync_reason(&projection_changed), + Some(FullResyncReason::ProjectionChanged) + ); + } + + fn padded(mut changing: Vec>) -> Vec> { + const IDENTITIES: [&str; 12] = [ + "pad-0", "pad-1", "pad-2", "pad-3", "pad-4", "pad-5", "pad-6", "pad-7", "pad-8", + "pad-9", "pad-10", "pad-11", + ]; + let mut nodes = vec![node("root", 0, "window stable fixture", None)]; + nodes.append(&mut changing); + nodes.extend(IDENTITIES.map(|identity| { + node( + identity, + 1, + "unchanged fixture element with deterministic state", + None, + ) + })); + nodes + } + + #[test] + fn first_observation_is_full_and_assigns_stable_ids() { + let mut lineage = lineage(4); + let result = lineage + .observe( + vec![ + node("root", 0, "window", None), + node("field", 1, "text value=one", Some(0)), + ], + None, + false, + ) + .unwrap(); + + assert_eq!(result.mode, ObservationMode::Full); + assert_eq!( + result.full_resync_reason, + Some(FullResyncReason::MissingBase) + ); + assert_eq!(result.nodes[0].element_id, 0); + assert_eq!(result.nodes[1].element_id, 1); + assert_eq!(result.nodes[1].parent_id, Some(0)); + assert!(result.stable_element_ids); + assert!(result.cache_estimate_bytes > 0); + assert!( + result.text.contains("element_token=rv1:lineage:1"), + "actionable rows must expose their stable revision token" + ); + } + + #[test] + fn unchanged_observation_names_the_requested_base() { + let mut lineage = lineage(4); + let first = lineage.observe(padded(Vec::new()), None, false).unwrap(); + let second = lineage + .observe(padded(Vec::new()), Some(&first.revision_id), false) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::NoChange); + assert_eq!( + second.base_revision_id.as_deref(), + Some(first.revision_id.as_str()) + ); + assert_eq!(second.nodes[0].element_id, first.nodes[0].element_id); + } + + #[test] + fn no_change_larger_than_full_falls_back_to_full() { + let mut lineage = lineage(4); + let first = lineage + .observe(vec![node("root", 0, "x", None)], None, false) + .unwrap(); + let second = lineage + .observe( + vec![node("root", 0, "x", None)], + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::Full); + assert_eq!( + second.full_resync_reason, + Some(FullResyncReason::DiffNotSmaller) + ); + assert_eq!(second.text, second.full_text); + } + + #[test] + fn rename_is_an_update_with_the_same_id() { + let mut lineage = lineage(4); + let first = lineage + .observe( + padded(vec![node("button", 1, "button old", Some(0))]), + None, + false, + ) + .unwrap(); + let second = lineage + .observe( + padded(vec![node("button", 1, "button new", Some(0))]), + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::Diff); + assert_eq!(second.nodes[1].element_id, first.nodes[1].element_id); + assert!(second.text.contains("CHANGED")); + assert!(!second.text.contains("REMOVED")); + } + + #[test] + fn inserting_duplicate_sibling_does_not_renumber_existing_nodes() { + let mut lineage = lineage(4); + let first = lineage + .observe( + padded(vec![ + node("a", 1, "button same", Some(0)), + node("b", 1, "button same", Some(1)), + ]), + None, + false, + ) + .unwrap(); + let second = lineage + .observe( + padded(vec![ + node("new", 1, "button same", Some(0)), + node("a", 1, "button same", Some(1)), + node("b", 1, "button same", Some(2)), + ]), + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::Diff); + assert_eq!(second.nodes[2].element_id, first.nodes[1].element_id); + assert_eq!(second.nodes[3].element_id, first.nodes[2].element_id); + assert!(second.nodes[1].element_id > first.nodes[2].element_id); + assert!(!second.text.contains("CHANGED")); + assert!(second.text.contains("ADDED")); + } + + #[test] + fn recreated_lookalike_gets_a_new_id() { + let mut lineage = lineage(4); + let first = lineage + .observe( + padded(vec![node("old", 1, "button same", Some(0))]), + None, + false, + ) + .unwrap(); + let second = lineage + .observe( + padded(vec![node("new", 1, "button same", Some(0))]), + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::Diff); + assert_ne!(second.nodes[1].element_id, first.nodes[1].element_id); + assert!(second.text.contains("ADDED")); + assert!(second.text.contains("REMOVED")); + } + + #[test] + fn insertion_only_does_not_mark_common_siblings_as_reordered() { + let mut lineage = lineage(4); + let first = lineage + .observe( + padded(vec![node("a", 1, "a", None), node("b", 1, "b", None)]), + None, + false, + ) + .unwrap(); + let second = lineage + .observe( + padded(vec![ + node("new", 1, "new", None), + node("a", 1, "a", None), + node("b", 1, "b", None), + ]), + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::Diff); + assert!(!second.text.contains("CHANGED")); + } + + #[test] + fn reordering_common_siblings_marks_them_changed() { + let mut lineage = lineage(4); + let first = lineage + .observe( + padded(vec![node("a", 1, "a", None), node("b", 1, "b", None)]), + None, + false, + ) + .unwrap(); + let second = lineage + .observe( + padded(vec![node("b", 1, "b", None), node("a", 1, "a", None)]), + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::Diff); + assert!(second.text.contains("CHANGED")); + } + + #[test] + fn reparent_keeps_identity_and_marks_the_node_changed() { + let mut lineage = lineage(4); + let first = lineage + .observe( + padded(vec![ + node("parent-a", 1, "group a", None), + node("moving", 2, "button moving", Some(0)), + node("parent-b", 1, "group b", None), + ]), + None, + false, + ) + .unwrap(); + let moving_id = first + .nodes + .iter() + .find(|node| node.body == "button moving") + .unwrap() + .element_id; + let second = lineage + .observe( + padded(vec![ + node("parent-a", 1, "group a", None), + node("parent-b", 1, "group b", None), + node("moving", 2, "button moving", Some(0)), + ]), + Some(&first.revision_id), + false, + ) + .unwrap(); + let moved = second + .nodes + .iter() + .find(|node| node.body == "button moving") + .unwrap(); + + assert_eq!(second.mode, ObservationMode::Diff); + assert_eq!(moved.element_id, moving_id); + assert!(second.text.contains("CHANGED")); + } + + #[test] + fn current_action_index_can_change_without_a_model_visible_update() { + let mut lineage = lineage(4); + let first = lineage + .observe( + padded(vec![node("button", 1, "button stable", Some(0))]), + None, + false, + ) + .unwrap(); + let second = lineage + .observe( + padded(vec![node("button", 1, "button stable", Some(7))]), + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::NoChange); + assert_eq!(second.nodes[1].actionable_index, Some(7)); + assert_eq!(second.nodes[1].element_id, first.nodes[1].element_id); + } + + #[test] + fn candidate_larger_than_full_falls_back_to_full() { + let mut lineage = lineage(4); + let first = lineage + .observe(vec![node("button", 0, "old", Some(0))], None, false) + .unwrap(); + let second = lineage + .observe( + vec![node("button", 0, "new", Some(0))], + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::Full); + assert_eq!( + second.full_resync_reason, + Some(FullResyncReason::DiffNotSmaller) + ); + } + + #[test] + fn can_diff_from_an_older_retained_model_base() { + let mut lineage = lineage(4); + let first = lineage + .observe(padded(vec![node("a", 1, "value=one", None)]), None, false) + .unwrap(); + let _undelivered = lineage + .observe( + padded(vec![node("a", 1, "value=two", None)]), + Some(&first.revision_id), + false, + ) + .unwrap(); + let third = lineage + .observe( + padded(vec![node("a", 1, "value=three", None)]), + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(third.mode, ObservationMode::Diff); + assert_eq!( + third.base_revision_id.as_deref(), + Some(first.revision_id.as_str()) + ); + } + + #[test] + fn evicted_base_forces_full() { + let mut lineage = lineage(1); + let first = lineage + .observe(vec![node("a", 0, "one", None)], None, false) + .unwrap(); + let _second = lineage + .observe( + vec![node("a", 0, "two", None)], + Some(&first.revision_id), + false, + ) + .unwrap(); + let third = lineage + .observe( + vec![node("a", 0, "three", None)], + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(third.mode, ObservationMode::Full); + assert_eq!( + third.full_resync_reason, + Some(FullResyncReason::BaseEvicted) + ); + } + + #[test] + fn explicit_full_becomes_a_retained_revision() { + let mut lineage = lineage(4); + let first = lineage + .observe(padded(vec![node("a", 1, "one", None)]), None, false) + .unwrap(); + let full = lineage + .observe( + padded(vec![node("a", 1, "two", None)]), + Some(&first.revision_id), + true, + ) + .unwrap(); + let third = lineage + .observe( + padded(vec![node("a", 1, "two", None)]), + Some(&full.revision_id), + false, + ) + .unwrap(); + + assert_eq!(full.mode, ObservationMode::Full); + assert_eq!(full.full_resync_reason, Some(FullResyncReason::Requested)); + assert_eq!(third.mode, ObservationMode::NoChange); + } + + #[test] + fn platform_full_reason_is_preserved() { + let mut lineage = lineage(4); + let result = lineage + .observe_with_reason( + vec![node("a", 0, "one", None)], + None, + Some(FullResyncReason::CaptureIncomplete), + ) + .unwrap(); + + assert_eq!(result.mode, ObservationMode::Full); + assert_eq!( + result.full_resync_reason, + Some(FullResyncReason::CaptureIncomplete) + ); + } + + #[test] + fn incomplete_capture_is_unretained_and_has_no_stable_tokens() { + let mut lineage = lineage(4); + let result = lineage + .observe_unretained_full( + vec![node("button", 0, "button", Some(0))], + FullResyncReason::CaptureIncomplete, + ) + .unwrap(); + + assert_eq!(result.mode, ObservationMode::Full); + assert!(!result.stable_element_ids); + assert_eq!(result.cache_estimate_bytes, 0); + assert!(!result.text.contains("element_token=")); + assert_eq!(lineage.retained_revision_count(), 0); + } + + #[test] + fn current_resolution_survives_unrelated_diff_and_rejects_removal() { + let mut lineage = lineage(4); + let first = lineage + .observe( + padded(vec![ + node("button", 1, "button stable", Some(0)), + node("field", 1, "value=one", Some(1)), + ]), + None, + false, + ) + .unwrap(); + let button_id = first.nodes[1].element_id; + let second = lineage + .observe( + padded(vec![ + node("button", 1, "button stable", Some(7)), + node("field", 1, "value=two", Some(8)), + ]), + Some(&first.revision_id), + false, + ) + .unwrap(); + + assert_eq!(second.mode, ObservationMode::Diff); + let (identity, current) = lineage.resolve_current(button_id).unwrap(); + assert_eq!(*identity, "button"); + assert_eq!(current.actionable_index, Some(7)); + + lineage + .observe( + padded(vec![node("field", 1, "value=two", Some(0))]), + Some(&second.revision_id), + false, + ) + .unwrap(); + assert!(lineage.resolve_current(button_id).is_none()); + } + + fn session(runtime: &str, session: &str, transport: &str) -> ObservationSessionIdentity { + ObservationSessionIdentity { + runtime_scope: runtime.to_owned(), + session_id: session.to_owned(), + transport_session_id: transport.to_owned(), + } + } + + #[test] + fn revision_token_tracks_the_current_action_index() { + let registry = RevisionTokenRegistry::new(); + let owner = session("runtime-a", "session-a", "transport-a"); + registry + .register_for_session( + owner.clone(), + "lineage-a", + 42, + 7, + 10, + HashMap::from([(3, 1)]), + ) + .unwrap(); + let token = revision_token_for("lineage-a", 3); + assert_eq!( + registry.resolve_for_session(&owner, 42, &token), + Ok((7, 1, 10)) + ); + + registry + .register_for_session( + owner.clone(), + "lineage-a", + 42, + 7, + 11, + HashMap::from([(3, 9)]), + ) + .unwrap(); + assert_eq!( + registry.resolve_for_session(&owner, 42, &token), + Ok((7, 9, 11)) + ); + + registry + .register_for_session(owner.clone(), "lineage-a", 42, 7, 12, HashMap::new()) + .unwrap(); + assert_eq!( + registry.resolve_for_session(&owner, 42, &token), + Err(RevisionTokenError::StaleElement) + ); + } + + #[test] + fn revision_token_is_session_runtime_and_target_scoped() { + let registry = RevisionTokenRegistry::new(); + let owner = session("runtime-a", "session-a", "transport-a"); + registry + .register_for_session( + owner.clone(), + "lineage-a", + 42, + 7, + 10, + HashMap::from([(3, 1)]), + ) + .unwrap(); + let token = revision_token_for("lineage-a", 3); + + assert_eq!( + registry.resolve_for_session( + &session("runtime-a", "session-b", "transport-b"), + 42, + &token, + ), + Err(RevisionTokenError::SessionMismatch) + ); + assert_eq!( + registry.resolve_for_session( + &session("runtime-b", "session-a", "transport-a"), + 42, + &token, + ), + Err(RevisionTokenError::RuntimeMismatch) + ); + assert_eq!( + registry.resolve_for_session(&owner, 99, &token), + Err(RevisionTokenError::TargetMismatch) + ); + + registry.clear_session("session-a"); + assert_eq!( + registry.resolve_for_session(&owner, 42, &token), + Err(RevisionTokenError::StaleLineage) + ); + } + + #[test] + fn clearing_one_lineage_does_not_invalidate_another() { + let registry = RevisionTokenRegistry::new(); + let owner = session("runtime-a", "session-a", "transport-a"); + for lineage_id in ["lineage-a", "lineage-b"] { + registry + .register_for_session( + owner.clone(), + lineage_id, + 42, + 7, + 10, + HashMap::from([(3, 1)]), + ) + .unwrap(); + } + + registry.clear_lineage("lineage-a"); + + assert_eq!( + registry.resolve_for_session(&owner, 42, &revision_token_for("lineage-a", 3),), + Err(RevisionTokenError::StaleLineage) + ); + assert_eq!( + registry.resolve_for_session(&owner, 42, &revision_token_for("lineage-b", 3),), + Ok((7, 1, 10)) + ); + } + + #[test] + fn duplicate_native_identity_is_rejected() { + let mut lineage = lineage(4); + let error = lineage + .observe( + vec![node("same", 0, "a", None), node("same", 0, "b", None)], + None, + false, + ) + .unwrap_err(); + + assert_eq!(error, ObservationRevisionError::DuplicateIdentity); + assert_eq!(lineage.retained_revision_count(), 0); + } + + #[test] + fn unstable_unretained_full_can_report_duplicate_provider_nodes() { + let mut lineage = lineage(4); + let result = lineage + .observe_unretained_full( + vec![node("same", 0, "a", None), node("same", 0, "b", None)], + FullResyncReason::IdentityUnavailable, + ) + .unwrap(); + + assert_eq!(result.mode, ObservationMode::Full); + assert_eq!( + result.full_resync_reason, + Some(FullResyncReason::IdentityUnavailable) + ); + assert!(!result.stable_element_ids); + assert_eq!(result.nodes[0].element_id, 0); + assert_eq!(result.nodes[1].element_id, 1); + assert_eq!(lineage.retained_revision_count(), 0); + } +} diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/policy.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/policy.rs index b674f55613..5029d6503e 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/policy.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/policy.rs @@ -106,6 +106,28 @@ impl PolicyEngine { Self::Disabled => PolicyDecision::Error("policy support is not enabled".to_owned()), } } + + /// Returns `true` when the tool should appear in `tools/list`. + /// + /// For YAML policies a tool is potentially listable when it is not + /// explicitly denied and has at least one allow path (unconditional or + /// rule-constrained). For Rego, the policy is evaluated with empty + /// arguments as an approximation; Rego rules can implement their own + /// "no-args" sentinel if finer control is needed. + pub fn is_potentially_listable(&self, tool: &str) -> bool { + let tool = canonical_tool_name(tool); + match self { + #[cfg(feature = "yaml")] + Self::Yaml(policy) => policy.is_potentially_listable(tool), + #[cfg(feature = "rego")] + Self::Rego(policy) => { + let empty_args = Value::Object(serde_json::Map::new()); + matches!(policy.evaluate(tool, &empty_args), PolicyDecision::Allow) + } + #[cfg(not(any(feature = "yaml", feature = "rego")))] + Self::Disabled => false, + } + } } fn canonical_tool_name(tool: &str) -> &str { @@ -288,6 +310,34 @@ pub fn authorize_tool_call(tool: &str, args: &Value) -> Result<(), Authorization authorize_policy_layers(tool, args, layers) } +/// Returns `true` when the tool should be advertised in `tools/list`. +/// +/// Unlike [`authorize_tool_call`], this does not require a full argument set: +/// it checks only whether the tool has any potential allow path (is not +/// unconditionally denied). Tools that are conditionally allowed via +/// `allow.rules` are still listed so that callers can attempt a constrained +/// invocation. When no policy is configured, all tools are listable. +/// +/// On a policy loading error, this returns `true` (fail-open for listing). +/// The error will surface at invocation time through [`authorize_tool_call`], +/// and [`validate_configured_policy`] is expected to have caught it at startup. +pub fn is_tool_listable(tool: &str) -> bool { + let managed = match configured_managed_policy() { + Ok(policy) => policy, + Err(_) => return true, + }; + let user = match configured_policy() { + Ok(policy) => policy, + Err(_) => return true, + }; + for policy in [managed, user].into_iter().flatten() { + if !policy.is_potentially_listable(tool) { + return false; + } + } + true +} + /// Eagerly validate the immutable process policy before any action endpoint is /// exposed. This prevents a configured typo from producing a listening daemon /// that only discovers the error after clients begin issuing calls. @@ -364,6 +414,20 @@ impl YamlPolicy { failures.join("; ") )) } + + /// Returns `true` when the tool is not explicitly denied and has at least + /// one potential allow path (an unconditional entry in `allow.tools` or an + /// entry in `allow.rules`). This is the correct predicate for advertising + /// a tool in `tools/list`: the tool may be conditionally allowed, so we + /// must not hide it just because a call with empty arguments would be + /// denied by unsatisfied constraints. + fn is_potentially_listable(&self, tool: &str) -> bool { + if self.denied_tools.iter().any(|denied| denied == tool) { + return false; + } + self.allowed_tools.iter().any(|allowed| allowed == tool) + || self.rules.iter().any(|rule| rule.tool == tool) + } } #[cfg(feature = "yaml")] diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/protocol.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/protocol.rs index b6a1bd7908..462f0cf040 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/protocol.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/protocol.rs @@ -406,7 +406,7 @@ Before starting UI work, classify the desired postcondition. For a non-GUI outco For an app or window outcome, use the narrowest semantic Cua route first: `set_window_frame` plus `list_windows` readback for geometry, typed browser tools for supported page content, and clipboard tools for clipboard state. Then climb through background `element_index` ({tree_kind}), background {coordinate_term}, foreground delivery, and desktop fallback.{coordinate_note} Never advance on transport success alone. Workflow per turn: -0. `start_session(session)` once; reuse that id and end it when done. +0. `start_session` is optional. For multi-call work, prefer a short `session` label and repeat it on every call that accepts it. Unnamed calls use the transport's implicit session. Only `start_session` revives an ended name; `end_session` explicitly cleans up. 1. `launch_app`, then `get_window_state(pid, window_id)` to refresh element indices. 2. Act with the fresh index. 3. `verify_state(pid, window_id, expect)` checks bounded postconditions. `unknown` is not success; `include_screenshot:true` lets the multimodal agent judge visual evidence. @@ -559,7 +559,7 @@ mod action_record_wire_tests { #[cfg(test)] mod agent_instruction_tests { - use super::agent_instructions; + use super::{agent_instructions, initialize_result}; #[test] fn instructions_route_structured_and_visual_verification_to_the_right_owner() { @@ -584,6 +584,25 @@ mod agent_instruction_tests { "initialize instructions should stay within the documented context budget" ); } + + #[test] + fn initialize_instructions_describe_implicit_session_lifecycle() { + let result = initialize_result(); + let instructions = result["instructions"] + .as_str() + .expect("initialize result should carry agent instructions"); + + assert!(instructions.contains("`start_session` is optional")); + assert!(instructions.contains("prefer a short `session` label")); + assert!(instructions.contains("repeat it on every call that accepts it")); + assert!(instructions.contains("transport's implicit session")); + assert!(instructions.contains("Only `start_session` revives an ended name")); + assert!(instructions.contains("`end_session` explicitly cleans up")); + assert!( + !instructions.contains("`start_session(session)` once"), + "initialize instructions must not require explicit session setup" + ); + } } #[cfg(test)] diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/recording.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/recording.rs index 8671836fbe..362f76118a 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/recording.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/recording.rs @@ -787,8 +787,16 @@ fn write_turn( } }; let click_family = matches!(tool_name.as_str(), "click" | "double_click" | "right_click"); + let action_refused = action_record + .is_some_and(|record| record.effect == crate::action_record::ActionEffect::Refused); let refused_before_target_resolution = click_family && result_is_error && click_point.is_none(); - let click_expected = click_family && !refused_before_target_resolution; + // A target may resolve successfully and still be refused before input + // dispatch (for example, a minimized Windows element). Retaining the + // resolved point in action.json is useful diagnostic context, but a + // crosshair would falsely imply that a click was delivered. + let refused_before_dispatch = click_family && result_is_error && action_refused; + let click_expected = + click_family && !refused_before_target_resolution && !refused_before_dispatch; let mut payload = serde_json::json!({ "tool": tool_name, @@ -820,14 +828,16 @@ fn write_turn( // the pre-action image. This also keeps modal-dismiss evidence available // after the modal HWND has closed. let mut click_captured = false; - if let (Some((cx, cy)), Some(screenshot), Some(marker)) = ( - click_point, - before.screenshot.as_deref(), - CLICK_MARKER_FN.get(), - ) { - if let Some(click_png) = marker(screenshot, cx, cy) { - std::fs::write(turn_dir.join("click.png"), click_png)?; - click_captured = true; + if click_expected { + if let (Some((cx, cy)), Some(screenshot), Some(marker)) = ( + click_point, + before.screenshot.as_deref(), + CLICK_MARKER_FN.get(), + ) { + if let Some(click_png) = marker(screenshot, cx, cy) { + std::fs::write(turn_dir.join("click.png"), click_png)?; + click_captured = true; + } } } write_evidence_manifest( @@ -839,6 +849,8 @@ fn write_turn( click_captured, if refused_before_target_resolution { "action_refused_before_target_resolution" + } else if refused_before_dispatch { + "action_refused_before_dispatch" } else { "not_a_click_action" }, @@ -1064,6 +1076,49 @@ mod tests { "action_refused_before_target_resolution" ); + let pending = session + .begin_turn( + "click", + &serde_json::json!({"pid": 1, "window_id": 2, "x": 3, "y": 4}), + now_ms(), + ) + .expect("resolved refusal should reserve an evidence turn"); + let refusal_record = crate::action_record::ActionExecutionRecord::builder( + crate::action_record::ActionEffect::Refused, + crate::action_record::ActionTransport::WindowsTargetedInjection, + crate::action_record::RequestedDelivery::Background, + ) + .build() + .expect("valid refusal record"); + session.finish_turn_with_outcome( + pending, + "refused before dispatch", + Some(&refusal_record), + true, + ); + let resolved_refusal_turn = output_dir.join("turn-00004"); + let resolved_refusal_action: Value = serde_json::from_slice( + &std::fs::read(resolved_refusal_turn.join("action.json")) + .expect("read resolved refusal action"), + ) + .expect("parse resolved refusal action"); + assert_eq!(resolved_refusal_action["click_point"]["x"], 3.0); + assert_eq!(resolved_refusal_action["action_truth"]["effect"], "refused"); + assert!(!resolved_refusal_turn.join("click.png").exists()); + let resolved_refusal_manifest: Value = serde_json::from_slice( + &std::fs::read(resolved_refusal_turn.join("evidence.json")) + .expect("read resolved refusal evidence"), + ) + .expect("parse resolved refusal evidence"); + assert_eq!( + resolved_refusal_manifest["click"]["status"], + "not_applicable" + ); + assert_eq!( + resolved_refusal_manifest["click"]["classification"], + "action_refused_before_dispatch" + ); + let files = [ "action.json", "app_state.json", @@ -1081,11 +1136,13 @@ mod tests { } std::fs::remove_dir(directory).expect("remove turn fixture directory"); } - for file in files.into_iter().filter(|file| *file != "click.png") { - std::fs::remove_file(refused_turn.join(file)) - .expect("remove refused turn fixture file"); + for directory in [&refused_turn, &resolved_refusal_turn] { + for file in files.iter().copied().filter(|file| *file != "click.png") { + std::fs::remove_file(directory.join(file)) + .expect("remove refused turn fixture file"); + } + std::fs::remove_dir(directory).expect("remove refused turn fixture directory"); } - std::fs::remove_dir(refused_turn).expect("remove refused turn fixture directory"); std::fs::remove_dir(&output_dir).expect("remove recording fixture directory"); } diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/server.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/server.rs index d3726af8b9..30b31c3f62 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/server.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/server.rs @@ -851,6 +851,33 @@ pub async fn handle_request( req: Request, id: serde_json::Value, provider: &dyn ToolProvider, +) -> Response { + handle_request_inner(req, id, provider, None).await +} + +/// Dispatch one MCP request with a transport identity proved by the local +/// adapter rather than supplied by the MCP client. +/// +/// The ordinary untrusted entry point above must continue to discard every +/// reserved argument. Direct stdio and authenticated HTTP each own a transport +/// lease, so their adapters pass that lease here after parsing the untrusted +/// request. The inner boundary sanitizes caller claims first and then stamps +/// this trusted owner onto both implicit and explicitly named lifecycle +/// sessions. +pub async fn handle_request_with_transport_session( + req: Request, + id: serde_json::Value, + provider: &dyn ToolProvider, + transport_session: &str, +) -> Response { + handle_request_inner(req, id, provider, Some(transport_session)).await +} + +async fn handle_request_inner( + req: Request, + id: serde_json::Value, + provider: &dyn ToolProvider, + transport_session: Option<&str>, ) -> Response { match req.method.as_str() { "initialize" => Response::ok(id, initialize_result()), @@ -877,23 +904,17 @@ pub async fn handle_request( .and_then(serde_json::Value::as_str) .filter(|session| !session.is_empty()) .map(str::to_owned); - if let (Some(arguments), Some(session)) = - (call.args.as_object_mut(), public_session) - { - arguments.insert( - "_session_id".to_owned(), - serde_json::Value::String(session.clone()), - ); - arguments.insert( - "_transport_session_id".to_owned(), - serde_json::Value::String(session.clone()), - ); - } - if call.name == "browser_prepare" { - if let Some(arguments) = call.args.as_object_mut() { + if let Some(arguments) = call.args.as_object_mut() { + if let Some(session) = public_session.as_deref().or(transport_session) { arguments.insert( - crate::browser::approval::MCP_HOST_APPROVAL_ARG.to_owned(), - serde_json::Value::Bool(true), + "_session_id".to_owned(), + serde_json::Value::String(session.to_owned()), + ); + } + if let Some(owner) = transport_session.or(public_session.as_deref()) { + arguments.insert( + "_transport_session_id".to_owned(), + serde_json::Value::String(owner.to_owned()), ); } } @@ -991,6 +1012,75 @@ mod observation_tests { assert!(arguments.get("_protected_process_fingerprint").is_none()); } + #[tokio::test] + async fn trusted_transport_boundary_keeps_public_label_separate_from_owner() { + let provider = CapturingProvider { + arguments: Mutex::new(None), + }; + let request: Request = serde_json::from_value(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "browser_download", + "arguments": { + "session": "public", + "_session_id": "forged-owner", + "_transport_session_id": "forged-transport", + "_cua_browser_download_mcp_host_approved": false, + "_protected_process_fingerprint": {"pid": 1} + } + } + })) + .unwrap(); + let response = handle_request_with_transport_session( + request, + serde_json::json!(1), + &provider, + "mcp-trusted-lease", + ) + .await; + assert!(matches!(response.body, ResponseBody::Result { .. })); + + let arguments = provider.arguments.lock().unwrap().clone().unwrap(); + assert_eq!(arguments["_session_id"], "public"); + assert_eq!(arguments["_transport_session_id"], "mcp-trusted-lease"); + assert_eq!(arguments["_cua_browser_download_mcp_host_approved"], true); + assert!(arguments.get("_protected_process_fingerprint").is_none()); + } + + #[tokio::test] + async fn trusted_transport_boundary_mints_implicit_identity_from_lease() { + let provider = CapturingProvider { + arguments: Mutex::new(None), + }; + let request: Request = serde_json::from_value(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_config", + "arguments": { + "_session_id": "forged-owner", + "_transport_session_id": "forged-transport" + } + } + })) + .unwrap(); + let response = handle_request_with_transport_session( + request, + serde_json::json!(1), + &provider, + "mcp-trusted-lease", + ) + .await; + assert!(matches!(response.body, ResponseBody::Result { .. })); + + let arguments = provider.arguments.lock().unwrap().clone().unwrap(); + assert_eq!(arguments["_session_id"], "mcp-trusted-lease"); + assert_eq!(arguments["_transport_session_id"], "mcp-trusted-lease"); + } + #[test] fn successful_mixed_output_is_shape_only() { let response = Response::ok( @@ -1118,8 +1208,7 @@ mod observation_tests { ( "browser_prepare", serde_json::json!({ - "strategy": {"kind": "existing_profile"}, - "approval_token": "private-token" + "strategy": {"kind": "existing_profile"} }), ToolOperation::BrowserPrepareExistingProfile, ), diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/session.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/session.rs index 3aaae6ac81..516ce8177f 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/session.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/session.rs @@ -27,7 +27,36 @@ use std::time::{Duration, Instant}; use cua_driver_contract::{CaptureScope, EscalationReason}; -type SessionEndHook = Arc; +pub const DEFAULT_SESSION_IDLE_TTL: Duration = Duration::from_secs(5 * 60); + +type SessionEndHook = Arc Result<(), String> + Send + Sync>; +type SessionReviveHook = Arc; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionCleanupFailure { + pub hook: String, + pub error: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionCleanupReport { + pub complete: bool, + pub in_progress: bool, + pub failures: Vec, +} + +#[derive(Clone)] +struct RegisteredSessionEndHook { + name: String, + callback: SessionEndHook, +} + +struct SessionCleanupProgress { + hooks: Vec<(u64, RegisteredSessionEndHook)>, + completed: HashSet, + failures: Vec, + running: bool, +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionDeclaration { @@ -43,6 +72,12 @@ pub enum SessionEndReason { Unknown, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptureModality { + Window, + Desktop, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionTransport { Cli, @@ -63,6 +98,18 @@ pub enum SessionClientKind { TypescriptSdk, } +pub fn infer_transport_metadata(owner: &str) -> (SessionTransport, SessionClientKind) { + if owner.contains(":http-") { + (SessionTransport::McpHttp, SessionClientKind::Mcp) + } else if owner.contains(":mcp-") { + (SessionTransport::McpStdio, SessionClientKind::Mcp) + } else if owner.contains(":cli-") { + (SessionTransport::Cli, SessionClientKind::Cli) + } else { + (SessionTransport::Daemon, SessionClientKind::Direct) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SessionStartObservation { pub declaration: SessionDeclaration, @@ -91,6 +138,9 @@ pub enum CursorThemeCategory { pub struct CursorOutcomeObservation { pub observed: bool, pub enabled: bool, + /// True only when the platform render state currently reports visible + /// pixels for this exact session cursor. + pub visible: bool, pub theme: CursorThemeCategory, pub motion_customized: bool, pub active_cursor_count: usize, @@ -101,6 +151,7 @@ pub struct CursorOutcomeObservation { pub fn bounded_cursor_outcome( observed: bool, enabled: bool, + visible: bool, theme_id: Option<&str>, motion_customized: bool, active_cursor_count: usize, @@ -116,6 +167,7 @@ pub fn bounded_cursor_outcome( CursorOutcomeObservation { observed, enabled: observed && enabled, + visible: observed && enabled && visible, theme, motion_customized: observed && motion_customized, active_cursor_count, @@ -135,6 +187,7 @@ pub trait SessionObserver: Send + Sync + 'static { session_id: &str, transport: SessionTransport, computer_action: bool, + capture_modality: Option, escalation_reason: Option, outcome: &crate::server::ToolCompletionObservation, ); @@ -150,6 +203,10 @@ static SESSION_OBSERVER: OnceLock> = OnceLock::new(); type CursorOutcomeReader = Arc CursorOutcomeObservation + Send + Sync>; static CURSOR_OUTCOME_READERS: OnceLock>> = OnceLock::new(); static NEXT_CURSOR_OUTCOME_READER_ID: AtomicU64 = AtomicU64::new(1); +type RecordingStateReader = Arc bool + Send + Sync>; +static RECORDING_STATE_READERS: OnceLock>> = + OnceLock::new(); +static NEXT_RECORDING_STATE_READER_ID: AtomicU64 = AtomicU64::new(1); pub fn set_session_observer(observer: Arc) -> bool { SESSION_OBSERVER.set(observer).is_ok() @@ -191,6 +248,55 @@ impl Drop for CursorOutcomeReaderRegistration { } } +pub fn register_scoped_recording_state_reader( + reader: RecordingStateReader, +) -> RecordingStateReaderRegistration { + let id = NEXT_RECORDING_STATE_READER_ID.fetch_add(1, Ordering::Relaxed); + RECORDING_STATE_READERS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .unwrap() + .insert(id, reader); + RecordingStateReaderRegistration { id } +} + +pub struct RecordingStateReaderRegistration { + id: u64, +} + +impl Drop for RecordingStateReaderRegistration { + fn drop(&mut self) { + if let Some(readers) = RECORDING_STATE_READERS.get() { + readers.lock().unwrap().remove(&self.id); + } + } +} + +pub fn cursor_visible(session_id: &str) -> bool { + CURSOR_OUTCOME_READERS + .get() + .map(|readers| { + readers.lock().unwrap().values().any(|reader| { + let outcome = reader(session_id); + outcome.visible + }) + }) + .unwrap_or(false) +} + +pub fn recording_active(session_id: &str) -> bool { + RECORDING_STATE_READERS + .get() + .map(|readers| { + readers + .lock() + .unwrap() + .values() + .any(|reader| reader(session_id)) + }) + .unwrap_or(false) +} + /// Private per-call context. The raw caller session id never crosses into a /// serialized observation; it is retained only until the bounded completion /// callback updates the process-local aggregate. @@ -198,6 +304,7 @@ pub struct SessionToolContext { session_id: String, transport: SessionTransport, computer_action: bool, + capture_modality: Option, escalation_reason: Option, } @@ -208,6 +315,7 @@ impl SessionToolContext { &self.session_id, self.transport, self.computer_action, + self.capture_modality, self.escalation_reason, outcome, ); @@ -215,18 +323,469 @@ impl SessionToolContext { } } -static SESSION_END_HOOKS: OnceLock>> = OnceLock::new(); +static SESSION_END_HOOKS: OnceLock>> = OnceLock::new(); static NEXT_SESSION_END_HOOK_ID: AtomicU64 = AtomicU64::new(1); +static SESSION_CLEANUP_PROGRESS: OnceLock>> = + OnceLock::new(); +static SESSION_REVIVE_HOOKS: OnceLock>> = OnceLock::new(); +static NEXT_SESSION_REVIVE_HOOK_ID: AtomicU64 = AtomicU64::new(1); -/// Last-activity timestamp per live session id. A session is "touched" every -/// time a tool call carries its explicit `session` id (see the daemon boundary -/// in `serve.rs`). The idle-TTL sweep ([`evict_idle`]) ends sessions that -/// haven't been touched within the TTL — this is the cleanup path that replaces -/// connection-EOF reaping now that a session is a caller-declared identity, not -/// a per-MCP-connection one. `"default"` and empty ids are never tracked (they -/// are the anonymous, cursor-less fallback). +/// Last-activity timestamp per live lifecycle session. Only an admitted, +/// session-requiring call that reaches dispatch refreshes this timestamp. +/// Transport close remains an immediate cleanup path; idle eviction is the +/// crash and inactivity fallback. `"default"` and empty ids are never tracked. static SESSION_ACTIVITY: OnceLock>> = OnceLock::new(); +#[derive(Debug, Clone)] +struct LifecycleRecord { + public_label: Option, + implicit: bool, + owner_transport: String, + transport: SessionTransport, + client_kind: SessionClientKind, + started_at: Instant, + /// Trusted host override. Ordinary sessions use the runtime sweep's + /// default TTL so tests and process-level configuration can still supply + /// that fallback. + idle_ttl: Option, + in_flight: usize, + pending_end: Option, +} + +static LIFECYCLE_RECORDS: OnceLock>> = OnceLock::new(); + +fn lifecycle_records() -> &'static Mutex> { + LIFECYCLE_RECORDS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[derive(Debug, Clone)] +pub struct LifecycleSessionSnapshot { + pub runtime_id: String, + pub public_label: Option, + pub implicit: bool, + pub transport: SessionTransport, + pub client_kind: SessionClientKind, + pub owner_transport: String, + pub ending: bool, + pub started_for: Duration, + pub idle: Duration, + pub expires_in: Duration, +} + +/// RAII protection for one admitted session-requiring dispatch. Idle eviction +/// cannot end the session while this guard is alive. Dropping it refreshes the +/// idle clock at platform-dispatch completion and completes any end that raced +/// the call. +pub struct SessionDispatchGuard { + session_id: String, +} + +impl Drop for SessionDispatchGuard { + fn drop(&mut self) { + let pending = { + let mut records = lifecycle_records().lock().unwrap(); + let Some(record) = records.get_mut(&self.session_id) else { + return; + }; + record.in_flight = record.in_flight.saturating_sub(1); + activity() + .lock() + .unwrap() + .insert(self.session_id.clone(), Instant::now()); + (record.in_flight == 0) + .then(|| record.pending_end.take()) + .flatten() + }; + if let Some(reason) = pending { + finish_session_end(&self.session_id, reason); + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn begin_session_dispatch( + session_id: &str, + public_label: Option<&str>, + owner_transport: &str, + implicit: bool, + transport: SessionTransport, + client_kind: SessionClientKind, +) -> Result { + begin_session_dispatch_inner( + session_id, + public_label, + owner_transport, + implicit, + transport, + client_kind, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn begin_session_dispatch_with_ttl( + session_id: &str, + public_label: Option<&str>, + owner_transport: &str, + implicit: bool, + transport: SessionTransport, + client_kind: SessionClientKind, + idle_ttl: Duration, +) -> Result { + begin_session_dispatch_inner( + session_id, + public_label, + owner_transport, + implicit, + transport, + client_kind, + Some(idle_ttl), + ) +} + +#[allow(clippy::too_many_arguments)] +fn begin_session_dispatch_inner( + session_id: &str, + public_label: Option<&str>, + owner_transport: &str, + implicit: bool, + transport: SessionTransport, + client_kind: SessionClientKind, + idle_ttl: Option, +) -> Result { + if !is_trackable(session_id) { + return Err("session has ended"); + } + let now = Instant::now(); + { + // Keep tombstone admission and live-record insertion in one critical + // section. Otherwise an end could land between the old pre-check and + // insertion, leaving a tombstoned record that was silently recreated + // by a racing first action. + let ended = ended_sessions().lock().unwrap(); + if ended.contains_key(session_id) { + return Err("session has ended"); + } + let mut records = lifecycle_records().lock().unwrap(); + let record = records + .entry(session_id.to_owned()) + .or_insert_with(|| LifecycleRecord { + public_label: public_label.map(str::to_owned), + implicit, + owner_transport: owner_transport.to_owned(), + transport, + client_kind, + started_at: now, + idle_ttl, + in_flight: 0, + pending_end: None, + }); + if record.owner_transport != owner_transport { + return Err("session is not available to this transport"); + } + if record.pending_end.is_some() { + return Err("session is ending"); + } + if idle_ttl.is_some() { + record.idle_ttl = idle_ttl; + } + record.in_flight += 1; + } + activity() + .lock() + .unwrap() + .insert(session_id.to_owned(), now); + Ok(SessionDispatchGuard { + session_id: session_id.to_owned(), + }) +} + +#[allow(clippy::too_many_arguments)] +pub fn activate_session( + session_id: &str, + public_label: Option<&str>, + owner_transport: &str, + implicit: bool, + transport: SessionTransport, + client_kind: SessionClientKind, +) -> Result<(), &'static str> { + activate_session_inner( + session_id, + public_label, + owner_transport, + implicit, + transport, + client_kind, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn activate_session_with_ttl( + session_id: &str, + public_label: Option<&str>, + owner_transport: &str, + implicit: bool, + transport: SessionTransport, + client_kind: SessionClientKind, + idle_ttl: Duration, +) -> Result<(), &'static str> { + activate_session_inner( + session_id, + public_label, + owner_transport, + implicit, + transport, + client_kind, + Some(idle_ttl), + ) +} + +#[allow(clippy::too_many_arguments)] +fn activate_session_inner( + session_id: &str, + public_label: Option<&str>, + owner_transport: &str, + implicit: bool, + transport: SessionTransport, + client_kind: SessionClientKind, + idle_ttl: Option, +) -> Result<(), &'static str> { + let now = Instant::now(); + { + let mut records = lifecycle_records().lock().unwrap(); + if let Some(record) = records.get_mut(session_id) { + if record.owner_transport != owner_transport { + return Err("session is not available to this transport"); + } + if record.pending_end.is_some() { + return Err("session is ending"); + } + if idle_ttl.is_some() { + record.idle_ttl = idle_ttl; + } + } else { + records.insert( + session_id.to_owned(), + LifecycleRecord { + public_label: public_label.map(str::to_owned), + implicit, + owner_transport: owner_transport.to_owned(), + transport, + client_kind, + started_at: now, + idle_ttl, + in_flight: 0, + pending_end: None, + }, + ); + } + } + activity() + .lock() + .unwrap() + .insert(session_id.to_owned(), now); + Ok(()) +} + +/// Atomically declare a lifecycle episode for one authenticated transport. +/// +/// Unlike calling `revive_session_for_owner` and `activate_session` in two +/// steps, this does not leave a gap where another transport can claim a +/// recycled public label after its tombstone is cleared. Cleanup is completed +/// before the transition, then the tombstone removal and live owner binding +/// happen under one lock order. +#[allow(clippy::too_many_arguments)] +pub fn activate_or_revive_session_for_owner( + session_id: &str, + public_label: Option<&str>, + owner_transport: &str, + implicit: bool, + transport: SessionTransport, + client_kind: SessionClientKind, + idle_ttl: Option, +) -> Result { + if !is_trackable(session_id) { + return Err("session is not available to this transport"); + } + + let now = Instant::now(); + loop { + let mut ended = ended_sessions().lock().unwrap(); + let revived = match ended.get(session_id) { + Some(Some(owner)) if owner != owner_transport => { + return Err("session is not available to this transport"); + } + Some(None) if owner_transport != session_id => { + return Err("session is not available to this transport"); + } + Some(_) => true, + None => false, + }; + let cleanup_pending = + revived && cleanup_progress().lock().unwrap().contains_key(session_id); + if cleanup_pending { + drop(ended); + if !retry_session_cleanup(session_id).complete { + return Err("session cleanup is incomplete; retry end_session before revival"); + } + continue; + } + + let mut records = lifecycle_records().lock().unwrap(); + if let Some(record) = records.get_mut(session_id) { + if record.owner_transport != owner_transport || record.pending_end.is_some() { + return Err("session is not available to this transport"); + } + if idle_ttl.is_some() { + record.idle_ttl = idle_ttl; + } + } else { + records.insert( + session_id.to_owned(), + LifecycleRecord { + public_label: public_label.map(str::to_owned), + implicit, + owner_transport: owner_transport.to_owned(), + transport, + client_kind, + started_at: now, + idle_ttl, + in_flight: 0, + pending_end: None, + }, + ); + } + if revived { + ended.remove(session_id); + } + drop(records); + drop(ended); + activity() + .lock() + .unwrap() + .insert(session_id.to_owned(), now); + return Ok(revived); + } +} + +pub fn session_snapshot( + session_id: &str, + owner_transport: &str, + ttl: Duration, +) -> Option { + let record = lifecycle_records().lock().unwrap().get(session_id)?.clone(); + if record.owner_transport != owner_transport { + return None; + } + let idle = session_idle_duration(session_id).unwrap_or_default(); + let ttl = record.idle_ttl.unwrap_or(ttl); + Some(LifecycleSessionSnapshot { + runtime_id: session_id.to_owned(), + public_label: record.public_label.clone(), + implicit: record.implicit, + transport: record.transport, + client_kind: record.client_kind, + owner_transport: record.owner_transport.clone(), + ending: record.pending_end.is_some(), + started_for: record.started_at.elapsed(), + idle, + expires_in: ttl.saturating_sub(idle), + }) +} + +fn session_owner_matches(session_id: &str, owner_transport: &str) -> bool { + if lifecycle_records() + .lock() + .unwrap() + .get(session_id) + .is_some_and(|record| record.owner_transport == owner_transport) + { + return true; + } + ended_sessions() + .lock() + .unwrap() + .get(session_id) + .is_some_and(|owner| owner.as_deref() == Some(owner_transport)) +} + +/// End a lifecycle episode only when it belongs to the authenticated +/// transport. Returns `false` for unknown and foreign ids alike so callers can +/// provide one non-enumerating response. +pub fn end_session_for_owner(session_id: &str, owner_transport: &str) -> bool { + if !session_owner_matches(session_id, owner_transport) { + return false; + } + end_session(session_id); + true +} + +pub fn list_session_snapshots( + owner_transport: &str, + ttl: Duration, +) -> Vec { + let mut ids = lifecycle_records() + .lock() + .unwrap() + .iter() + .filter(|(_, record)| record.owner_transport == owner_transport) + .map(|(id, record)| (record.started_at, id.clone())) + .collect::>(); + ids.sort_by_key(|(started, id)| (*started, id.clone())); + ids.into_iter() + .filter_map(|(_, id)| session_snapshot(&id, owner_transport, ttl)) + .collect() +} + +/// Trusted local-host view scoped to one runtime generation. This is not used +/// by agent tools; the operator adapter applies redaction before serialization. +pub fn list_session_snapshots_with_prefix( + runtime_prefix: &str, + ttl: Duration, +) -> Vec { + let mut records = lifecycle_records() + .lock() + .unwrap() + .iter() + .filter(|(id, _)| id.starts_with(runtime_prefix)) + .map(|(id, record)| (record.started_at, id.clone(), record.clone())) + .collect::>(); + records.sort_by_key(|(started, id, _)| (*started, id.clone())); + records + .into_iter() + .map(|(_, runtime_id, record)| { + let idle = session_idle_duration(&runtime_id).unwrap_or_default(); + LifecycleSessionSnapshot { + runtime_id, + public_label: record.public_label, + implicit: record.implicit, + transport: record.transport, + client_kind: record.client_kind, + owner_transport: record.owner_transport, + ending: record.pending_end.is_some(), + started_for: record.started_at.elapsed(), + idle, + expires_in: record.idle_ttl.unwrap_or(ttl).saturating_sub(idle), + } + }) + .collect() +} + +pub fn end_sessions_for_owner(owner_transport: &str, reason: SessionEndReason) -> usize { + let ids = lifecycle_records() + .lock() + .unwrap() + .iter() + .filter(|(_, record)| record.owner_transport == owner_transport) + .map(|(id, _)| id.clone()) + .collect::>(); + for id in &ids { + end_session_with_reason(id, reason); + } + ids.len() +} + fn activity() -> &'static Mutex> { SESSION_ACTIVITY.get_or_init(|| Mutex::new(HashMap::new())) } @@ -249,7 +808,7 @@ pub fn begin_tool_call( begin_tool_call_with_state(tool_name, args, known_tool, transport, client_kind, None) } -/// Begin observation with a runtime-owner-provided view of public session +/// Begin observation with a runtime-owner-provided view of session /// state. Runtime owners use this seam because their mutable scope and /// tombstone state is keyed by a private runtime generation, while telemetry /// must continue to report the caller's stable public session label. @@ -264,10 +823,11 @@ pub fn begin_tool_call_with_state( if !known_tool { return None; } - let session_id = args - .get("session") - .and_then(serde_json::Value::as_str) - .filter(|id| is_trackable(id))?; + let session_id = ["session", "_session_id"].into_iter().find_map(|key| { + args.get(key) + .and_then(serde_json::Value::as_str) + .filter(|id| is_trackable(id)) + })?; let is_start = tool_name == "start_session"; let is_end = tool_name == "end_session"; let ended = observed_state @@ -293,6 +853,7 @@ pub fn begin_tool_call_with_state( .then(|| args.get("reason").cloned()) .flatten() .and_then(|value| serde_json::from_value(value).ok()); + let capture_modality = capture_modality_for(tool_name, args); if !is_end { if let Some(observer) = SESSION_OBSERVER.get() { observer.on_session_started( @@ -319,10 +880,47 @@ pub fn begin_tool_call_with_state( tool_name, crate::server::tool_operation(tool_name, Some(args)), ), + capture_modality, escalation_reason, }) } +fn capture_modality_for(tool_name: &str, args: &serde_json::Value) -> Option { + if let Some(kind) = args + .get("target") + .and_then(serde_json::Value::as_object) + .and_then(|target| target.get("kind")) + .and_then(serde_json::Value::as_str) + { + return match kind { + "window" => Some(CaptureModality::Window), + "desktop" => Some(CaptureModality::Desktop), + _ => None, + }; + } + if let Some(scope) = args.get("scope").and_then(serde_json::Value::as_str) { + return match scope { + "window" => Some(CaptureModality::Window), + "desktop" => Some(CaptureModality::Desktop), + _ => None, + }; + } + if args.get("pid").is_some_and(serde_json::Value::is_number) + || args + .get("window_id") + .is_some_and(serde_json::Value::is_number) + { + return Some(CaptureModality::Window); + } + match tool_name { + "get_window_state" => Some(CaptureModality::Window), + "get_desktop_state" | "get_screen_size" | "get_cursor_position" => { + Some(CaptureModality::Desktop) + } + _ => None, + } +} + /// Session ids that have already had their `session_end` fired. Dedupes the /// control-connection EOF teardown (the reaper) against any stray legacy /// `session_end` method that a mixed-version (new proxy / old proxy) rollout @@ -330,7 +928,7 @@ pub fn begin_tool_call_with_state( /// be idempotent because the overlay Remove + recording stop must run exactly /// once. Growth is bounded (one short string per ended session over the /// daemon's lifetime); eviction is a deliberate non-blocking follow-up. -static ENDED_SESSIONS: OnceLock>> = OnceLock::new(); +static ENDED_SESSIONS: OnceLock>>> = OnceLock::new(); /// Runtime generations that have received terminal revoke-all. /// /// This latch is intentionally independent of grants and public session @@ -338,12 +936,20 @@ static ENDED_SESSIONS: OnceLock>> = OnceLock::new(); /// generation fails closed until that runtime is destroyed. static SUSPENDED_RUNTIME_SCOPES: OnceLock>> = OnceLock::new(); -fn hooks() -> &'static Mutex> { +fn hooks() -> &'static Mutex> { SESSION_END_HOOKS.get_or_init(|| Mutex::new(HashMap::new())) } -fn ended_sessions() -> &'static Mutex> { - ENDED_SESSIONS.get_or_init(|| Mutex::new(HashSet::new())) +fn cleanup_progress() -> &'static Mutex> { + SESSION_CLEANUP_PROGRESS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn revive_hooks() -> &'static Mutex> { + SESSION_REVIVE_HOOKS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn ended_sessions() -> &'static Mutex>> { + ENDED_SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) } fn suspended_runtime_scopes() -> &'static Mutex> { @@ -376,7 +982,7 @@ pub fn forget_suspended_runtime_scope(runtime_scope: &str) -> bool { .remove(runtime_scope) } -fn public_session_label(session_id: &str) -> &str { +pub(crate) fn public_session_label(session_id: &str) -> &str { let Some(rest) = session_id.strip_prefix("__cua_runtime_") else { return session_id; }; @@ -405,9 +1011,27 @@ pub fn register_session_end_hook(hook: impl Fn(&str) + Send + Sync + 'static) { /// platform and browser hooks. pub fn register_scoped_session_end_hook( hook: impl Fn(&str) + Send + Sync + 'static, +) -> SessionEndHookRegistration { + register_scoped_fallible_session_end_hook("session_state", move |session_id| { + hook(session_id); + Ok(()) + }) +} + +/// Register a named cleanup hook whose failure is retained for bounded retry. +/// A successful hook is never run twice for the same lifecycle episode. +pub fn register_scoped_fallible_session_end_hook( + name: impl Into, + hook: impl Fn(&str) -> Result<(), String> + Send + Sync + 'static, ) -> SessionEndHookRegistration { let id = NEXT_SESSION_END_HOOK_ID.fetch_add(1, Ordering::Relaxed); - hooks().lock().unwrap().insert(id, Arc::new(hook)); + hooks().lock().unwrap().insert( + id, + RegisteredSessionEndHook { + name: name.into(), + callback: Arc::new(hook), + }, + ); SessionEndHookRegistration { id } } @@ -421,6 +1045,70 @@ impl Drop for SessionEndHookRegistration { } } +/// Register a runtime-owned callback for a successful explicit revival. +/// Dropping the returned guard removes the callback. +pub fn register_scoped_session_revive_hook( + hook: impl Fn(&str) + Send + Sync + 'static, +) -> SessionReviveHookRegistration { + let id = NEXT_SESSION_REVIVE_HOOK_ID.fetch_add(1, Ordering::Relaxed); + revive_hooks().lock().unwrap().insert(id, Arc::new(hook)); + SessionReviveHookRegistration { id } +} + +pub struct SessionReviveHookRegistration { + id: u64, +} + +impl Drop for SessionReviveHookRegistration { + fn drop(&mut self) { + revive_hooks().lock().unwrap().remove(&self.id); + } +} + +/// Notify session-owned subsystems after `start_session` has successfully +/// revived and rebound a recycled id. Unlike [`revive_session`], this does not +/// alter core lifecycle state; it fans the completed transition out to +/// platform-owned tombstones. +pub fn fire_session_revive(session_id: &str) { + let owner = lifecycle_records() + .lock() + .unwrap() + .get(session_id) + .map(|record| record.owner_transport.clone()); + if let Some(owner) = owner { + let _ = fire_session_revive_for_owner(session_id, &owner); + } +} + +/// Notify render-side owners only while the revived episode is still live and +/// owned by the same transport. Holding the tombstone lock across callbacks +/// orders a racing end after revival, so its cleanup cannot be followed by a +/// late overlay resurrection. +pub fn fire_session_revive_for_owner(session_id: &str, owner_transport: &str) -> bool { + let registered = revive_hooks() + .lock() + .unwrap() + .values() + .cloned() + .collect::>(); + let ended = ended_sessions().lock().unwrap(); + if ended.contains_key(session_id) + || !lifecycle_records() + .lock() + .unwrap() + .get(session_id) + .is_some_and(|record| { + record.owner_transport == owner_transport && record.pending_end.is_none() + }) + { + return false; + } + for hook in registered { + hook(session_id); + } + true +} + #[doc(hidden)] pub fn session_end_hook_count() -> usize { hooks().lock().unwrap().len() @@ -435,26 +1123,145 @@ pub fn session_end_hook_count() -> usize { /// later calls return `false`. The first fire still returns `true` when no /// hooks are registered. pub fn fire_session_end(session_id: &str) -> bool { - // Mark-then-fan-out under a short critical section, releasing the lock - // before running hooks (hooks may be slow / re-entrant and must not hold - // the dedupe lock). - { - let mut ended = ended_sessions().lock().unwrap(); - if !ended.insert(session_id.to_owned()) { - return false; // already ended — idempotent no-op. - } - } - crate::capture_scope::clear_session(session_id); - let registered = hooks() + fire_session_end_for_owner(session_id, None) +} + +fn fire_session_end_for_owner(session_id: &str, owner_transport: Option<&str>) -> bool { + let first_fire = mark_session_ended(session_id, owner_transport); + let _ = retry_session_cleanup(session_id); + first_fire +} + +/// Atomically remove a live lifecycle record and install its tombstone. +/// +/// Hooks run after this short critical section. Keeping this transition under +/// the same lock order as dispatch admission prevents a racing first action +/// from recreating a record immediately before or after termination. +fn mark_session_ended(session_id: &str, owner_transport: Option<&str>) -> bool { + activity().lock().unwrap().remove(session_id); + let mut ended = ended_sessions().lock().unwrap(); + let record_owner = lifecycle_records() .lock() .unwrap() - .values() - .cloned() - .collect::>(); - for hook in registered { - hook(session_id); + .remove(session_id) + .map(|record| record.owner_transport); + if ended.contains_key(session_id) { + false + } else { + crate::capture_scope::clear_session(session_id); + let mut registered = hooks() + .lock() + .unwrap() + .iter() + .map(|(id, hook)| (*id, hook.clone())) + .collect::>(); + registered.sort_by_key(|(id, _)| *id); + cleanup_progress().lock().unwrap().insert( + session_id.to_owned(), + SessionCleanupProgress { + hooks: registered, + completed: HashSet::new(), + failures: Vec::new(), + running: false, + }, + ); + ended.insert( + session_id.to_owned(), + owner_transport.map(str::to_owned).or(record_owner), + ); + true + } +} + +/// Retry only the cleanup hooks that have not yet succeeded for this ended +/// lifecycle episode. Panics are converted to structured failures so another +/// explicit end can retry them instead of losing cleanup state. +pub fn retry_session_cleanup(session_id: &str) -> SessionCleanupReport { + let work = { + let mut all = cleanup_progress().lock().unwrap(); + let Some(progress) = all.get_mut(session_id) else { + return SessionCleanupReport { + complete: true, + in_progress: false, + failures: Vec::new(), + }; + }; + if progress.running { + return SessionCleanupReport { + complete: false, + in_progress: true, + failures: progress.failures.clone(), + }; + } + progress.running = true; + progress + .hooks + .iter() + .filter(|(id, _)| !progress.completed.contains(id)) + .cloned() + .collect::>() + }; + + let mut successes = Vec::new(); + let mut failures = Vec::new(); + for (id, hook) in work { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (hook.callback)(session_id))); + match result { + Ok(Ok(())) => successes.push(id), + Ok(Err(error)) => failures.push(SessionCleanupFailure { + hook: hook.name, + error, + }), + Err(_) => failures.push(SessionCleanupFailure { + hook: hook.name, + error: "cleanup hook panicked".into(), + }), + } + } + + let mut all = cleanup_progress().lock().unwrap(); + let Some(progress) = all.get_mut(session_id) else { + return SessionCleanupReport { + complete: true, + in_progress: false, + failures: Vec::new(), + }; + }; + progress.completed.extend(successes); + progress.failures = failures; + progress.running = false; + let complete = progress.completed.len() == progress.hooks.len(); + let failures = progress.failures.clone(); + if complete { + all.remove(session_id); + } + SessionCleanupReport { + complete, + in_progress: false, + failures, + } +} + +/// Inspect cleanup progress without starting another attempt. +/// +/// Lifecycle tools use this after requesting an end so one public call runs +/// each unfinished hook at most once. A later explicit `end_session` call is +/// the bounded retry boundary for hooks that failed. +pub fn session_cleanup_status(session_id: &str) -> SessionCleanupReport { + let all = cleanup_progress().lock().unwrap(); + let Some(progress) = all.get(session_id) else { + return SessionCleanupReport { + complete: true, + in_progress: false, + failures: Vec::new(), + }; + }; + SessionCleanupReport { + complete: progress.completed.len() == progress.hooks.len(), + in_progress: progress.running, + failures: progress.failures.clone(), } - true } /// Revoke every currently tracked session. This is intentionally callable by @@ -490,9 +1297,13 @@ pub fn revoke_sessions_with_prefix(prefix: &str) -> usize { pub fn forget_ended_sessions_with_prefix(prefix: &str) -> usize { let mut ended = ended_sessions().lock().unwrap(); let before = ended.len(); - ended.retain(|session| !session.starts_with(prefix)); + ended.retain(|session, _| !session.starts_with(prefix)); let forgotten = before - ended.len(); drop(ended); + cleanup_progress() + .lock() + .unwrap() + .retain(|session, _| !session.starts_with(prefix)); crate::capture_scope::clear_sessions_with_prefix(prefix); forgotten } @@ -501,7 +1312,7 @@ pub fn forget_ended_sessions_with_prefix(prefix: &str) -> usize { /// daemon-side authority for "this session is permanently gone"; the macOS /// overlay keeps its own render-side tombstone keyed on the same id. pub fn is_session_ended(session_id: &str) -> bool { - ended_sessions().lock().unwrap().contains(session_id) + ended_sessions().lock().unwrap().contains_key(session_id) } /// Revive a previously-ended session id by clearing its tombstone, so a fresh @@ -518,7 +1329,61 @@ pub fn revive_session(session_id: &str) -> bool { if !is_trackable(session_id) { return false; } - ended_sessions().lock().unwrap().remove(session_id) + loop { + let mut ended = ended_sessions().lock().unwrap(); + if !ended.contains_key(session_id) { + return false; + } + if cleanup_progress().lock().unwrap().contains_key(session_id) { + drop(ended); + if !retry_session_cleanup(session_id).complete { + return false; + } + continue; + } + return ended.remove(session_id).is_some(); + } +} + +/// Owner-checked revival used by the public `start_session` tool. A public +/// label is never proof that a new transport owns a prior episode: only the +/// transport that ended the episode may revive it without a separate trusted +/// host resume proof. +pub fn revive_session_for_owner( + session_id: &str, + owner_transport: &str, +) -> Result { + if !is_trackable(session_id) { + return Ok(false); + } + + loop { + let mut ended = ended_sessions().lock().unwrap(); + let Some(ended_owner) = ended.get(session_id) else { + return Ok(false); + }; + match ended_owner.as_deref() { + Some(owner) if owner != owner_transport => { + return Err("session is not available to this transport"); + } + // Legacy tombstones did not retain ownership. Allow only the + // direct runtime shape where the trusted owner key and lifecycle + // id coincide; transport-backed callers fail closed. + None if owner_transport != session_id => { + return Err("session is not available to this transport"); + } + _ => {} + } + if cleanup_progress().lock().unwrap().contains_key(session_id) { + drop(ended); + if !retry_session_cleanup(session_id).complete { + return Err("session cleanup is incomplete; retry end_session before revival"); + } + continue; + } + ended.remove(session_id); + return Ok(true); + } } /// Record activity for an explicit runtime-private session id, resetting its @@ -563,7 +1428,24 @@ fn end_session_with_reason(session_id: &str, reason: SessionEndReason) { if !is_trackable(session_id) { return; } - activity().lock().unwrap().remove(session_id); + let defer = { + let mut records = lifecycle_records().lock().unwrap(); + match records.get_mut(session_id) { + Some(record) if record.in_flight > 0 => { + record.pending_end.get_or_insert(reason); + true + } + _ => false, + } + }; + if defer { + return; + } + finish_session_end(session_id, reason); +} + +fn finish_session_end(session_id: &str, reason: SessionEndReason) { + let first_fire = mark_session_ended(session_id, None); let mut cursor_readers = CURSOR_OUTCOME_READERS .get() .map(|readers| { @@ -587,7 +1469,8 @@ fn end_session_with_reason(session_id: &str, reason: SessionEndReason) { } }); let cursor = cursor.or(fallback); - if fire_session_end(session_id) { + let _ = retry_session_cleanup(session_id); + if first_fire { if let Some(observer) = SESSION_OBSERVER.get() { observer.on_session_ended(public_session_label(session_id), reason, cursor); } @@ -602,10 +1485,19 @@ fn end_session_with_reason(session_id: &str, reason: SessionEndReason) { /// TTL are left untouched. pub fn evict_idle(ttl: Duration) -> Vec { let now = Instant::now(); + let lifecycle = lifecycle_records() + .lock() + .unwrap() + .iter() + .map(|(id, record)| (id.clone(), (record.in_flight, record.idle_ttl))) + .collect::>(); let stale: Vec = { let map = activity().lock().unwrap(); map.iter() - .filter(|(_, last)| now.duration_since(**last) >= ttl) + .filter(|(id, last)| { + let (in_flight, session_ttl) = lifecycle.get(*id).copied().unwrap_or_default(); + now.duration_since(**last) >= session_ttl.unwrap_or(ttl) && in_flight == 0 + }) .map(|(id, _)| id.clone()) .collect() }; @@ -619,10 +1511,21 @@ pub fn evict_idle(ttl: Duration) -> Vec { /// the trusted runtime and never accepted from a tool argument. pub fn evict_idle_with_prefix(ttl: Duration, prefix: &str) -> Vec { let now = Instant::now(); + let lifecycle = lifecycle_records() + .lock() + .unwrap() + .iter() + .map(|(id, record)| (id.clone(), (record.in_flight, record.idle_ttl))) + .collect::>(); let stale: Vec = { let map = activity().lock().unwrap(); map.iter() - .filter(|(id, last)| id.starts_with(prefix) && now.duration_since(**last) >= ttl) + .filter(|(id, last)| { + let (in_flight, session_ttl) = lifecycle.get(*id).copied().unwrap_or_default(); + id.starts_with(prefix) + && now.duration_since(**last) >= session_ttl.unwrap_or(ttl) + && in_flight == 0 + }) .map(|(id, _)| id.clone()) .collect() }; @@ -641,7 +1544,7 @@ pub fn active_session_count() -> usize { mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; + use std::sync::{Arc, Barrier}; #[derive(Default)] struct ProbeObserver { @@ -661,6 +1564,7 @@ mod tests { _: &str, _: SessionTransport, _: bool, + _: Option, _: Option, _: &crate::server::ToolCompletionObservation, ) { @@ -761,11 +1665,11 @@ mod tests { let sid = "test-ttl-session-DDEEFF"; touch_session(sid); // A huge TTL leaves it alone (just touched). - assert!(evict_idle(Duration::from_secs(3600)) + assert!(evict_idle_with_prefix(Duration::from_secs(3600), sid) .iter() .all(|s| s != sid)); // A zero TTL treats any prior activity as idle → evicts it. - let evicted = evict_idle(Duration::ZERO); + let evicted = evict_idle_with_prefix(Duration::ZERO, sid); assert!( evicted.iter().any(|s| s == sid), "zero-TTL must evict a touched session" @@ -778,13 +1682,14 @@ mod tests { touch_session("default"); touch_session(""); // Neither shows up under a zero-TTL sweep (they were never inserted). - let evicted = evict_idle(Duration::ZERO); + let evicted = evict_idle_with_prefix(Duration::ZERO, "test-anonymous-never-matches"); assert!(!evicted.iter().any(|s| s == "default" || s.is_empty())); } #[test] fn cursor_outcomes_are_fixed_categories_without_raw_values() { - let custom = bounded_cursor_outcome(true, true, Some("private.customer.theme"), true, 7); + let custom = + bounded_cursor_outcome(true, true, true, Some("private.customer.theme"), true, 7); assert_eq!(custom.theme, CursorThemeCategory::Custom); assert!(custom.motion_customized); assert_eq!(custom.active_cursor_count, 7); @@ -795,9 +1700,10 @@ mod tests { "cursor outcome leaked {forbidden}: {debug}" ); - let unknown = bounded_cursor_outcome(false, true, Some("cua.default"), true, 0); + let unknown = bounded_cursor_outcome(false, true, true, Some("cua.default"), true, 0); assert_eq!(unknown.theme, CursorThemeCategory::Unknown); assert!(!unknown.enabled); + assert!(!unknown.visible); assert!(!unknown.motion_customized); } @@ -808,7 +1714,330 @@ mod tests { end_session(sid); assert!(is_session_ended(sid)); // Its TTL entry is gone, so a later sweep doesn't re-fire for it. - assert!(!evict_idle(Duration::ZERO).iter().any(|s| s == sid)); + assert!(!evict_idle_with_prefix(Duration::ZERO, sid) + .iter() + .any(|s| s == sid)); + } + + #[test] + fn lifecycle_identity_is_scoped_to_its_transport_owner() { + let sid = "test-owner-scope-session-A1B2C3"; + let owner_a = "test-owner-transport-a"; + let owner_b = "test-owner-transport-b"; + assert!(activate_session( + sid, + Some("shared-public-label"), + owner_a, + false, + SessionTransport::McpHttp, + SessionClientKind::Mcp, + ) + .is_ok()); + + assert!(session_snapshot(sid, owner_a, DEFAULT_SESSION_IDLE_TTL).is_some()); + assert!(session_snapshot(sid, owner_b, DEFAULT_SESSION_IDLE_TTL).is_none()); + assert!(activate_session( + sid, + Some("shared-public-label"), + owner_b, + false, + SessionTransport::McpHttp, + SessionClientKind::Mcp, + ) + .is_err()); + assert!(begin_session_dispatch( + sid, + Some("shared-public-label"), + owner_b, + false, + SessionTransport::McpHttp, + SessionClientKind::Mcp, + ) + .is_err()); + assert!(!end_session_for_owner(sid, owner_b)); + assert!(!is_session_ended(sid)); + + assert!(end_session_for_owner(sid, owner_a)); + assert!(is_session_ended(sid)); + assert!(revive_session_for_owner(sid, owner_b).is_err()); + assert!(is_session_ended(sid)); + assert_eq!(revive_session_for_owner(sid, owner_a), Ok(true)); + } + + #[test] + fn atomic_start_cannot_transfer_an_ended_label_to_another_transport() { + let sid = "test-atomic-owner-revival-B1C2D3"; + let owner_a = "test-atomic-owner-a"; + let owner_b = "test-atomic-owner-b"; + activate_session( + sid, + Some("recycled-label"), + owner_a, + false, + SessionTransport::McpHttp, + SessionClientKind::Mcp, + ) + .unwrap(); + assert!(end_session_for_owner(sid, owner_a)); + + assert!(activate_or_revive_session_for_owner( + sid, + Some("recycled-label"), + owner_b, + false, + SessionTransport::McpHttp, + SessionClientKind::Mcp, + None, + ) + .is_err()); + assert!(is_session_ended(sid)); + + assert_eq!( + activate_or_revive_session_for_owner( + sid, + Some("recycled-label"), + owner_a, + false, + SessionTransport::McpHttp, + SessionClientKind::Mcp, + None, + ), + Ok(true) + ); + assert!(session_snapshot(sid, owner_a, DEFAULT_SESSION_IDLE_TTL).is_some()); + assert!(session_snapshot(sid, owner_b, DEFAULT_SESSION_IDLE_TTL).is_none()); + assert!(end_session_for_owner(sid, owner_a)); + } + + #[test] + fn foreign_revival_does_not_run_an_owners_pending_cleanup() { + let sid = "test-owner-cleanup-scope-B2C3D4"; + let owner_a = "test-owner-cleanup-transport-a"; + let owner_b = "test-owner-cleanup-transport-b"; + let cleanup_calls = Arc::new(AtomicUsize::new(0)); + let calls_for_hook = cleanup_calls.clone(); + let _registration = + register_scoped_fallible_session_end_hook("owner-cleanup-test", move |ended| { + if ended != sid { + return Ok(()); + } + let call = calls_for_hook.fetch_add(1, Ordering::SeqCst); + if call == 0 { + Err("retry me".into()) + } else { + Ok(()) + } + }); + + activate_session( + sid, + Some("owner-cleanup"), + owner_a, + false, + SessionTransport::McpHttp, + SessionClientKind::Mcp, + ) + .unwrap(); + assert!(end_session_for_owner(sid, owner_a)); + assert_eq!(cleanup_calls.load(Ordering::SeqCst), 1); + + assert!(revive_session_for_owner(sid, owner_b).is_err()); + assert_eq!( + cleanup_calls.load(Ordering::SeqCst), + 1, + "a foreign caller must not trigger cleanup callbacks" + ); + + assert_eq!(revive_session_for_owner(sid, owner_a), Ok(true)); + assert_eq!(cleanup_calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn concurrent_first_dispatches_share_one_lifecycle_record() { + let sid = "test-concurrent-first-session-D4E5F6"; + let owner = "test-concurrent-first-owner"; + let barrier = Arc::new(Barrier::new(9)); + let mut workers = Vec::new(); + for _ in 0..8 { + let barrier = barrier.clone(); + workers.push(std::thread::spawn(move || { + barrier.wait(); + let guard = begin_session_dispatch( + sid, + None, + owner, + true, + SessionTransport::McpStdio, + SessionClientKind::Mcp, + ) + .expect("same owner may join its implicit lifecycle"); + barrier.wait(); + drop(guard); + })); + } + barrier.wait(); + barrier.wait(); + assert_eq!( + list_session_snapshots(owner, DEFAULT_SESSION_IDLE_TTL) + .into_iter() + .filter(|snapshot| snapshot.runtime_id == sid) + .count(), + 1 + ); + for worker in workers { + worker.join().unwrap(); + } + assert!(end_session_for_owner(sid, owner)); + } + + #[test] + fn end_waits_for_in_flight_dispatch_and_cleanup_runs_once() { + let sid = "test-in-flight-end-session-F7A8B9"; + let owner = "test-in-flight-end-owner"; + let cleanup_calls = Arc::new(AtomicUsize::new(0)); + let cleanup_calls_for_hook = cleanup_calls.clone(); + let _registration = register_scoped_session_end_hook(move |ended| { + if ended == sid { + cleanup_calls_for_hook.fetch_add(1, Ordering::SeqCst); + } + }); + let guard = begin_session_dispatch( + sid, + None, + owner, + true, + SessionTransport::McpStdio, + SessionClientKind::Mcp, + ) + .unwrap(); + + activate_session( + sid, + None, + owner, + true, + SessionTransport::McpStdio, + SessionClientKind::Mcp, + ) + .expect("idempotent start must preserve the live dispatch record"); + + assert!(end_session_for_owner(sid, owner)); + let snapshot = session_snapshot(sid, owner, DEFAULT_SESSION_IDLE_TTL).unwrap(); + assert!(snapshot.ending); + assert!(!is_session_ended(sid)); + assert!(!evict_idle_with_prefix(Duration::ZERO, sid) + .iter() + .any(|id| id == sid)); + + drop(guard); + assert!(is_session_ended(sid)); + assert_eq!(cleanup_calls.load(Ordering::SeqCst), 1); + assert!(end_session_for_owner(sid, owner)); + assert_eq!(cleanup_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn trusted_idle_ttl_override_controls_snapshot_and_eviction() { + let sid = "test-trusted-lifecycle-ttl-A9B0C1"; + let owner = "test-trusted-lifecycle-ttl-owner"; + activate_session_with_ttl( + sid, + Some("trusted-ttl"), + owner, + false, + SessionTransport::Daemon, + SessionClientKind::PythonSdk, + Duration::from_millis(1), + ) + .unwrap(); + let snapshot = session_snapshot(sid, owner, Duration::from_secs(3600)).unwrap(); + assert!(snapshot.expires_in <= Duration::from_millis(1)); + let operator_snapshot = list_session_snapshots_with_prefix( + "test-trusted-lifecycle-ttl-", + Duration::from_secs(3600), + ) + .into_iter() + .find(|snapshot| snapshot.runtime_id == sid) + .unwrap(); + assert!(operator_snapshot.expires_in <= Duration::from_millis(1)); + + std::thread::sleep(Duration::from_millis(5)); + assert_eq!( + evict_idle_with_prefix(Duration::from_secs(3600), sid), + vec![sid.to_owned()], + "the trusted per-session TTL must override the runtime fallback" + ); + } + + #[test] + fn partial_cleanup_retries_only_failed_hooks_then_allows_revival() { + let sid = "test-partial-cleanup-retry-C8D9E0"; + let retryable_calls = Arc::new(AtomicUsize::new(0)); + let retryable_for_hook = retryable_calls.clone(); + let _retryable = + register_scoped_fallible_session_end_hook("retryable-test-hook", move |ended| { + if ended != sid { + return Ok(()); + } + let attempt = retryable_for_hook.fetch_add(1, Ordering::SeqCst); + if attempt == 0 { + Err("synthetic first-attempt failure".into()) + } else { + Ok(()) + } + }); + let successful_calls = Arc::new(AtomicUsize::new(0)); + let successful_for_hook = successful_calls.clone(); + let _successful = register_scoped_session_end_hook(move |ended| { + if ended == sid { + successful_for_hook.fetch_add(1, Ordering::SeqCst); + } + }); + + touch_session(sid); + end_session(sid); + assert!(is_session_ended(sid)); + assert_eq!(retryable_calls.load(Ordering::SeqCst), 1); + assert_eq!(successful_calls.load(Ordering::SeqCst), 1); + + let report = retry_session_cleanup(sid); + assert!(report.complete); + assert!(report.failures.is_empty()); + assert_eq!(retryable_calls.load(Ordering::SeqCst), 2); + assert_eq!( + successful_calls.load(Ordering::SeqCst), + 1, + "successful cleanup hooks must not run twice" + ); + assert!(revive_session(sid)); + } + + #[test] + fn tombstone_publishes_cleanup_progress_before_it_becomes_visible() { + let sid = "test-atomic-cleanup-progress-D9E0F1"; + let cleanup_calls = Arc::new(AtomicUsize::new(0)); + let calls_for_hook = cleanup_calls.clone(); + let _registration = + register_scoped_fallible_session_end_hook("atomic-progress-test", move |ended| { + if ended == sid { + calls_for_hook.fetch_add(1, Ordering::SeqCst); + return Err("synthetic cleanup failure".into()); + } + Ok(()) + }); + + assert!(mark_session_ended(sid, None)); + let pending = session_cleanup_status(sid); + assert!( + !pending.complete, + "a visible tombstone must never look fully cleaned before hooks run" + ); + assert_eq!(cleanup_calls.load(Ordering::SeqCst), 0); + + let attempted = retry_session_cleanup(sid); + assert!(!attempted.complete); + assert_eq!(cleanup_calls.load(Ordering::SeqCst), 1); + forget_ended_sessions_with_prefix(sid); } #[test] @@ -857,7 +2086,7 @@ mod tests { } #[test] - fn tool_context_requires_a_public_session_and_uses_fixed_action_classes() { + fn tool_context_accepts_trusted_implicit_session_and_uses_fixed_action_classes() { let _ = probe_observer(); assert!(begin_tool_call( "click", @@ -866,7 +2095,7 @@ mod tests { SessionTransport::McpStdio, SessionClientKind::Mcp, ) - .is_none()); + .is_some()); assert!(begin_tool_call( "click", &serde_json::json!({"session": "default"}), @@ -941,13 +2170,32 @@ mod tests { escalation.escalation_reason, Some(EscalationReason::NoWindowTarget) ); + + assert_eq!( + capture_modality_for( + "click", + &serde_json::json!({ + "target": {"kind": "window", "pid": 7, "window_id": 9} + }), + ), + Some(CaptureModality::Window) + ); + assert_eq!( + capture_modality_for( + "click", + &serde_json::json!({ + "target": {"kind": "desktop", "display_id": "primary"} + }), + ), + Some(CaptureModality::Desktop) + ); } #[test] fn observer_distinguishes_explicit_idle_revival_and_control_cleanup() { let probe = probe_observer(); let _ = set_cursor_outcome_reader(Arc::new(|_| { - bounded_cursor_outcome(true, true, Some("cua.default"), false, 2) + bounded_cursor_outcome(true, true, true, Some("cua.default"), false, 2) })); let explicit = "test-observer-explicit-IJ90"; begin_tool_call( diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/session_authorization.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/session_authorization.rs index 839e92256b..f4563e310b 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/session_authorization.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/session_authorization.rs @@ -180,7 +180,7 @@ pub struct EffectiveAuthorizationContext { public_session: Option, transport_session: Option, host_lease: Option, - bounded_manifest: Option>, + capability_manifest: Option>, user_policy_sha256: Option, managed_policy_sha256: Option, expires_unix_ms: Option, @@ -219,15 +219,23 @@ impl EffectiveAuthorizationContext { } pub fn bounded_manifest(&self) -> Option<&SessionManifest> { - self.bounded_manifest.as_deref() + self.capability_manifest.as_deref() + } + + pub fn capability_manifest(&self) -> Option<&SessionManifest> { + self.capability_manifest.as_deref() } pub fn bounded_manifest_sha256(&self) -> Option<&str> { - self.bounded_manifest + self.capability_manifest .as_deref() .map(SessionManifest::sha256) } + pub fn capability_manifest_sha256(&self) -> Option<&str> { + self.bounded_manifest_sha256() + } + pub fn public_session(&self) -> Option<&str> { self.public_session.as_deref() } @@ -241,6 +249,15 @@ impl EffectiveAuthorizationContext { self.transport_session.as_deref() } + /// Lifecycle idle TTL selected by a trusted delegated-session host. An + /// ordinary compatibility context uses the product default instead. + #[doc(hidden)] + pub fn lifecycle_idle_ttl_override(&self) -> Option { + (self.source == AuthorizationContextSource::TrustedHost) + .then_some(self.idle_ttl) + .flatten() + } + #[doc(hidden)] pub fn user_policy_sha256(&self) -> Option<&str> { self.user_policy_sha256.as_deref() @@ -278,7 +295,7 @@ impl EffectiveAuthorizationContext { false } - pub fn authorize_dispatch(&self) -> Result<(), String> { + fn commit_context_dispatch(&self) -> Result<(), String> { if self.revoked.load(Ordering::Acquire) { return Err("authorization context has been revoked".to_owned()); } @@ -301,6 +318,28 @@ impl EffectiveAuthorizationContext { *previous = now; Ok(()) } + + /// Commit one dispatch after tool admission and every typed-resource + /// check have succeeded. A rejected resource must not refresh either + /// lifetime lease. + pub fn commit_authorized_dispatch(&self) -> Result<(), String> { + if self.is_expired() { + return Err("authorization context expired".to_owned()); + } + if let Some(manifest) = self.capability_manifest() { + manifest.check_lifetime()?; + } + self.commit_context_dispatch()?; + if let Some(manifest) = self.capability_manifest() { + manifest.commit_authorized_dispatch()?; + } + Ok(()) + } + + /// Compatibility alias for callers that commit a fully admitted dispatch. + pub fn authorize_dispatch(&self) -> Result<(), String> { + self.commit_authorized_dispatch() + } } pub struct DelegatedSessionRequest { @@ -309,7 +348,7 @@ pub struct DelegatedSessionRequest { pub mode: PermissionMode, pub ttl: Duration, pub idle_ttl: Duration, - pub bounded_manifest: Option>, + pub capability_manifest: Option>, } impl std::fmt::Debug for AuthenticatedActionConnection { @@ -338,7 +377,10 @@ impl std::fmt::Debug for EffectiveAuthorizationContext { .field("mode", &self.mode) .field("sessions", &"[redacted]") .field("host_lease", &self.host_lease.is_some()) - .field("bounded_manifest_sha256", &self.bounded_manifest_sha256()) + .field( + "capability_manifest_sha256", + &self.capability_manifest_sha256(), + ) .field("user_policy_bound", &self.user_policy_sha256.is_some()) .field( "managed_policy_bound", @@ -358,9 +400,9 @@ impl std::fmt::Debug for DelegatedSessionRequest { .field("ttl", &self.ttl) .field("idle_ttl", &self.idle_ttl) .field( - "bounded_manifest_sha256", + "capability_manifest_sha256", &self - .bounded_manifest + .capability_manifest .as_deref() .map(SessionManifest::sha256), ) @@ -394,10 +436,13 @@ pub enum SessionAuthorizationError { Expired, #[error("session and idle TTLs must be non-zero and within the daemon ceiling")] InvalidTtl, - #[error("bounded mode requires an immutable per-session manifest")] + #[error("bounded mode requires an immutable capability manifest")] MissingBoundedManifest, - #[error("a bounded manifest is valid only for bounded mode")] + #[deprecated(note = "capability manifests are now valid in every permission profile")] + #[error("the capability manifest is unexpected")] UnexpectedBoundedManifest, + #[error("the capability manifest is invalid for the selected permission mode")] + InvalidManifest, } #[derive(Debug, Default)] @@ -475,14 +520,10 @@ impl SessionAuthorizationRegistry { pub fn legacy_context(&self) -> Result, String> { let mode = crate::authorization::configured_permission_mode()?; - let bounded_manifest = if mode == PermissionMode::Bounded { - crate::session_manifest::configured_session_manifest()? - .cloned() - .map(Arc::new) - } else { - None - }; - self.compatibility_context(mode, bounded_manifest) + let capability_manifest = crate::session_manifest::configured_capability_manifest()? + .cloned() + .map(Arc::new); + self.compatibility_context(mode, capability_manifest) } /// Construct the compatibility action context from explicit trusted @@ -490,21 +531,16 @@ impl SessionAuthorizationRegistry { pub fn compatibility_context( &self, mode: PermissionMode, - bounded_manifest: Option>, + capability_manifest: Option>, ) -> Result, String> { if !self.ceiling.allows(mode) { return Err("compatibility permission mode is outside the runtime ceiling".to_owned()); } - match (mode, bounded_manifest.as_ref()) { - (PermissionMode::Bounded, None) => { - return Err("bounded compatibility mode requires a session manifest".to_owned()) - } - (PermissionMode::Standard | PermissionMode::Unrestricted, Some(_)) => { - return Err( - "a compatibility session manifest is valid only for bounded mode".to_owned(), - ) - } - _ => {} + if mode == PermissionMode::Bounded && capability_manifest.is_none() { + return Err("bounded compatibility mode requires a capability manifest".to_owned()); + } + if let Some(manifest) = capability_manifest.as_ref() { + manifest.validate_for_mode(mode)?; } Ok(Arc::new(EffectiveAuthorizationContext { daemon_generation: self.daemon_generation, @@ -513,7 +549,7 @@ impl SessionAuthorizationRegistry { public_session: None, transport_session: None, host_lease: None, - bounded_manifest, + capability_manifest, user_policy_sha256: self.ceiling.user_policy_sha256.clone(), managed_policy_sha256: self.ceiling.managed_policy_sha256.clone(), expires_unix_ms: None, @@ -562,14 +598,15 @@ impl SessionAuthorizationRegistry { { return Err(SessionAuthorizationError::InvalidTtl); } - match (request.mode, request.bounded_manifest.as_ref()) { - (PermissionMode::Bounded, None) => { - return Err(SessionAuthorizationError::MissingBoundedManifest) - } - (PermissionMode::Standard | PermissionMode::Unrestricted, Some(_)) => { - return Err(SessionAuthorizationError::UnexpectedBoundedManifest) - } - _ => {} + if request.mode == PermissionMode::Bounded && request.capability_manifest.is_none() { + return Err(SessionAuthorizationError::MissingBoundedManifest); + } + if request + .capability_manifest + .as_ref() + .is_some_and(|manifest| manifest.validate_for_mode(request.mode).is_err()) + { + return Err(SessionAuthorizationError::InvalidManifest); } let mut state = self.state.lock().unwrap(); state.by_connection.retain(|_, context| { @@ -595,7 +632,7 @@ impl SessionAuthorizationRegistry { public_session: Some(request.public_session), transport_session: Some(request.transport_session), host_lease: Some(host.id), - bounded_manifest: request.bounded_manifest, + capability_manifest: request.capability_manifest, user_policy_sha256: self.ceiling.user_policy_sha256.clone(), managed_policy_sha256: self.ceiling.managed_policy_sha256.clone(), expires_unix_ms: Some(now_unix_ms() + request.ttl.as_millis()), @@ -791,7 +828,7 @@ mod tests { mode, ttl: Duration::from_secs(60), idle_ttl: Duration::from_secs(30), - bounded_manifest: None, + capability_manifest: None, } } @@ -1088,7 +1125,7 @@ mod tests { } #[test] - fn bounded_manifests_are_stored_per_connection_context() { + fn capability_manifests_are_stored_per_connection_context() { let registry = SessionAuthorizationRegistry::delegated_for_test( SessionModeCeiling::delegated([PermissionMode::Bounded], false), ); @@ -1100,7 +1137,7 @@ mod tests { let hash_b = manifest_b.sha256().to_owned(); let mut request_a = request(PermissionMode::Bounded); - request_a.bounded_manifest = Some(manifest_a); + request_a.capability_manifest = Some(manifest_a); registry .bind_delegated_session(&host, &connection_a, request_a) .unwrap(); @@ -1108,7 +1145,7 @@ mod tests { let mut request_b = request(PermissionMode::Bounded); request_b.public_session = "public-b".to_owned(); request_b.transport_session = "transport-b".to_owned(); - request_b.bounded_manifest = Some(manifest_b); + request_b.capability_manifest = Some(manifest_b); registry .bind_delegated_session(&host, &connection_b, request_b) .unwrap(); @@ -1118,24 +1155,28 @@ mod tests { registry .resolve_delegated(&connection_a, "public-a", "transport-a") .unwrap() - .bounded_manifest_sha256(), + .capability_manifest_sha256(), Some(hash_a.as_str()) ); assert_eq!( registry .resolve_delegated(&connection_b, "public-b", "transport-b") .unwrap() - .bounded_manifest_sha256(), + .capability_manifest_sha256(), Some(hash_b.as_str()) ); } #[test] - fn manifest_presence_matches_the_exact_mode() { + fn capability_manifest_is_optional_except_in_bounded_mode() { let registry = SessionAuthorizationRegistry::delegated_for_test(SessionModeCeiling::delegated( - [PermissionMode::Standard, PermissionMode::Bounded], - false, + [ + PermissionMode::Standard, + PermissionMode::Bounded, + PermissionMode::Unrestricted, + ], + true, )); let (host, bounded_connection) = registry.trusted_pair_for_test(None); assert_eq!( @@ -1151,13 +1192,33 @@ mod tests { let (_, standard_connection) = registry.trusted_pair_for_test(Some(&host)); let mut standard = request(PermissionMode::Standard); - standard.bounded_manifest = Some(manifest_with_allowed_tool("click")); - assert_eq!( - registry - .bind_delegated_session(&host, &standard_connection, standard) - .unwrap_err(), - SessionAuthorizationError::UnexpectedBoundedManifest - ); + standard.capability_manifest = Some(manifest_with_allowed_tool("click")); + registry + .bind_delegated_session(&host, &standard_connection, standard) + .unwrap(); + assert!(registry + .resolve_delegated(&standard_connection, "public-a", "transport-a") + .unwrap() + .capability_manifest() + .is_some()); + + let (_, unrestricted_connection) = registry.trusted_pair_for_test(Some(&host)); + let mut unrestricted = request(PermissionMode::Unrestricted); + unrestricted.public_session = "public-unrestricted".to_owned(); + unrestricted.transport_session = "transport-unrestricted".to_owned(); + unrestricted.capability_manifest = Some(manifest_with_allowed_tool("click")); + registry + .bind_delegated_session(&host, &unrestricted_connection, unrestricted) + .unwrap(); + assert!(registry + .resolve_delegated( + &unrestricted_connection, + "public-unrestricted", + "transport-unrestricted", + ) + .unwrap() + .capability_manifest() + .is_some()); } #[test] diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/session_manifest.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/session_manifest.rs index 2c80907f61..9956593665 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/session_manifest.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/session_manifest.rs @@ -1,4 +1,4 @@ -//! Immutable bounded-autonomy manifest loaded at trusted daemon startup. +//! Immutable capability manifest loaded at trusted daemon startup. //! //! The agent may propose this file, but selecting and approving it is a //! launcher responsibility. The manifest only narrows the built-in, @@ -17,6 +17,8 @@ use sha2::{Digest, Sha256}; pub const SESSION_POLICY_FILE_ENV: &str = "CUA_DRIVER_SESSION_POLICY_FILE"; pub const SESSION_POLICY_APPROVED_ENV: &str = "CUA_DRIVER_SESSION_POLICY_APPROVED"; +pub const CAPABILITY_MANIFEST_FILE_ENV: &str = "CUA_DRIVER_CAPABILITY_MANIFEST_FILE"; +pub const CAPABILITY_MANIFEST_APPROVED_ENV: &str = "CUA_DRIVER_CAPABILITY_MANIFEST_APPROVED"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ManifestDecision { @@ -30,8 +32,8 @@ pub enum ManifestDecision { pub struct SessionManifest { version: u32, sha256: String, - expires_unix_ms: u128, - idle_timeout: Duration, + expires_unix_ms: Option, + idle_timeout: Option, allow: HashSet, deny: HashSet, ask: HashSet, @@ -84,11 +86,11 @@ impl SessionManifest { &self.sha256 } - pub fn expires_unix_ms(&self) -> u128 { + pub fn expires_unix_ms(&self) -> Option { self.expires_unix_ms } - pub fn idle_timeout(&self) -> Duration { + pub fn idle_timeout(&self) -> Option { self.idle_timeout } @@ -134,7 +136,7 @@ impl SessionManifest { && !self.existing_profiles.contains(&(pid, window_id)) { return Err(format!( - "browser pid {pid} window {window_id} is outside the bounded session policy" + "browser pid {pid} window {window_id} is outside the capability manifest" )); } } @@ -156,12 +158,12 @@ impl SessionManifest { if self.browser_origins.contains(&origin) { Ok(()) } else { - Err("the live browser origin is outside the bounded session policy".to_owned()) + Err("the live browser origin is outside the capability manifest".to_owned()) } } /// Match the implementation-attested resource of an active adapter - /// against the immutable bounded manifest. A tool allow-list entry alone + /// against the immutable capability manifest. A tool allow-list entry alone /// never widens the resources approved at launch. pub fn authorize_protected_resource( &self, @@ -174,7 +176,7 @@ impl SessionManifest { .ok_or_else(|| format!("{adapter_id} did not attest a resource kind"))?; let refused = || { Err(format!( - "{adapter_id} resource kind '{kind}' is outside the bounded session policy" + "{adapter_id} resource kind '{kind}' is outside the capability manifest" )) }; match adapter_id { @@ -182,7 +184,7 @@ impl SessionManifest { "window" => self.authorize_desktop_window(resource), "application" => self.authorize_desktop_application(resource), "display" => self.desktop_display.then_some(()).ok_or_else(|| { - "desktop display observation is outside the bounded session policy".to_owned() + "desktop display observation is outside the capability manifest".to_owned() }), "browser_target" | "authenticated_browser_tab" => { self.authorize_resource_origin(resource) @@ -193,7 +195,7 @@ impl SessionManifest { // directly instead of keying enforcement on a field the tool // can never send. "session_capture_scope" => self.desktop_display.then_some(()).ok_or_else(|| { - "desktop capture escalation is outside the bounded session policy".to_owned() + "desktop capture escalation is outside the capability manifest".to_owned() }), "session_trajectory_recording" => Ok(()), _ => refused(), @@ -202,7 +204,7 @@ impl SessionManifest { "window_input" => self.authorize_desktop_window(resource), "application_input" => self.authorize_desktop_application(resource), "display_input" => self.desktop_display.then_some(()).ok_or_else(|| { - "desktop-wide input is outside the bounded session policy".to_owned() + "desktop-wide input is outside the capability manifest".to_owned() }), _ => refused(), }, @@ -240,7 +242,7 @@ impl SessionManifest { Ok(()) } else { Err(format!( - "process pid {pid} is outside the bounded session policy" + "process pid {pid} is outside the capability manifest" )) } } @@ -260,7 +262,7 @@ impl SessionManifest { }) { return Err(format!( - "driver configuration change '{key}' is outside the bounded session policy" + "driver configuration change '{key}' is outside the capability manifest" )); } } @@ -286,7 +288,7 @@ impl SessionManifest { Ok(()) } else { Err(format!( - "desktop pid {pid} window {window_id} is outside the bounded session policy" + "desktop pid {pid} window {window_id} is outside the capability manifest" )) } } @@ -306,7 +308,7 @@ impl SessionManifest { Ok(()) } else { Err(format!( - "desktop application pid {pid} is outside the bounded session policy" + "desktop application pid {pid} is outside the capability manifest" )) } } @@ -347,7 +349,7 @@ impl SessionManifest { if self.browser_origins.contains(origin) { Ok(()) } else { - Err("the live browser origin is outside the bounded session policy".to_owned()) + Err("the live browser origin is outside the capability manifest".to_owned()) } } @@ -376,10 +378,7 @@ impl SessionManifest { ) { Ok(()) } else { - Err( - "one or more upload paths are outside the bounded session policy" - .to_owned(), - ) + Err("one or more upload paths are outside the capability manifest".to_owned()) } } "trajectory_replay" => self.authorize_exact_path( @@ -411,7 +410,7 @@ impl SessionManifest { // reviewed bounded decision. "dependency_install" => Ok(()), _ => Err(format!( - "file resource kind '{kind}' is outside the bounded session policy" + "file resource kind '{kind}' is outside the capability manifest" )), } } @@ -430,34 +429,75 @@ impl SessionManifest { if path_allowed(path, allowed, roots) { Ok(()) } else { - Err("the exact path is outside the bounded session policy".to_owned()) + Err("the exact path is outside the capability manifest".to_owned()) } } pub fn is_expired(&self) -> bool { - self.expires_unix_ms < now_unix_ms() + self.expires_unix_ms + .is_some_and(|expires_unix_ms| expires_unix_ms < now_unix_ms()) } pub fn is_idle_expired(&self) -> bool { self.idle_expired.load(Ordering::Acquire) } - /// Refresh the bounded-mode idle lease only after a call passed every + /// Check the optional idle lease without refreshing it. + pub fn check_lifetime(&self) -> Result<(), String> { + if self.is_expired() { + return Err("capability manifest expired".to_owned()); + } + let Some(idle_timeout) = self.idle_timeout else { + return Ok(()); + }; + if self.is_idle_expired() { + return Err("capability manifest idle timeout exceeded".to_owned()); + } + let last = self.last_authorized_dispatch.lock().unwrap(); + if Instant::now().duration_since(*last) > idle_timeout { + self.idle_expired.store(true, Ordering::Release); + return Err("capability manifest idle timeout exceeded".to_owned()); + } + Ok(()) + } + + /// Refresh the capability-manifest idle lease only after a call passed every /// manifest decision and resource check. Once the idle lease expires it is /// terminal for this daemon instance; a tool call cannot revive it. - pub fn authorize_dispatch(&self) -> Result<(), String> { - if self.is_idle_expired() { - return Err("bounded session policy idle timeout exceeded".to_owned()); - } + pub fn commit_authorized_dispatch(&self) -> Result<(), String> { + self.check_lifetime()?; + let Some(idle_timeout) = self.idle_timeout else { + return Ok(()); + }; let now = Instant::now(); let mut last = self.last_authorized_dispatch.lock().unwrap(); - if now.duration_since(*last) > self.idle_timeout { + if now.duration_since(*last) > idle_timeout { self.idle_expired.store(true, Ordering::Release); - return Err("bounded session policy idle timeout exceeded".to_owned()); + return Err("capability manifest idle timeout exceeded".to_owned()); } *last = now; Ok(()) } + + /// Compatibility alias retained for downstream callers during migration. + pub fn authorize_dispatch(&self) -> Result<(), String> { + self.commit_authorized_dispatch() + } + + pub fn validate_for_mode( + &self, + mode: crate::authorization::PermissionMode, + ) -> Result<(), String> { + if mode == crate::authorization::PermissionMode::Bounded + && (self.expires_unix_ms.is_none() || self.idle_timeout.is_none()) + { + return Err( + "bounded mode requires capability manifest expires_after and idle_timeout" + .to_owned(), + ); + } + self.check_lifetime() + } } #[cfg(feature = "yaml")] @@ -465,9 +505,12 @@ impl SessionManifest { #[serde(deny_unknown_fields)] struct RawManifest { version: u32, - mode: String, - expires_after: String, - idle_timeout: String, + #[serde(default)] + mode: Option, + #[serde(default)] + expires_after: Option, + #[serde(default)] + idle_timeout: Option, #[serde(default)] resources: RawResources, #[serde(default)] @@ -475,7 +518,7 @@ struct RawManifest { #[serde(default)] deny: RawToolSet, #[serde(default)] - ask: RawToolSet, + ask: Option, } #[cfg(feature = "yaml")] @@ -622,26 +665,66 @@ pub fn configured_session_manifest() -> Result, } } +pub fn configured_capability_manifest() -> Result, String> { + configured_session_manifest() +} + +pub fn capability_manifest_configured() -> bool { + std::env::var_os(CAPABILITY_MANIFEST_FILE_ENV).is_some() + || std::env::var_os(SESSION_POLICY_FILE_ENV).is_some() +} + +pub fn capability_manifest_approved() -> bool { + [ + CAPABILITY_MANIFEST_APPROVED_ENV, + SESSION_POLICY_APPROVED_ENV, + ] + .into_iter() + .any(|name| { + std::env::var(name).is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + }) +} + fn load_configured_manifest() -> Result, String> { - let Some(path) = std::env::var_os(SESSION_POLICY_FILE_ENV) else { + let capability_path = std::env::var_os(CAPABILITY_MANIFEST_FILE_ENV); + let legacy_path = std::env::var_os(SESSION_POLICY_FILE_ENV); + if capability_path.is_some() && legacy_path.is_some() && capability_path != legacy_path { + return Err(format!( + "{CAPABILITY_MANIFEST_FILE_ENV} conflicts with deprecated {SESSION_POLICY_FILE_ENV}" + )); + } + let Some(path) = capability_path.or(legacy_path) else { return Ok(None); }; load_manifest(Path::new(&path)).map(Some) } -/// Load and validate an immutable bounded-session manifest for a trusted +/// Load and validate an immutable capability manifest for a trusted /// runtime/session constructor. pub fn load_manifest(path: &Path) -> Result { - let bytes = std::fs::read(path) - .map_err(|error| format!("failed to read session policy {}: {error}", path.display()))?; + let bytes = std::fs::read(path).map_err(|error| { + format!( + "failed to read capability manifest {}: {error}", + path.display() + ) + })?; if bytes.is_empty() { - return Err(format!("session policy {} is empty", path.display())); + return Err(format!("capability manifest {} is empty", path.display())); } #[cfg(feature = "yaml")] - let raw: RawManifest = serde_yaml_ng::from_slice(&bytes) - .map_err(|error| format!("failed to parse session policy {}: {error}", path.display()))?; + let raw: RawManifest = serde_yaml_ng::from_slice(&bytes).map_err(|error| { + format!( + "failed to parse capability manifest {}: {error}", + path.display() + ) + })?; #[cfg(not(feature = "yaml"))] - return Err("session policy support requires the yaml feature".to_owned()); + return Err("capability manifest support requires the yaml feature".to_owned()); #[cfg(feature = "yaml")] { @@ -684,42 +767,70 @@ pub fn load_manifest(path: &Path) -> Result { changes: raw_configuration_changes, } = driver_configuration; - if !matches!(version, 1 | 2) { + if !matches!(version, 1..=3) { return Err(format!( - "unsupported session policy version {}; expected 1 or 2", + "unsupported capability manifest version {}; expected 1, 2, or 3", version )); } - if !matches!(mode.as_str(), "bounded" | "autonomous") { - return Err("session policy mode must be bounded".to_owned()); + if version < 3 { + if !mode + .as_deref() + .is_some_and(|mode| matches!(mode, "bounded" | "autonomous")) + { + return Err("legacy capability manifest mode must be bounded".to_owned()); + } + } else if mode.is_some() { + return Err("capability manifest version 3 must not declare mode".to_owned()); } - let expires_after = parse_duration(&expires_after)?; - let idle_timeout = parse_duration(&idle_timeout)?; - if expires_after.is_zero() || expires_after > Duration::from_secs(24 * 60 * 60) { - return Err("expires_after must be greater than zero and no more than 24h".to_owned()); - } - if idle_timeout.is_zero() || idle_timeout > expires_after { + if version < 3 && (expires_after.is_none() || idle_timeout.is_none()) { return Err( - "idle_timeout must be greater than zero and no more than expires_after".to_owned(), + "legacy capability manifests require expires_after and idle_timeout".to_owned(), ); } + let expires_after = expires_after.as_deref().map(parse_duration).transpose()?; + let idle_timeout = idle_timeout.as_deref().map(parse_duration).transpose()?; + if expires_after.is_some_and(|duration| { + duration.is_zero() || duration > Duration::from_secs(24 * 60 * 60) + }) { + return Err("expires_after must be greater than zero and no more than 24h".to_owned()); + } + if idle_timeout.is_some_and(|idle| { + idle.is_zero() || expires_after.is_some_and(|expires| idle > expires) + }) { + return Err("idle_timeout must be greater than zero and no more than expires_after when both are declared".to_owned()); + } let allow = validate_tools("allow", allow.tools)?; - let deny = validate_tools("deny", deny.tools)?; - let ask = validate_tools("ask", ask.tools)?; + let mut deny = validate_tools("deny", deny.tools)?; + let legacy_ask = if version < 3 { + validate_tools("ask", ask.unwrap_or_default().tools)? + } else if ask.is_some() { + return Err("capability manifest version 3 does not support ask.tools".to_owned()); + } else { + HashSet::new() + }; + for tool in legacy_ask { + if !deny.insert(tool.clone()) { + return Err(format!( + "capability manifest tool '{tool}' appears in more than one decision set" + )); + } + } + let ask = HashSet::new(); if allow.is_empty() { - return Err("session policy allow.tools must not be empty".to_owned()); + return Err("capability manifest allow.tools must not be empty".to_owned()); } for tool in &allow { if deny.contains(tool) || ask.contains(tool) { return Err(format!( - "session policy tool '{tool}' appears in more than one decision set" + "capability manifest tool '{tool}' appears in more than one decision set" )); } } for tool in &deny { if ask.contains(tool) { return Err(format!( - "session policy tool '{tool}' appears in more than one decision set" + "capability manifest tool '{tool}' appears in more than one decision set" )); } } @@ -739,7 +850,9 @@ pub fn load_manifest(path: &Path) -> Result { } } if version == 1 && !raw_browser_profiles.is_empty() { - return Err("browser profiles require session policy version 2".to_owned()); + return Err( + "browser profiles require capability manifest version 2 or later".to_owned(), + ); } let mut existing_profile_kind = false; for profile in raw_browser_profiles { @@ -769,7 +882,8 @@ pub fn load_manifest(path: &Path) -> Result { validate_positive_pids("desktop applications", raw_desktop_applications)?; if version == 1 && !raw_applications.is_empty() { return Err( - "application identity resources require session policy version 2".to_owned(), + "application identity resources require capability manifest version 2 or later" + .to_owned(), ); } let applications = validate_applications(raw_applications)?; @@ -836,18 +950,23 @@ pub fn load_manifest(path: &Path) -> Result { .find(|tool| allow.contains(**tool)) { return Err(format!( - "origin-scoped bounded manifests cannot allow '{tool}' because it bypasses the typed browser origin adapter" + "origin-scoped capability manifests cannot allow '{tool}' because it bypasses the typed browser origin adapter" )); } } let mut digest = Sha256::new(); - digest.update(format!("cua-driver-session-policy-v{version}\0").as_bytes()); + let digest_domain = if version == 3 { + "cua-driver-capability-manifest" + } else { + "cua-driver-session-policy" + }; + digest.update(format!("{digest_domain}-v{version}\0").as_bytes()); digest.update(&bytes); Ok(SessionManifest { version, sha256: format!("{:x}", digest.finalize()), - expires_unix_ms: now_unix_ms() + expires_after.as_millis(), + expires_unix_ms: expires_after.map(|duration| now_unix_ms() + duration.as_millis()), idle_timeout, allow, deny, @@ -881,12 +1000,12 @@ fn validate_tools(section: &str, tools: Vec) -> Result, == crate::authorization::RiskClass::Unclassified { return Err(format!( - "session policy {section}.tools contains unknown or unreviewed tool '{tool}'" + "capability manifest {section}.tools contains unknown or unreviewed tool '{tool}'" )); } if !validated.insert(canonical.clone()) { return Err(format!( - "session policy {section}.tools repeats '{canonical}'" + "capability manifest {section}.tools repeats '{canonical}'" )); } } @@ -996,7 +1115,7 @@ fn validate_manifest_path_resources( RawPathResource::Directory { dir, recursive } => { if version < 2 { return Err(format!( - "{section} directory roots require session policy version 2" + "{section} directory roots require capability manifest version 2 or later" )); } let canonical = validate_manifest_paths(section, vec![dir])? @@ -1200,7 +1319,7 @@ mod tests { #[cfg(feature = "yaml")] #[test] - fn manifest_is_deny_by_default_and_preserves_ask() { + fn legacy_ask_is_deny_and_undeclared_tools_fail_closed() { let loaded = manifest( r#" version: 1 @@ -1219,10 +1338,55 @@ ask: .unwrap(); assert_eq!(loaded.decision("start_session"), ManifestDecision::Allow); assert_eq!(loaded.decision("page"), ManifestDecision::Deny); - assert_eq!(loaded.decision("browser_download"), ManifestDecision::Ask); + assert_eq!(loaded.decision("browser_download"), ManifestDecision::Deny); assert_eq!(loaded.decision("click"), ManifestDecision::Undeclared); } + #[cfg(feature = "yaml")] + #[test] + fn version_three_removes_mode_and_ask_and_allows_optional_lifetimes() { + let loaded = + manifest("version: 3\nallow:\n tools: [get_config]\ndeny:\n tools: [page]\n") + .unwrap(); + assert_eq!(loaded.version(), 3); + assert_eq!(loaded.expires_unix_ms(), None); + assert_eq!(loaded.idle_timeout(), None); + assert!(loaded + .validate_for_mode(crate::authorization::PermissionMode::Standard) + .is_ok()); + assert!(loaded + .validate_for_mode(crate::authorization::PermissionMode::Unrestricted) + .is_ok()); + assert!(loaded + .validate_for_mode(crate::authorization::PermissionMode::Bounded) + .is_err()); + + assert!( + manifest("version: 3\nmode: bounded\nallow:\n tools: [get_config]\n") + .unwrap_err() + .contains("must not declare mode") + ); + assert!( + manifest("version: 3\nallow:\n tools: [get_config]\nask:\n tools: [page]\n") + .unwrap_err() + .contains("does not support ask.tools") + ); + } + + #[cfg(feature = "yaml")] + #[test] + fn version_three_lifetimes_are_enforced_when_declared() { + let loaded = manifest( + "version: 3\nexpires_after: 1h\nidle_timeout: 5m\nallow:\n tools: [get_config]\n", + ) + .unwrap(); + assert!(loaded.expires_unix_ms().is_some()); + assert_eq!(loaded.idle_timeout(), Some(Duration::from_secs(5 * 60))); + assert!(loaded + .validate_for_mode(crate::authorization::PermissionMode::Bounded) + .is_ok()); + } + #[cfg(feature = "yaml")] #[test] fn autonomous_manifest_mode_remains_a_compatibility_alias() { @@ -1652,7 +1816,11 @@ allow: #[cfg(feature = "yaml")] #[test] fn manifest_file_resources_reject_filesystem_roots() { - let root = std::path::Path::new(std::path::MAIN_SEPARATOR_STR) + let current_dir = std::env::current_dir().unwrap(); + let root = current_dir + .ancestors() + .last() + .expect("an absolute current directory has a filesystem root") .to_string_lossy() .into_owned(); let error = validate_manifest_paths("files.write", vec![root]).unwrap_err(); @@ -1690,9 +1858,19 @@ allow: .unwrap(); *loaded.last_authorized_dispatch.lock().unwrap() = Instant::now().checked_sub(Duration::from_secs(2)).unwrap(); - assert!(loaded.authorize_dispatch().is_err()); + for mode in [ + crate::authorization::PermissionMode::Standard, + crate::authorization::PermissionMode::Bounded, + crate::authorization::PermissionMode::Unrestricted, + ] { + assert!( + loaded.validate_for_mode(mode).is_err(), + "declared idle timeout must be enforced in {mode:?}" + ); + } + assert!(loaded.commit_authorized_dispatch().is_err()); assert!(loaded.is_idle_expired()); - assert!(loaded.authorize_dispatch().is_err()); + assert!(loaded.commit_authorized_dispatch().is_err()); for bypass_tool in ["page", "verify_state"] { let policy = format!( diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/session_tools.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/session_tools.rs index 4797b83442..24aecdf7cb 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/session_tools.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/session_tools.rs @@ -1,11 +1,10 @@ //! Session lifecycle and per-session capture-scope tools. //! -//! A session is a **caller-declared** identity for an agent run (see -//! [`crate::session`]). It owns the agent cursor and is the key for -//! per-session config + recording. These tools bookend a run's lifetime -//! explicitly, decoupled from the MCP connection: the same `session` id works -//! identically over MCP, the CLI (`--session`), or the raw socket, and a run -//! can span any number of apps/windows. +//! A session is lifecycle identity for an agent run (see [`crate::session`]). +//! It owns the agent cursor, per-run configuration, recording, cleanup, and +//! telemetry. A trusted transport lease receives one implicit session when a +//! session-requiring call omits a public label; explicit labels remain an +//! optional coordination mechanism inside the same trusted namespace. //! //! The daemon may mirror an explicit `session` arg into reserved transport //! fields, but lifecycle and policy tools accept only the public `session` @@ -17,20 +16,95 @@ use crate::tool::{Tool, ToolDef}; use crate::tool_args::parse_typed_input; use async_trait::async_trait; use cua_driver_contract::{ - CaptureScope, EndSessionInput, EscalateSessionInput, EscalationReason, GetSessionStateInput, - StartSessionInput, + CaptureScope, EndSessionInput, EscalateSessionInput, EscalationReason, GetSessionInput, + GetSessionStateInput, ListSessionsInput, ListSessionsOutput, SessionClientKindOutput, + SessionLifecycleState, SessionOutput, SessionTransportOutput, StartSessionInput, }; use serde_json::{json, Value}; use std::sync::OnceLock; -/// Read the public, caller-declared session id. Capture-scope policy must never -/// bind to the daemon's reserved `_session_id` transport mirror. +/// Resolve the runtime-private lifecycle id injected at the trusted registry +/// boundary. An explicit public label wins; otherwise the transport lease's +/// implicit id is used. fn session_id_of(args: &Value) -> Option { - args.as_object()? - .get("session") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty() && *s != "default") - .map(|s| s.to_owned()) + let args = args.as_object()?; + ["session", "_session_id"].into_iter().find_map(|key| { + args.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty() && *s != "default") + .map(str::to_owned) + }) +} + +fn public_label_of(args: &Value) -> Option { + args.get("_public_session_label") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +fn response_session_label(args: &Value, session_id: &str) -> String { + public_label_of(args).unwrap_or_else(|| { + if args + .get("session") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty() && value != "default") + { + crate::session::public_session_label(session_id).to_owned() + } else { + // Never expose a transport UUID or runtime-private key merely + // because the caller used its unnamed implicit session. + "implicit".to_owned() + } + }) +} + +fn owner_transport_of(args: &Value, session_id: &str) -> String { + args.get("_transport_session_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or(session_id) + .to_owned() +} + +fn lifecycle_idle_ttl_of(args: &Value) -> Option { + args.get("_session_idle_ttl_ms") + .and_then(Value::as_u64) + .filter(|milliseconds| *milliseconds > 0) + .map(std::time::Duration::from_millis) +} + +fn lifecycle_output(snapshot: crate::session::LifecycleSessionSnapshot) -> SessionOutput { + let cursor_visible = crate::session::cursor_visible(&snapshot.runtime_id); + let recording_active = crate::session::recording_active(&snapshot.runtime_id); + SessionOutput { + session: snapshot.public_label, + implicit: snapshot.implicit, + state: if snapshot.ending { + SessionLifecycleState::Ending + } else { + SessionLifecycleState::Active + }, + client_kind: match snapshot.client_kind { + crate::session::SessionClientKind::Cli => SessionClientKindOutput::Cli, + crate::session::SessionClientKind::Direct => SessionClientKindOutput::Direct, + crate::session::SessionClientKind::Mcp => SessionClientKindOutput::Mcp, + crate::session::SessionClientKind::PythonSdk => SessionClientKindOutput::PythonSdk, + crate::session::SessionClientKind::TypescriptSdk => { + SessionClientKindOutput::TypescriptSdk + } + }, + transport: match snapshot.transport { + crate::session::SessionTransport::Cli => SessionTransportOutput::Cli, + crate::session::SessionTransport::Daemon => SessionTransportOutput::Daemon, + crate::session::SessionTransport::McpStdio => SessionTransportOutput::McpStdio, + crate::session::SessionTransport::McpHttp => SessionTransportOutput::McpHttp, + }, + cursor_visible, + recording_active, + idle_seconds: snapshot.idle.as_secs(), + expires_in_seconds: snapshot.expires_in.as_secs(), + } } // ── start_session ───────────────────────────────────────────────────────────── @@ -47,7 +121,7 @@ impl Tool for StartSessionTool { async fn invoke(&self, args: Value) -> ToolResult { let Some(id) = session_id_of(&args) else { - return ToolResult::error("start_session requires a non-empty `session` id."); + return ToolResult::error("start_session requires an authenticated transport lease or a non-empty `session` label."); }; if let Some(raw) = args.get("capture_scope") { let Some(raw) = raw.as_str() else { @@ -66,15 +140,50 @@ impl Tool for StartSessionTool { })); } } - let input = match parse_typed_input::("start_session", args) { + let input = match parse_typed_input::("start_session", args.clone()) { Ok(input) => input, Err(result) => return result, }; let requested = input.capture_scope; - let was_ended = crate::session::is_session_ended(&id); - if !was_ended { + let legacy_capture = requested.is_some(); + let public_label = public_label_of(&args); + let owner = owner_transport_of(&args, &id); + let (transport, client_kind) = crate::session::infer_transport_metadata(&owner); + if let Some(requested) = requested { + if let Some(existing) = get_session(&id) { + if existing.policy != requested { + return ToolResult::error( + "session already uses a different deprecated capture policy", + ) + .with_structured(json!({ + "code": "session_policy_conflict", + "capture_scope": existing.policy.as_str(), + "requested_capture_scope": requested.as_str(), + })); + } + } + } + // Tombstone removal and owner binding are one atomic lifecycle + // transition. A guessed public label cannot be claimed in the gap + // between revival and activation. + let revived = match crate::session::activate_or_revive_session_for_owner( + &id, + public_label.as_deref(), + &owner, + public_label.is_none(), + transport, + client_kind, + lifecycle_idle_ttl_of(&args), + ) { + Ok(revived) => revived, + Err(_) => { + return ToolResult::error("session is not available to this transport") + .with_structured(json!({ "code": "session_unavailable" })); + } + }; + let scope = if legacy_capture { match bind_session(&id, requested) { - Ok(_) => {} + Ok((bound, _)) => bound, Err(BindError::Conflict { existing, requested, @@ -90,52 +199,35 @@ impl Tool for StartSessionTool { })); } Err(BindError::Ended) => { - return ToolResult::error(format!( - "session '{id}' ended concurrently; retry start_session to revive it" - )) - .with_structured(json!({ "code": "session_ended", "session": id })); + return ToolResult::error(format!("session '{id}' could not be revived")) + .with_structured(json!({ "code": "session_ended", "session": id })); } } - } - // Revive a recycled id: if this session was previously ended (explicit - // `end_session` / idle-TTL / connection EOF), clear its tombstone so its - // actions stop being rejected by the daemon's resurrection guard. - // Re-declaring a session is the EXPLICIT, caller-driven way to reuse an - // id — a stray late action still can't silently resurrect a dead one. - let revived = crate::session::revive_session(&id); - let (scope, _) = match bind_session(&id, requested) { - Ok(bound) => bound, - Err(BindError::Conflict { - existing, - requested, - }) => { - return ToolResult::error(format!( - "session '{id}' already uses capture_scope='{existing}'; capture scope is immutable until the session ends" - )) - .with_structured(json!({ - "code": "session_policy_conflict", - "session": id, - "capture_scope": existing.as_str(), - "requested_capture_scope": requested.as_str(), - })); - } - Err(BindError::Ended) => { - return ToolResult::error(format!("session '{id}' could not be revived")) - .with_structured(json!({ "code": "session_ended", "session": id })); - } + } else { + get_session(&id).unwrap_or_else(|| { + crate::capture_scope::SessionCaptureScope::new(CaptureScope::Auto) + }) }; - // Refresh (or begin) the session's idle-TTL clock. The cursor appears on - // the first action carrying this `session`. - crate::session::touch_session(&id); + // Platform overlays keep their own late-command tombstones. Clear + // those only after core revival and capture-policy binding both + // succeeded, so a failed declaration cannot revive render state. + if revived { + crate::session::fire_session_revive_for_owner(&id, &owner); + } let structured = serde_json::to_value(cua_driver_contract::StartSessionOutput { - state: scope.output(&id), + state: scope.output(&response_session_label(&args, &id)), active: true, revived, }) .expect("start_session output serializes"); ToolResult::text(format!( - "✅ Session '{id}' is active with capture_scope='{}'.", - scope.policy + "✅ Session '{}' is active{}.", + public_label.as_deref().unwrap_or("implicit"), + if legacy_capture { + " with deprecated capture_scope compatibility" + } else { + "" + } )) .with_structured(structured) } @@ -167,10 +259,11 @@ impl Tool for EscalateSessionTool { return ToolResult::error("escalate_session.reason is invalid.") .with_structured(json!({ "code": "invalid_escalation_reason" })); } - let input = match parse_typed_input::("escalate_session", args) { - Ok(input) => input, - Err(result) => return result, - }; + let input = + match parse_typed_input::("escalate_session", args.clone()) { + Ok(input) => input, + Err(result) => return result, + }; if input .detail .as_ref() @@ -179,9 +272,16 @@ impl Tool for EscalateSessionTool { return ToolResult::error("escalate_session.detail must be at most 200 characters.") .with_structured(json!({ "code": "invalid_escalation_detail" })); } + let owner = owner_transport_of(&args, &id); + if crate::session::session_snapshot(&id, &owner, crate::session::DEFAULT_SESSION_IDLE_TTL) + .is_none() + { + return ToolResult::error("session is not visible to this transport") + .with_structured(json!({ "code": "session_not_started" })); + } match escalate_session(&id, input.reason, input.detail.as_deref()) { - Ok(state) => ToolResult::text(format!("✅ Session '{id}' escalated to desktop scope.")) - .with_structured(state.as_json(&id)), + Ok(state) => ToolResult::text("✅ Session escalated to desktop scope.") + .with_structured(state.as_json(&response_session_label(&args, &id))), Err(error) => { let (code, message) = match error { EscalateError::Ended => ( @@ -227,19 +327,119 @@ impl Tool for GetSessionStateTool { return ToolResult::error("get_session_state requires a public `session` id.") .with_structured(json!({ "code": "session_required" })); }; - if let Err(result) = parse_typed_input::("get_session_state", args) { + if let Err(result) = + parse_typed_input::("get_session_state", args.clone()) + { return result; } - let Some(state) = get_session(&id) else { - return ToolResult::error(format!("session '{id}' is not active")) - .with_structured(json!({ "code": "session_not_started", "session": id })); - }; + let owner = owner_transport_of(&args, &id); + if crate::session::session_snapshot(&id, &owner, crate::session::DEFAULT_SESSION_IDLE_TTL) + .is_none() + { + return ToolResult::error("session is not visible to this transport") + .with_structured(json!({ "code": "session_not_started" })); + } + let state = get_session(&id) + .unwrap_or_else(|| crate::capture_scope::SessionCaptureScope::new(CaptureScope::Auto)); ToolResult::text(format!( "Session '{id}' uses capture_scope='{}' (effective_scope='{}').", state.policy, state.effective_scope().as_str() )) - .with_structured(state.as_json(&id)) + .with_structured(state.as_json(&response_session_label(&args, &id))) + } +} + +// ── get_session / list_sessions ───────────────────────────────────────────── + +pub struct GetSessionTool; + +static GET_DEF: OnceLock = OnceLock::new(); + +#[async_trait] +impl Tool for GetSessionTool { + fn def(&self) -> &ToolDef { + GET_DEF.get_or_init(|| session_tool_def("get_session")) + } + + async fn invoke(&self, args: Value) -> ToolResult { + if let Err(result) = parse_typed_input::("get_session", args.clone()) { + return result; + } + let Some(id) = session_id_of(&args) else { + return ToolResult::error("no implicit session is attached to this transport") + .with_structured(json!({ "code": "session_not_started" })); + }; + let owner = owner_transport_of(&args, &id); + let Some(snapshot) = + crate::session::session_snapshot(&id, &owner, crate::session::DEFAULT_SESSION_IDLE_TTL) + else { + return ToolResult::error("session is not visible to this transport") + .with_structured(json!({ "code": "session_not_started" })); + }; + let output = lifecycle_output(snapshot); + ToolResult::text("Session is active.") + .with_structured(serde_json::to_value(output).expect("get_session output serializes")) + } +} + +pub struct ListSessionsTool; + +static LIST_DEF: OnceLock = OnceLock::new(); + +#[async_trait] +impl Tool for ListSessionsTool { + fn def(&self) -> &ToolDef { + LIST_DEF.get_or_init(|| session_tool_def("list_sessions")) + } + + async fn invoke(&self, args: Value) -> ToolResult { + let input = match parse_typed_input::("list_sessions", args.clone()) { + Ok(input) => input, + Err(result) => return result, + }; + let Some(owner) = args + .get("_transport_session_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return ToolResult::error("list_sessions requires an authenticated transport lease") + .with_structured(json!({ "code": "transport_lease_required" })); + }; + let limit = input.limit.unwrap_or(50); + if limit == 0 || limit > 100 { + return ToolResult::error("list_sessions.limit must be from 1 to 100") + .with_structured(json!({ "code": "invalid_limit" })); + } + let offset = match input.cursor.as_deref() { + None => 0, + Some(cursor) => match cursor + .strip_prefix("o:") + .and_then(|v| v.parse::().ok()) + { + Some(offset) => offset, + None => { + return ToolResult::error("list_sessions.cursor is invalid") + .with_structured(json!({ "code": "invalid_cursor" })) + } + }, + }; + let sessions = + crate::session::list_session_snapshots(owner, crate::session::DEFAULT_SESSION_IDLE_TTL); + let end = (offset + limit as usize).min(sessions.len()); + let page = sessions + .get(offset..end) + .unwrap_or_default() + .iter() + .cloned() + .map(lifecycle_output) + .collect::>(); + let output = ListSessionsOutput { + sessions: page, + next_cursor: (end < sessions.len()).then(|| format!("o:{end}")), + }; + ToolResult::text(format!("{} visible session(s).", output.sessions.len())) + .with_structured(serde_json::to_value(output).expect("list_sessions output serializes")) } } @@ -257,15 +457,51 @@ impl Tool for EndSessionTool { async fn invoke(&self, args: Value) -> ToolResult { let Some(id) = session_id_of(&args) else { - return ToolResult::error("end_session requires a non-empty `session` id."); + return ToolResult::error( + "end_session requires an attached implicit session or a non-empty `session` label.", + ); }; - if let Err(result) = parse_typed_input::("end_session", args) { + if let Err(result) = parse_typed_input::("end_session", args.clone()) { return result; } - crate::session::end_session(&id); - ToolResult::text(format!("✅ Session '{id}' ended.")).with_structured( + let owner = owner_transport_of(&args, &id); + let owned = crate::session::end_session_for_owner(&id, &owner); + if owned { + let cleanup = if crate::session::is_session_ended(&id) { + crate::session::session_cleanup_status(&id) + } else { + crate::session::SessionCleanupReport { + complete: false, + in_progress: true, + failures: Vec::new(), + } + }; + if !cleanup.complete { + return ToolResult::error(if cleanup.in_progress { + "Session is ending after its in-flight action completes; retry end_session for final cleanup status." + } else { + "Session ended, but one or more cleanup hooks failed; retry end_session." + }) + .with_structured(json!({ + "code": if cleanup.in_progress { "session_cleanup_pending" } else { "session_cleanup_partial" }, + "session": public_label_of(&args) + .unwrap_or_else(|| crate::session::public_session_label(&id).to_owned()), + "cleanup_complete": false, + "cleanup_in_progress": cleanup.in_progress, + // Hook names are stable, non-sensitive categories. Keep + // implementation errors private because adapters may + // include local paths or platform details in them. + "failed_hooks": cleanup.failures.into_iter().map(|failure| json!({ + "hook": failure.hook, + "error": "cleanup failed", + })).collect::>(), + })); + } + } + let response_label = response_session_label(&args, &id); + ToolResult::text(format!("✅ Session '{response_label}' ended.")).with_structured( serde_json::to_value(cua_driver_contract::EndSessionOutput { - session: id, + session: response_label, active: false, }) .expect("end_session output serializes"), @@ -282,27 +518,57 @@ fn session_tool_def(name: &str) -> ToolDef { #[cfg(test)] mod tests { use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; fn result_code(result: &ToolResult) -> Option<&str> { result.structured_content.as_ref()?.get("code")?.as_str() } #[test] - fn session_id_of_reads_only_public_session() { + fn session_id_of_prefers_public_and_uses_trusted_implicit_fallback() { assert_eq!( session_id_of(&json!({ "session": "a" })).as_deref(), Some("a") ); - assert_eq!(session_id_of(&json!({ "_session_id": "b" })), None); assert_eq!( - session_id_of(&json!({ "session": "", "_session_id": "c" })), - None + session_id_of(&json!({ "_session_id": "b" })).as_deref(), + Some("b") + ); + assert_eq!( + session_id_of(&json!({ "session": "", "_session_id": "c" })).as_deref(), + Some("c") ); assert_eq!(session_id_of(&json!({})), None); assert_eq!(session_id_of(&json!({ "session": "" })), None); assert_eq!(session_id_of(&json!({ "session": "default" })), None); } + #[tokio::test] + async fn implicit_start_does_not_expose_its_private_transport_identity() { + let id = format!("private-implicit-session-{}", std::process::id()); + let owner = format!("private-transport-{}", std::process::id()); + let result = StartSessionTool + .invoke(json!({ + "_session_id": id.clone(), + "_transport_session_id": owner.clone(), + })) + .await; + assert_ne!(result.is_error, Some(true)); + let structured = result.structured_content.as_ref().unwrap(); + assert_eq!(structured["session"], "implicit"); + let encoded = serde_json::to_string(structured).unwrap(); + assert!(!encoded.contains(&owner)); + EndSessionTool + .invoke(json!({ + "_session_id": id, + "_transport_session_id": owner, + })) + .await; + } + #[tokio::test] async fn live_policy_is_immutable_and_ended_id_gets_fresh_policy() { let id = format!("session-tool-policy-{}", std::process::id()); @@ -334,10 +600,137 @@ mod tests { assert_eq!(structured["revived"], true); } + #[tokio::test] + async fn successful_explicit_revival_notifies_session_owned_subsystems_once() { + let id = format!("session-tool-revive-hook-{}", std::process::id()); + let notifications = Arc::new(AtomicUsize::new(0)); + let observed_id = id.clone(); + let notifications_for_hook = notifications.clone(); + let _registration = crate::session::register_scoped_session_revive_hook(move |got| { + if got == observed_id { + notifications_for_hook.fetch_add(1, Ordering::SeqCst); + } + }); + let start = StartSessionTool; + + let fresh = start + .invoke(json!({"session": id, "capture_scope": "window"})) + .await; + assert_ne!(fresh.is_error, Some(true)); + assert_eq!(notifications.load(Ordering::SeqCst), 0); + + EndSessionTool.invoke(json!({"session": id})).await; + let revived = start + .invoke(json!({"session": id, "capture_scope": "desktop"})) + .await; + assert_ne!(revived.is_error, Some(true)); + assert_eq!( + revived.structured_content.as_ref().unwrap()["revived"], + true + ); + assert_eq!(notifications.load(Ordering::SeqCst), 1); + + let idempotent = start + .invoke(json!({"session": id, "capture_scope": "desktop"})) + .await; + assert_ne!(idempotent.is_error, Some(true)); + assert_eq!(notifications.load(Ordering::SeqCst), 1); + EndSessionTool.invoke(json!({"session": id})).await; + } + + #[tokio::test] + async fn cleanup_failure_response_redacts_adapter_error_details() { + let id = format!("session-tool-cleanup-redaction-{}", std::process::id()); + let calls = Arc::new(AtomicUsize::new(0)); + let calls_for_hook = calls.clone(); + let observed_id = id.clone(); + let _registration = + crate::session::register_scoped_fallible_session_end_hook("recording", move |ended| { + if ended != observed_id { + return Ok(()); + } + let call = calls_for_hook.fetch_add(1, Ordering::SeqCst); + if call < 2 { + Err("failed at /Users/private/recording.mov".into()) + } else { + Ok(()) + } + }); + + let started = StartSessionTool.invoke(json!({"session": id})).await; + assert_ne!(started.is_error, Some(true)); + let ended = EndSessionTool.invoke(json!({"session": id})).await; + assert_eq!(ended.is_error, Some(true)); + assert_eq!(result_code(&ended), Some("session_cleanup_partial")); + let encoded = serde_json::to_string(&ended.structured_content).unwrap(); + assert!(encoded.contains("cleanup failed")); + assert!(!encoded.contains("/Users/private")); + + let second = EndSessionTool.invoke(json!({"session": id})).await; + assert_eq!(second.is_error, Some(true)); + assert_eq!(calls.load(Ordering::SeqCst), 2); + let third = EndSessionTool.invoke(json!({"session": id})).await; + assert_ne!(third.is_error, Some(true)); + assert_eq!(calls.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn lifecycle_tools_do_not_expose_or_mutate_foreign_transport_sessions() { + let id = format!("session-tool-owner-scope-{}", std::process::id()); + let owner_a = format!("http-owner-a-{}", std::process::id()); + let owner_b = format!("http-owner-b-{}", std::process::id()); + let owned_args = json!({ + "session": id, + "_public_session_label": "shared-label", + "_transport_session_id": owner_a, + "capture_scope": "auto" + }); + let started = StartSessionTool.invoke(owned_args.clone()).await; + assert_ne!(started.is_error, Some(true)); + + let foreign = json!({ + "session": id, + "_public_session_label": "shared-label", + "_transport_session_id": owner_b + }); + let get = GetSessionTool.invoke(foreign.clone()).await; + assert_eq!(get.is_error, Some(true)); + assert_eq!(result_code(&get), Some("session_not_started")); + + let state = GetSessionStateTool.invoke(foreign.clone()).await; + assert_eq!(state.is_error, Some(true)); + assert_eq!(result_code(&state), Some("session_not_started")); + + let list = ListSessionsTool + .invoke(json!({"_transport_session_id": owner_b})) + .await; + assert_eq!( + list.structured_content.as_ref().unwrap()["sessions"], + json!([]) + ); + + let ended = EndSessionTool.invoke(foreign.clone()).await; + assert_ne!(ended.is_error, Some(true), "end remains non-enumerating"); + assert!(crate::session::session_snapshot( + &id, + &owner_a, + crate::session::DEFAULT_SESSION_IDLE_TTL + ) + .is_some()); + + let stolen = StartSessionTool.invoke(foreign).await; + assert_eq!(stolen.is_error, Some(true)); + assert_eq!(result_code(&stolen), Some("session_unavailable")); + + EndSessionTool.invoke(owned_args).await; + } + #[tokio::test] async fn auto_requires_explicit_bounded_escalation() { let id = format!("session-tool-auto-{}", std::process::id()); - let started = StartSessionTool.invoke(json!({"session": id})).await; + let started = StartSessionTool + .invoke(json!({"session": id, "capture_scope": "auto"})) + .await; assert_eq!( started.structured_content.as_ref().unwrap()["capture_scope"], "auto" diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/tool.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/tool.rs index 307a56466c..29b8db290f 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/tool.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/tool.rs @@ -56,6 +56,45 @@ pub fn current_dispatch_runtime_scope() -> Option { .or_else(|| CONSTRUCTION_RUNTIME_SCOPE.with(|scope| scope.borrow().clone())) } +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct DispatchSessionIdentity { + pub runtime_scope: String, + pub session_id: String, + pub transport_session_id: String, +} + +pub(crate) fn current_dispatch_session_identity() -> Option { + let runtime_scope = current_dispatch_runtime_scope()?; + let context = DISPATCH_AUTHORIZATION_CONTEXT.try_with(Arc::clone).ok()?; + let evidence = DISPATCH_TRUSTED_INVOCATION_EVIDENCE + .try_with(Clone::clone) + .unwrap_or_default(); + let session = evidence + .session_id + .as_deref() + .or_else(|| context.public_session()) + .or(evidence.transport_session_id.as_deref()) + .or_else(|| context.transport_session())?; + let transport = evidence + .transport_session_id + .as_deref() + .or_else(|| context.transport_session()) + .unwrap_or(session); + let prefix = format!("__cua_runtime_{runtime_scope}:"); + let namespace = |value: &str| { + if value.starts_with(&prefix) { + value.to_owned() + } else { + format!("{prefix}{value}") + } + }; + Some(DispatchSessionIdentity { + runtime_scope, + session_id: namespace(session), + transport_session_id: namespace(transport), + }) +} + #[doc(hidden)] pub fn with_runtime_scope(scope: String, action: impl FnOnce() -> T) -> T { struct RestoreScope(Option); @@ -163,12 +202,13 @@ impl ToolDef { // // Published SDK tools resolve capabilities from their typed Rust // contract. The legacy map remains only for runtime-only tools. - let caps = advertised_capabilities_for(&self.name, &self.input_schema); + let input_schema = advertised_runtime_input_schema(&self.name, &self.input_schema); + let caps = advertised_capabilities_for(&self.name, &input_schema); let risk = crate::authorization::risk_metadata_json(&self.name); let mut entry = serde_json::json!({ "name": self.name, "description": self.description, - "inputSchema": self.input_schema, + "inputSchema": input_schema, "annotations": { "readOnlyHint": self.read_only, "destructiveHint": self.destructive, @@ -187,15 +227,46 @@ impl ToolDef { cua_driver_contract::tool_success_output_schema(&self.name) }; if let Some(output_schema) = output_schema { + // Advertise the refusal envelope alongside the success shape. MCP + // holds every `structuredContent` we emit — refusals included — to + // the advertised schema, and a success-only schema made strict + // clients discard our refusal message in favour of a schema error. entry .as_object_mut() .expect("tool list entry is an object") - .insert("outputSchema".into(), output_schema); + .insert( + "outputSchema".into(), + cua_driver_contract::advertised_output_schema(output_schema), + ); } entry } } +fn advertised_runtime_input_schema(tool_name: &str, schema: &Value) -> Value { + let mut schema = schema.clone(); + if !crate::action_target::supports_typed_target(tool_name) { + return schema; + } + let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else { + return schema; + }; + // Reuse the portable contract's exact tagged-union schema while retaining + // the live runtime's broader legacy `scope=window|desktop` decoder. + if let Some(portable) = cua_driver_contract::tool_contract(tool_name) { + if let Some(portable_properties) = portable + .input_schema + .get("properties") + .and_then(Value::as_object) + { + if let Some(field_schema) = portable_properties.get("target") { + properties.insert("target".into(), field_schema.clone()); + } + } + } + schema +} + /// Centralised tool name → capability tokens map. Lookup is by name so /// platform-specific tool modules don't have to declare their own /// capabilities — keeps the additive-only contract tight and avoids @@ -227,9 +298,10 @@ impl ToolDef { /// `system.permissions.tcc.accessibility`, /// `system.permissions.tcc.screen_recording` /// - `system.config.read`, `system.config.write` -/// - `session.lifecycle.start`, `session.lifecycle.end`, -/// `session.capture_scope`, `session.capture_scope.read`, -/// `session.capture_scope.escalate` +/// - `session.lifecycle.start`, `session.lifecycle.read`, +/// `session.lifecycle.list`, `session.lifecycle.end`, plus the deprecated +/// `session.capture_scope`, `session.capture_scope.read`, and +/// `session.capture_scope.escalate` compatibility tokens /// - `agent_cursor.move`, `agent_cursor.set_enabled`, /// `agent_cursor.set_motion`, `agent_cursor.set_theme`, /// `agent_cursor.state` @@ -308,20 +380,10 @@ pub fn default_capabilities_for(tool_name: &str) -> Vec { ], // ── accessibility / window state ───────────────────────────── "get_accessibility_tree" => &["accessibility.tree", "accessibility.tree.structured"], - "get_window_state" => &[ - "accessibility.window_state", - "accessibility.tree", - "accessibility.tree.structured", - "accessibility.tree.bounded", - // Surface 6: emits `element_token` on every structured - // element entry — paired with the existing integer - // `element_index`. - "accessibility.element_tokens", - // capture_mode:"vision" returns a window screenshot — see - // platform-{macos,windows,linux}/src/tools/get_window_state.rs. - "screen.capture", - "screen.capture.window", - ], + // `get_window_state` is declared in the published contract + // (cua-driver-contract/src/desktop.rs) and resolved through the + // contract lookup above, including + // `accessibility.observation_revision.v1`. // ── apps / windows ─────────────────────────────────────────── "launch_app" => &["app.launch"], @@ -487,7 +549,6 @@ impl Drop for RuntimeCleanup { pub struct TrustedInvocationEvidence { session_id: Option, transport_session_id: Option, - browser_prepare_mcp_host_approved: bool, browser_download_mcp_host_approved: bool, } @@ -504,10 +565,6 @@ impl TrustedInvocationEvidence { .remove("_transport_session_id") .and_then(|value| value.as_str().map(str::to_owned)) .filter(|value| !value.is_empty()); - evidence.browser_prepare_mcp_host_approved = arguments - .remove(crate::browser::approval::MCP_HOST_APPROVAL_ARG) - .and_then(|value| value.as_bool()) - == Some(true); evidence.browser_download_mcp_host_approved = arguments .remove(crate::browser::download::MCP_HOST_DOWNLOAD_APPROVAL_ARG) .and_then(|value| value.as_bool()) @@ -530,12 +587,6 @@ impl TrustedInvocationEvidence { Value::String(session.clone()), ); } - if self.browser_prepare_mcp_host_approved { - arguments.insert( - crate::browser::approval::MCP_HOST_APPROVAL_ARG.to_owned(), - Value::Bool(true), - ); - } if self.browser_download_mcp_host_approved { arguments.insert( crate::browser::download::MCP_HOST_DOWNLOAD_APPROVAL_ARG.to_owned(), @@ -554,7 +605,9 @@ pub struct ToolRegistry { pub recording: Arc, replay_registry: ReplayRegistrySlot, session_end_hooks: Vec, + session_revive_hooks: Vec, cursor_outcome_readers: Vec, + _recording_state_readers: Vec, runtime_cleanups: Vec, /// Runtime-owned protected-consent broker shared by every resource /// adapter. Keeping it at the canonical dispatch boundary prevents @@ -605,13 +658,24 @@ impl ToolRegistry { }); } }); + let recording = Arc::new(RecordingSession::new()); + let weak_recording = Arc::downgrade(&recording); + let recording_state_reader = + crate::session::register_scoped_recording_state_reader(Arc::new(move |session_id| { + weak_recording.upgrade().is_some_and(|recording| { + let state = recording.current_state(); + state.enabled && state.owner.as_deref() == Some(session_id) + }) + })); Self { tools: HashMap::new(), order: Vec::new(), - recording: Arc::new(RecordingSession::new()), + recording, replay_registry: Arc::new(std::sync::Mutex::new(std::sync::Weak::new())), session_end_hooks: vec![session_end_hook], + session_revive_hooks: Vec::new(), cursor_outcome_readers: Vec::new(), + _recording_state_readers: vec![recording_state_reader], runtime_cleanups: Vec::new(), approval_broker, protected_resource_grants, @@ -719,6 +783,13 @@ impl ToolRegistry { self.session_end_hooks.push(registration); } + pub fn retain_session_revive_hook( + &mut self, + registration: crate::session::SessionReviveHookRegistration, + ) { + self.session_revive_hooks.push(registration); + } + pub fn retain_cursor_outcome_reader( &mut self, registration: crate::session::CursorOutcomeReaderRegistration, @@ -744,15 +815,19 @@ impl ToolRegistry { self.register(Box::new(crate::recording_tools::InstallFfmpegTool)); } - /// Register the platform-independent session-lifecycle tools - /// (`start_session` / `end_session`). Call alongside + /// Register the platform-independent lifecycle and compatibility tools + /// (`start_session`, `get_session`, `list_sessions`, `end_session`, and + /// the legacy capture-scope readers). Call alongside /// `register_recording_tools` from each platform's `register_all`. pub fn register_session_tools(&mut self) { use crate::session_tools::{ - EndSessionTool, EscalateSessionTool, GetSessionStateTool, StartSessionTool, + EndSessionTool, EscalateSessionTool, GetSessionStateTool, GetSessionTool, + ListSessionsTool, StartSessionTool, }; self.register(Box::new(StartSessionTool)); self.register(Box::new(EscalateSessionTool)); + self.register(Box::new(GetSessionTool)); + self.register(Box::new(ListSessionsTool)); self.register(Box::new(GetSessionStateTool)); self.register(Box::new(EndSessionTool)); } @@ -767,8 +842,9 @@ impl ToolRegistry { let list: Vec = self .order .iter() - .filter_map(|n| self.tools.get(n)) - .map(|t| t.def().to_list_entry()) + .filter(|name| crate::policy::is_tool_listable(name)) + .filter_map(|name| self.tools.get(name)) + .map(|tool| tool.def().to_list_entry()) .collect(); // `capability_version` is the contract version for the // capability tokens claimed by each tool entry. Bumped on @@ -967,6 +1043,11 @@ impl ToolRegistry { return ToolResult::error(format!("Unknown tool: {name}")); }; + if let Err(result) = crate::action_target::normalize_action_target(resolved_name, &mut args) + { + return result; + } + // This registry is the canonical native dispatch boundary shared by // the same-process SDK and every transport adapter. Authorization must // live here: transport-only checks leave CuaDriver::create() able to @@ -989,8 +1070,22 @@ impl ToolRegistry { // caller-chosen label alone. Translate it only after authorization so // policy and manifests continue to evaluate the public request. let runtime_prefix = namespace_runtime_args(&mut args, context, evidence); + let has_lifecycle_session = args + .get("_session_id") + .and_then(Value::as_str) + .is_some_and(|session| !session.is_empty() && session != "default"); + if session_selecting_tool(resolved_name) && !has_lifecycle_session { + let implicit = args + .get("_transport_session_id") + .and_then(Value::as_str) + .filter(|session| !session.is_empty() && *session != "default") + .map(str::to_owned) + .unwrap_or_else(|| format!("{runtime_prefix}implicit-direct")); + args["_session_id"] = Value::String(implicit.clone()); + args["_transport_session_id"] = Value::String(implicit); + } let runtime_session = args - .get("session") + .get("_session_id") .and_then(Value::as_str) .map(str::to_owned); if let Some(session) = runtime_session.as_deref() { @@ -1004,7 +1099,6 @@ impl ToolRegistry { restore_public_runtime_result(&mut result, &runtime_prefix); return result; } - crate::session::touch_session(session); } // Convert only the private dispatch copy. Authorization, consent, @@ -1065,13 +1159,15 @@ impl ToolRegistry { } else { None }; - let runtime_proves_driver_owned = runtime_session - .as_deref() - .zip(current_process_fingerprint.as_ref()) - .is_some_and(|(session, fingerprint)| { - self.protected_resource_ownership - .reprove_driver_owned_process(session, fingerprint) - }); + let process_ownership_key = + process_ownership_key(runtime_session.as_deref(), &runtime_prefix); + let runtime_proves_driver_owned = + current_process_fingerprint + .as_ref() + .is_some_and(|fingerprint| { + self.protected_resource_ownership + .reprove_driver_owned_process(&process_ownership_key, fingerprint) + }); if context.mode() == crate::authorization::PermissionMode::Standard && has_adapter("process_control") @@ -1120,15 +1216,16 @@ impl ToolRegistry { // Resource adapters run at the same canonical boundary as policy and // manifest admission, after session capture-scope validation but // before recording or the platform tool can observe user state. This - // avoids prompting for a call the session policy will refuse, while + // avoids prompting for a call the capability manifest will refuse, while // preserving the public arguments in the grant scope and the private // runtime session key in its revocation lifecycle. if has_adapter("private_observation") - && !runtime_proves_driver_owned - && tool - .protected_resource_ownership("private_observation", &public_args) - .await - != ProtectedResourceOwnership::DriverOwned + && ((!runtime_proves_driver_owned + && tool + .protected_resource_ownership("private_observation", &public_args) + .await + != ProtectedResourceOwnership::DriverOwned) + || context.capability_manifest().is_some()) { if let Err(error) = self .authorize_private_observation( @@ -1175,7 +1272,9 @@ impl ToolRegistry { namespace_runtime_args(&mut args, context, evidence); } let recording_args = recording_args_for(resolved_name, &public_args); - if has_adapter("desktop_input") && !runtime_proves_driver_owned { + if has_adapter("desktop_input") + && (!runtime_proves_driver_owned || context.capability_manifest().is_some()) + { if let Err(error) = self .authorize_desktop_input( resolved_name, @@ -1189,10 +1288,11 @@ impl ToolRegistry { } } if has_adapter("browser_consequential_action") - && tool + && (tool .protected_resource_ownership("browser_consequential_action", &public_args) .await != ProtectedResourceOwnership::DriverOwned + || context.capability_manifest().is_some()) { let approved_scope = match self .authorize_attested_resource( @@ -1226,10 +1326,11 @@ impl ToolRegistry { } } if has_adapter("browser_bound_input") - && tool + && (tool .protected_resource_ownership("browser_bound_input", &public_args) .await != ProtectedResourceOwnership::DriverOwned + || context.capability_manifest().is_some()) { let approved_scope = match self .authorize_attested_resource( @@ -1263,8 +1364,7 @@ impl ToolRegistry { } } if has_adapter("process_control") - && (!runtime_proves_driver_owned - || context.mode() == crate::authorization::PermissionMode::Bounded) + && (!runtime_proves_driver_owned || context.capability_manifest().is_some()) { let approved_scope = match self .authorize_attested_resource( @@ -1314,8 +1414,55 @@ impl ToolRegistry { } } + if let Err(error) = context.commit_authorized_dispatch() { + return permission_denied_result(error); + } + + let lifecycle_dispatch = + if session_requiring_tool(resolved_name) && resolved_name != "start_session" { + let Some(session_id) = runtime_session.as_deref() else { + return protected_refusal( + "session_unavailable", + "an admitted session-requiring call has no lifecycle identity", + ); + }; + let owner = args + .get("_transport_session_id") + .and_then(Value::as_str) + .unwrap_or(session_id); + let public_label = args.get("_public_session_label").and_then(Value::as_str); + let (transport, client_kind) = crate::session::infer_transport_metadata(owner); + let admitted = match context.lifecycle_idle_ttl_override() { + Some(idle_ttl) => crate::session::begin_session_dispatch_with_ttl( + session_id, + public_label, + owner, + public_label.is_none(), + transport, + client_kind, + idle_ttl, + ), + None => crate::session::begin_session_dispatch( + session_id, + public_label, + owner, + public_label.is_none(), + transport, + client_kind, + ), + }; + match admitted { + Ok(guard) => Some(guard), + Err(message) => { + return protected_refusal("session_ended", message); + } + } + } else { + None + }; + // Capture start time for recording timestamps only after validation. - let launch_snapshot = if resolved_name == "launch_app" && runtime_session.is_some() { + let launch_snapshot = if resolved_name == "launch_app" { self.snapshot_running_pids().await } else { None @@ -1358,6 +1505,7 @@ impl ToolRegistry { .flatten(); let mut result = tool.invoke(args.clone()).await; + drop(lifecycle_dispatch); if self.normalized_coordinates { crate::coord_norm::ingest_window_size(resolved_name, &args, &result); crate::coord_norm::ingest_screen_size(resolved_name, &result); @@ -1387,8 +1535,7 @@ impl ToolRegistry { } } if resolved_name == "launch_app" && result.is_error != Some(true) { - if let (Some(session), Some(before), Some(pid)) = ( - runtime_session.as_deref(), + if let (Some(before), Some(pid)) = ( launch_snapshot.as_ref(), result .structured_content @@ -1399,7 +1546,7 @@ impl ToolRegistry { if pid > 0 && !before.contains(&pid) { if let Some(fingerprint) = self.attest_process_fingerprint(pid).await { self.protected_resource_ownership - .mark_driver_owned_process(session, fingerprint); + .mark_driver_owned_process(&process_ownership_key, fingerprint); } } } @@ -1410,12 +1557,6 @@ impl ToolRegistry { // recording/PiP screenshots would unnecessarily block an unrelated // runtime after the input side effect has already completed. drop(_desktop_action); - // A long-running authorized action is active work, not idle time. - // Refresh again at completion so the idle TTL measures the gap - // between calls rather than time spent executing the previous call. - if let Some(session) = runtime_session.as_deref() { - crate::session::touch_session(session); - } restore_public_runtime_result(&mut result, &runtime_prefix); // Preserve the producer's private summary for recording/replay before // the public ActionResult projection deliberately replaces legacy @@ -1504,12 +1645,16 @@ impl ToolRegistry { context: &crate::session_authorization::EffectiveAuthorizationContext, lifecycle_session: Option<&str>, ) -> Result<(), crate::consent::ConsentError> { - if context.mode() == crate::authorization::PermissionMode::Unrestricted { + if context.mode() == crate::authorization::PermissionMode::Unrestricted + && context.capability_manifest().is_none() + { return Ok(()); } let browser_target = args.get("target_id").and_then(Value::as_str); let browser_tab = args.get("tab_id").and_then(Value::as_str); - if context.mode() == crate::authorization::PermissionMode::Standard { + if context.mode() == crate::authorization::PermissionMode::Standard + && context.capability_manifest().is_none() + { // Standard observation is promptless, but browser observations // still have to attest their live target before dispatch. if browser_target.is_some() @@ -1630,7 +1775,7 @@ impl ToolRegistry { ) }; - if context.mode() == crate::authorization::PermissionMode::Bounded { + if context.capability_manifest().is_some() { if let Some(pid) = resource.get("pid").and_then(Value::as_i64) { self.enrich_application_resource(&mut resource, pid).await; } @@ -1657,11 +1802,9 @@ impl ToolRegistry { context: &crate::session_authorization::EffectiveAuthorizationContext, lifecycle_session: Option<&str>, ) -> Result<(), crate::consent::ConsentError> { - if matches!( - context.mode(), - crate::authorization::PermissionMode::Standard - | crate::authorization::PermissionMode::Unrestricted - ) { + if context.mode() != crate::authorization::PermissionMode::Bounded + && context.capability_manifest().is_none() + { // Standard and unrestricted input do not need a protected grant. // Bounded mode continues below so the exact resource can be // checked against its approved manifest. @@ -1736,7 +1879,7 @@ impl ToolRegistry { ) }; - if context.mode() == crate::authorization::PermissionMode::Bounded { + if context.capability_manifest().is_some() { if let Some(pid) = resource.get("pid").and_then(Value::as_i64) { self.enrich_application_resource(&mut resource, pid).await; } @@ -1763,7 +1906,9 @@ impl ToolRegistry { context: &crate::session_authorization::EffectiveAuthorizationContext, lifecycle_session: Option<&str>, ) -> Result<(), crate::consent::ConsentError> { - if context.mode() != crate::authorization::PermissionMode::Bounded { + if context.mode() != crate::authorization::PermissionMode::Bounded + && context.capability_manifest().is_none() + { return Ok(()); } let (operation, content_kind, summary) = if tool_name == "clipboard_read" { @@ -1816,7 +1961,9 @@ impl ToolRegistry { context: &crate::session_authorization::EffectiveAuthorizationContext, lifecycle_session: Option<&str>, ) -> Result<(), ToolResult> { - if context.mode() == crate::authorization::PermissionMode::Unrestricted { + if context.mode() == crate::authorization::PermissionMode::Unrestricted + && context.capability_manifest().is_none() + { return Ok(()); } let (resource, summary) = match tool_name { @@ -1976,7 +2123,9 @@ impl ToolRegistry { idle_ttl: Duration, absolute_ttl: Duration, ) -> Result { - if context.mode() == crate::authorization::PermissionMode::Unrestricted { + if context.mode() == crate::authorization::PermissionMode::Unrestricted + && context.capability_manifest().is_none() + { return Ok(Value::Null); } let mut resource = tool @@ -2022,7 +2171,9 @@ impl ToolRegistry { lifecycle_session: Option<&str>, approved_scope: &Value, ) -> Result<(), ToolResult> { - if context.mode() == crate::authorization::PermissionMode::Unrestricted { + if context.mode() == crate::authorization::PermissionMode::Unrestricted + && context.capability_manifest().is_none() + { return Ok(()); } let mut validation_scope = approved_scope.clone(); @@ -2063,16 +2214,18 @@ impl ToolRegistry { { return Err( ToolResult::error( - "config key 'capture_scope' is retired; pass capture_scope=auto|window|desktop to start_session", + "config key 'capture_scope' is retired; select a window or desktop target on each action", ) .with_structured(serde_json::json!({ "code": "config_key_retired", "key": "capture_scope", - "replacement": "start_session.capture_scope", + "replacement": "action.target", })), ); } - if context.mode() == crate::authorization::PermissionMode::Unrestricted { + if context.mode() == crate::authorization::PermissionMode::Unrestricted + && context.capability_manifest().is_none() + { return Ok(()); } let properties = tool @@ -2329,6 +2482,27 @@ fn is_physical_desktop_action(tool: &str) -> bool { ) } +/// Bucket that owns the processes a call is allowed to terminate. +/// +/// A call that declares a session keys its launches to that session, so +/// concurrent agents sharing a runtime cannot kill each other's processes. A +/// sessionless call carries no such identity: bucketing its launches per +/// runtime scope keeps exactly the guarantee the refusal states — "launched +/// by this Cua runtime" — instead of recording no ownership at all, which +/// left a sessionless `launch_app` followed by `kill_app` permanently +/// refused in standard mode. +/// +/// The bucket is namespaced like a session but named with a NUL, which no +/// namespaced public session label contains, so it cannot be selected by +/// passing a crafted `session` argument. Even a collision would only reach +/// processes this same runtime scope launched. +fn process_ownership_key(runtime_session: Option<&str>, runtime_prefix: &str) -> String { + match runtime_session { + Some(session) => session.to_owned(), + None => format!("{runtime_prefix}\u{0}anonymous-launch"), + } +} + fn namespace_runtime_args( args: &mut Value, context: &crate::session_authorization::EffectiveAuthorizationContext, @@ -2342,6 +2516,13 @@ fn namespace_runtime_args( let Some(arguments) = args.as_object_mut() else { return runtime_prefix; }; + if arguments + .get("_session_id") + .and_then(Value::as_str) + .is_some_and(|session| session.is_empty() || session == "default") + { + arguments.remove("_session_id"); + } if let Some(public_session) = arguments .get("session") .and_then(Value::as_str) @@ -2363,6 +2544,13 @@ fn namespace_runtime_args( ); } } + if let Some(idle_ttl) = context.lifecycle_idle_ttl_override() { + let idle_ttl_ms = idle_ttl.as_millis().min(u64::MAX as u128) as u64; + arguments.insert( + "_session_idle_ttl_ms".to_owned(), + Value::Number(idle_ttl_ms.into()), + ); + } // The registry is the first boundary shared by every transport and the // direct SDK. Transport adapters may already have injected `_session_id`, // but a direct runtime call only carries the public `session` field. Mint @@ -2373,7 +2561,7 @@ fn namespace_runtime_args( if let Some(session) = arguments .get("session") .and_then(Value::as_str) - .filter(|value| !value.is_empty()) + .filter(|value| !value.is_empty() && *value != "default") .map(str::to_owned) { arguments.insert("_session_id".to_owned(), Value::String(session)); @@ -2403,6 +2591,26 @@ fn namespace_runtime_args( runtime_prefix } +fn session_requiring_tool(tool_name: &str) -> bool { + !matches!( + tool_name, + "check_permissions" + | "health_report" + | "get_session" + | "list_sessions" + | "get_session_state" + | "end_session" + ) +} + +fn session_selecting_tool(tool_name: &str) -> bool { + session_requiring_tool(tool_name) + || matches!( + tool_name, + "get_session" | "list_sessions" | "get_session_state" | "end_session" + ) +} + fn publish_action_result(result: &mut ToolResult) -> Result<(), String> { let action = result .action_record @@ -2520,6 +2728,13 @@ mod runtime_isolation_tests { fn bounded_context( manifest_source: &str, + ) -> Arc { + manifest_context(PermissionMode::Bounded, manifest_source) + } + + fn manifest_context( + mode: PermissionMode, + manifest_source: &str, ) -> Arc { let mut file = tempfile::NamedTempFile::new().unwrap(); file.write_all(manifest_source.as_bytes()).unwrap(); @@ -2528,14 +2743,14 @@ mod runtime_isolation_tests { .expect("test bounded manifest loads"), ); let ceiling = SessionModeCeiling::for_trusted_sessions( - [PermissionMode::Bounded], - false, + [mode], + mode == PermissionMode::Unrestricted, Duration::from_secs(60), Duration::from_secs(30), ) .unwrap(); SessionAuthorizationRegistry::with_ceiling(ceiling) - .compatibility_context(PermissionMode::Bounded, Some(manifest)) + .compatibility_context(mode, Some(manifest)) .unwrap() } @@ -2884,6 +3099,26 @@ mod runtime_isolation_tests { assert_ne!(result.is_error, Some(true)); } + #[tokio::test] + async fn default_public_session_uses_an_implicit_runtime_lifecycle() { + let hits = Arc::new(AtomicUsize::new(0)); + let registry = observation_registry(None, hits.clone()); + let context = standard_context(); + let runtime_prefix = format!("__cua_runtime_{}:", context.runtime_scope_key()); + let result = registry + .invoke_with_context( + "get_window_state", + serde_json::json!({"pid": 42, "window_id": 7, "session": "default"}), + context, + ) + .await; + + crate::session::revoke_sessions_with_prefix(&runtime_prefix); + crate::session::forget_ended_sessions_with_prefix(&runtime_prefix); + assert_eq!(hits.load(Ordering::SeqCst), 1); + assert_ne!(result.is_error, Some(true)); + } + #[tokio::test] async fn bounded_observation_uses_only_the_manifest_without_a_protected_host() { let hits = Arc::new(AtomicUsize::new(0)); @@ -2916,6 +3151,155 @@ resources: assert_ne!(result.is_error, Some(true)); } + #[tokio::test] + async fn capability_manifest_typed_resources_narrow_standard_and_unrestricted() { + for mode in [PermissionMode::Standard, PermissionMode::Unrestricted] { + let allowed_hits = Arc::new(AtomicUsize::new(0)); + let registry = attested_registry("get_window_state", None, allowed_hits.clone(), false); + let allowed = manifest_context( + mode, + r#" +version: 3 +allow: + tools: [get_window_state] +resources: + apps: + - executable: /synthetic/fixture + windows: all +"#, + ); + let result = registry + .invoke_with_context( + "get_window_state", + serde_json::json!({"pid": 424242, "window_id": 7, "session": "review"}), + allowed, + ) + .await; + assert_ne!(result.is_error, Some(true), "{mode:?} in-scope call"); + assert_eq!(allowed_hits.load(Ordering::SeqCst), 1); + + let denied_hits = Arc::new(AtomicUsize::new(0)); + let registry = attested_registry("get_window_state", None, denied_hits.clone(), false); + let denied = manifest_context( + mode, + r#" +version: 3 +allow: + tools: [get_window_state] +resources: + apps: + - executable: /another/application + windows: all +"#, + ); + let result = registry + .invoke_with_context( + "get_window_state", + serde_json::json!({"pid": 424242, "window_id": 7, "session": "review"}), + denied, + ) + .await; + assert_eq!(result.is_error, Some(true), "{mode:?} out-of-scope call"); + assert_eq!(denied_hits.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn capability_manifest_tools_narrow_standard_and_unrestricted() { + for mode in [PermissionMode::Standard, PermissionMode::Unrestricted] { + let hits = Arc::new(AtomicUsize::new(0)); + let registry = attested_registry("get_window_state", None, hits.clone(), false); + let context = manifest_context( + mode, + r#" +version: 3 +allow: + tools: [click] +"#, + ); + let result = registry + .invoke_with_context( + "get_window_state", + serde_json::json!({"pid": 424242, "window_id": 7, "session": "review"}), + context, + ) + .await; + assert_eq!(result.is_error, Some(true), "{mode:?} undeclared tool"); + assert_eq!(hits.load(Ordering::SeqCst), 0); + } + } + + #[tokio::test] + async fn rejected_typed_resource_does_not_refresh_manifest_idle_lease() { + let denied_hits = Arc::new(AtomicUsize::new(0)); + let allowed_hits = Arc::new(AtomicUsize::new(0)); + let mut registry = super::ToolRegistry::new(); + for (name, window_id, hits) in [ + ("get_desktop_state", 8_u64, denied_hits.clone()), + ("get_window_state", 7_u64, allowed_hits.clone()), + ] { + registry.register(Box::new(AttestedProbe { + hits, + scope: serde_json::json!({ + "kind": "window", + "pid": 42, + "window_id": window_id, + }), + stale_before_dispatch: false, + def: super::ToolDef { + name: name.to_owned(), + description: "manifest idle lease probe".into(), + input_schema: serde_json::json!({"type": "object"}), + read_only: true, + destructive: false, + idempotent: true, + open_world: false, + }, + })); + } + let context = manifest_context( + PermissionMode::Standard, + r#" +version: 3 +expires_after: 1h +idle_timeout: 1s +allow: + tools: [get_desktop_state, get_window_state] +resources: + desktop: + windows: + - pid: 42 + window_id: 7 +"#, + ); + + tokio::time::sleep(Duration::from_millis(650)).await; + let denied = registry + .invoke_with_context( + "get_desktop_state", + serde_json::json!({"session": "lease"}), + context.clone(), + ) + .await; + assert_eq!(denied.is_error, Some(true)); + assert_eq!(denied_hits.load(Ordering::SeqCst), 0); + + tokio::time::sleep(Duration::from_millis(650)).await; + let expired = registry + .invoke_with_context( + "get_window_state", + serde_json::json!({"pid": 42, "window_id": 7, "session": "lease"}), + context, + ) + .await; + assert_eq!(expired.is_error, Some(true)); + assert_eq!(allowed_hits.load(Ordering::SeqCst), 0); + assert!(expired + .content + .iter() + .any(|content| matches!(content, crate::protocol::Content::Text { text, .. } if text.contains("idle timeout")))); + } + #[tokio::test] async fn ended_session_and_runtime_suspend_latches_fail_closed_at_dispatch() { let ended_hits = Arc::new(AtomicUsize::new(0)); @@ -3323,6 +3707,80 @@ resources: ); } + #[tokio::test] + async fn manifest_cannot_widen_standard_foreign_process_termination() { + let hits = Arc::new(AtomicUsize::new(0)); + let provider = Arc::new(AcceptingProvider { + requests: AtomicUsize::new(0), + }); + let registry = attested_registry("kill_app", Some(provider.clone()), hits.clone(), false); + let context = manifest_context( + PermissionMode::Standard, + r#" +version: 3 +allow: + tools: [kill_app] +resources: + processes: + terminate: [424242] +"#, + ); + let result = registry + .invoke_with_context( + "kill_app", + serde_json::json!({"pid": 424242, "session": "process"}), + context, + ) + .await; + assert_eq!(hits.load(Ordering::SeqCst), 0); + assert_eq!(provider.requests.load(Ordering::SeqCst), 0); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.pointer("/refusal/code")) + .and_then(serde_json::Value::as_str), + Some("foreign_process_termination_denied") + ); + } + + #[tokio::test] + async fn manifest_preserves_standard_owned_process_termination_inside_scope() { + let hits = Arc::new(AtomicUsize::new(0)); + let registry = attested_registry("kill_app", None, hits.clone(), false); + let context = manifest_context( + PermissionMode::Standard, + r#" +version: 3 +allow: + tools: [kill_app] +resources: + processes: + terminate: [424242] +"#, + ); + registry + .protected_resource_ownership() + .mark_driver_owned_process( + &context.runtime_session_key("process"), + crate::browser::ProcessFingerprint { + pid: 424242, + start_time: Some(7), + executable: Some("/synthetic/fixture".to_owned()), + }, + ); + + let result = registry + .invoke_with_context( + "kill_app", + serde_json::json!({"pid": 424242, "session": "process"}), + context, + ) + .await; + assert_ne!(result.is_error, Some(true)); + assert_eq!(hits.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn standard_owned_process_termination_reproves_the_fingerprint() { let hits = Arc::new(AtomicUsize::new(0)); @@ -3351,6 +3809,153 @@ resources: assert_eq!(hits.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn standard_sessionless_owned_process_termination_reproves_the_fingerprint() { + // A call without a public label now receives the runtime's implicit + // lifecycle identity, so launch provenance and the matching kill_app + // resolve through that stable private bucket. + let hits = Arc::new(AtomicUsize::new(0)); + let registry = attested_registry("kill_app", None, hits.clone(), false); + let context = standard_context(); + registry + .protected_resource_ownership() + .mark_driver_owned_process( + &context.runtime_session_key("implicit-direct"), + crate::browser::ProcessFingerprint { + pid: 424242, + start_time: Some(7), + executable: Some("/synthetic/fixture".to_owned()), + }, + ); + + let result = registry + .invoke_with_context("kill_app", serde_json::json!({"pid": 424242}), context) + .await; + assert_ne!(result.is_error, Some(true)); + assert_eq!(hits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn standard_sessionless_foreign_process_termination_still_denies() { + let hits = Arc::new(AtomicUsize::new(0)); + let provider = Arc::new(AcceptingProvider { + requests: AtomicUsize::new(0), + }); + let registry = attested_registry("kill_app", Some(provider.clone()), hits.clone(), true); + let result = registry + .invoke_with_context( + "kill_app", + serde_json::json!({"pid": 424242}), + standard_context(), + ) + .await; + assert_eq!(hits.load(Ordering::SeqCst), 0); + assert_eq!(provider.requests.load(Ordering::SeqCst), 0); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.pointer("/refusal/code")) + .and_then(serde_json::Value::as_str), + Some("foreign_process_termination_denied") + ); + } + + #[tokio::test] + async fn lifecycle_session_selection_cannot_turn_a_denied_action_into_allow() { + let hits = Arc::new(AtomicUsize::new(0)); + let registry = attested_registry("kill_app", None, hits.clone(), false); + let context = standard_context(); + let runtime_prefix = context.runtime_session_key(""); + let before = crate::session::list_session_snapshots_with_prefix( + &runtime_prefix, + crate::session::DEFAULT_SESSION_IDLE_TTL, + ) + .len(); + let variants = [ + serde_json::json!({"pid": 424242}), + serde_json::json!({"pid": 424242, "session": "named-a"}), + serde_json::json!({"pid": 424242, "session": "named-b"}), + serde_json::json!({ + "pid": 424242, + "session": "named-a", + "_session_id": "forged-private-session", + "_transport_session_id": "forged-transport" + }), + ]; + let mut refusal_codes = Vec::new(); + for arguments in variants { + let result = registry + .invoke_with_context("kill_app", arguments, context.clone()) + .await; + refusal_codes.push( + result + .structured_content + .as_ref() + .and_then(|value| value.pointer("/refusal/code")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + ); + } + assert_eq!(hits.load(Ordering::SeqCst), 0); + assert!(refusal_codes + .iter() + .all(|code| code.as_deref() == Some("foreign_process_termination_denied"))); + assert_eq!( + crate::session::list_session_snapshots_with_prefix( + &runtime_prefix, + crate::session::DEFAULT_SESSION_IDLE_TTL, + ) + .len(), + before, + "denied calls must not create or refresh lifecycle sessions" + ); + } + + #[tokio::test] + async fn sessionless_termination_cannot_reach_a_session_scoped_launch() { + // The anonymous bucket must not become a backdoor into another + // agent's processes: a launch attributed to a session stays killable + // only through that session. + let hits = Arc::new(AtomicUsize::new(0)); + let registry = attested_registry("kill_app", None, hits.clone(), false); + let context = standard_context(); + registry + .protected_resource_ownership() + .mark_driver_owned_process( + &context.runtime_session_key("process"), + crate::browser::ProcessFingerprint { + pid: 424242, + start_time: Some(7), + executable: Some("/synthetic/fixture".to_owned()), + }, + ); + + let result = registry + .invoke_with_context("kill_app", serde_json::json!({"pid": 424242}), context) + .await; + assert_eq!(hits.load(Ordering::SeqCst), 0); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.pointer("/refusal/code")) + .and_then(serde_json::Value::as_str), + Some("foreign_process_termination_denied") + ); + } + + #[test] + fn anonymous_process_ownership_key_cannot_be_selected_by_a_session_argument() { + let prefix = "__cua_runtime_scope:"; + let anonymous = super::process_ownership_key(None, prefix); + assert_eq!(super::process_ownership_key(Some("s"), prefix), "s"); + assert!(anonymous.starts_with(prefix)); + // A namespaced public session label is prefix + the caller's string; + // the NUL keeps the anonymous bucket outside that space. + assert!(anonymous.contains('\u{0}')); + } + #[tokio::test] async fn standard_browser_identity_is_reproved_without_repeating_attach_authorization() { let hits = Arc::new(AtomicUsize::new(0)); @@ -3442,7 +4047,6 @@ resources: "session": "public", "_session_id": "trusted-owner", "_transport_session_id": "transport-a", - "_cua_browser_prepare_mcp_host_approved": true, "_cua_browser_download_mcp_host_approved": true, "_protected_process_fingerprint": {"pid": 1}, "files": ["/does/not/matter"] @@ -3473,10 +4077,6 @@ resources: .get("_transport_session_id") .and_then(serde_json::Value::as_str) .is_some_and(|value| value.ends_with(":transport-a"))); - assert_eq!( - received["_cua_browser_prepare_mcp_host_approved"], - serde_json::Value::Bool(true) - ); assert_eq!( received["_cua_browser_download_mcp_host_approved"], serde_json::Value::Bool(true) @@ -3845,7 +4445,7 @@ resources: } #[test] - fn anonymous_default_identity_keeps_its_legacy_process_scope() { + fn anonymous_default_identity_does_not_claim_a_lifecycle_key() { let context = standard_context(); let mut args = serde_json::json!({ "session": "default", @@ -3858,7 +4458,7 @@ resources: &TrustedInvocationEvidence::default(), ); assert_eq!(args["session"], "default"); - assert_eq!(args["_session_id"], "default"); + assert!(args.get("_session_id").is_none()); assert_eq!(args["cursor_id"], "default"); assert!(args.get("_public_session_label").is_none()); } @@ -3922,13 +4522,6 @@ fn recording_args_for(tool_name: &str, args: &Value) -> Value { if let Some(arguments) = redacted.as_object_mut() { match tool_name { "browser_prepare" => { - if arguments.contains_key("approval_token") { - arguments.insert( - "approval_token".to_owned(), - Value::String("[redacted]".to_owned()), - ); - } - arguments.remove("_cua_browser_prepare_mcp_host_approved"); arguments.remove("_transport_session_id"); } "browser_dialog" => { @@ -4033,7 +4626,7 @@ mod capability_tests { use super::*; #[test] - fn browser_prepare_recording_redacts_authority_and_transport_secrets() { + fn browser_prepare_recording_redacts_transport_identity() { let recorded = recording_args_for( "browser_prepare", &serde_json::json!({ @@ -4041,15 +4634,9 @@ mod capability_tests { "window_id": 7, "session": "public-session", "strategy": {"kind": "existing_profile"}, - "approval_token": "one-use-secret", - "_cua_browser_prepare_mcp_host_approved": true, "_transport_session_id": "private-transport", }), ); - assert_eq!(recorded["approval_token"], "[redacted]"); - assert!(recorded - .get("_cua_browser_prepare_mcp_host_approved") - .is_none()); assert!(recorded.get("_transport_session_id").is_none()); assert_eq!(recorded["pid"], 42); assert_eq!(recorded["window_id"], 7); @@ -4161,6 +4748,7 @@ mod capability_tests { "press_key", "hotkey", "set_value", + "perform_secondary_action", // screen "zoom", "get_screen_size", @@ -4183,6 +4771,8 @@ mod capability_tests { "set_config", // sessions "start_session", + "get_session", + "list_sessions", "escalate_session", "get_session_state", "end_session", @@ -4247,6 +4837,13 @@ mod capability_tests { // Surface 6 — claimed by tools that accept the opaque // `element_token` arg + get_window_state which emits them. "accessibility.element_tokens", + "accessibility.value.set", + "accessibility.action.secondary", + // Versioned opt-in revision/diff protocol on get_window_state. + // Advertising the token promises the versioned protocol is accepted; + // platforms without approved native identity still answer with + // explicit full-only responses. + "accessibility.observation_revision.v1", // app / window "app.launch", "app.list", @@ -4264,6 +4861,8 @@ mod capability_tests { "system.config.write", // sessions "session.lifecycle.start", + "session.lifecycle.read", + "session.lifecycle.list", "session.lifecycle.end", "session.capture_scope", "session.capture_scope.read", @@ -4448,7 +5047,9 @@ mod capability_tests { "type_text", "type_text_chars", "press_key", + "hotkey", "set_value", + "perform_secondary_action", // get_window_state emits the tokens — same capability // claim, from the other side of the contract. "get_window_state", @@ -4563,27 +5164,82 @@ mod capability_tests { assert!(entry["capabilities"].is_array()); } + fn action_tool_entry(name: &str) -> serde_json::Value { + ToolDef { + name: name.into(), + description: "Action.".into(), + input_schema: serde_json::json!({"type":"object","properties":{}}), + read_only: false, + destructive: false, + idempotent: false, + open_world: true, + } + .to_list_entry() + } + #[test] fn action_tools_advertise_the_same_closed_output_schema() { let expected = ::output_schema(); for name in ["click", "browser_click", "browser_pointer", "browser_type"] { - let def = ToolDef { - name: name.into(), - description: "Action.".into(), - input_schema: serde_json::json!({"type":"object","properties":{}}), - read_only: false, - destructive: false, - idempotent: false, - open_world: true, - }; - let entry = def.to_list_entry(); - assert_eq!(entry["outputSchema"], expected, "{name}"); - assert_eq!( - entry["outputSchema"]["additionalProperties"], false, - "{name}" + let entry = action_tool_entry(name); + // The success variant is unchanged and still closed; it now sits + // beside the refusal envelope instead of standing alone. + let success = &entry["outputSchema"]["anyOf"][0]; + assert_eq!(entry["outputSchema"]["type"], "object", "{name}"); + assert_eq!(success, &expected, "{name}"); + assert_eq!(success["additionalProperties"], false, "{name}"); + } + } + + /// Regression: refusals must validate against the advertised schema. + /// + /// Both payloads below are verbatim captures from a live `cua-driver mcp` + /// stdio session. Advertising only the success shape made strict MCP + /// clients reject them with `-32602`, which discarded the refusal message + /// the driver had put in `content` and left agents with no signal to + /// re-snapshot. + #[test] + fn advertised_output_schema_accepts_live_refusal_payloads() { + let schema = &action_tool_entry("click")["outputSchema"]; + let compiled = jsonschema::validator_for(schema).expect("advertised schema compiles"); + + let stale_element_token = serde_json::json!({ + "refusal": { + "code": "stale_element_token", + "message": "element_token is stale; call get_window_state again to refresh" + }, + "status": "refused" + }); + let window_target_not_found = serde_json::json!({ + "candidates": [], + "code": "window_target_not_found", + "effect": "refused", + "pid": 999_999 + }); + let success = serde_json::json!({ + "delivery": {"mode": "background"}, + "effect": "unverifiable", + "route": "accessibility" + }); + + for payload in [&stale_element_token, &window_target_not_found, &success] { + assert!( + compiled.is_valid(payload), + "advertised schema rejected {payload}" ); } + + // The success variant stays strict: an unknown key is still a bug, and + // must not be laundered through the permissive refusal variant. + assert!( + !compiled.is_valid(&serde_json::json!({ + "effect": "confirmed", + "route": "accessibility", + "bogus_key": true + })), + "a malformed success payload must not validate" + ); } #[test] @@ -4625,8 +5281,9 @@ mod capability_tests { open_world: false, } .to_list_entry(); - let z_index = - &entry["outputSchema"]["properties"]["windows"]["items"]["properties"]["z_index"]; + // anyOf[0] is the success variant; anyOf[1] is the refusal envelope. + let z_index = &entry["outputSchema"]["anyOf"][0]["properties"]["windows"]["items"] + ["properties"]["z_index"]; assert_eq!(z_index["type"], serde_json::json!(["integer", "null"])); assert!(z_index["description"] .as_str() diff --git a/packages/cua-driver/rust/crates/cua-driver-core/src/tool_schema.rs b/packages/cua-driver/rust/crates/cua-driver-core/src/tool_schema.rs index c7e0bb1b51..b42c0f6040 100644 --- a/packages/cua-driver/rust/crates/cua-driver-core/src/tool_schema.rs +++ b/packages/cua-driver/rust/crates/cua-driver-core/src/tool_schema.rs @@ -15,26 +15,34 @@ //! required-set spec. Each platform calls it from a test against its own //! registry, so CI fails the moment a platform drifts. //! -//! Descriptions are intentionally NOT compared — prose can legitimately vary +//! Descriptions are generally not compared because prose can legitimately vary //! per tool (a `delivery_mode` blurb mentioning "PIXEL click" vs "AX insert"). -//! The gate enforces *shape* (`type` / `enum` / `items`), which is what governs -//! client compatibility. +//! The `session` guidance is the exception: action tools must tell callers that +//! public labels are preferred for multi-call work and must be repeated on each +//! call. Lifecycle-management tools keep their resource-specific wording. use serde_json::{json, Value}; // ── Fragments ──────────────────────────────────────────────────────────────── -/// `session` — the per-run agent-cursor identity. Uniform across every tool. +/// `session` is an optional public lifecycle label. Uniform across every tool. pub fn session_schema() -> Value { json!({ "type": "string", - "description": "Optional session id: declares/uses the agent cursor and \ - per-session state for this run. The same id works over MCP, the CLI, \ - or the raw socket, and follows the run across apps/windows. Omit to \ - run cursor-less." + "description": cua_driver_contract::MULTI_CALL_SESSION_DESCRIPTION }) } +/// Canonical lifecycle guidance plus a tool-specific ownership or precedence note. +pub fn session_schema_with(note: &str) -> Value { + let mut schema = session_schema(); + schema["description"] = Value::String(format!( + "{} {note}", + cua_driver_contract::MULTI_CALL_SESSION_DESCRIPTION + )); + schema +} + /// `delivery_mode` — the best-effort-background ladder rung. The prose varies by /// tool (the surface it injects through differs), so callers may pass their own /// `description`; the shape is fixed here. @@ -171,6 +179,33 @@ fn structural(schema: &Value) -> Value { Value::Object(out) } +fn session_guidance_is_exempt(tool_name: &str) -> bool { + matches!( + tool_name, + "start_session" + | "end_session" + | "get_session" + | "get_session_state" + | "list_sessions" + | "escalate_session" + | "set_agent_cursor_enabled" + | "set_agent_cursor_motion" + | "set_agent_cursor_theme" + | "get_agent_cursor_state" + ) +} + +fn session_description_has_multi_call_guidance(schema: &Value) -> bool { + schema + .get("description") + .and_then(Value::as_str) + .map(|description| description.split_whitespace().collect::>().join(" ")) + .is_some_and(|description| { + description.contains("prefer a short public session label") + && description.contains("repeat it on every call that accepts it") + }) +} + /// Check one tool's `input_schema` against the shared canon. Returns a list of /// human-readable violation strings (empty == consistent). Each platform calls /// this for every tool in its registry from a test. @@ -188,6 +223,15 @@ pub fn shared_schema_violations(tool_name: &str, input_schema: &Value) -> Vec &'static str { + match self { + Self::NotObservable => BROWSER_CHROME_COVERAGE_STATUS, + Self::MayBeIncomplete => BROWSER_CHROME_MAY_BE_INCOMPLETE_STATUS, + } + } +} /// Describe the browser-chrome coverage limit of a Chromium-family window /// snapshot. /// /// This deliberately does **not** claim that a prompt is present. Presence is -/// not observable from a single native-window surface on every supported -/// platform. It also carries no prompt text or choices, so routine traces can -/// retain the recovery signal without retaining permission content. -pub fn mark_browser_chrome_capture_coverage(structured: &mut Value, chromium_family_window: bool) { - if !chromium_family_window { - return; - } +/// not reliably complete from a single native-window surface on every +/// supported platform. It also carries no prompt text or choices, so routine +/// traces can retain the recovery signal without retaining permission content. +pub fn mark_browser_chrome_capture_coverage( + structured: &mut Value, + coverage: Option, +) { + let Some(coverage) = coverage else { return }; structured["capture_coverage"] = json!({ "browser_chrome": { - "status": BROWSER_CHROME_COVERAGE_STATUS + "status": coverage.status() }, "recovery": { "when": "verified_window_action_ineffective", - "escalate": { - "tool": "escalate_session", - "reason": "foreground_ineffective" - }, "inspect": "get_desktop_state", - "act_scope": "desktop", + "act_target": { + "kind": "desktop", + "display_id": "primary" + }, "verify": "get_desktop_state" } }); @@ -54,8 +70,14 @@ mod tests { }); let mut after_visible_browser_blocker = before.clone(); - mark_browser_chrome_capture_coverage(&mut before, true); - mark_browser_chrome_capture_coverage(&mut after_visible_browser_blocker, true); + mark_browser_chrome_capture_coverage( + &mut before, + Some(BrowserChromeCaptureCoverage::NotObservable), + ); + mark_browser_chrome_capture_coverage( + &mut after_visible_browser_blocker, + Some(BrowserChromeCaptureCoverage::NotObservable), + ); // Even when the page-owned state is byte-for-byte unchanged, callers // receive an explicit recovery branch instead of treating the window @@ -69,17 +91,17 @@ mod tests { before["capture_coverage"]["recovery"]["when"], "verified_window_action_ineffective" ); - assert_eq!( - before["capture_coverage"]["recovery"]["escalate"], - json!({ - "tool": "escalate_session", - "reason": "foreground_ineffective" - }) - ); + assert!(before["capture_coverage"]["recovery"] + .get("escalate") + .is_none()); assert_eq!( before["capture_coverage"]["recovery"]["inspect"], "get_desktop_state" ); + assert_eq!( + before["capture_coverage"]["recovery"]["act_target"], + json!({"kind": "desktop", "display_id": "primary"}) + ); assert!(before.get("browser_chrome_prompt").is_none()); } @@ -87,11 +109,18 @@ mod tests { fn signal_is_privacy_minimal_and_does_not_change_non_browser_snapshots() { let mut ordinary = json!({"window_id": 9, "pid": 3}); let unchanged = ordinary.clone(); - mark_browser_chrome_capture_coverage(&mut ordinary, false); + mark_browser_chrome_capture_coverage(&mut ordinary, None); assert_eq!(ordinary, unchanged); let mut browser = unchanged; - mark_browser_chrome_capture_coverage(&mut browser, true); + mark_browser_chrome_capture_coverage( + &mut browser, + Some(BrowserChromeCaptureCoverage::MayBeIncomplete), + ); + assert_eq!( + browser["capture_coverage"]["browser_chrome"]["status"], + BROWSER_CHROME_MAY_BE_INCOMPLETE_STATUS + ); let public = browser.to_string(); for sensitive_key in ["text", "message", "choice", "allow", "deny"] { assert!( diff --git a/packages/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs b/packages/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs index 03d8c8d38d..8f40c8ba02 100644 --- a/packages/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs +++ b/packages/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs @@ -9,11 +9,13 @@ use crate::runtime::{DriverRuntime, RuntimeCreateError, RuntimeOptions, RuntimeS use crate::{DriverError, DriverMetadata}; use cua_driver_core::{ authorization::{ - PermissionMode, DANGEROUS_BYPASS_ENV, DISABLE_UNRESTRICTED_ENV, - LEGACY_EXISTING_PROFILE_APPROVAL_ENV, PERMISSION_MODE_ENV, + PermissionMode, DANGEROUS_BYPASS_ENV, DISABLE_UNRESTRICTED_ENV, PERMISSION_MODE_ENV, }, session_authorization::{DelegatedSessionRequest, SessionModeCeiling}, - session_manifest::{load_manifest, SESSION_POLICY_APPROVED_ENV, SESSION_POLICY_FILE_ENV}, + session_manifest::{ + load_manifest, CAPABILITY_MANIFEST_APPROVED_ENV, CAPABILITY_MANIFEST_FILE_ENV, + SESSION_POLICY_APPROVED_ENV, SESSION_POLICY_FILE_ENV, + }, }; use serde::Deserialize; use serde_json::Value; @@ -176,12 +178,34 @@ struct AbiDriverOptions { struct AbiRuntimeAuthorizationOptions { allowed_modes: Vec, compatibility_mode: PermissionMode, + #[serde(default)] + compatibility_capability_manifest_path: Option, + #[serde(default)] compatibility_bounded_manifest_path: Option, unrestricted_acknowledged: bool, max_session_ttl_seconds: u64, max_idle_ttl_seconds: u64, } +impl AbiRuntimeAuthorizationOptions { + fn capability_manifest_path(&self) -> Result, AbiFailure> { + if self.compatibility_capability_manifest_path.is_some() + && self.compatibility_bounded_manifest_path.is_some() + && self.compatibility_capability_manifest_path + != self.compatibility_bounded_manifest_path + { + return Err(AbiFailure::new( + CuaDriverStatus::InvalidArgument, + "compatibility capability manifest path conflicts with its deprecated bounded-manifest alias", + )); + } + Ok(self + .compatibility_capability_manifest_path + .as_deref() + .or(self.compatibility_bounded_manifest_path.as_deref())) + } +} + fn validate_explicit_authorization_sources( authorization: &AbiRuntimeAuthorizationOptions, ) -> Result<(), AbiFailure> { @@ -226,24 +250,28 @@ fn validate_explicit_authorization_sources( )); } - if environment_flag(LEGACY_EXISTING_PROFILE_APPROVAL_ENV) { + let capability_environment_manifest = std::env::var_os(CAPABILITY_MANIFEST_FILE_ENV); + let legacy_environment_manifest = std::env::var_os(SESSION_POLICY_FILE_ENV); + if capability_environment_manifest.is_some() + && legacy_environment_manifest.is_some() + && capability_environment_manifest != legacy_environment_manifest + { return Err(AbiFailure::new( CuaDriverStatus::InvalidArgument, - "explicit runtime authorization conflicts with the legacy existing-profile approval escape hatch", + "capability manifest environment path conflicts with its deprecated session-policy alias", )); } - - let environment_manifest = - std::env::var_os(SESSION_POLICY_FILE_ENV).map(std::path::PathBuf::from); + let environment_manifest = capability_environment_manifest + .or(legacy_environment_manifest) + .map(std::path::PathBuf::from); if let Some(environment_manifest) = environment_manifest { let Some(explicit_manifest) = authorization - .compatibility_bounded_manifest_path - .as_deref() + .capability_manifest_path()? .map(std::path::Path::new) else { return Err(AbiFailure::new( CuaDriverStatus::InvalidArgument, - "explicit runtime authorization conflicts with a compatibility session policy environment value", + "explicit runtime authorization conflicts with a compatibility capability-manifest environment value", )); }; let environment_manifest = @@ -251,7 +279,7 @@ fn validate_explicit_authorization_sources( AbiFailure::new( CuaDriverStatus::InvalidArgument, format!( - "canonicalize compatibility session policy {}: {error}", + "canonicalize compatibility capability manifest {}: {error}", environment_manifest.display() ), ) @@ -260,7 +288,7 @@ fn validate_explicit_authorization_sources( AbiFailure::new( CuaDriverStatus::InvalidArgument, format!( - "canonicalize explicit compatibility session policy {}: {error}", + "canonicalize explicit compatibility capability manifest {}: {error}", explicit_manifest.display() ), ) @@ -268,15 +296,16 @@ fn validate_explicit_authorization_sources( if environment_manifest != explicit_manifest { return Err(AbiFailure::new( CuaDriverStatus::InvalidArgument, - "explicit compatibility session policy conflicts with the trusted environment path", + "explicit compatibility capability manifest conflicts with the trusted environment path", )); } - } else if environment_flag(SESSION_POLICY_APPROVED_ENV) - && authorization.compatibility_bounded_manifest_path.is_none() + } else if (environment_flag(CAPABILITY_MANIFEST_APPROVED_ENV) + || environment_flag(SESSION_POLICY_APPROVED_ENV)) + && authorization.capability_manifest_path()?.is_none() { return Err(AbiFailure::new( CuaDriverStatus::InvalidArgument, - "explicit runtime authorization conflicts with a compatibility session-policy approval", + "explicit runtime authorization conflicts with a compatibility capability-manifest approval", )); } Ok(()) @@ -294,8 +323,7 @@ fn runtime_options_from_abi(options: AbiDriverOptions) -> Result, + #[serde(default)] bounded_manifest_path: Option, + /// Host-generated lifecycle lease identity. This field is accepted only + /// by the in-process Rust bridge; it is not part of the generated public + /// TrustedSessionOptions record or any agent-visible tool schema. + #[serde(default)] + transport_session: Option, +} + +impl AbiTrustedSessionOptions { + fn capability_manifest_path(&self) -> Result, AbiFailure> { + if self.capability_manifest_path.is_some() + && self.bounded_manifest_path.is_some() + && self.capability_manifest_path != self.bounded_manifest_path + { + return Err(AbiFailure::new( + CuaDriverStatus::InvalidArgument, + "capability manifest path conflicts with its deprecated bounded-manifest alias", + )); + } + Ok(self + .capability_manifest_path + .as_deref() + .or(self.bounded_manifest_path.as_deref())) + } } fn with_ffi_guard( @@ -758,27 +812,29 @@ pub unsafe extern "C" fn cua_driver_session_create_v1( "public_session must not be empty", )); } - let bounded_manifest = options - .bounded_manifest_path - .as_deref() + let capability_manifest = options + .capability_manifest_path()? .map(|path| { cua_driver_core::session_manifest::load_manifest(std::path::Path::new(path)) .map(Arc::new) .map_err(|error| { AbiFailure::new( CuaDriverStatus::InvalidArgument, - format!("invalid bounded session manifest: {error}"), + format!("invalid capability manifest: {error}"), ) }) }) .transpose()?; let request = DelegatedSessionRequest { public_session: options.public_session, - transport_session: format!("direct-{}", uuid::Uuid::new_v4()), + transport_session: options + .transport_session + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("direct-{}", uuid::Uuid::new_v4())), mode: options.mode, ttl: Duration::from_secs(options.ttl_seconds), idle_ttl: Duration::from_secs(options.idle_ttl_seconds), - bounded_manifest, + capability_manifest, }; let session = driver .runtime diff --git a/packages/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs b/packages/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs index 5bfaf95804..684e190030 100644 --- a/packages/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs +++ b/packages/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs @@ -43,6 +43,11 @@ pub struct EmbeddedDriverHostOptions { pub startup_timeout_ms: Option, pub shutdown_timeout_ms: Option, pub permission_mode: Option, + #[uniffi(default = None)] + pub capability_manifest_path: Option, + #[uniffi(default = false)] + pub approve_capability_manifest: bool, + /// Deprecated aliases retained for compatibility. pub session_policy_path: Option, pub approve_session_policy: bool, pub dangerously_bypass_approvals: bool, @@ -251,6 +256,8 @@ impl EmbeddedCuaDriverHost { startup_timeout_ms: None, shutdown_timeout_ms: None, permission_mode: None, + capability_manifest_path: None, + approve_capability_manifest: false, session_policy_path: None, approve_session_policy: false, dangerously_bypass_approvals: false, @@ -633,10 +640,10 @@ impl EmbeddedCuaDriverHost { .into(), ]; if let Some(path) = &self.options.session_policy_path { - args.extend(["--session-policy".into(), path.clone()]); + args.extend(["--capability-manifest".into(), path.clone()]); } if self.options.approve_session_policy { - args.push("--approve-session-policy".into()); + args.push("--approve-capability-manifest".into()); } if self.options.dangerously_bypass_approvals { args.push("--dangerously-bypass-approvals".into()); @@ -732,13 +739,33 @@ fn validate_options( let permission_mode = options .permission_mode .unwrap_or(EmbeddedPermissionMode::Standard); + if options.capability_manifest_path.is_some() + && options.session_policy_path.is_some() + && options.capability_manifest_path != options.session_policy_path + { + return configuration_error( + "capability_manifest_path conflicts with deprecated session_policy_path", + ); + } + let capability_manifest_path = options + .capability_manifest_path + .clone() + .or_else(|| options.session_policy_path.clone()); + let capability_manifest_approved = + options.approve_capability_manifest || options.approve_session_policy; + let manifest_configured = capability_manifest_path + .as_deref() + .is_some_and(|path| !path.trim().is_empty()); + if capability_manifest_path.is_some() && !manifest_configured { + return configuration_error("capability manifest path must not be empty"); + } + if manifest_configured != capability_manifest_approved { + return configuration_error( + "capability manifest path and approval acknowledgement must be supplied together", + ); + } match permission_mode { EmbeddedPermissionMode::Standard => { - if options.session_policy_path.is_some() || options.approve_session_policy { - return configuration_error( - "session policy options are valid only in bounded mode", - ); - } if options.dangerously_bypass_approvals { return configuration_error( "dangerously_bypass_approvals is valid only in unrestricted mode", @@ -746,15 +773,8 @@ fn validate_options( } } EmbeddedPermissionMode::Bounded => { - if options - .session_policy_path - .as_deref() - .is_none_or(|path| path.trim().is_empty()) - { - return configuration_error("bounded mode requires session_policy_path"); - } - if !options.approve_session_policy { - return configuration_error("bounded mode requires approve_session_policy=true"); + if !manifest_configured { + return configuration_error("bounded mode requires a capability manifest"); } if options.dangerously_bypass_approvals { return configuration_error("bounded mode cannot bypass runtime approvals"); @@ -766,11 +786,6 @@ fn validate_options( "unrestricted mode requires dangerously_bypass_approvals=true", ); } - if options.session_policy_path.is_some() || options.approve_session_policy { - return configuration_error( - "unrestricted mode cannot use a bounded session policy", - ); - } } } for variable in &options.environment { @@ -795,8 +810,8 @@ fn validate_options( startup_timeout: Duration::from_millis(startup_timeout_ms), shutdown_timeout: Duration::from_millis(shutdown_timeout_ms), permission_mode, - session_policy_path: options.session_policy_path, - approve_session_policy: options.approve_session_policy, + session_policy_path: capability_manifest_path, + approve_session_policy: capability_manifest_approved, dangerously_bypass_approvals: options.dangerously_bypass_approvals, environment: options.environment, inherit_stderr: options.inherit_stderr, @@ -846,9 +861,10 @@ pub(crate) fn inherited_managed_environment_name(name: &str) -> bool { "CUA_DRIVER_PERMISSION_MODE" | "CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS" | "CUA_DRIVER_DISABLE_UNRESTRICTED" - | "CUA_DRIVER_ALLOW_LEGACY_EXISTING_PROFILE_APPROVAL" | "CUA_DRIVER_SESSION_POLICY_FILE" | "CUA_DRIVER_SESSION_POLICY_APPROVED" + | "CUA_DRIVER_CAPABILITY_MANIFEST_FILE" + | "CUA_DRIVER_CAPABILITY_MANIFEST_APPROVED" | "CUA_DRIVER_POLICY_FILE" | "CUA_DRIVER_MANAGED_POLICY_FILE" ) @@ -1127,6 +1143,8 @@ mod tests { startup_timeout_ms: None, shutdown_timeout_ms: None, permission_mode: Some(mode), + capability_manifest_path: None, + approve_capability_manifest: false, session_policy_path: None, approve_session_policy: false, dangerously_bypass_approvals: false, @@ -1139,6 +1157,11 @@ mod tests { fn authorization_modes_require_explicit_acknowledgements() { assert!(validate_options(options(EmbeddedPermissionMode::Standard)).is_ok()); + let mut standard_manifest = options(EmbeddedPermissionMode::Standard); + standard_manifest.capability_manifest_path = Some("capabilities.yaml".into()); + standard_manifest.approve_capability_manifest = true; + assert!(validate_options(standard_manifest).is_ok()); + let bounded = options(EmbeddedPermissionMode::Bounded); assert!(validate_options(bounded).is_err()); let mut bounded = options(EmbeddedPermissionMode::Bounded); @@ -1151,6 +1174,21 @@ mod tests { let mut unrestricted = options(EmbeddedPermissionMode::Unrestricted); unrestricted.dangerously_bypass_approvals = true; assert!(validate_options(unrestricted).is_ok()); + + let mut unrestricted_manifest = options(EmbeddedPermissionMode::Unrestricted); + unrestricted_manifest.dangerously_bypass_approvals = true; + unrestricted_manifest.capability_manifest_path = Some("capabilities.yaml".into()); + unrestricted_manifest.approve_capability_manifest = true; + assert!(validate_options(unrestricted_manifest).is_ok()); + } + + #[test] + fn capability_manifest_aliases_must_not_conflict() { + let mut options = options(EmbeddedPermissionMode::Standard); + options.capability_manifest_path = Some("capabilities-v3.yaml".into()); + options.session_policy_path = Some("legacy-v2.yaml".into()); + options.approve_capability_manifest = true; + assert!(validate_options(options).is_err()); } #[test] diff --git a/packages/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs b/packages/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs index 87f013773a..2e988dbfe7 100644 --- a/packages/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs +++ b/packages/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs @@ -6,12 +6,17 @@ //! MCP and daemon transports are downstream adapters rather than peer contracts. use cua_driver_contract::{ - ActionResult, ClickInput, ClipboardReadInput, ClipboardWriteInput, DragInput, EndSessionInput, - EndSessionOutput, EscalateSessionInput, GetAgentCursorStateInput, GetCursorPositionInput, - GetDesktopStateInput, GetScreenSizeInput, GetSessionStateInput, HotkeyInput, InvokeMenuInput, - MoveCursorInput, PressKeyInput, ScrollInput, SessionStateOutput, SetAgentCursorEnabledInput, - SetAgentCursorMotionInput, SetAgentCursorThemeInput, SetWindowFrameInput, StartSessionInput, + ActionResult, ClickInput, ClipboardReadInput, ClipboardWriteInput, DoubleClickInput, DragInput, + EndSessionInput, EndSessionOutput, EscalateSessionInput, GetAgentCursorStateInput, + GetCursorPositionInput, GetDesktopStateInput, GetScreenSizeInput, GetSessionInput, + GetSessionStateInput, GetWindowStateInput, HotkeyInput, InvokeMenuInput, ListAppsInput, + ListSessionsInput, ListSessionsOutput, ListWindowsInput, MoveCursorInput, + PerformSecondaryActionInput, PressKeyInput, RightClickInput, ScrollInput, SessionOutput, + SessionStateOutput, SetAgentCursorEnabledInput, SetAgentCursorMotionInput, + SetAgentCursorThemeInput, SetValueInput, SetWindowFrameInput, StartSessionInput, StartSessionOutput, ToolInput, TypeTextInput, VerifyStateInput, VerifyStateOutput, + WindowClickInput, WindowDragInput, WindowHotkeyInput, WindowPressKeyInput, WindowScrollInput, + WindowTypeTextInput, }; use cua_driver_core::daemon::{ is_daemon_listening, request_daemon_metadata, send_request, socket_path_for_namespace, @@ -43,6 +48,43 @@ use runtime::RuntimeOptions; use service_session::ServiceSessionClient; use worker::{ActionCompletion, PrivateWorkerClient}; +fn host_sessions_json_for_prefix(runtime_prefix: &str) -> Value { + let sessions = cua_driver_core::session::list_session_snapshots_with_prefix( + runtime_prefix, + cua_driver_core::session::DEFAULT_SESSION_IDLE_TTL, + ) + .into_iter() + .map(|session| { + let transport = match session.transport { + cua_driver_core::session::SessionTransport::Cli => "cli", + cua_driver_core::session::SessionTransport::Daemon => "daemon", + cua_driver_core::session::SessionTransport::McpStdio => "mcp_stdio", + cua_driver_core::session::SessionTransport::McpHttp => "mcp_http", + }; + let client_kind = match session.client_kind { + cua_driver_core::session::SessionClientKind::Cli => "cli", + cua_driver_core::session::SessionClientKind::Direct => "direct", + cua_driver_core::session::SessionClientKind::Mcp => "mcp", + cua_driver_core::session::SessionClientKind::PythonSdk => "python_sdk", + cua_driver_core::session::SessionClientKind::TypescriptSdk => "typescript_sdk", + }; + serde_json::json!({ + "session": session.public_label.as_deref().and_then(cursor_overlay::sanitize_session_label), + "implicit": session.implicit, + "state": if session.ending { "ending" } else { "active" }, + "client_kind": client_kind, + "transport": transport, + "cursor_visible": cua_driver_core::session::cursor_visible(&session.runtime_id), + "recording_active": cua_driver_core::session::recording_active(&session.runtime_id), + "started_seconds_ago": session.started_for.as_secs(), + "idle_seconds": session.idle.as_secs(), + "expires_in_seconds": session.expires_in.as_secs(), + }) + }) + .collect::>(); + serde_json::json!({"count": sessions.len(), "sessions": sessions}) +} + #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct ImageContent { pub mime_type: String, @@ -241,11 +283,15 @@ enum DriverBackend { struct DaemonBackend { socket_path: String, + transport_session: String, } impl DaemonBackend { fn new(socket_path: String) -> Arc { - Arc::new(Self { socket_path }) + Arc::new(Self { + socket_path, + transport_session: format!("sdk-{}", uuid::Uuid::new_v4()), + }) } async fn compatible_metadata(&self) -> Result { @@ -268,6 +314,20 @@ impl DaemonBackend { } } +impl Drop for DaemonBackend { + fn drop(&mut self) { + let request = DaemonRequest { + method: "session_end".into(), + name: None, + args: None, + session_id: Some(self.transport_session.clone()), + observation_origin: Some(ToolObservationOrigin::Direct), + client_kind: Some(DaemonClientKind::Unknown), + }; + let _ = send_request(&self.socket_path, &request); + } +} + fn validate_daemon_metadata(metadata: &DaemonMetadata) -> Result<(), DriverError> { let mismatch = if metadata.contract_version != cua_driver_contract::CONTRACT_VERSION { Some(format!( @@ -346,7 +406,10 @@ pub struct RuntimeAuthorizationOptions { /// Mode inherited by calls made through the released `CuaDriver` object /// rather than a trusted session-bound action surface. pub compatibility_mode: SessionPermissionMode, - /// Required only when `compatibility_mode` is bounded. + /// Optional in standard and unrestricted; required when compatibility mode is bounded. + #[uniffi(default = None)] + pub compatibility_capability_manifest_path: Option, + /// Deprecated alias for `compatibility_capability_manifest_path`. pub compatibility_bounded_manifest_path: Option, pub unrestricted_acknowledged: bool, pub max_session_ttl_seconds: u64, @@ -373,6 +436,7 @@ fn configured_driver_options_json(options: &ConfiguredDriverOptions) -> Value { "authorization": { "allowed_modes": allowed_modes, "compatibility_mode": options.authorization.compatibility_mode.as_str(), + "compatibility_capability_manifest_path": options.authorization.compatibility_capability_manifest_path.clone(), "compatibility_bounded_manifest_path": options.authorization.compatibility_bounded_manifest_path.clone(), "unrestricted_acknowledged": options.authorization.unrestricted_acknowledged, "max_session_ttl_seconds": options.authorization.max_session_ttl_seconds, @@ -388,6 +452,9 @@ pub struct TrustedSessionOptions { pub mode: SessionPermissionMode, pub ttl_seconds: u64, pub idle_ttl_seconds: u64, + #[uniffi(default = None)] + pub capability_manifest_path: Option, + /// Deprecated alias for `capability_manifest_path`. pub bounded_manifest_path: Option, } @@ -551,7 +618,10 @@ impl From for DaemonClientKind { macro_rules! desktop_tool_methods { ($callback:ident) => { $callback! { + list_apps: ListAppsInput, + list_windows: ListWindowsInput, get_desktop_state: GetDesktopStateInput, + get_window_state: GetWindowStateInput, get_screen_size: GetScreenSizeInput, get_cursor_position: GetCursorPositionInput, verify_state: VerifyStateInput, @@ -559,13 +629,23 @@ macro_rules! desktop_tool_methods { set_window_frame: SetWindowFrameInput, invoke_menu: InvokeMenuInput, click: ClickInput, + window_click: WindowClickInput, + double_click: DoubleClickInput, + right_click: RightClickInput, drag: DragInput, + window_drag: WindowDragInput, scroll: ScrollInput, + window_scroll: WindowScrollInput, clipboard_read: ClipboardReadInput, clipboard_write: ClipboardWriteInput, type_text: TypeTextInput, + window_type_text: WindowTypeTextInput, press_key: PressKeyInput, + window_press_key: WindowPressKeyInput, hotkey: HotkeyInput, + window_hotkey: WindowHotkeyInput, + set_value: SetValueInput, + perform_secondary_action: PerformSecondaryActionInput, set_agent_cursor_enabled: SetAgentCursorEnabledInput, set_agent_cursor_motion: SetAgentCursorMotionInput, set_agent_cursor_theme: SetAgentCursorThemeInput, @@ -606,6 +686,8 @@ macro_rules! define_exported_tool_names { const EXPORTED_TOOL_NAMES: &[&str] = &[ ::TOOL_NAME, ::TOOL_NAME, + ::TOOL_NAME, + ::TOOL_NAME, ::TOOL_NAME, ::TOOL_NAME, $(<$input as ToolInput>::TOOL_NAME,)* @@ -961,6 +1043,7 @@ impl CuaDriver { "mode": options.mode.as_str(), "ttl_seconds": options.ttl_seconds, "idle_ttl_seconds": options.idle_ttl_seconds, + "capability_manifest_path": options.capability_manifest_path, "bounded_manifest_path": options.bounded_manifest_path, }); Ok(Arc::new(CuaDriverSession { @@ -1000,6 +1083,40 @@ impl CuaDriver { } } + /// Daemon-host bridge for reconnecting one trusted host lease without + /// exposing its transport identity through generated bindings. + #[doc(hidden)] + pub fn create_trusted_session_for_transport( + &self, + options: TrustedSessionOptions, + transport_session: &str, + ) -> Result, DriverError> { + let DriverBackend::Embedded(runtime) = &self.backend else { + return Err(DriverError::Configuration { + reason: + "stable trusted-session transport binding requires an embedded host runtime" + .into(), + }); + }; + if transport_session.trim().is_empty() { + return Err(DriverError::Configuration { + reason: "trusted-session transport identity must not be empty".into(), + }); + } + let native_options = serde_json::json!({ + "public_session": options.public_session, + "mode": options.mode.as_str(), + "ttl_seconds": options.ttl_seconds, + "idle_ttl_seconds": options.idle_ttl_seconds, + "capability_manifest_path": options.capability_manifest_path, + "bounded_manifest_path": options.bounded_manifest_path, + "transport_session": transport_session, + }); + Ok(Arc::new(CuaDriverSession { + backend: SessionBackend::Embedded(Arc::new(runtime.create_session(native_options)?)), + })) + } + /// Bind a trusted session through an authenticated remote carrier without /// exposing serialized authority to the caller. pub async fn create_remote_trusted_session( @@ -1065,14 +1182,10 @@ impl CuaDriver { reason: format!("authorization configuration is invalid: {error}"), } })?; - let manifest = if mode == cua_driver_core::authorization::PermissionMode::Bounded { - cua_driver_core::session_manifest::configured_session_manifest() - .map_err(|error| DriverError::Configuration { reason: error })? - .cloned() - .map(Arc::new) - } else { - None - }; + let manifest = cua_driver_core::session_manifest::configured_capability_manifest() + .map_err(|error| DriverError::Configuration { reason: error })? + .cloned() + .map(Arc::new); let ceiling = cua_driver_core::session_authorization::SessionModeCeiling::for_trusted_sessions( [mode], @@ -1121,6 +1234,7 @@ impl CuaDriver { "authorization": { "allowed_modes": allowed_modes, "compatibility_mode": options.authorization.compatibility_mode.as_str(), + "compatibility_capability_manifest_path": options.authorization.compatibility_capability_manifest_path, "compatibility_bounded_manifest_path": options.authorization.compatibility_bounded_manifest_path, "unrestricted_acknowledged": options.authorization.unrestricted_acknowledged, "max_session_ttl_seconds": options.authorization.max_session_ttl_seconds, @@ -1208,7 +1322,7 @@ impl CuaDriver { method: "list".into(), name: None, args: None, - session_id: None, + session_id: Some(daemon.transport_session.clone()), observation_origin: Some(ToolObservationOrigin::Direct), client_kind: Some(self.client_kind), }; @@ -1237,6 +1351,54 @@ impl CuaDriver { }) } + /// Content-free lifecycle summaries for the trusted host's runtime or + /// host-lease namespace. This is deliberately separate from the + /// agent-facing `list_sessions` tool, whose view is transport scoped. + pub async fn list_host_sessions_json(&self) -> Result { + let result = match &self.backend { + DriverBackend::Embedded(runtime) => { + let prefix = format!("__cua_runtime_{}:", runtime.runtime_scope_key()); + host_sessions_json_for_prefix(&prefix) + } + DriverBackend::PrivateWorker(worker) => worker.list_host_sessions().await?, + DriverBackend::Remote(remote) => remote.list_host_sessions().await?, + DriverBackend::Daemon(daemon) => { + daemon.compatible_metadata().await?; + let socket_path = daemon.socket_path.clone(); + let request_path = socket_path.clone(); + let request = DaemonRequest { + method: "sessions_list".into(), + name: None, + args: None, + session_id: None, + observation_origin: Some(ToolObservationOrigin::Direct), + client_kind: Some(self.client_kind), + }; + let response = + tokio::task::spawn_blocking(move || send_request(&request_path, &request)) + .await + .map_err(|error| DriverError::Protocol { + reason: format!("host session listing task failed: {error}"), + })? + .map_err(|error| DriverError::Transport { + socket_path, + reason: error.to_string(), + })?; + if !response.ok { + return Err(DriverError::Protocol { + reason: response + .error + .unwrap_or_else(|| "host session listing failed".into()), + }); + } + response.result.unwrap_or(Value::Null) + } + }; + serde_json::to_string(&result).map_err(|error| DriverError::Protocol { + reason: error.to_string(), + }) + } + pub async fn start_session( &self, input: StartSessionInput, @@ -1264,6 +1426,21 @@ impl CuaDriver { .typed_success(GetSessionStateInput::TOOL_NAME) } + pub async fn get_session(&self, input: GetSessionInput) -> Result { + self.invoke_typed(GetSessionInput::TOOL_NAME, input) + .await? + .typed_success(GetSessionInput::TOOL_NAME) + } + + pub async fn list_sessions( + &self, + input: ListSessionsInput, + ) -> Result { + self.invoke_typed(ListSessionsInput::TOOL_NAME, input) + .await? + .typed_success(ListSessionsInput::TOOL_NAME) + } + pub async fn end_session( &self, input: EndSessionInput, @@ -1327,6 +1504,21 @@ impl CuaDriverSession { .typed_success(GetSessionStateInput::TOOL_NAME) } + pub async fn get_session(&self, input: GetSessionInput) -> Result { + self.invoke_typed(GetSessionInput::TOOL_NAME, input) + .await? + .typed_success(GetSessionInput::TOOL_NAME) + } + + pub async fn list_sessions( + &self, + input: ListSessionsInput, + ) -> Result { + self.invoke_typed(ListSessionsInput::TOOL_NAME, input) + .await? + .typed_success(ListSessionsInput::TOOL_NAME) + } + pub async fn end_session( &self, input: EndSessionInput, @@ -1461,7 +1653,7 @@ impl CuaDriver { method: "call".into(), name: Some(name.into()), args: Some(arguments), - session_id: None, + session_id: Some(daemon.transport_session.clone()), observation_origin: Some(ToolObservationOrigin::Direct), client_kind: Some(self.client_kind), }; @@ -1656,15 +1848,20 @@ mod tests { #[test] fn exported_typed_methods_cover_every_published_contract() { - let mut expected = cua_driver_contract::manifest() + let expected = cua_driver_contract::manifest() .tools .into_iter() .map(|tool| tool.name) - .collect::>(); - expected.sort(); - let mut exported = EXPORTED_TOOL_NAMES.to_vec(); - exported.sort_unstable(); - assert_eq!(exported, expected); + .collect::>(); + let exported = EXPORTED_TOOL_NAMES + .iter() + .map(|name| (*name).to_owned()) + .collect::>(); + let missing = expected.difference(&exported).cloned().collect::>(); + assert!( + missing.is_empty(), + "typed SDK methods missing for {missing:?}" + ); } #[cfg(unix)] @@ -1706,6 +1903,7 @@ mod tests { authorization: RuntimeAuthorizationOptions { allowed_modes: vec![SessionPermissionMode::Standard], compatibility_mode: SessionPermissionMode::Standard, + compatibility_capability_manifest_path: None, compatibility_bounded_manifest_path: None, unrestricted_acknowledged: false, max_session_ttl_seconds: 60, @@ -1736,6 +1934,7 @@ mod tests { authorization: RuntimeAuthorizationOptions { allowed_modes: vec![SessionPermissionMode::Standard], compatibility_mode: SessionPermissionMode::Standard, + compatibility_capability_manifest_path: None, compatibility_bounded_manifest_path: None, unrestricted_acknowledged: false, max_session_ttl_seconds: 60, @@ -1767,6 +1966,7 @@ mod tests { authorization: RuntimeAuthorizationOptions { allowed_modes: vec![SessionPermissionMode::Unrestricted], compatibility_mode: SessionPermissionMode::Unrestricted, + compatibility_capability_manifest_path: None, compatibility_bounded_manifest_path: None, unrestricted_acknowledged: true, max_session_ttl_seconds: 60, @@ -1874,6 +2074,47 @@ mod tests { )); } + #[tokio::test] + async fn trusted_host_listing_spans_its_runtime_without_exposing_owner_keys() { + let _runtime_test = crate::runtime::TEST_RUNTIME_LOCK.lock().unwrap(); + let driver = CuaDriver::create(None).unwrap(); + let public = format!("host-listing-{}", uuid::Uuid::new_v4()); + driver + .start_session(StartSessionInput { + session: Some(public.clone()), + capture_scope: None, + cursor_theme: None, + }) + .await + .unwrap(); + + let listed: Value = + serde_json::from_str(&driver.list_host_sessions_json().await.unwrap()).unwrap(); + let session = listed["sessions"] + .as_array() + .unwrap() + .iter() + .find(|session| { + session["session"] + .as_str() + .is_some_and(|label| label.starts_with("host-listing-")) + }) + .unwrap_or_else(|| { + panic!("host-scoped listing omitted its live named session: {listed}") + }); + assert_eq!(session["implicit"], false); + assert!(session.get("owner_transport").is_none()); + assert!(session.get("runtime_id").is_none()); + + driver + .end_session(EndSessionInput { + session: Some(public), + }) + .await + .unwrap(); + driver.shutdown().await.unwrap(); + } + #[tokio::test] async fn shutdown_drains_an_already_admitted_call() { let _runtime_test = crate::runtime::TEST_RUNTIME_LOCK.lock().unwrap(); @@ -1969,6 +2210,7 @@ mod tests { SessionPermissionMode::Unrestricted, ], compatibility_mode: SessionPermissionMode::Standard, + compatibility_capability_manifest_path: None, compatibility_bounded_manifest_path: None, unrestricted_acknowledged: true, max_session_ttl_seconds: 60, @@ -1982,6 +2224,7 @@ mod tests { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -1991,6 +2234,7 @@ mod tests { mode: SessionPermissionMode::Unrestricted, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -2126,6 +2370,45 @@ mod tests { ); } + #[tokio::test] + async fn trusted_session_idle_ttl_reaches_the_lifecycle_contract() { + let _runtime_test = crate::runtime::TEST_RUNTIME_LOCK.lock().unwrap(); + let driver = configured_standard_driver(); + let session = driver + .create_trusted_session(TrustedSessionOptions { + public_session: "trusted-lifecycle-ttl".into(), + mode: SessionPermissionMode::Standard, + ttl_seconds: 60, + idle_ttl_seconds: 2, + capability_manifest_path: None, + bounded_manifest_path: None, + }) + .unwrap(); + + let started = session + .call_tool("start_session".into(), "{}".into()) + .await + .unwrap(); + assert!(!started.is_error, "{}", started.text); + + let inspected = session + .call_tool("get_session".into(), "{}".into()) + .await + .unwrap(); + assert!(!inspected.is_error, "{}", inspected.text); + let lifecycle: Value = + serde_json::from_str(inspected.structured_json.as_deref().unwrap()).unwrap(); + let expires_in = lifecycle["expires_in_seconds"].as_u64().unwrap(); + assert!( + expires_in <= 2, + "trusted lifecycle TTL was not applied: {lifecycle}" + ); + assert_eq!(lifecycle["session"], "trusted-lifecycle-ttl"); + + session.close(); + driver.shutdown().await.unwrap(); + } + #[tokio::test] async fn authorization_expiry_in_one_runtime_does_not_affect_another() { let _runtime_test = crate::runtime::TEST_RUNTIME_LOCK.lock().unwrap(); @@ -2134,6 +2417,7 @@ mod tests { authorization: RuntimeAuthorizationOptions { allowed_modes: vec![SessionPermissionMode::Standard], compatibility_mode: SessionPermissionMode::Standard, + compatibility_capability_manifest_path: None, compatibility_bounded_manifest_path: None, unrestricted_acknowledged: false, max_session_ttl_seconds: 2, @@ -2148,6 +2432,7 @@ mod tests { mode: SessionPermissionMode::Standard, ttl_seconds: 1, idle_ttl_seconds: 1, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -2157,6 +2442,7 @@ mod tests { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -2191,6 +2477,7 @@ mod tests { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -2235,6 +2522,7 @@ mod tests { SessionPermissionMode::Unrestricted, ], compatibility_mode: SessionPermissionMode::Standard, + compatibility_capability_manifest_path: None, compatibility_bounded_manifest_path: None, unrestricted_acknowledged: true, max_session_ttl_seconds: 60, @@ -2248,6 +2536,7 @@ mod tests { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -2290,6 +2579,7 @@ mod tests { mode: SessionPermissionMode::Unrestricted, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }), Err(DriverError::Configuration { .. }) @@ -2300,6 +2590,7 @@ mod tests { mode: SessionPermissionMode::Unrestricted, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -2554,7 +2845,7 @@ mod tests { let driver = CuaDriver::connect(Some(socket)).unwrap(); let output = driver .start_session(StartSessionInput { - session: "run-2".into(), + session: Some("run-2".into()), capture_scope: Some(cua_driver_contract::CaptureScope::Auto), cursor_theme: None, }) @@ -2660,6 +2951,7 @@ mod tests { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .await @@ -2862,6 +3154,7 @@ mod tests { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .await diff --git a/packages/cua-driver/rust/crates/cua-driver-sdk/src/remote.rs b/packages/cua-driver/rust/crates/cua-driver-sdk/src/remote.rs index 48db07017d..0b6729ce7c 100644 --- a/packages/cua-driver/rust/crates/cua-driver-sdk/src/remote.rs +++ b/packages/cua-driver/rust/crates/cua-driver-sdk/src/remote.rs @@ -136,6 +136,10 @@ impl RemoteDriverClient { exchange(&self.channel, "list", None, None).await } + pub(crate) async fn list_host_sessions(&self) -> Result { + exchange(&self.channel, "sessions_list", None, None).await + } + pub(crate) async fn invoke(&self, name: &str, arguments: Value) -> Result { exchange( &self.channel, diff --git a/packages/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs b/packages/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs index 793dee0fc3..f0e8b17aff 100644 --- a/packages/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs +++ b/packages/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs @@ -132,7 +132,11 @@ impl RuntimeSession { .runtime .invoke_with_context(name, args, self.context.clone()) .await; - if name == "end_session" && result.is_some() { + if name == "end_session" + && result + .as_ref() + .is_some_and(|result| result.is_error != Some(true)) + { self.authorization_registry .revoke_connection(&self.connection); } @@ -556,16 +560,17 @@ fn build_registry(options: &RuntimeOptions) -> ToolRegistry { register_host_tools(&mut registry); } let recording = Arc::downgrade(®istry.recording); - let recording_session_end = - cua_driver_core::session::register_scoped_session_end_hook(move |session| { + let recording_session_end = cua_driver_core::session::register_scoped_fallible_session_end_hook( + "recording", + move |session| { let Some(recording) = recording.upgrade() else { - return; + return Ok(()); }; - let session = session.to_owned(); - std::thread::spawn(move || { - let _ = recording.stop_owner(Some(&session)); - }); - }); + recording + .stop_owner(Some(session)) + .map_err(|error| error.to_string()) + }, + ); registry.retain_session_end_hook(recording_session_end); registry } @@ -718,7 +723,7 @@ mod tests { let idle_before_refresh = cua_driver_core::session::session_idle_duration(&internal).unwrap(); runtime - .invoke("health_report", serde_json::json!({"session": public})) + .invoke("start_session", serde_json::json!({"session": public})) .await .unwrap(); let idle_after_refresh = @@ -785,4 +790,82 @@ mod tests { runtime.shutdown().await; } + + #[tokio::test] + async fn incomplete_end_keeps_trusted_authorization_live_for_cleanup_retry() { + let _runtime_test = TEST_RUNTIME_LOCK.lock().unwrap(); + let runtime = DriverRuntime::create(standard_options()).unwrap(); + let public_session = "runtime-end-cleanup-retry"; + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let attempts_for_hook = attempts.clone(); + let _hook = cua_driver_core::session::register_scoped_fallible_session_end_hook( + "runtime-end-cleanup-retry-test", + move |_| { + if attempts_for_hook.fetch_add(1, Ordering::SeqCst) == 0 { + Err("synthetic first-attempt failure".into()) + } else { + Ok(()) + } + }, + ); + let session = runtime + .create_trusted_session(DelegatedSessionRequest { + public_session: public_session.into(), + transport_session: "runtime-end-cleanup-retry-transport".into(), + mode: PermissionMode::Standard, + ttl: Duration::from_secs(60), + idle_ttl: Duration::from_secs(30), + capability_manifest: None, + }) + .unwrap(); + + let started = session + .invoke("start_session", serde_json::json!({})) + .await + .unwrap(); + assert_ne!(started.is_error, Some(true)); + let runtime_prefix = format!( + "__cua_runtime_{}:", + runtime.compatibility_context.runtime_scope_key() + ); + let started_sessions = cua_driver_core::session::list_session_snapshots_with_prefix( + &runtime_prefix, + Duration::from_secs(300), + ); + assert_eq!(started_sessions.len(), 1, "{started_sessions:?}"); + + let first_end = session + .invoke("end_session", serde_json::json!({})) + .await + .unwrap(); + assert_eq!( + first_end.is_error, + Some(true), + "result={first_end:?} started={started_sessions:?} attempts={}", + attempts.load(Ordering::SeqCst) + ); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + + let retried_end = session + .invoke("end_session", serde_json::json!({})) + .await + .unwrap(); + assert_ne!(retried_end.is_error, Some(true)); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + + let after_end = session + .invoke("health_report", serde_json::json!({})) + .await + .unwrap(); + assert_eq!( + after_end + .structured_content + .as_ref() + .and_then(|value| value.pointer("/refusal/code")) + .and_then(Value::as_str), + Some("authorization_revoked") + ); + + runtime.shutdown().await; + } } diff --git a/packages/cua-driver/rust/crates/cua-driver-sdk/src/service_session.rs b/packages/cua-driver/rust/crates/cua-driver-sdk/src/service_session.rs index 18eefe3d87..fd46d1c140 100644 --- a/packages/cua-driver/rust/crates/cua-driver-sdk/src/service_session.rs +++ b/packages/cua-driver/rust/crates/cua-driver-sdk/src/service_session.rs @@ -8,11 +8,14 @@ use cua_driver_core::daemon::{DaemonClientKind, DaemonRequest, DaemonResponse}; use serde_json::Value; use std::io::{BufRead, BufReader, Write}; use std::sync::{Arc, Mutex}; +use std::time::Duration; #[cfg(unix)] -use std::time::{Duration, Instant}; +use std::time::Instant; #[cfg(unix)] const SERVICE_REQUEST_DEADLINE: Duration = Duration::from_secs(120); +const RESUME_REGISTRATION_RETRIES: usize = 20; +const RESUME_REGISTRATION_DELAY: Duration = Duration::from_millis(10); #[cfg(unix)] type ServiceStream = std::os::unix::net::UnixStream; @@ -23,6 +26,7 @@ struct ServiceConnection { reader: BufReader, writer: ServiceStream, closed: bool, + resume_credential: String, } pub(crate) struct ServiceSessionClient { @@ -55,12 +59,13 @@ impl ServiceSessionClient { reader: BufReader::new(stream), writer, closed: false, + resume_credential: String::new(), }), }); let arguments = serde_json::to_value(options).map_err(|error| DriverError::Protocol { reason: format!("serialize trusted service session options: {error}"), })?; - client.request(DaemonRequest { + let bound = client.request(DaemonRequest { method: "trusted_session_begin".into(), name: None, args: Some(arguments), @@ -68,6 +73,14 @@ impl ServiceSessionClient { observation_origin: None, client_kind: Some(client_kind), })?; + let resume_credential = bound + .get("resume_credential") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| DriverError::Protocol { + reason: "trusted service bind omitted resume credential".into(), + })?; + client.connection.lock().unwrap().resume_credential = resume_credential.to_owned(); Ok(client) } @@ -97,7 +110,9 @@ impl ServiceSessionClient { pub(crate) fn close(&self) { let mut connection = self.connection.lock().unwrap(); if connection.closed { - return; + if self.resume_connection(&mut connection).is_err() { + return; + } } let request = DaemonRequest { method: "trusted_session_end".into(), @@ -121,7 +136,7 @@ impl ServiceSessionClient { fn request(&self, request: DaemonRequest) -> Result { let mut connection = self.connection.lock().unwrap(); if connection.closed { - return Err(DriverError::Shutdown); + self.resume_connection(&mut connection)?; } let action_call = request.method == "trusted_session_call"; write_request(&mut connection.writer, &request).map_err(|error| { @@ -172,6 +187,85 @@ impl ServiceSessionClient { Ok(response.result.unwrap_or(Value::Null)) } + fn resume_connection(&self, connection: &mut ServiceConnection) -> Result<(), DriverError> { + if connection.resume_credential.is_empty() { + return Err(DriverError::Shutdown); + } + let credential = connection.resume_credential.clone(); + for attempt in 0..=RESUME_REGISTRATION_RETRIES { + let stream = connect(&self.socket_path)?; + let writer = stream.try_clone().map_err(|error| DriverError::Transport { + socket_path: self.socket_path.clone(), + reason: format!("clone resumed trusted service connection: {error}"), + })?; + + // Replace both handles before presenting the credential. Dropping + // the old client handles makes the daemon observe EOF and publish + // the detachable lease on Unix sockets and Windows named pipes. + connection.reader = BufReader::new(stream); + connection.writer = writer; + let request = DaemonRequest { + method: "trusted_session_resume".into(), + name: None, + args: Some(serde_json::json!({ + "resume_credential": credential, + })), + session_id: None, + observation_origin: None, + client_kind: None, + }; + write_request(&mut connection.writer, &request).map_err(|error| { + DriverError::Transport { + socket_path: self.socket_path.clone(), + reason: format!("write trusted session resume: {error}"), + } + })?; + let line = read_response_line(&mut connection.reader) + .map_err(|error| DriverError::Transport { + socket_path: self.socket_path.clone(), + reason: format!("read trusted session resume: {error}"), + })? + .ok_or_else(|| DriverError::Transport { + socket_path: self.socket_path.clone(), + reason: "trusted service closed during resume".into(), + })?; + let response: DaemonResponse = + serde_json::from_str(&line).map_err(|error| DriverError::Protocol { + reason: format!("parse trusted session resume response: {error}"), + })?; + if response.ok { + let rotated = response + .result + .as_ref() + .and_then(|value| value.get("resume_credential")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| DriverError::Protocol { + reason: "trusted session resume omitted rotated credential".into(), + })?; + connection.resume_credential = rotated.to_owned(); + connection.closed = false; + return Ok(()); + } + + let reason = response + .error + .unwrap_or_else(|| "trusted session resume failed".into()); + let registration_race = reason.contains("unavailable or already used") + && attempt < RESUME_REGISTRATION_RETRIES; + if registration_race { + std::thread::sleep(RESUME_REGISTRATION_DELAY); + continue; + } + connection.resume_credential.clear(); + return Err(DriverError::Transport { + socket_path: self.socket_path.clone(), + reason, + }); + } + unreachable!("bounded resume loop returns on its final attempt") + } + fn connection_failure(&self, action_call: bool, reason: String) -> DriverError { if action_call { DriverError::ActionInterrupted { @@ -363,7 +457,10 @@ mod tests { writeln!( writer, "{}", - serde_json::to_string(&DaemonResponse::ok(serde_json::json!({}))).unwrap() + serde_json::to_string(&DaemonResponse::ok(serde_json::json!({ + "resume_credential": "resume-test-1" + }))) + .unwrap() ) .unwrap(); @@ -382,6 +479,7 @@ mod tests { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }, DaemonClientKind::Unknown, @@ -399,6 +497,122 @@ mod tests { server.join().unwrap(); } + #[tokio::test] + async fn next_call_resumes_with_single_use_host_credential_after_disconnect() { + let directory = tempfile::tempdir().unwrap(); + let socket = directory.path().join("resumable-service.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + serve_compatible_metadata(&listener); + { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut writer = stream; + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let begin: DaemonRequest = serde_json::from_str(&line).unwrap(); + assert_eq!(begin.method, "trusted_session_begin"); + writeln!( + writer, + "{}", + serde_json::to_string(&DaemonResponse::ok(serde_json::json!({ + "resume_credential": "resume-original" + }))) + .unwrap() + ) + .unwrap(); + + line.clear(); + reader.read_line(&mut line).unwrap(); + let call: DaemonRequest = serde_json::from_str(&line).unwrap(); + assert_eq!(call.method, "trusted_session_call"); + // Drop without a response. The SDK must not retry this action. + } + + // The reconnect can beat the daemon task that records EOF from the + // previous connection. A transient unavailable response must keep + // the single-use credential intact and retry on a fresh socket. + { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut writer = stream; + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let resume: DaemonRequest = serde_json::from_str(&line).unwrap(); + assert_eq!(resume.method, "trusted_session_resume"); + writeln!( + writer, + "{}", + serde_json::to_string(&DaemonResponse::err( + "trusted session resume credential is unavailable or already used", + 77, + )) + .unwrap() + ) + .unwrap(); + } + + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut writer = stream; + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let resume: DaemonRequest = serde_json::from_str(&line).unwrap(); + assert_eq!(resume.method, "trusted_session_resume"); + assert_eq!(resume.args.unwrap()["resume_credential"], "resume-original"); + writeln!( + writer, + "{}", + serde_json::to_string(&DaemonResponse::ok(serde_json::json!({ + "resume_credential": "resume-rotated" + }))) + .unwrap() + ) + .unwrap(); + + line.clear(); + reader.read_line(&mut line).unwrap(); + let call: DaemonRequest = serde_json::from_str(&line).unwrap(); + assert_eq!(call.method, "trusted_session_call"); + writeln!( + writer, + "{}", + serde_json::to_string(&DaemonResponse::ok(serde_json::json!({ + "resumed": true + }))) + .unwrap() + ) + .unwrap(); + }); + + let client = ServiceSessionClient::connect_and_bind( + socket.to_string_lossy().into_owned(), + TrustedSessionOptions { + public_session: "resume-test".into(), + mode: SessionPermissionMode::Standard, + ttl_seconds: 60, + idle_ttl_seconds: 30, + capability_manifest_path: None, + bounded_manifest_path: None, + }, + DaemonClientKind::Unknown, + ) + .unwrap(); + assert!(matches!( + client.invoke("click", serde_json::json!({})).await, + Err(DriverError::ActionInterrupted { + completion: crate::worker::ActionCompletion::Unknown, + .. + }) + )); + let resumed = client + .invoke("get_session", serde_json::json!({})) + .await + .unwrap(); + assert_eq!(resumed["resumed"], true); + server.join().unwrap(); + } + #[tokio::test] async fn transient_read_timeouts_are_poll_intervals_not_action_deadlines() { let directory = tempfile::tempdir().unwrap(); @@ -415,7 +629,10 @@ mod tests { writeln!( writer, "{}", - serde_json::to_string(&DaemonResponse::ok(serde_json::json!({}))).unwrap() + serde_json::to_string(&DaemonResponse::ok(serde_json::json!({ + "resume_credential": "resume-test-2" + }))) + .unwrap() ) .unwrap(); @@ -443,6 +660,7 @@ mod tests { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }, DaemonClientKind::Unknown, diff --git a/packages/cua-driver/rust/crates/cua-driver-sdk/src/worker.rs b/packages/cua-driver/rust/crates/cua-driver-sdk/src/worker.rs index 1c76c76333..d454564a75 100644 --- a/packages/cua-driver/rust/crates/cua-driver-sdk/src/worker.rs +++ b/packages/cua-driver/rust/crates/cua-driver-sdk/src/worker.rs @@ -233,6 +233,10 @@ impl PrivateWorkerClient { self.request_async("list", None, None, None).await } + pub(crate) async fn list_host_sessions(self: &Arc) -> Result { + self.request_async("sessions_list", None, None, None).await + } + pub(crate) async fn invoke( self: &Arc, name: &str, @@ -550,7 +554,10 @@ mod tests { for name in [ "CUA_DRIVER_DISABLE_UNRESTRICTED", "CUA_DRIVER_MANAGED_POLICY_FILE", + "CUA_DRIVER_CAPABILITY_MANIFEST_FILE", + "CUA_DRIVER_CAPABILITY_MANIFEST_APPROVED", "CUA_DRIVER_SESSION_POLICY_FILE", + "CUA_DRIVER_SESSION_POLICY_APPROVED", ] { assert!(inherited_managed_environment_name(name)); assert!( diff --git a/packages/cua-driver/rust/crates/cua-driver-sdk/tests/runtime_configuration.rs b/packages/cua-driver/rust/crates/cua-driver-sdk/tests/runtime_configuration.rs index 2424953d7b..9be141bf31 100644 --- a/packages/cua-driver/rust/crates/cua-driver-sdk/tests/runtime_configuration.rs +++ b/packages/cua-driver/rust/crates/cua-driver-sdk/tests/runtime_configuration.rs @@ -12,6 +12,7 @@ fn options(allowed_modes: Vec) -> ConfiguredDriverOptions authorization: RuntimeAuthorizationOptions { allowed_modes, compatibility_mode: SessionPermissionMode::Standard, + compatibility_capability_manifest_path: None, compatibility_bounded_manifest_path: None, unrestricted_acknowledged: true, max_session_ttl_seconds: 60, @@ -33,9 +34,6 @@ fn child_configuration_probe() { SessionPermissionMode::Standard, SessionPermissionMode::Unrestricted, ])), - "legacy_approval" => { - CuaDriver::create_configured(options(vec![SessionPermissionMode::Standard])) - } other => panic!("unknown probe case {other}"), }; @@ -45,7 +43,7 @@ fn child_configuration_probe() { panic!("matching environment was rejected: {error}"); } } - "conflicting_mode" | "managed_disable" | "legacy_approval" => { + "conflicting_mode" | "managed_disable" => { assert!(result.is_err(), "contradictory environment was accepted") } _ => unreachable!(), @@ -60,7 +58,8 @@ fn run_probe(case: &str, environment: &[(&str, &str)]) { .env_remove("CUA_DRIVER_PERMISSION_MODE") .env_remove("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS") .env_remove("CUA_DRIVER_DISABLE_UNRESTRICTED") - .env_remove("CUA_DRIVER_ALLOW_LEGACY_EXISTING_PROFILE_APPROVAL") + .env_remove("CUA_DRIVER_CAPABILITY_MANIFEST_FILE") + .env_remove("CUA_DRIVER_CAPABILITY_MANIFEST_APPROVED") .env_remove("CUA_DRIVER_SESSION_POLICY_FILE") .env_remove("CUA_DRIVER_SESSION_POLICY_APPROVED"); for (name, value) in environment { @@ -89,8 +88,4 @@ fn explicit_runtime_configuration_rejects_contradictory_environment() { "managed_disable", &[("CUA_DRIVER_DISABLE_UNRESTRICTED", "1")], ); - run_probe( - "legacy_approval", - &[("CUA_DRIVER_ALLOW_LEGACY_EXISTING_PROFILE_APPROVAL", "1")], - ); } diff --git a/packages/cua-driver/rust/crates/cua-driver-testkit/Cargo.toml b/packages/cua-driver/rust/crates/cua-driver-testkit/Cargo.toml index fbc3719abf..6a20b23b05 100644 --- a/packages/cua-driver/rust/crates/cua-driver-testkit/Cargo.toml +++ b/packages/cua-driver/rust/crates/cua-driver-testkit/Cargo.toml @@ -31,11 +31,14 @@ objc2-app-kit = { version = "0.2", features = [ [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.61", features = [ "Win32_Foundation", + "Win32_Storage_FileSystem", # Job Object: assign every spawned test child to a KILL_ON_JOB_CLOSE job so # the OS terminates the whole tree when the test process dies for ANY reason. "Win32_System_JobObjects", "Win32_System_Console", + "Win32_System_IO", "Win32_Security", + "Win32_System_Pipes", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging", diff --git a/packages/cua-driver/rust/crates/cua-driver-testkit/src/daemon.rs b/packages/cua-driver/rust/crates/cua-driver-testkit/src/daemon.rs index 4f8d07e297..2a29c55c80 100644 --- a/packages/cua-driver/rust/crates/cua-driver-testkit/src/daemon.rs +++ b/packages/cua-driver/rust/crates/cua-driver-testkit/src/daemon.rs @@ -6,7 +6,7 @@ use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; -use crate::reaper::ChildReaper; +use crate::reaper::{spawn_in_job, ChildReaper}; #[cfg(not(unix))] static DAEMON_SEQUENCE: AtomicU64 = AtomicU64::new(1); @@ -14,6 +14,7 @@ static DAEMON_SEQUENCE: AtomicU64 = AtomicU64::new(1); /// Keeps the temporary socket directory alive for the daemon lifetime. pub(crate) struct TestDaemon { pub(crate) socket: String, + pub(crate) pid: u32, #[cfg(unix)] _socket_dir: tempfile::TempDir, } @@ -85,16 +86,35 @@ impl TestDaemon { for (key, value) in env { command.env(key, value); } - reaper - .spawn(&mut command) + // Spawn directly so the `Child` stays reachable for `try_wait` below; + // the reaper adopts it on every exit path. Without the handle, a daemon + // that dies during startup is indistinguishable from one that is merely + // slow, which is what made #2480 undiagnosable from CI logs alone. + let mut child = spawn_in_job(&mut command) .inspect_err(|error| eprintln!("[testkit] daemon spawn failed: {error}")) .ok()?; + let pid = child.id(); - let deadline = Instant::now() + Duration::from_secs(10); + let deadline = Instant::now() + READINESS_WINDOW; + // Keep the furthest point any probe reached. A late attempt can regress + // (the pipe is torn down as the daemon exits), and the furthest outcome + // is the one that explains where startup actually stopped. + let mut furthest = ProbeOutcome::PipeUnavailable; + let mut exit_status = None; while Instant::now() < deadline { - if daemon_is_listening(binary, &socket) { + // A daemon that has already exited will never become ready; report + // its status now instead of burning the rest of the window. + if let Ok(Some(status)) = child.try_wait() { + exit_status = Some(status); + break; + } + let outcome = daemon_is_listening(binary, &socket); + furthest = furthest.max(outcome); + if outcome == ProbeOutcome::Ready { + reaper.push(child); return Some(Self { socket, + pid, #[cfg(unix)] _socket_dir: socket_dir, }); @@ -102,18 +122,118 @@ impl TestDaemon { std::thread::sleep(Duration::from_millis(50)); } - eprintln!("[testkit] daemon did not become ready on {socket}"); + match exit_status { + Some(status) => eprintln!( + "[testkit] daemon exited before becoming ready on {socket}: {status}. \ + Furthest readiness probe: {}. Set CUA_TEST_DRIVER_STDERR=1 to inherit \ + daemon stderr.", + furthest.describe() + ), + None => eprintln!( + "[testkit] daemon did not become ready on {socket} within {}s (process still \ + running). Furthest readiness probe: {}. Set CUA_TEST_DRIVER_STDERR=1 to \ + inherit daemon stderr.", + READINESS_WINDOW.as_secs(), + furthest.describe() + ), + } + reaper.push(child); None } } +/// Total time the daemon has to answer a readiness probe. +const READINESS_WINDOW: Duration = Duration::from_secs(10); + +/// Per-attempt bound on a single readiness probe. +/// +/// A Windows named-pipe read has no timeout, so a server that accepts the +/// connection but never answers blocks forever. The outer deadline is only +/// consulted *between* attempts, so without an independent per-attempt bound +/// one stalled connection consumes the entire readiness window and the failure +/// surfaces as a bare "did not become ready" (#2480). +#[cfg(target_os = "windows")] +const PROBE_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(1); + +/// How far a single readiness probe progressed. +/// +/// Ordered by progress so `max` across the window keeps the most informative +/// outcome: knowing the pipe was reachable but never answered points at daemon +/// startup, whereas never opening the pipe points at spawn or naming. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum ProbeOutcome { + /// The pipe/socket could not be opened at all. + PipeUnavailable, + /// Opened, but the request could not be written. + WriteFailed, + /// Request written, but no response line arrived before the attempt bound. + NoResponse, + /// A response arrived but was not valid JSON. + MalformedResponse, + /// Valid JSON, but not the `ok: true` the protocol promises. + NotOk, + /// Fully serviced — the daemon is ready. + Ready, +} + +impl ProbeOutcome { + pub(crate) fn describe(self) -> &'static str { + match self { + Self::PipeUnavailable => { + "pipe never became connectable (daemon had not created it, or the name differs)" + } + Self::WriteFailed => "pipe opened but the request could not be written", + Self::NoResponse => { + "request written but the daemon sent no response line before the per-attempt \ + timeout (accepted-but-stalled connection)" + } + Self::MalformedResponse => "daemon replied but the response was not valid JSON", + Self::NotOk => "daemon replied with valid JSON but without ok:true", + Self::Ready => "ready", + } + } +} + #[cfg(unix)] -fn daemon_is_listening(_binary: &Path, socket: &str) -> bool { - std::os::unix::net::UnixStream::connect(socket).is_ok() +fn daemon_is_listening(_binary: &Path, socket: &str) -> ProbeOutcome { + match std::os::unix::net::UnixStream::connect(socket) { + Ok(_) => ProbeOutcome::Ready, + Err(_) => ProbeOutcome::PipeUnavailable, + } +} + +/// Run one named-pipe probe under an independent timeout. +/// +/// The probe runs on a worker thread because the blocking read it performs has +/// no native timeout. If the attempt bound expires the thread is abandoned: it +/// is unblocked when the daemon is reaped at teardown (the kill-on-close job +/// object closes the pipe), and the harness is short-lived, so leaking a parked +/// thread is preferable to hanging the whole readiness window. +#[cfg(target_os = "windows")] +fn daemon_is_listening(_binary: &Path, socket: &str) -> ProbeOutcome { + probe_pipe_with_timeout(socket, PROBE_ATTEMPT_TIMEOUT) } #[cfg(target_os = "windows")] -fn daemon_is_listening(_binary: &Path, socket: &str) -> bool { +fn probe_pipe_with_timeout(socket: &str, timeout: Duration) -> ProbeOutcome { + use std::sync::mpsc; + + let socket = socket.to_string(); + let (tx, rx) = mpsc::channel(); + if std::thread::Builder::new() + .name("cua-testkit-pipe-probe".into()) + .spawn(move || { + let _ = tx.send(probe_pipe_once(&socket)); + }) + .is_err() + { + return ProbeOutcome::PipeUnavailable; + } + rx.recv_timeout(timeout).unwrap_or(ProbeOutcome::NoResponse) +} + +#[cfg(target_os = "windows")] +fn probe_pipe_once(socket: &str) -> ProbeOutcome { use std::io::{BufRead, BufReader, Write}; // Exercise the real named-pipe protocol instead of spawning `status`. @@ -126,35 +246,254 @@ fn daemon_is_listening(_binary: &Path, socket: &str) -> bool { .write(true) .open(socket) else { - return false; + return ProbeOutcome::PipeUnavailable; }; let Ok(mut writer) = pipe.try_clone() else { - return false; + return ProbeOutcome::WriteFailed; }; if writer .write_all(b"{\"method\":\"list\"}\n") .and_then(|()| writer.flush()) .is_err() { - return false; + return ProbeOutcome::WriteFailed; } let mut response = String::new(); if BufReader::new(pipe).read_line(&mut response).is_err() { - return false; + return ProbeOutcome::NoResponse; + } + let Ok(value) = serde_json::from_str::(&response) else { + return ProbeOutcome::MalformedResponse; + }; + if value.get("ok").and_then(serde_json::Value::as_bool) == Some(true) { + ProbeOutcome::Ready + } else { + ProbeOutcome::NotOk } - serde_json::from_str::(&response) - .ok() - .and_then(|value| value.get("ok").and_then(serde_json::Value::as_bool)) - == Some(true) } #[cfg(not(any(unix, target_os = "windows")))] -fn daemon_is_listening(binary: &Path, socket: &str) -> bool { - Command::new(binary) +fn daemon_is_listening(binary: &Path, socket: &str) -> ProbeOutcome { + let ready = Command::new(binary) .args(["status", "--socket", socket]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() - .is_ok_and(|status| status.success()) + .is_ok_and(|status| status.success()); + if ready { + ProbeOutcome::Ready + } else { + ProbeOutcome::PipeUnavailable + } +} + +#[cfg(test)] +mod readiness_probe_tests { + use super::*; + + /// The furthest-progress ordering is what makes the expiry message useful, + /// so pin it rather than leaving it to derive order by accident. + #[test] + fn outcomes_are_ordered_by_progress() { + let ordered = [ + ProbeOutcome::PipeUnavailable, + ProbeOutcome::WriteFailed, + ProbeOutcome::NoResponse, + ProbeOutcome::MalformedResponse, + ProbeOutcome::NotOk, + ProbeOutcome::Ready, + ]; + for pair in ordered.windows(2) { + assert!( + pair[0] < pair[1], + "{:?} must rank below {:?}", + pair[0], + pair[1] + ); + } + } + + /// The reported outcome must survive a late regression: as a daemon exits, + /// its pipe stops opening, and reporting `PipeUnavailable` would point at + /// spawn/naming when the real evidence was an accepted-but-stalled pipe. + #[test] + fn furthest_outcome_survives_a_late_regression() { + let observed = [ + ProbeOutcome::PipeUnavailable, + ProbeOutcome::NoResponse, + ProbeOutcome::PipeUnavailable, + ]; + let furthest = observed + .iter() + .copied() + .fold(ProbeOutcome::PipeUnavailable, ProbeOutcome::max); + assert_eq!(furthest, ProbeOutcome::NoResponse); + } + + #[test] + fn ready_outranks_every_failure() { + for outcome in [ + ProbeOutcome::PipeUnavailable, + ProbeOutcome::WriteFailed, + ProbeOutcome::NoResponse, + ProbeOutcome::MalformedResponse, + ProbeOutcome::NotOk, + ] { + assert!(ProbeOutcome::Ready > outcome); + } + } + + /// Every outcome must explain itself; the whole point of #2480 is that the + /// operator can tell a stall from a genuine startup failure. + #[test] + fn every_outcome_has_a_distinct_description() { + let all = [ + ProbeOutcome::PipeUnavailable, + ProbeOutcome::WriteFailed, + ProbeOutcome::NoResponse, + ProbeOutcome::MalformedResponse, + ProbeOutcome::NotOk, + ProbeOutcome::Ready, + ]; + let mut seen = Vec::new(); + for outcome in all { + let text = outcome.describe(); + assert!(!text.is_empty(), "{outcome:?} needs a description"); + assert!(!seen.contains(&text), "duplicate description: {text}"); + seen.push(text); + } + } + + /// The per-attempt bound must leave room for several attempts inside the + /// window; if one attempt could span it, the flake in #2480 returns. + #[cfg(target_os = "windows")] + #[test] + fn attempt_bound_permits_multiple_attempts_per_window() { + assert!( + PROBE_ATTEMPT_TIMEOUT * 4 <= READINESS_WINDOW, + "one stalled attempt must not consume the readiness window" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn stalled_pipe_attempt_is_bounded_and_does_not_block_the_next_probe() { + use std::io::{BufRead, BufReader, Write}; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; + use std::sync::mpsc; + use windows::core::HSTRING; + use windows::Win32::Foundation::{ERROR_PIPE_CONNECTED, HANDLE}; + use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX; + use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_TYPE_BYTE, PIPE_WAIT, + }; + + fn socket_name(label: &str) -> String { + let sequence = DAEMON_SEQUENCE.fetch_add(1, Ordering::Relaxed); + format!( + r"\\.\pipe\cua-driver-test-{label}-{}-{sequence}", + std::process::id() + ) + } + + fn spawn_server( + socket: String, + response: Option<&'static [u8]>, + release: Option>, + ) -> ( + mpsc::Receiver<()>, + mpsc::Receiver<()>, + std::thread::JoinHandle<()>, + ) { + let (ready_tx, ready_rx) = mpsc::channel(); + let (request_tx, request_rx) = mpsc::channel(); + let handle = std::thread::spawn(move || { + let name = HSTRING::from(socket); + let raw = unsafe { + CreateNamedPipeW( + &name, + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, + 4096, + 4096, + 0, + None, + ) + }; + assert!(!raw.is_invalid(), "CreateNamedPipeW failed"); + let mut pipe = unsafe { std::fs::File::from_raw_handle(raw.0) }; + ready_tx.send(()).unwrap(); + + let raw = HANDLE(pipe.as_raw_handle()); + if let Err(error) = unsafe { ConnectNamedPipe(raw, None) } { + assert_eq!( + error.code(), + ERROR_PIPE_CONNECTED.to_hresult(), + "ConnectNamedPipe failed: {error}" + ); + } + + let mut request = String::new(); + BufReader::new(pipe.try_clone().unwrap()) + .read_line(&mut request) + .unwrap(); + assert_eq!(request, "{\"method\":\"list\"}\n"); + request_tx.send(()).unwrap(); + + if let Some(response) = response { + pipe.write_all(response).unwrap(); + pipe.flush().unwrap(); + } else { + release + .unwrap() + .recv_timeout(Duration::from_secs(5)) + .expect("stalled server was not released"); + } + }); + (ready_rx, request_rx, handle) + } + + let stalled_socket = socket_name("stalled"); + let (release_tx, release_rx) = mpsc::channel(); + let (stalled_ready, stalled_request, stalled_server) = + spawn_server(stalled_socket.clone(), None, Some(release_rx)); + stalled_ready.recv_timeout(Duration::from_secs(2)).unwrap(); + + let attempt_timeout = Duration::from_millis(250); + let started = Instant::now(); + assert_eq!( + probe_pipe_with_timeout(&stalled_socket, attempt_timeout), + ProbeOutcome::NoResponse + ); + assert!( + started.elapsed() < attempt_timeout * 4, + "stalled probe exceeded its attempt bound by too much: {:?}", + started.elapsed() + ); + stalled_request + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + + // Leave the first probe worker blocked while proving a later probe can + // still complete normally in the same harness process. + let responsive_socket = socket_name("responsive"); + let (responsive_ready, responsive_request, responsive_server) = + spawn_server(responsive_socket.clone(), Some(b"{\"ok\":true}\n"), None); + responsive_ready + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + assert_eq!( + probe_pipe_with_timeout(&responsive_socket, Duration::from_secs(2)), + ProbeOutcome::Ready + ); + responsive_request + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + + release_tx.send(()).unwrap(); + stalled_server.join().unwrap(); + responsive_server.join().unwrap(); + } } diff --git a/packages/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs b/packages/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs index 23a42cab59..eb07f5c804 100644 --- a/packages/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs +++ b/packages/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs @@ -315,6 +315,7 @@ pub enum RefusalCode { BrowserReconnectExhausted, BrowserInputIncomplete, BrowserActionUnavailable, + WindowMinimized, } impl RefusalCode { @@ -336,6 +337,7 @@ impl RefusalCode { "browser_reconnect_exhausted" => Some(Self::BrowserReconnectExhausted), "browser_input_incomplete" => Some(Self::BrowserInputIncomplete), "browser_action_unavailable" => Some(Self::BrowserActionUnavailable), + "window_minimized" => Some(Self::WindowMinimized), _ => None, } } @@ -1493,6 +1495,12 @@ fn validate_one_turn(turn: &Path, cell_id: &str, errors: &mut Vec) { .as_str() .is_some_and(|summary| summary.starts_with("✅ bring_to_front:")) }); + let refused_minimized_action = action.as_ref().is_some_and(|value| { + value["result_error"].as_bool() == Some(true) + && value["result_summary"] + .as_str() + .is_some_and(|summary| summary.starts_with("refused (window_minimized):")) + }); let restored_state_captured = manifest .as_ref() .is_some_and(|value| value["after"]["state"]["status"].as_str() == Some("captured")); @@ -1506,6 +1514,8 @@ fn validate_one_turn(turn: &Path, cell_id: &str, errors: &mut Vec) { && successful_restore && restored_state_captured && classification(phase, "screenshot") == Some("capture_failed")))) + || (refused_minimized_action + && classification(phase, "screenshot") == Some("target_minimized")) }; for (phase, kind) in [("before", "screenshot"), ("after", "screenshot")] { @@ -1577,15 +1587,23 @@ fn validate_one_turn(turn: &Path, cell_id: &str, errors: &mut Vec) { let refused_before_target_resolution = action.as_ref().is_some_and(|value| { value["result_error"].as_bool() == Some(true) && value.get("click_point").is_none() }); - if refused_before_target_resolution { + let refused_before_dispatch = action.as_ref().is_some_and(|value| { + value["result_error"].as_bool() == Some(true) + && value["action_truth"]["effect"].as_str() == Some("refused") + }); + if refused_before_target_resolution || refused_before_dispatch { let click = manifest.as_ref().map(|value| &value["click"]); + let expected_classification = if refused_before_target_resolution { + "action_refused_before_target_resolution" + } else { + "action_refused_before_dispatch" + }; if !click.is_some_and(|value| { value["status"].as_str() == Some("not_applicable") - && value["classification"].as_str() - == Some("action_refused_before_target_resolution") + && value["classification"].as_str() == Some(expected_classification) }) { errors.push(format!( - "invalid refused-click evidence for {cell_id}/{turn_name}: expected not_applicable/action_refused_before_target_resolution" + "invalid refused-click evidence for {cell_id}/{turn_name}: expected not_applicable/{expected_classification}" )); } return; @@ -2144,7 +2162,6 @@ mod tests { br#"{ "tool":"browser_prepare", "arguments":{ - "approval_token":"[redacted]", "pid":1, "window_id":2, "strategy":{"kind":"existing_profile"} @@ -2222,6 +2239,42 @@ mod tests { .expect("a minimized target cannot provide a pre-restore screenshot"); } + #[test] + fn validator_accepts_missing_images_for_typed_minimized_refusal() { + let (root, case, result, turn) = complete_turn_fixture(); + std::fs::write( + turn.join("action.json"), + br#"{ + "tool":"click", + "arguments":{"pid":1,"window_id":2,"element_index":3}, + "result_summary":"refused (window_minimized): restore before retrying", + "result_error":true, + "click_point":{"x":10,"y":20}, + "action_truth":{"effect":"refused"} + }"#, + ) + .expect("write minimized refusal action"); + std::fs::write( + turn.join("evidence.json"), + br#"{ + "schema":"cua-turn-evidence/v1", + "before":{"state":{"status":"captured"},"screenshot":{"status":"unavailable","classification":"target_minimized"}}, + "after":{"state":{"status":"captured"},"screenshot":{"status":"unavailable","classification":"target_minimized"}}, + "click":{"status":"not_applicable","classification":"action_refused_before_dispatch"} + }"#, + ) + .expect("write minimized refusal evidence manifest"); + for file in ["before.png", "after.png", "screenshot.png", "click.png"] { + let path = turn.join(file); + if path.exists() { + std::fs::remove_file(path).expect("remove unavailable image"); + } + } + + validate_catalog(&[case], &[result], Some(root.path()), true) + .expect("a typed minimized refusal cannot provide target images"); + } + #[test] fn validator_accepts_restored_state_when_host_capture_remains_unavailable() { let (root, case, result, turn) = complete_turn_fixture(); diff --git a/packages/cua-driver/rust/crates/cua-driver-testkit/src/raw.rs b/packages/cua-driver/rust/crates/cua-driver-testkit/src/raw.rs index fc003fe791..401f76298d 100644 --- a/packages/cua-driver/rust/crates/cua-driver-testkit/src/raw.rs +++ b/packages/cua-driver/rust/crates/cua-driver-testkit/src/raw.rs @@ -27,6 +27,13 @@ pub struct RawDriver { } impl RawDriver { + /// Process id of the daemon that owns platform UI state, when this raw + /// transport is daemon-backed. Linux lifecycle tests use it to inspect + /// the daemon's kernel thread accounting without relying on `ps` races. + pub fn daemon_pid(&self) -> Option { + self._daemon.as_ref().map(|daemon| daemon.pid) + } + /// Spawn the driver with piped stdio. Returns `None` (with a skip eprintln) /// if the binary isn't built — callers early-return so an un-built binary /// skips rather than fails. diff --git a/packages/cua-driver/rust/crates/cua-driver/Cargo.toml b/packages/cua-driver/rust/crates/cua-driver/Cargo.toml index 652499f10e..1567528325 100644 --- a/packages/cua-driver/rust/crates/cua-driver/Cargo.toml +++ b/packages/cua-driver/rust/crates/cua-driver/Cargo.toml @@ -97,3 +97,6 @@ windows = { version = "0.61", features = [ "Win32_Security", "Win32_System_Threading", ] } + +[target.'cfg(target_os = "macos")'.dev-dependencies] +core-foundation = "0.10" diff --git a/packages/cua-driver/rust/crates/cua-driver/src/autostart.rs b/packages/cua-driver/rust/crates/cua-driver/src/autostart.rs index def01e2ce7..feacd66258 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/autostart.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/autostart.rs @@ -132,30 +132,58 @@ pub fn kick() -> Result<()> { platform::kick() } -/// Find the qwen-cua-driver executable to bake into the autostart entry. -/// Uses `std::env::current_exe`, canonicalised to its real path (resolves -/// junction / symlink chains so a versioned upgrade flipping `current` -/// stays transparent to the registered task). The resolved path is what -/// gets stored in the Scheduled Task / LaunchAgent / unit file. +/// Find the qwen-cua-driver executable to bake into the autostart entry. The +/// resolved path is what gets stored in the Scheduled Task / LaunchAgent / +/// unit file. +/// +/// On Windows the path is used **as invoked**, without canonicalisation. +/// Canonicalising resolves the `bin -> current -> releases/` junction +/// chain down to a versioned release path, which then gets baked into the +/// Scheduled Task — so the next upgrade (which only flips `current`) leaves the +/// task launching the previous build. Keeping the junction path is what makes a +/// versioned upgrade transparent to the registered task. +/// +/// Elsewhere the path is canonicalised (best-effort — on error the path is +/// used as invoked) to resolve symlink chains. fn current_exe_for_autostart() -> Result { let exe = std::env::current_exe() .map_err(|e| anyhow!("could not resolve current executable: {e}"))?; - let canonical = std::fs::canonicalize(&exe).unwrap_or(exe); - let path = canonical.to_string_lossy().into_owned(); - // On Windows, `canonicalize` returns a `\\?\C:\...` extended-length - // path. PowerShell + the Task Scheduler XML schema both handle it - // correctly, but it looks alarming in `schtasks /Query` output. - // Strip the prefix for readability — the unprefixed form is still - // valid as long as the path fits MAX_PATH (260 chars), which any - // realistic install will. #[cfg(target_os = "windows")] - let path = path - .strip_prefix(r"\\?\") - .map(str::to_owned) - .unwrap_or(path); + let resolved = exe; + #[cfg(not(target_os = "windows"))] + let resolved = std::fs::canonicalize(&exe).unwrap_or(exe); + let path = resolved.to_string_lossy().into_owned(); + // Defensive: should the path ever arrive in the `\\?\C:\...` + // extended-length form, strip the prefix for readability. PowerShell and + // the Task Scheduler XML schema handle both forms correctly, but the + // prefixed one looks alarming in `schtasks /Query` output. + // + // Only the plain drive-letter form is stripped, and only while the result + // still fits MAX_PATH: `\\?\UNC\server\share\...` is a different namespace + // (stripping it yields a bogus `UNC\server\...`), and a path longer than + // 260 chars needs the prefix to remain addressable. + #[cfg(target_os = "windows")] + let path = windows_task_path(path); Ok(path) } +#[cfg(any(target_os = "windows", test))] +fn windows_task_path(path: String) -> String { + match path.strip_prefix(r"\\?\") { + Some(stripped) if stripped.len() < 260 && starts_with_drive_letter(stripped) => { + stripped.to_owned() + } + _ => path, + } +} + +/// `true` when the path opens with a plain `:` — i.e. not the +/// `UNC\server\share` form the extended-length namespace also carries. +#[cfg(any(target_os = "windows", test))] +fn starts_with_drive_letter(path: &str) -> bool { + matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()) +} + // ── Windows impl ────────────────────────────────────────────────────────── #[cfg(target_os = "windows")] @@ -495,6 +523,97 @@ pub fn run_autostart_cmd(subcommand: &str) { mod tests { use super::*; + #[test] + fn windows_task_path_strips_short_extended_drive_prefix() { + assert_eq!( + windows_task_path(r"\\?\C:\Users\Example\cua-driver.exe".to_owned()), + r"C:\Users\Example\cua-driver.exe" + ); + } + + #[test] + fn windows_task_path_preserves_extended_unc_path() { + let path = r"\\?\UNC\server\share\cua-driver.exe"; + assert_eq!(windows_task_path(path.to_owned()), path); + } + + #[test] + fn windows_task_path_preserves_extended_long_drive_path() { + let path = format!(r"\\?\C:\{}\cua-driver.exe", "nested".repeat(50)); + assert!(path.len() >= 260); + assert_eq!(windows_task_path(path.clone()), path); + } + + #[cfg(target_os = "windows")] + #[test] + fn child_reports_current_exe_for_junction_probe() { + let Ok(output_path) = std::env::var("CUA_CURRENT_EXE_PROBE_OUTPUT") else { + return; + }; + std::fs::write(output_path, current_exe_for_autostart().unwrap()).unwrap(); + } + + #[cfg(target_os = "windows")] + #[test] + fn current_exe_preserves_installer_junction_chain() { + use std::process::Command; + + let temp = tempfile::tempdir().unwrap(); + let releases = temp.path().join("packages").join("releases"); + let release = releases.join("probe-v1"); + let current = temp.path().join("packages").join("current"); + let visible = temp.path().join("bin"); + std::fs::create_dir_all(&release).unwrap(); + + let test_exe = std::env::current_exe().unwrap(); + let exe_name = test_exe.file_name().unwrap(); + std::fs::copy(&test_exe, release.join(exe_name)).unwrap(); + + for (link, target) in [(¤t, &release), (&visible, ¤t)] { + let output = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .output() + .unwrap(); + assert!( + output.status.success(), + "failed to create junction {} -> {}: {}{}", + link.display(), + target.display(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + let visible_exe = visible.join(exe_name); + let probe_output = temp.path().join("current-exe.txt"); + let child = Command::new(&visible_exe) + .args([ + "--exact", + "autostart::tests::child_reports_current_exe_for_junction_probe", + "--nocapture", + ]) + .env("CUA_CURRENT_EXE_PROBE_OUTPUT", &probe_output) + .output() + .unwrap(); + assert!( + child.status.success(), + "junction-launched child failed: {}{}", + String::from_utf8_lossy(&child.stdout), + String::from_utf8_lossy(&child.stderr) + ); + + let observed = std::fs::read_to_string(&probe_output).unwrap(); + let observed = windows_task_path(observed.trim().to_owned()); + let expected = visible_exe.to_string_lossy(); + assert_eq!( + observed.to_ascii_lowercase(), + expected.to_ascii_lowercase(), + "Windows resolved the invoked installer junction path to a release path" + ); + } + #[test] fn successful_task_query_is_registered() { assert_eq!( diff --git a/packages/cua-driver/rust/crates/cua-driver/src/check_update_tool.rs b/packages/cua-driver/rust/crates/cua-driver/src/check_update_tool.rs index 6d6747226d..8593872e68 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/check_update_tool.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/check_update_tool.rs @@ -30,8 +30,8 @@ static DEF: std::sync::OnceLock = std::sync::OnceLock::new(); fn def() -> &'static ToolDef { DEF.get_or_init(|| ToolDef { name: "check_for_update".into(), - description: "Check whether a newer cua-driver-rs release is available on GitHub. \ - Returns the current and latest versions, an `update_available` boolean, \ + description: "Check the saved stable/nightly Cua Driver channel for a release on GitHub. \ + Returns current and selected channels, current and latest versions, an `update_available` boolean, \ the install one-liner, and the release notes URL. Read-only — never \ installs. Mirror of `qwen-cua-driver check-update --json`." .into(), diff --git a/packages/cua-driver/rust/crates/cua-driver/src/cli.rs b/packages/cua-driver/rust/crates/cua-driver/src/cli.rs index f3707c2398..d6d83f7916 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -55,14 +55,11 @@ pub enum Command { permission_mode: Option, /// Deliberate unrestricted-mode selector and risk acknowledgement. dangerously_bypass_approvals: bool, - /// Temporary trusted-launcher compatibility path for the forgeable - /// file-backed existing-profile approval artifact. - allow_legacy_existing_profile_approval: bool, - /// Immutable bounded-autonomy manifest selected by the trusted - /// launcher. Valid only in bounded mode. - session_policy: Option, + /// Immutable narrow-only capability manifest selected by the trusted + /// launcher. Required in bounded mode and optional in other profiles. + capability_manifest: Option, /// Deliberate launch-time confirmation that the manifest was reviewed. - approve_session_policy: bool, + approve_capability_manifest: bool, /// True when `--no-permissions-gate` is on argv. The env-var /// `CUA_DRIVER_RS_PERMISSIONS_GATE=0` short-circuits the gate too /// (checked inside the gate itself), so the flag is only one of @@ -87,6 +84,12 @@ pub enum Command { Status { socket: Option, }, + /// `qwen-cua-driver sessions list [--json]` — content-free operator view of + /// the live sessions owned by the selected daemon runtime. + Sessions { + json: bool, + socket: Option, + }, Recording { subcommand: String, args: Vec, @@ -109,6 +112,12 @@ pub enum Command { json: bool, no_cache: bool, }, + /// Persist or inspect the stable/nightly release preference. + Channel { + subcommand: String, + value: Option, + json: bool, + }, Doctor { json: bool, }, @@ -158,7 +167,7 @@ pub enum Command { /// `qwen-cua-driver skills {install|update|uninstall|status|path}` — /// agent skill-pack management. The verb is the ONLY way a user /// installs or updates the qwen-cua-driver skill pack into their agent - /// dirs (Claude Code / Codex / OpenClaw / OpenCode); the install + /// dirs (Claude Code / Codex / Prime Agent / OpenClaw / OpenCode); the install /// scripts never touch ~/.claude/skills/ etc. directly. `install` /// fetches the matching versioned release asset /// (`cua-driver-rs-v-skills.tar.gz` — the asset filename keeps @@ -176,16 +185,6 @@ pub enum Command { CursorTheme { args: Vec, }, - /// Mint a short-lived, single-use approval token for a direct/raw - /// `browser_prepare` call. This command requires an interactive terminal. - BrowserApprove { - pid: i64, - strategy: Option, - window_id: Option, - session: Option, - profile_mode: Option, - profile_name: Option, - }, } pub enum TelemetryCommand { @@ -211,6 +210,7 @@ const VALUE_FLAGS: &[&str] = &[ "--permission-mode", "--grant", "--session-policy", + "--capability-manifest", "--pid-file", "--type", "--host-bundle-id", @@ -226,6 +226,32 @@ const VALUE_FLAGS: &[&str] = &[ "--experimental-pip-geometry", ]; +/// Authorization selectors are trusted-daemon startup inputs. Direct MCP +/// accepts their environment-variable equivalents, while a daemon-backed MCP +/// client inherits the already-fixed profile through `--socket`. Silently +/// consuming these flags on `mcp` would leave the default profile active while +/// telling the operator nothing. +const SERVE_ONLY_AUTHORIZATION_FLAGS: &[&str] = &[ + "--permission-mode", + "--capability-manifest", + "--approve-capability-manifest", + "--session-policy", + "--approve-session-policy", + "--dangerously-bypass-approvals", + "--no-permissions-gate", +]; + +fn serve_only_authorization_flag(args: &[String]) -> Option<&'static str> { + SERVE_ONLY_AUTHORIZATION_FLAGS.iter().copied().find(|flag| { + args.iter().any(|arg| { + arg == flag + || arg + .strip_prefix(flag) + .is_some_and(|remainder| remainder.starts_with('=')) + }) + }) +} + /// Classify the requested finite command without parsing its arguments. The /// parent process uses this before `parse_command` so invalid JSON and other /// parser exits are still observed as completed failures. @@ -269,17 +295,18 @@ fn finite_command_name_from_args(args: &[String]) -> Option<&'static str> { Some("stop") => Some("stop"), Some("revoke") => Some("revoke"), Some("status") => Some("status"), + Some("sessions") => Some("sessions"), Some("recording") => Some("recording"), Some("dump-docs") => Some("dump_docs"), Some("update") => Some("update"), Some("check-update") => Some("check_update"), + Some("channel") => Some("channel"), Some("doctor") => Some("doctor"), Some("diagnose") => Some("diagnose"), Some("permissions") => Some("permissions"), Some("autostart") => Some("autostart"), Some("skills") => Some("skills"), Some("cursor-theme") => Some("cursor_theme"), - Some("browser-approve") => Some("browser_approve"), Some("config") => Some("config"), Some(_) => Some("call"), } @@ -346,6 +373,10 @@ fn finite_operation_from_args(args: &[String]) -> &'static str { "reset" => "reset", _ => "other", }, + Some("sessions") => match subcommand.unwrap_or("list") { + "list" => "list", + _ => "other", + }, Some("autostart") => match subcommand.unwrap_or("") { "enable" => "enable", "disable" => "disable", @@ -363,6 +394,11 @@ fn finite_operation_from_args(args: &[String]) -> &'static str { }, Some("update") if args.iter().any(|arg| arg == "--apply") => "apply", Some("update") => "check_only", + Some("channel") => match subcommand.unwrap_or("status") { + "status" => "status", + "set" => "set", + _ => "other", + }, _ => "not_applicable", } } @@ -393,6 +429,7 @@ fn finite_client_kind_from_args(args: &[String]) -> &'static str { "opencode" => "opencode", "hermes" => "hermes", "pi" => "pi", + "prime-agent" => "prime_agent", "antigravity" | "gemini" => "antigravity", "qwen" | "qwen-code" => "qwen_code", "droid" | "factory" => "factory_droid", @@ -434,7 +471,7 @@ pub fn parse_command() -> Command { env!("CARGO_PKG_VERSION") ); println!("Usage: {cli_name} [SUBCOMMAND] [OPTIONS]"); - println!("Subcommands: mcp, list-tools, describe, call, serve, stop, revoke, status, config, telemetry, recording, update, check-update, doctor, diagnose, permissions, autostart, skills, browser-approve, manifest, cursor-theme"); + println!("Subcommands: mcp, list-tools, describe, call, serve, stop, revoke, status, config, telemetry, recording, update, check-update, doctor, diagnose, permissions, autostart, skills, manifest, channel, cursor-theme, sessions"); println!(); println!("permissions options (macOS):"); println!(" {cli_name} permissions status Report Accessibility + Screen Recording status. Read-only (no prompt)."); @@ -455,6 +492,9 @@ pub fn parse_command() -> Command { println!(" {cli_name} update Same check as above, then suggest --apply if outdated."); println!(" --apply Download + install the latest release via the canonical installer."); println!(" --json Emit the structured check payload (does not change --apply behaviour)."); + println!(" {cli_name} channel status Show the saved stable/nightly update channel."); + println!(" {cli_name} channel set Save stable or nightly; run update --apply to switch binaries."); + println!(" --json Emit machine-readable channel state."); println!(); println!("autostart options (Windows-only today):"); println!(" qwen-cua-driver autostart enable Register a logon Scheduled Task so serve starts at every interactive logon."); @@ -463,25 +503,15 @@ pub fn parse_command() -> Command { println!(" qwen-cua-driver autostart kick Start the entry now without re-logging."); println!(); println!("skills options (agent skill-pack management, opt-in):"); - println!(" qwen-cua-driver skills install Fetch the versioned skill pack from GitHub Releases and symlink it"); - println!(" into each detected agent's skills/ dir (Claude Code, Codex, OpenClaw,"); - println!(" OpenCode). Idempotent. Never overwrites existing user links."); - println!(" qwen-cua-driver skills update Re-fetch the skill pack from GitHub, refreshing the local copy + links."); - println!(" qwen-cua-driver skills uninstall Remove the agent symlinks. Add --all to also delete the local copy."); - println!(" qwen-cua-driver skills status Report local install state + per-agent link state. Read-only."); - println!(" qwen-cua-driver skills path Print where the local skill pack lives."); + println!(" {cli_name} skills install Fetch the versioned skill pack from GitHub Releases and symlink it"); + println!(" into each detected agent's skills/ dir (Claude Code, Codex, Prime Agent,"); + println!(" OpenClaw, OpenCode). Idempotent. Never overwrites existing user links."); + println!(" {cli_name} skills update Re-fetch the skill pack from GitHub, refreshing the local copy + links."); + println!(" {cli_name} skills uninstall Remove the agent symlinks. Add --all to also delete the local copy."); + println!(" {cli_name} skills status Report local install state + per-agent link state. Read-only."); + println!(" {cli_name} skills path Print where the local skill pack lives."); println!(" --from main (install only) Fetch latest from main branch instead of the tagged release."); println!(); - println!("browser preparation approval:"); - println!(" qwen-cua-driver browser-approve --pid --profile-mode isolated_new"); - println!(" qwen-cua-driver browser-approve --pid --profile-mode isolated_named --profile-name "); - println!(" Interactively mint a five-minute, single-use token for a"); - println!(" direct CLI/raw browser_prepare call. MCP hosts use their"); - println!(" destructive-tool approval flow instead."); - println!(" qwen-cua-driver browser-approve --strategy existing_profile --pid "); - println!(" --window-id --session "); - println!(" Approve attachment to one exact existing browser request."); - println!(); println!("agent authorization (serve only):"); println!(" --permission-mode standard (default), bounded, or unrestricted."); println!( @@ -493,18 +523,19 @@ pub fn parse_command() -> Command { ); println!(" The mode is fixed for the daemon lifetime and cannot"); println!(" be changed by a tool call."); - println!(" --allow-legacy-existing-profile-approval"); - println!(" Temporary migration flag for the forgeable file-backed"); - println!(" approval artifact; never treated as protected consent."); println!(" This is separate from --no-permissions-gate, which only"); println!( " controls the macOS OS-permission onboarding UI." ); println!( - " --session-policy Required in bounded mode; immutable tool manifest." + " --capability-manifest Narrow-only tool/resource manifest; required in bounded mode." ); - println!(" --approve-session-policy Required with --session-policy; the trusted launcher asserts"); + println!(" --approve-capability-manifest Required with --capability-manifest; the trusted launcher asserts"); println!(" that the human reviewed this exact manifest at startup."); + println!(" --session-policy Deprecated alias for --capability-manifest."); + println!( + " --approve-session-policy Deprecated alias for --approve-capability-manifest." + ); println!(); println!("authorization revocation:"); println!(" qwen-cua-driver revoke --session Stop and revoke one session's grants."); @@ -605,12 +636,7 @@ pub fn parse_command() -> Command { let screenshot_out_file = flag_value(&args, "--screenshot-out-file"); let mcp_client = flag_value(&args, "--client"); let socket = flag_value(&args, "--socket"); - let approval_pid = flag_value(&args, "--pid"); - let approval_strategy = flag_value(&args, "--strategy"); - let approval_window_id = flag_value(&args, "--window-id"); let approval_session = flag_value(&args, "--session"); - let approval_profile_mode = flag_value(&args, "--profile-mode"); - let approval_profile_name = flag_value(&args, "--profile-name"); let grants = flag_values(&args, "--grant"); // `--embedded` / `--host-bundle-id` export to the environment rather @@ -645,6 +671,15 @@ pub fn parse_command() -> Command { } } + if matches!(positionals.first().copied(), None | Some("mcp")) { + if let Some(flag) = serve_only_authorization_flag(&args) { + eprintln!("qwen-cua-driver mcp does not accept {flag}; authorization flags belong to `qwen-cua-driver serve`."); + eprintln!("For direct MCP, use CUA_DRIVER_PERMISSION_MODE and the related CUA_DRIVER_* environment variables."); + eprintln!("Otherwise start a configured daemon and connect with `qwen-cua-driver mcp --socket `." ); + process::exit(64); + } + } + let claude_code_compat = args .iter() .any(|a| a == "--claude-code-computer-use-compat"); @@ -652,7 +687,7 @@ pub fn parse_command() -> Command { let mut pos = positionals.into_iter(); match pos.next() { None => { - // Bare `cua-driver` defaults to MCP, which reads JSON-RPC from + // Bare `qwen-cua-driver` defaults to MCP, which reads JSON-RPC from // stdin forever. From a terminal that looks like a hang. If // stdin is a TTY (i.e. interactive shell, no client piping // stdio), surface a hint and exit. Piped / redirected stdin — @@ -660,7 +695,9 @@ pub fn parse_command() -> Command { // Explicit `qwen-cua-driver mcp` bypasses the check entirely. use std::io::IsTerminal as _; if std::io::stdin().is_terminal() { - eprintln!("cua-driver: bare invocation defaults to the MCP server, which reads"); + eprintln!( + "qwen-cua-driver: bare invocation defaults to the MCP server, which reads" + ); eprintln!("JSON-RPC from stdin. From a terminal that looks like a hang."); eprintln!(); eprintln!("You probably meant one of:"); @@ -700,11 +737,14 @@ pub fn parse_command() -> Command { dangerously_bypass_approvals: args .iter() .any(|a| a == "--dangerously-bypass-approvals"), - allow_legacy_existing_profile_approval: args + capability_manifest: aliased_flag_value( + &args, + "--capability-manifest", + "--session-policy", + ), + approve_capability_manifest: args .iter() - .any(|a| a == "--allow-legacy-existing-profile-approval"), - session_policy: flag_value(&args, "--session-policy"), - approve_session_policy: args.iter().any(|a| a == "--approve-session-policy"), + .any(|a| a == "--approve-capability-manifest" || a == "--approve-session-policy"), // Bare flag — present anywhere on argv counts as "skip the gate". no_permissions_gate: args.iter().any(|a| a == "--no-permissions-gate"), claude_code_compat, @@ -724,6 +764,17 @@ pub fn parse_command() -> Command { } } Some("status") => Command::Status { socket }, + Some("sessions") => { + let subcommand = pos.next().unwrap_or("list"); + if subcommand != "list" { + eprintln!("Unknown sessions subcommand '{subcommand}'. Valid: list"); + process::exit(64); + } + Command::Sessions { + json: args.iter().any(|arg| arg == "--json"), + socket, + } + } Some("recording") => { let subcommand = pos.next().unwrap_or("status").to_string(); let rest: Vec = pos.map(str::to_owned).collect(); @@ -755,6 +806,23 @@ pub fn parse_command() -> Command { let no_cache = args.iter().any(|a| a == "--no-cache"); Command::CheckUpdate { json, no_cache } } + Some("channel") => { + let subcommand = pos.next().unwrap_or("status").to_owned(); + let value = pos.next().map(str::to_owned); + if !matches!(subcommand.as_str(), "status" | "set") { + eprintln!("Unknown channel subcommand '{subcommand}'. Valid: status, set"); + process::exit(64); + } + if subcommand == "set" && value.is_none() { + eprintln!("Usage: qwen-cua-driver channel set "); + process::exit(64); + } + Command::Channel { + subcommand, + value, + json: args.iter().any(|arg| arg == "--json"), + } + } Some("doctor") => { // `--json` switches to machine-readable output for scripting. // Bare flag — no value, position-independent. @@ -881,24 +949,6 @@ pub fn parse_command() -> Command { args: args[index + 1..].to_vec(), } } - Some("browser-approve") => { - let pid = approval_pid - .as_deref() - .and_then(|value| value.parse::().ok()) - .filter(|pid| *pid > 0) - .unwrap_or_else(|| { - eprintln!("browser-approve requires --pid "); - process::exit(64); - }); - Command::BrowserApprove { - pid, - strategy: approval_strategy, - window_id: approval_window_id.and_then(|value| value.parse::().ok()), - session: approval_session, - profile_mode: approval_profile_mode, - profile_name: approval_profile_name, - } - } Some(first) => { // Implicit call: unrecognised first positional → treat as tool name. // Same parse-error handling as the explicit `call` branch above. See #1637. @@ -935,114 +985,6 @@ pub fn parse_command() -> Command { } } -pub fn run_browser_approve( - pid: i64, - strategy: Option<&str>, - window_id: Option, - session: Option<&str>, - profile_mode: Option<&str>, - profile_name: Option<&str>, -) { - use std::io::{IsTerminal as _, Write as _}; - - if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { - eprintln!("browser-approve requires an interactive terminal; approval cannot be piped or scripted"); - process::exit(1); - } - if let Some(strategy) = strategy { - if strategy != "existing_profile" { - eprintln!("unsupported browser approval strategy {strategy:?}; use existing_profile"); - process::exit(64); - } - if profile_mode.is_some() || profile_name.is_some() { - eprintln!("--strategy existing_profile cannot be combined with profile flags"); - process::exit(64); - } - let Some(window_id) = window_id.filter(|window_id| *window_id > 0) else { - eprintln!("existing-profile approval requires --window-id "); - process::exit(64); - }; - let Some(session) = session.filter(|session| !session.trim().is_empty()) else { - eprintln!("existing-profile approval requires --session "); - process::exit(64); - }; - eprintln!("Approve CUA Driver to attach to this existing Chromium profile?"); - eprintln!(" browser pid: {pid}"); - eprintln!(" native window id: {window_id}"); - eprintln!(" caller session: {session}"); - eprintln!( - "This grant stays in daemon memory, expires, and never authorizes arbitrary dialogs." - ); - eprint!("Type APPROVE to continue: "); - let _ = std::io::stderr().flush(); - let mut confirmation = String::new(); - if std::io::stdin().read_line(&mut confirmation).is_err() - || confirmation.trim() != "APPROVE" - { - eprintln!("browser attachment approval declined; no artifact was created"); - process::exit(1); - } - let scope = cua_driver_core::browser::approval::ExistingProfileApprovalScope { - pid, - window_id, - session: session.to_owned(), - }; - match cua_driver_core::browser::approval::mint_existing_profile_approval(scope) { - Ok(token) => println!("{token}"), - Err(error) => { - eprintln!("{}", error.message); - process::exit(1); - } - } - return; - } - if window_id.is_some() || session.is_some() { - eprintln!("--window-id/--session require --strategy existing_profile"); - process::exit(64); - } - let profile_mode = profile_mode.unwrap_or_else(|| { - eprintln!("browser-approve requires --profile-mode isolated_new|isolated_named"); - process::exit(64); - }); - let mode = match profile_mode { - "isolated_new" => cua_driver_core::browser::PrepareProfileMode::IsolatedNew, - "isolated_named" => cua_driver_core::browser::PrepareProfileMode::IsolatedNamed, - other => { - eprintln!("unsupported profile mode {other:?}; use isolated_new or isolated_named"); - process::exit(64); - } - }; - let profile = cua_driver_core::browser::PrepareProfile { - mode, - name: profile_name.map(str::to_owned), - }; - if let Err(error) = cua_driver_core::browser::approval::validate_profile(&profile) { - eprintln!("{}", error.message); - process::exit(64); - } - eprintln!("Approve CUA Driver to launch a separate driver-owned Chromium profile?"); - eprintln!(" source pid: {pid}"); - eprintln!(" profile mode: {profile_mode}"); - if let Some(name) = profile_name { - eprintln!(" profile name: {name}"); - } - eprintln!("The existing browser process and its profile will not be modified or terminated."); - eprint!("Type APPROVE to continue: "); - let _ = std::io::stderr().flush(); - let mut confirmation = String::new(); - if std::io::stdin().read_line(&mut confirmation).is_err() || confirmation.trim() != "APPROVE" { - eprintln!("browser preparation approval declined; no artifact was created"); - process::exit(1); - } - match cua_driver_core::browser::approval::mint_prepare_approval(pid, profile) { - Ok(token) => println!("{token}"), - Err(error) => { - eprintln!("{}", error.message); - process::exit(1); - } - } -} - /// Return the value of `--flag value` from argv, or `None`. fn flag_value(args: &[String], flag: &str) -> Option { let mut it = args.iter(); @@ -1060,6 +1002,19 @@ fn flag_value(args: &[String], flag: &str) -> Option { None } +fn aliased_flag_value(args: &[String], preferred: &str, deprecated: &str) -> Option { + let preferred_value = flag_value(args, preferred); + let deprecated_value = flag_value(args, deprecated); + if preferred_value.is_some() + && deprecated_value.is_some() + && preferred_value != deprecated_value + { + eprintln!("{preferred} conflicts with deprecated {deprecated}"); + process::exit(64); + } + preferred_value.or(deprecated_value) +} + /// Return every value of a repeatable `--flag value` or `--flag=value`. fn flag_values(args: &[String], flag: &str) -> Vec { let mut values = Vec::new(); @@ -1487,9 +1442,10 @@ pub fn build_manifest() -> serde_json::Value { { "name": "--permission-mode", "type": "string", "description": "Immutable daemon authorization mode: standard, bounded, or unrestricted." }, { "name": "--grant", "type": "repeatable-string", "description": "Pre-authorize a residual standard-mode boundary. Supported value: existing-profile." }, { "name": "--dangerously-bypass-approvals", "type": "flag", "description": "Select unrestricted mode and acknowledge its risk." }, - { "name": "--allow-legacy-existing-profile-approval", "type": "flag", "description": "Temporary migration flag for the unprotected file-backed existing-profile artifact." }, - { "name": "--session-policy", "type": "string", "description": "Immutable tool manifest required in bounded mode." }, - { "name": "--approve-session-policy", "type": "flag", "description": "Trusted-launcher confirmation that the exact bounded manifest was reviewed." }, + { "name": "--capability-manifest", "type": "string", "description": "Optional narrow-only tool/resource ceiling; required in bounded mode." }, + { "name": "--approve-capability-manifest", "type": "flag", "description": "Trusted-launcher confirmation that the exact capability manifest was reviewed." }, + { "name": "--session-policy", "type": "string", "description": "Deprecated alias for --capability-manifest." }, + { "name": "--approve-session-policy", "type": "flag", "description": "Deprecated alias for --approve-capability-manifest." }, { "name": "--no-permissions-gate", "type": "flag", "description": "Skip the macOS TCC first-launch gate." }, { "name": "--claude-code-computer-use-compat", "type": "flag", "description": "Forwarded by the MCP proxy when the client asked for the compat surface." }, { "name": "--embedded", "type": "flag", "description": "Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1." }, @@ -1508,6 +1464,13 @@ pub fn build_manifest() -> serde_json::Value { { "name": "status", "description": "Report daemon status (running / not / unhealthy).", "args": [ { "name": "--socket", "type": "string", "description": "Override the daemon socket path." } ] }, + { "name": "sessions", + "description": "List content-free lifecycle summaries for sessions owned by the daemon runtime.", + "args": [ + { "name": "subcommand", "type": "positional-string", "description": "Only: list. Default: list." }, + { "name": "--json", "type": "flag", "description": "Emit the machine-readable session summary." }, + { "name": "--socket", "type": "string", "description": "Override the daemon socket path." } + ] }, { "name": "list-tools", "description": "Print the canonical tool name + one-line summary for every registered MCP tool.", "args": [] }, @@ -1523,8 +1486,8 @@ pub fn build_manifest() -> serde_json::Value { { "name": "--socket", "type": "string", "description": "Override the required daemon socket path." } ] }, { "name": "mcp-config", - "description": "Print the MCP server config snippet or a client-specific install command.", - "args": [ { "name": "--client", "type": "string", "description": "One of: claude, codex, cursor, hermes, antigravity, openclaw, opencode, pi, qwen, droid, zcode. Omit for the generic snippet." } ] }, + "description": "Print client-specific connection guidance (MCP config where supported).", + "args": [ { "name": "--client", "type": "string", "description": "One of: claude, codex, cursor, hermes, antigravity, openclaw, opencode, pi, prime-agent, qwen, droid, zcode. Omit for the generic snippet." } ] }, { "name": "manifest", "description": "Emit this machine-readable description of the CLI surface.", "args": [ { "name": "--pretty", "type": "flag", "description": "Pretty-print the JSON." } ] }, @@ -1552,6 +1515,13 @@ pub fn build_manifest() -> serde_json::Value { { "name": "--json", "type": "flag", "description": "Emit the structured check payload." }, { "name": "--no-cache", "type": "flag", "description": "Force a fresh GitHub round-trip." } ] }, + { "name": "channel", + "description": "Inspect or persist the stable/nightly release channel.", + "args": [ + { "name": "subcommand", "type": "positional-string", "description": "status | set. Default: status." }, + { "name": "channel", "type": "positional-string", "description": "stable | nightly (required for set)." }, + { "name": "--json", "type": "flag", "description": "Emit machine-readable channel state." } + ] }, { "name": "doctor", "description": "Self-diagnose probes for runtime prerequisites (permissions, accessibility, capture, etc.).", "args": [ { "name": "--json", "type": "flag", "description": "Machine-readable doctor report." } ] }, @@ -1589,10 +1559,11 @@ pub fn build_manifest() -> serde_json::Value { }) } -/// Print the MCP server config snippet or a client-specific install command. +/// Print client-specific connection guidance (MCP config where supported). /// /// `--client ` selects one of: claude, codex, cursor, hermes, -/// antigravity, openclaw, opencode, pi. Omit for the generic JSON snippet. +/// antigravity, openclaw, opencode, pi, prime-agent, qwen, droid, zcode. +/// Omit for the generic JSON snippet. pub fn run_mcp_config(client: Option<&str>) { let binary = std::env::current_exe() .ok() @@ -1768,6 +1739,18 @@ pub fn run_mcp_config(client: Option<&str>) { exactly the shape Pi is designed around." ); } + Some("prime-agent") => { + println!( + "Prime Agent loads Agent Skills and can call cua-driver directly from its\n\ + persistent IPython control environment. No MCP registration is required.\n\n\ + Install and verify the Cua Driver skill pack:\n\n\ + {binary} skills install\n\ + {binary} skills status\n\n\ + Then run /reload in Prime Agent (or start a new session) and ask it to\n\ + use the Cua Driver skill. Use /skill:cua-driver to invoke it explicitly.\n\ + The skill calls the cua-driver CLI with a snapshot/action/verify workflow." + ); + } Some("qwen") | Some("qwen-code") => { // Qwen Code (Alibaba's open-source coding CLI, a Gemini-CLI fork). // Config: ~/.qwen/settings.json (user) or .qwen/settings.json @@ -1807,7 +1790,7 @@ pub fn run_mcp_config(client: Option<&str>) { ); } Some(other) => { - eprintln!("Unknown client '{other}'. Valid: claude, codex, cursor, antigravity, openclaw, opencode, hermes, pi, qwen, droid, zcode."); + eprintln!("Unknown client '{other}'. Valid: claude, codex, cursor, antigravity, openclaw, opencode, hermes, pi, prime-agent, qwen, droid, zcode."); process::exit(2); } } @@ -1906,16 +1889,46 @@ pub fn run_call( .clone() .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); cua_driver_core::tool_args::sanitize_reserved_args(&mut args_for_daemon); + let named_session = args_for_daemon + .get("session") + .and_then(serde_json::Value::as_str) + .is_some_and(|session| !session.is_empty() && session != "default"); + // One-shot CLI processes share one daemon-scoped ownership namespace + // for explicit public labels. The daemon adds its runtime prefix, so + // this cannot attach to another daemon generation or transport kind. + // Anonymous calls keep their disposable per-process lease below. + let transport_session = if named_session { + "cli-explicit".to_owned() + } else { + format!("cli-{}", uuid::Uuid::new_v4()) + }; let req = crate::serve::DaemonRequest { method: "call".into(), name: Some(tool.to_owned()), args: Some(args_for_daemon), - // CLI one-shot is its own ephemeral, anonymous/global session. - session_id: None, + // Every one-shot call owns one disposable implicit transport + // session. The daemon closes all lifecycle state attached to it + // synchronously after the response is received. + session_id: Some(transport_session.clone()), observation_origin: Some(crate::serve::ToolObservationOrigin::Direct), client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), }; - match crate::serve::send_request(&socket_path, &req) { + let response = crate::serve::send_request(&socket_path, &req); + if !named_session { + let cleanup = crate::serve::DaemonRequest { + method: "session_end".into(), + name: None, + args: None, + session_id: Some(transport_session), + observation_origin: None, + client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), + }; + let cleanup_result = crate::serve::send_request(&socket_path, &cleanup); + if let Err(error) = cleanup_result { + eprintln!("warning: disposable session cleanup failed: {error}"); + } + } + match response { Ok(resp) => { if resp.ok { if let Some(result) = resp.result { @@ -2304,6 +2317,14 @@ pub fn run_update_cmd(apply: bool, json: bool) { } let current = env!("CARGO_PKG_VERSION"); + let selected_channel = crate::release_channel::selected().unwrap_or_else(|error| { + eprintln!("Cannot read release channel: {error}"); + eprintln!( + "Repair it with `qwen-cua-driver channel set stable` or `qwen-cua-driver channel set nightly`." + ); + process::exit(1); + }); + let current_channel = crate::release_channel::ReleaseChannel::from_version(current); if !json { println!("Current version: {current}"); println!("Checking for updates…"); @@ -2336,7 +2357,14 @@ pub fn run_update_cmd(apply: bool, json: bool) { } process::exit(1); } - Ok(v) if !crate::version_check::is_newer(&v, current) => { + Ok(v) + if !crate::version_check::update_is_available( + &v, + current, + current_channel, + selected_channel, + ) => + { crate::telemetry::capture_update_checked( crate::telemetry::UpdateCheckSource::Cli, crate::telemetry::UpdateCheckOutcome::UpToDate, @@ -2984,6 +3012,60 @@ pub fn run_check_update_cmd(json: bool, no_cache: bool) { } } +/// Inspect or persist the release channel. Selection never installs by itself; +/// replacement remains explicit through `qwen-cua-driver update --apply`. +pub fn run_channel_cmd(subcommand: &str, value: Option<&str>, json: bool) { + let result = match subcommand { + "status" => crate::release_channel::selected(), + "set" => { + let channel = value + .unwrap_or_default() + .parse::() + .unwrap_or_else(|error| { + eprintln!("{error}"); + process::exit(64); + }); + if let Err(error) = crate::release_channel::set(channel) { + eprintln!("Failed to save release channel: {error}"); + process::exit(1); + } + Ok(channel) + } + _ => unreachable!("validated by parse_command"), + }; + + let selected = result.unwrap_or_else(|error| { + eprintln!("Failed to read release channel: {error}"); + eprintln!( + "Repair it with `qwen-cua-driver channel set stable` or `qwen-cua-driver channel set nightly`." + ); + process::exit(1); + }); + let current = crate::release_channel::ReleaseChannel::from_version(env!("CARGO_PKG_VERSION")); + + if json { + println!( + "{}", + serde_json::json!({ + "selected_channel": selected.as_str(), + "current_channel": current.map(|channel| channel.as_str()), + "current_version": env!("CARGO_PKG_VERSION"), + }) + ); + } else { + println!("Selected channel: {selected}"); + match current { + Some(channel) => println!("Current channel: {channel}"), + None => println!("Current channel: development"), + } + if subcommand == "set" && current != Some(selected) { + println!( + "Run `qwen-cua-driver update --apply` to install the latest {selected} release." + ); + } + } +} + fn cli_docs_json() -> serde_json::Value { let cli_name = crate::bundle::cli_name(); let app_name = crate::bundle::app_name(); @@ -3059,13 +3141,14 @@ fn cli_docs_json() -> serde_json::Value { {"name":"pid-file","short_name":null,"help":"Override the pid-file path on Unix targets.","type":"String","default_value":null,"is_optional":true}, {"name":"permission-mode","short_name":null,"help":"Immutable agent authorization mode: standard, bounded, or unrestricted.","type":"String","default_value":"standard","is_optional":true}, {"name":"grant","short_name":null,"help":"Pre-authorize a residual standard-mode boundary. Repeatable; supported value: existing-profile.","type":"String","default_value":null,"is_optional":true,"is_repeatable":true}, - {"name":"session-policy","short_name":null,"help":"Immutable tool manifest required in bounded mode.","type":"String","default_value":null,"is_optional":true}, + {"name":"capability-manifest","short_name":null,"help":"Optional narrow-only tool/resource ceiling; required in bounded mode.","type":"String","default_value":null,"is_optional":true}, + {"name":"session-policy","short_name":null,"help":"Deprecated alias for capability-manifest.","type":"String","default_value":null,"is_optional":true}, {"name":"host-bundle-id","short_name":null,"help":"Advisory host bundle id label echoed in check_permissions output (embedded mode).","type":"String","default_value":null,"is_optional":true} ], "flags": [ {"name":"dangerously-bypass-approvals","short_name":null,"help":"Select unrestricted mode and acknowledge its risk.","default_value":false}, - {"name":"allow-legacy-existing-profile-approval","short_name":null,"help":"Temporary migration flag for the unprotected file-backed existing-profile artifact.","default_value":false}, - {"name":"approve-session-policy","short_name":null,"help":"Trusted-launcher confirmation that the exact bounded manifest was reviewed.","default_value":false}, + {"name":"approve-capability-manifest","short_name":null,"help":"Trusted-launcher confirmation that the exact capability manifest was reviewed.","default_value":false}, + {"name":"approve-session-policy","short_name":null,"help":"Deprecated alias for approve-capability-manifest.","default_value":false}, {"name":"no-permissions-gate","short_name":null,"help":"Skip the macOS first-launch permissions gate.","default_value":false}, {"name":"embedded","short_name":null,"help":"Run embedded inside a host app: inherit the host's TCC grants, never prompt or relaunch. Also CUA_DRIVER_EMBEDDED=1.","default_value":false}, {"name":"no-overlay","short_name":null,"help":"Disable the agent cursor overlay for this daemon.","default_value":false} @@ -3109,8 +3192,8 @@ fn cli_docs_json() -> serde_json::Value { }, { "name": "mcp-config", - "abstract": "Print MCP server config or a client-specific install command.", - "discussion": "Supported clients include claude, codex, cursor, antigravity, openclaw, opencode, hermes, pi, qwen, droid, and zcode.", + "abstract": "Print client-specific connection guidance (MCP config where supported).", + "discussion": "Supported clients include claude, codex, cursor, antigravity, openclaw, opencode, hermes, pi, prime-agent, qwen, droid, and zcode.", "arguments": no_args, "options": [{"name":"client","short_name":null,"help":"Client name to print configuration for.","type":"String","default_value":null,"is_optional":true}], "flags": no_flags, @@ -3218,6 +3301,18 @@ fn cli_docs_json() -> serde_json::Value { ], "subcommands": no_subcommands }, + { + "name": "channel", + "abstract": "Inspect or change the stable/nightly update channel.", + "discussion": "Selection is persistent but never installs by itself; use qwen-cua-driver update --apply after changing it.", + "arguments": no_args, + "options": no_options, + "flags": no_flags, + "subcommands": [ + {"name":"status","abstract":"Show selected and current release channels.","discussion":"","arguments":[],"options":[],"flags":[{"name":"json","short_name":null,"help":"Emit machine-readable channel state.","default_value":false}],"subcommands":[]}, + {"name":"set","abstract":"Save stable or nightly as the update channel.","discussion":"","arguments":[{"name":"channel","help":"stable or nightly","type":"String","is_optional":false}],"options":[],"flags":[{"name":"json","short_name":null,"help":"Emit machine-readable channel state.","default_value":false}],"subcommands":[]} + ] + }, { "name": "doctor", "abstract": "Run platform-aware diagnostic probes.", @@ -3688,7 +3783,7 @@ pub fn run_config_cmd( }; if key == "capture_scope" { eprintln!( - "config key 'capture_scope' is retired; use start_session(capture_scope=auto|window|desktop)" + "config key 'capture_scope' is retired; select a window or desktop target on each action" ); process::exit(64); } @@ -3729,7 +3824,7 @@ pub fn run_config_cmd( }; if key == "capture_scope" { eprintln!( - "config key 'capture_scope' is retired; use start_session(capture_scope=auto|window|desktop)" + "config key 'capture_scope' is retired; select a window or desktop target on each action" ); process::exit(64); } @@ -3893,6 +3988,26 @@ mod tests { values.iter().map(|value| (*value).to_owned()).collect() } + #[test] + fn deprecated_session_policy_flag_remains_a_capability_manifest_alias() { + let argv = args(&["serve", "--session-policy", "/tmp/legacy.yaml"]); + assert_eq!( + aliased_flag_value(&argv, "--capability-manifest", "--session-policy"), + Some("/tmp/legacy.yaml".to_owned()) + ); + + let identical = args(&[ + "serve", + "--capability-manifest=/tmp/shared.yaml", + "--session-policy", + "/tmp/shared.yaml", + ]); + assert_eq!( + aliased_flag_value(&identical, "--capability-manifest", "--session-policy"), + Some("/tmp/shared.yaml".to_owned()) + ); + } + #[test] fn finite_call_tool_extraction_supports_subcommand_and_legacy_forms() { assert_eq!( @@ -3947,11 +4062,21 @@ mod tests { "set" ); assert_eq!(finite_operation_from_args(&args(&["skills"])), "status"); + assert_eq!(finite_operation_from_args(&args(&["sessions"])), "list"); + assert_eq!( + finite_operation_from_args(&args(&["sessions", "private-value"])), + "other" + ); assert_eq!( finite_operation_from_args(&args(&["update", "--apply"])), "apply" ); assert_eq!(finite_operation_from_args(&args(&["update"])), "check_only"); + assert_eq!(finite_operation_from_args(&args(&["channel"])), "status"); + assert_eq!( + finite_operation_from_args(&args(&["channel", "set", "private-value"])), + "set" + ); assert_eq!( finite_operation_from_args(&args(&["doctor", "private-value"])), "not_applicable" @@ -3976,6 +4101,10 @@ mod tests { finite_client_kind_from_args(&args(&["mcp-config", "--client", "antigravity"])), "antigravity" ); + assert_eq!( + finite_client_kind_from_args(&args(&["mcp-config", "--client", "prime-agent"])), + "prime_agent" + ); assert_eq!( finite_client_kind_from_args(&args(&["mcp-config", "--client", "/private/client"])), "other" diff --git a/packages/cua-driver/rust/crates/cua-driver/src/main.rs b/packages/cua-driver/rust/crates/cua-driver/src/main.rs index 0ce2d6fc67..f7ead0504b 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/main.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/main.rs @@ -25,6 +25,7 @@ mod doctor; mod mcp_http; mod private_worker; mod proxy; +mod release_channel; mod responsibility; mod sdk_adapter; mod serve; @@ -47,9 +48,8 @@ fn init_logging() { fn configure_startup_permission_mode( permission_mode: Option<&str>, dangerously_bypass_approvals: bool, - allow_legacy_existing_profile_approval: bool, - session_policy: Option<&str>, - approve_session_policy: bool, + capability_manifest: Option<&str>, + approve_capability_manifest: bool, grants: &[String], ) -> anyhow::Result<()> { if let Some(mode) = permission_mode { @@ -69,21 +69,15 @@ fn configure_startup_permission_mode( if dangerously_bypass_approvals { std::env::set_var(cua_driver_core::authorization::DANGEROUS_BYPASS_ENV, "1"); } - if allow_legacy_existing_profile_approval { + if let Some(path) = capability_manifest { std::env::set_var( - cua_driver_core::authorization::LEGACY_EXISTING_PROFILE_APPROVAL_ENV, - "1", - ); - } - if let Some(path) = session_policy { - std::env::set_var( - cua_driver_core::session_manifest::SESSION_POLICY_FILE_ENV, + cua_driver_core::session_manifest::CAPABILITY_MANIFEST_FILE_ENV, path, ); } - if approve_session_policy { + if approve_capability_manifest { std::env::set_var( - cua_driver_core::session_manifest::SESSION_POLICY_APPROVED_ENV, + cua_driver_core::session_manifest::CAPABILITY_MANIFEST_APPROVED_ENV, "1", ); } @@ -150,14 +144,14 @@ fn run_telemetry_command(command: cli::TelemetryCommand) { cli::TelemetryCommand::Enable => match telemetry::set_enabled(true) { Ok(()) => println!("Telemetry enabled. The retained installation ID will be reused."), Err(error) => { - eprintln!("cua-driver: failed to enable telemetry: {error}"); + eprintln!("qwen-cua-driver: failed to enable telemetry: {error}"); std::process::exit(1); } }, cli::TelemetryCommand::Disable => match telemetry::set_enabled(false) { Ok(()) => println!("Telemetry disabled. The local installation ID was retained; run `qwen-cua-driver telemetry reset-id` to erase it."), Err(error) => { - eprintln!("cua-driver: failed to disable telemetry: {error}"); + eprintln!("qwen-cua-driver: failed to disable telemetry: {error}"); std::process::exit(1); } }, @@ -175,14 +169,14 @@ fn run_telemetry_command(command: cli::TelemetryCommand) { cli::TelemetryCommand::ResetId => match telemetry::reset_id() { Ok(()) => println!("Telemetry installation ID and event markers erased. The enable/disable preference was retained."), Err(error) => { - eprintln!("cua-driver: failed to reset telemetry ID: {error}"); + eprintln!("qwen-cua-driver: failed to reset telemetry ID: {error}"); std::process::exit(1); } }, cli::TelemetryCommand::Inspect { event } => match telemetry::inspect_event(&event) { Ok(payload) => println!("{}", serde_json::to_string_pretty(&payload).expect("serialize telemetry payload")), Err(error) => { - eprintln!("cua-driver: {error}"); + eprintln!("qwen-cua-driver: {error}"); std::process::exit(64); } }, @@ -193,7 +187,7 @@ fn run_cursor_theme_command(args: &[String]) -> ! { let executable = match std::env::current_exe() { Ok(path) => path, Err(error) => { - eprintln!("cua-driver: cannot locate cursor-theme compiler: {error}"); + eprintln!("qwen-cua-driver: cannot locate cursor-theme compiler: {error}"); std::process::exit(1); } }; @@ -210,7 +204,7 @@ fn run_cursor_theme_command(args: &[String]) -> ! { Ok(status) => status, Err(error) => { eprintln!( - "cua-driver: cursor-theme compiler is unavailable at {}: {error}", + "qwen-cua-driver: cursor-theme compiler is unavailable at {}: {error}", sidecar.display() ); eprintln!("Reinstall Qwen Cua Driver so the matching authoring sidecar is present."); @@ -511,9 +505,8 @@ fn main() { socket, permission_mode, dangerously_bypass_approvals, - allow_legacy_existing_profile_approval, - session_policy, - approve_session_policy, + capability_manifest, + approve_capability_manifest, no_permissions_gate, claude_code_compat, grants, @@ -521,12 +514,11 @@ fn main() { if let Err(error) = configure_startup_permission_mode( permission_mode.as_deref(), dangerously_bypass_approvals, - allow_legacy_existing_profile_approval, - session_policy.as_deref(), - approve_session_policy, + capability_manifest.as_deref(), + approve_capability_manifest, &grants, ) { - eprintln!("cua-driver: authorization startup error: {error}"); + eprintln!("qwen-cua-driver: authorization startup error: {error}"); std::process::exit(64); } responsibility::reexec_disclaimed_if_needed(); @@ -577,7 +569,7 @@ fn main() { ) { Ok(driver) => driver, Err(error) => { - eprintln!("cua-driver: cannot create desktop runtime: {error}"); + eprintln!("qwen-cua-driver: cannot create desktop runtime: {error}"); std::process::exit(1); } }; @@ -657,9 +649,9 @@ fn main() { ); } if let Err(e) = gate_result { - eprintln!("[cua-driver] permissions gate: {e}"); + eprintln!("[qwen-cua-driver] permissions gate: {e}"); eprintln!( - "[cua-driver] continuing — tool calls touching AX or \ + "[qwen-cua-driver] continuing — tool calls touching AX or \ Screen Recording fail until you grant the missing TCC \ permissions." ); @@ -704,6 +696,10 @@ fn main() { let pid_path = serve::default_pid_file_path(); serve::run_status_cmd(&sp, &pid_path); } + cli::Command::Sessions { json, socket } => { + let sp = socket.unwrap_or_else(serve::default_socket_path); + serve::run_sessions_list_cmd(&sp, json); + } cli::Command::Recording { subcommand, args, @@ -721,6 +717,13 @@ fn main() { cli::Command::CheckUpdate { json, no_cache } => { cli::run_check_update_cmd(json, no_cache); } + cli::Command::Channel { + subcommand, + value, + json, + } => { + cli::run_channel_cmd(&subcommand, value.as_deref(), json); + } cli::Command::Doctor { json } => { // Long-running interactive entry point — kick off the // background "new version available?" check so the banner @@ -746,23 +749,6 @@ fn main() { cli::Command::CursorTheme { args } => { run_cursor_theme_command(&args); } - cli::Command::BrowserApprove { - pid, - strategy, - window_id, - session, - profile_mode, - profile_name, - } => { - cli::run_browser_approve( - pid, - strategy.as_deref(), - window_id, - session.as_deref(), - profile_mode.as_deref(), - profile_name.as_deref(), - ); - } cli::Command::Config { subcommand, key, @@ -789,7 +775,7 @@ fn main() { let result = match mcp_uses_direct_runtime(socket.as_deref(), direct) { Ok(true) => { if let Err(error) = - configure_startup_permission_mode(None, false, false, None, false, &grants) + configure_startup_permission_mode(None, false, None, false, &grants) { Err(error) } else { @@ -818,7 +804,7 @@ fn main() { ), }; if let Err(e) = result { - eprintln!("cua-driver-rs: {e}"); + eprintln!("qwen-cua-driver: {e}"); telemetry::flush_pending(std::time::Duration::from_millis(750)); std::process::exit(1); } @@ -892,9 +878,8 @@ fn main() -> anyhow::Result<()> { socket, permission_mode, dangerously_bypass_approvals, - allow_legacy_existing_profile_approval, - session_policy, - approve_session_policy, + capability_manifest, + approve_capability_manifest, no_permissions_gate, claude_code_compat, grants, @@ -902,9 +887,8 @@ fn main() -> anyhow::Result<()> { configure_startup_permission_mode( permission_mode.as_deref(), dangerously_bypass_approvals, - allow_legacy_existing_profile_approval, - session_policy.as_deref(), - approve_session_policy, + capability_manifest.as_deref(), + approve_capability_manifest, &grants, )?; responsibility::reexec_disclaimed_if_needed(); @@ -958,6 +942,11 @@ fn main() -> anyhow::Result<()> { serve::run_status_cmd(&sp, &pid_path); return Ok(()); } + cli::Command::Sessions { json, socket } => { + let sp = socket.unwrap_or_else(serve::default_socket_path); + serve::run_sessions_list_cmd(&sp, json); + return Ok(()); + } cli::Command::Recording { subcommand, args, @@ -979,6 +968,14 @@ fn main() -> anyhow::Result<()> { cli::run_check_update_cmd(json, no_cache); return Ok(()); } + cli::Command::Channel { + subcommand, + value, + json, + } => { + cli::run_channel_cmd(&subcommand, value.as_deref(), json); + return Ok(()); + } cli::Command::Doctor { json } => { // Long-running interactive entry point — kick off the // background update check so the banner can land on stderr. @@ -1008,24 +1005,6 @@ fn main() -> anyhow::Result<()> { cli::Command::CursorTheme { args } => { run_cursor_theme_command(&args); } - cli::Command::BrowserApprove { - pid, - strategy, - window_id, - session, - profile_mode, - profile_name, - } => { - cli::run_browser_approve( - pid, - strategy.as_deref(), - window_id, - session.as_deref(), - profile_mode.as_deref(), - profile_name.as_deref(), - ); - return Ok(()); - } cli::Command::Config { subcommand, key, @@ -1052,7 +1031,7 @@ fn main() -> anyhow::Result<()> { version_check::maybe_announce_update(); let result = match mcp_uses_direct_runtime(socket.as_deref(), direct) { Ok(true) => { - configure_startup_permission_mode(None, false, false, None, false, &grants)?; + configure_startup_permission_mode(None, false, None, false, &grants)?; telemetry::capture_mcp_startup_completed( "sdk_owned_runtime", "not_applicable", @@ -1077,7 +1056,7 @@ fn main() -> anyhow::Result<()> { ), }; if let Err(e) = result { - eprintln!("cua-driver-rs: {e}"); + eprintln!("qwen-cua-driver: {e}"); telemetry::flush_pending(std::time::Duration::from_millis(750)); std::process::exit(1); } diff --git a/packages/cua-driver/rust/crates/cua-driver/src/mcp_http.rs b/packages/cua-driver/rust/crates/cua-driver/src/mcp_http.rs index cb78bc0793..bc555e8163 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/mcp_http.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/mcp_http.rs @@ -22,7 +22,9 @@ use std::net::SocketAddr; use std::sync::Arc; use cua_driver_core::protocol::{Request, Response}; -use cua_driver_core::server::{handle_request, tool_observation_timer, StdioExecutionPath}; +use cua_driver_core::server::{ + handle_request_with_transport_session, tool_observation_timer, StdioExecutionPath, +}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tracing::{debug, info, warn}; @@ -91,6 +93,20 @@ async fn serve_conn( sdk: Arc, token: Arc, ) -> anyhow::Result<()> { + let transport_session = format!("http-{}", uuid::Uuid::new_v4()); + struct TransportCleanup { + sdk: Arc, + transport_session: String, + } + impl Drop for TransportCleanup { + fn drop(&mut self) { + self.sdk.end_transport_sessions(&self.transport_session); + } + } + let _cleanup = TransportCleanup { + sdk: sdk.clone(), + transport_session: transport_session.clone(), + }; loop { let Some(req) = read_http_request(&mut stream, &token).await? else { return Ok(()); // clean EOF @@ -122,7 +138,7 @@ async fn serve_conn( ) .await?; } else { - match dispatch(&req.body, &sdk).await { + match dispatch(&req.body, &sdk, &transport_session).await { Some(resp_json) => { write_http(&mut stream, 200, resp_json.as_bytes(), keep_alive).await? } @@ -142,13 +158,18 @@ async fn serve_conn( /// Parse a JSON-RPC request body and dispatch via the shared MCP handler. Returns /// `Some(json)` for a request, or `None` for a notification (no `id`). Applies the /// caller-declared `session` identity so HTTP behaves identically to stdio. -async fn dispatch(body: &[u8], sdk: &Arc) -> Option { +async fn dispatch( + body: &[u8], + sdk: &Arc, + transport_session: &str, +) -> Option { let mut req: Request = match serde_json::from_slice(body) { Ok(r) => r, Err(_) => return Some(serialize(&Response::parse_error())), }; req.id.as_ref()?; let initialize_metadata = req.initialize_metadata(); + apply_session_identity(&mut req, transport_session); let session_context = req.tool_call().ok().and_then(|call| { sdk.begin_tool_call( &call.name, @@ -158,9 +179,9 @@ async fn dispatch(body: &[u8], sdk: &Arc) -> Opt ) }); let id = req.id.clone().unwrap_or(serde_json::Value::Null); - apply_session_identity(&mut req); let timer = http_tool_observation_timer(&req, |name| sdk.is_known_tool(name)); - let response = handle_request(req, id, sdk.as_ref()).await; + let response = + handle_request_with_transport_session(req, id, sdk.as_ref(), transport_session).await; if let Some(timer) = timer { let outcome = timer.finish(&response); if let Some(context) = session_context { @@ -194,7 +215,7 @@ fn serialize(resp: &Response) -> String { /// recording key) — the HTTP-side equivalent of /// `serve.rs::apply_session_identity`. Runtime-private idle-TTL activity is /// refreshed later at the authorized registry boundary. -fn apply_session_identity(req: &mut Request) { +fn apply_session_identity(req: &mut Request, transport_session: &str) { let Some(params) = req.params.as_mut() else { return; }; @@ -204,12 +225,17 @@ fn apply_session_identity(req: &mut Request) { let session = args .get("session") .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) + .filter(|s| !s.is_empty() && *s != "default") .map(|s| s.to_owned()); - if let Some(sess) = session { - args.entry("_session_id") - .or_insert_with(|| serde_json::Value::String(sess.clone())); - } + let effective = session.unwrap_or_else(|| transport_session.to_owned()); + args.insert( + "_session_id".to_owned(), + serde_json::Value::String(effective), + ); + args.insert( + "_transport_session_id".to_owned(), + serde_json::Value::String(transport_session.to_owned()), + ); } /// One parsed HTTP/1.1 request. @@ -418,7 +444,7 @@ mod tests { "params": { "name": "click", "arguments": { "pid": 1, "session": "alpha" } } })) .unwrap(); - apply_session_identity(&mut req); + apply_session_identity(&mut req, "http-test"); let args = req.params.unwrap(); let args = args.get("arguments").unwrap(); assert_eq!(args.get("_session_id").unwrap(), "alpha"); @@ -426,15 +452,30 @@ mod tests { } #[test] - fn apply_session_identity_noop_without_session() { + fn apply_session_identity_uses_transport_implicit_without_session() { let mut req: Request = serde_json::from_value(json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_apps", "arguments": {} } })) .unwrap(); - apply_session_identity(&mut req); + apply_session_identity(&mut req, "http-test"); let args = req.params.unwrap(); - assert!(args.get("arguments").unwrap().get("_session_id").is_none()); + assert_eq!(args["arguments"]["_session_id"], "http-test"); + assert_eq!(args["arguments"]["_transport_session_id"], "http-test"); + } + + #[test] + fn apply_session_identity_uses_transport_implicit_for_default() { + let mut req: Request = serde_json::from_value(json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "list_apps", "arguments": { "session": "default" } } + })) + .unwrap(); + apply_session_identity(&mut req, "http-default"); + let args = req.params.unwrap(); + assert_eq!(args["arguments"]["session"], "default"); + assert_eq!(args["arguments"]["_session_id"], "http-default"); + assert_eq!(args["arguments"]["_transport_session_id"], "http-default"); } #[test] @@ -503,6 +544,7 @@ mod tests { let response = dispatch( br#"{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{}}"#, &sdk, + "http-test", ) .await .expect("JSON-RPC response"); diff --git a/packages/cua-driver/rust/crates/cua-driver/src/private_worker.rs b/packages/cua-driver/rust/crates/cua-driver/src/private_worker.rs index da0f4fd25d..a7405f059d 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/private_worker.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/private_worker.rs @@ -239,6 +239,11 @@ async fn handle_request( .await .map_err(|error| error.to_string()) .and_then(|json| serde_json::from_str(&json).map_err(|error| error.to_string())), + "sessions_list" => driver + .list_host_sessions_json() + .await + .map_err(|error| error.to_string()) + .and_then(|json| serde_json::from_str(&json).map_err(|error| error.to_string())), "call" => { let name = request.name.as_deref().unwrap_or(""); let arguments = request diff --git a/packages/cua-driver/rust/crates/cua-driver/src/proxy.rs b/packages/cua-driver/rust/crates/cua-driver/src/proxy.rs index 310d7a250c..df087d1f55 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/proxy.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/proxy.rs @@ -21,10 +21,10 @@ use std::sync::Arc; use cua_driver_core::policy::{authorize_tool_call, validate_configured_policy}; use cua_driver_core::protocol::{initialize_result, Request, Response}; use cua_driver_core::server::{ - handle_request, observe_proxy_session_started, observe_proxy_tool_completed, - tool_observation_timer, StdioExecutionPath, + observe_proxy_session_started, observe_proxy_tool_completed, tool_observation_timer, + StdioExecutionPath, }; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; use tracing::{debug, error, warn}; use crate::serve::{is_daemon_listening, send_request, DaemonRequest, ToolObservationOrigin}; @@ -48,7 +48,20 @@ pub async fn run_direct(driver: Arc) -> anyhow::Resul let mut writer = tokio::io::BufWriter::new(stdout); let mut line = String::new(); let mut session_observed = false; - let mut public_sessions = std::collections::HashSet::new(); + let transport_session = format!("mcp-{}", uuid::Uuid::new_v4()); + struct DirectTransportCleanup { + sdk: Arc, + transport_session: String, + } + impl Drop for DirectTransportCleanup { + fn drop(&mut self) { + self.sdk.end_transport_sessions(&self.transport_session); + } + } + let _cleanup = DirectTransportCleanup { + sdk: sdk.clone(), + transport_session: transport_session.clone(), + }; loop { line.clear(); @@ -65,39 +78,32 @@ pub async fn run_direct(driver: Arc) -> anyhow::Resul Response::parse_error() } Ok(request) if request.is_notification() => continue, - Ok(request) => { + Ok(mut request) => { + apply_direct_session_identity(&mut request, &transport_session); let initialize_metadata = (!session_observed) .then(|| request.initialize_metadata()) .flatten(); - let session_context = request - .tool_call() - .ok() - .and_then(|call| { - call.args - .get("session") - .and_then(serde_json::Value::as_str) - .filter(|session| !session.is_empty()) - .map(str::to_owned) - }) - .map(|session| { - public_sessions.insert(session); - request.tool_call().ok().and_then(|call| { - sdk.begin_tool_call( - &call.name, - &call.args, - cua_driver_core::session::SessionTransport::McpStdio, - cua_driver_core::session::SessionClientKind::Mcp, - ) - }) - }) - .flatten(); + let session_context = request.tool_call().ok().and_then(|call| { + sdk.begin_tool_call( + &call.name, + &call.args, + cua_driver_core::session::SessionTransport::McpStdio, + cua_driver_core::session::SessionClientKind::Mcp, + ) + }); let timer = tool_observation_timer( &request, |name| sdk.is_known_tool(name), StdioExecutionPath::DirectDaemon, ); let id = request.id.clone().unwrap_or(serde_json::Value::Null); - let response = handle_request(request, id, sdk.as_ref()).await; + let response = cua_driver_core::server::handle_request_with_transport_session( + request, + id, + sdk.as_ref(), + &transport_session, + ) + .await; if let Some(metadata) = initialize_metadata { observe_proxy_session_started(metadata); session_observed = true; @@ -122,12 +128,31 @@ pub async fn run_direct(driver: Arc) -> anyhow::Resul writer.flush().await?; } - for session in public_sessions { - let _ = sdk.end_session(&session).await; - } sdk.shutdown().await.map_err(anyhow::Error::msg) } +fn apply_direct_session_identity(request: &mut Request, transport_session: &str) { + let Some(arguments) = request + .params + .as_mut() + .and_then(|params| params.get_mut("arguments")) + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + let effective = arguments + .get("session") + .and_then(serde_json::Value::as_str) + .filter(|session| !session.is_empty() && *session != "default") + .unwrap_or(transport_session) + .to_owned(); + arguments.insert("_session_id".into(), serde_json::Value::String(effective)); + arguments.insert( + "_transport_session_id".into(), + serde_json::Value::String(transport_session.to_owned()), + ); +} + /// Run the MCP stdio proxy. Reads JSON-RPC lines from stdin, forwards /// the body of each `tools/list` / `tools/call` to the daemon at /// `socket_path`, and writes the daemon's response back as a proper @@ -204,8 +229,34 @@ pub async fn run_proxy(socket_path: String) -> anyhow::Result<()> { let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); - let mut reader = BufReader::new(stdin); - let mut writer = tokio::io::BufWriter::new(stdout); + run_proxy_io( + BufReader::new(stdin), + tokio::io::BufWriter::new(stdout), + &socket_path, + &cached_tools_list, + &session_id, + daemon_observes_tool_calls, + ) + .await +} + +/// Run the service-owned stdio loop over caller-provided I/O. +/// +/// A clean reader EOF must return `Ok(())` promptly. The caller then drops the +/// persistent control connection, allowing the daemon to reap the MCP session +/// and its recording, preview, and overlay state (issue #2002). +async fn run_proxy_io( + mut reader: R, + mut writer: W, + socket_path: &str, + cached_tools_list: &Arc, + session_id: &str, + daemon_observes_tool_calls: bool, +) -> anyhow::Result<()> +where + R: AsyncBufRead + Unpin, + W: AsyncWrite + Unpin, +{ let mut line = String::new(); let mut session_observed = false; @@ -237,7 +288,7 @@ pub async fn run_proxy(socket_path: String) -> anyhow::Result<()> { let session_context = (!daemon_observes_tool_calls) .then(|| { req.tool_call().ok().and_then(|call| { - let known_tool = proxy_knows_tool(&cached_tools_list, &call.name); + let known_tool = proxy_knows_tool(cached_tools_list, &call.name); cua_driver_core::session::begin_tool_call( &call.name, &call.args, @@ -252,7 +303,7 @@ pub async fn run_proxy(socket_path: String) -> anyhow::Result<()> { .then(|| { tool_observation_timer( &req, - |name| proxy_knows_tool(&cached_tools_list, name), + |name| proxy_knows_tool(cached_tools_list, name), StdioExecutionPath::DaemonProxy, ) }) @@ -261,9 +312,9 @@ pub async fn run_proxy(socket_path: String) -> anyhow::Result<()> { let response = handle_proxy_request( req, id, - &socket_path, - &cached_tools_list, - &session_id, + socket_path, + cached_tools_list, + session_id, daemon_observes_tool_calls, ) .await; @@ -761,18 +812,89 @@ async fn forward_tool_call( // ── Tests ──────────────────────────────────────────────────────────────────── // -// Unit-test only the JSON shape of the proxy's tool-error envelope. -// The full proxy loop is exercised by the macOS integration test -// (the daemon-backed integration harness); these tests just lock -// in the per-branch reshape so a -// regression to `Response::error` for tool-level failures would fail -// fast in CI on every platform. +// The daemon-backed integration harness exercises the full proxy lifecycle. +// These tests lock in the I/O loop's transport contract and the per-branch +// response reshaping without requiring a live daemon. #[cfg(test)] mod tests { use super::*; use crate::serve::DaemonResponse; + #[test] + fn direct_proxy_uses_transport_identity_for_default_session() { + let mut request: Request = serde_json::from_value(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "list_apps", + "arguments": {"session": "default"} + } + })) + .unwrap(); + apply_direct_session_identity(&mut request, "direct-default"); + let arguments = &request.params.unwrap()["arguments"]; + assert_eq!(arguments["session"], "default"); + assert_eq!(arguments["_session_id"], "direct-default"); + assert_eq!(arguments["_transport_session_id"], "direct-default"); + } + + #[tokio::test] + async fn proxy_loop_returns_promptly_on_clean_eof() { + let reader = BufReader::new(&b""[..]); + let mut writer = Vec::new(); + let cached_tools = Arc::new(serde_json::json!({"tools": []})); + + let result = tokio::time::timeout( + std::time::Duration::from_millis(250), + run_proxy_io( + reader, + &mut writer, + "unused.sock", + &cached_tools, + "eof-test-session", + false, + ), + ) + .await + .expect("clean EOF must return promptly"); + + assert!(result.is_ok(), "clean EOF must not error: {result:?}"); + assert!(writer.is_empty(), "no request means no output"); + } + + #[tokio::test] + async fn proxy_loop_serves_initialize_before_eof() { + let input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n"; + let reader = BufReader::new(&input[..]); + let mut writer = Vec::new(); + let cached_tools = Arc::new(serde_json::json!({"tools": []})); + + let result = tokio::time::timeout( + std::time::Duration::from_millis(250), + run_proxy_io( + reader, + &mut writer, + "unused.sock", + &cached_tools, + "initialize-test-session", + false, + ), + ) + .await + .expect("initialize followed by EOF must return promptly"); + + assert!( + result.is_ok(), + "initialize exchange must not error: {result:?}" + ); + let response: serde_json::Value = + serde_json::from_slice(&writer).expect("response must be JSON"); + assert_eq!(response["id"], 1); + assert!(response.get("result").is_some()); + } + /// Reconstruct the `!resp.ok` branch in isolation so we can assert /// on the serialized shape without spinning up a real daemon / /// tokio runtime. Keep this in sync with `forward_tool_call`. diff --git a/packages/cua-driver/rust/crates/cua-driver/src/release_channel.rs b/packages/cua-driver/rust/crates/cua-driver/src/release_channel.rs new file mode 100644 index 0000000000..35a922e281 --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver/src/release_channel.rs @@ -0,0 +1,178 @@ +//! Persistent stable/nightly update-channel preference. + +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; + +pub const FILE_NAME: &str = "release-channel"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ReleaseChannel { + Stable, + Nightly, +} + +impl ReleaseChannel { + pub const fn as_str(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Nightly => "nightly", + } + } + + pub fn from_version(version: &str) -> Option { + let parsed = semver::Version::parse(version).ok()?; + if parsed.build.is_empty() && parsed.pre.is_empty() && parsed.to_string() == version { + Some(Self::Stable) + } else if parsed.build.is_empty() + && parsed.to_string() == version + && is_canonical_nightly_prerelease(parsed.pre.as_str()) + { + Some(Self::Nightly) + } else { + None + } + } +} + +fn is_canonical_nightly_prerelease(value: &str) -> bool { + let Some(suffix) = value.strip_prefix("nightly.") else { + return false; + }; + let Some((date, run)) = suffix.split_once('.') else { + return false; + }; + date.len() == 8 + && date.bytes().all(|byte| byte.is_ascii_digit()) + && !run.is_empty() + && run.bytes().all(|byte| byte.is_ascii_digit()) + && !run.starts_with('0') +} + +impl Default for ReleaseChannel { + fn default() -> Self { + Self::Stable + } +} + +impl fmt::Display for ReleaseChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ReleaseChannel { + type Err = String; + + fn from_str(value: &str) -> Result { + match value.trim() { + "stable" => Ok(Self::Stable), + "nightly" => Ok(Self::Nightly), + other => Err(format!( + "invalid release channel {other:?}; expected `stable` or `nightly`" + )), + } + } +} + +pub fn selected() -> Result { + let path = state_path()?; + selected_at(&path) +} + +fn selected_at(path: &std::path::Path) -> Result { + match std::fs::read_to_string(path) { + Ok(raw) => raw.parse(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ReleaseChannel::Stable), + Err(error) => Err(format!( + "cannot read release channel at {}: {error}", + path.display() + )), + } +} + +pub fn set(channel: ReleaseChannel) -> Result<(), String> { + let path = state_path()?; + set_at(&path, channel) +} + +fn set_at(path: &std::path::Path, channel: ReleaseChannel) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "release-channel path has no parent".to_owned())?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; + + let temporary = parent.join(format!(".{FILE_NAME}.tmp-{}", std::process::id())); + std::fs::write(&temporary, format!("{}\n", channel.as_str())) + .map_err(|error| format!("cannot write {}: {error}", temporary.display()))?; + if let Err(error) = std::fs::rename(&temporary, &path) { + // std::fs::rename cannot replace an existing destination on Windows. + // Keep partial contents out of the canonical path on all platforms. + if path.exists() { + std::fs::remove_file(&path) + .map_err(|remove| format!("cannot replace {}: {remove}", path.display()))?; + std::fs::rename(&temporary, &path) + .map_err(|move_error| format!("cannot replace {}: {move_error}", path.display()))?; + } else { + return Err(format!("cannot persist {}: {error}", path.display())); + } + } + Ok(()) +} + +pub fn state_path() -> Result { + if let Some(home) = std::env::var_os("CUA_DRIVER_RS_HOME") { + return Ok(PathBuf::from(home).join(FILE_NAME)); + } + let root = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .ok_or_else(|| "neither HOME nor USERPROFILE is set".to_owned())?; + Ok(PathBuf::from(root) + .join(crate::bundle::user_home_subdirectory()) + .join(FILE_NAME)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strict_channel_vocabulary() { + assert_eq!("stable".parse(), Ok(ReleaseChannel::Stable)); + assert_eq!("nightly\n".parse(), Ok(ReleaseChannel::Nightly)); + assert!("beta".parse::().is_err()); + assert!("".parse::().is_err()); + } + + #[test] + fn current_channel_uses_strict_version_shape() { + assert_eq!( + ReleaseChannel::from_version("0.19.3"), + Some(ReleaseChannel::Stable) + ); + assert_eq!( + ReleaseChannel::from_version("0.19.4-nightly.20260812.42"), + Some(ReleaseChannel::Nightly) + ); + assert_eq!(ReleaseChannel::from_version("0.19.4-dev"), None); + assert_eq!(ReleaseChannel::from_version("0.19.4-nightly.foo"), None); + assert_eq!(ReleaseChannel::from_version("0.19.4+build"), None); + assert_eq!( + ReleaseChannel::from_version("0.19.4-nightly.20260812.0"), + None + ); + } + + #[test] + fn missing_state_defaults_stable_and_valid_state_round_trips() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join(FILE_NAME); + assert_eq!(selected_at(&path), Ok(ReleaseChannel::Stable)); + set_at(&path, ReleaseChannel::Nightly).expect("persist nightly"); + assert_eq!(selected_at(&path), Ok(ReleaseChannel::Nightly)); + set_at(&path, ReleaseChannel::Stable).expect("persist stable"); + assert_eq!(selected_at(&path), Ok(ReleaseChannel::Stable)); + } +} diff --git a/packages/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs b/packages/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs index 9cf90faab9..f835bd482c 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs @@ -150,10 +150,21 @@ impl SdkAdapter { .and_then(Value::as_str) .filter(|session| !session.is_empty() && *session != "default") .map(str::to_owned); + // The socket adapter's compatibility tombstone is keyed by the value + // its early guard sees. Explicit sessions use their public label; + // unnamed sessions use the transport-minted lifecycle id. Tracking + // both shapes lets an explicit start_session revive either episode. + let mirror_session = public_session.clone().or_else(|| { + arguments + .get("_session_id") + .and_then(Value::as_str) + .filter(|session| !session.is_empty() && *session != "default") + .map(str::to_owned) + }); let ending_session = (name == "end_session") - .then_some(public_session.as_deref()) + .then_some(mirror_session.as_deref()) .flatten(); - if let Some(session) = &public_session { + if let Some(session) = &mirror_session { self.public_sessions .lock() .unwrap() @@ -200,7 +211,7 @@ impl SdkAdapter { .unwrap() .rollback_ended(session, marker, previous); } - } else if let Some(session) = public_session.as_deref() { + } else if let Some(session) = mirror_session.as_deref() { let capture_scope = value .pointer("/structuredContent/capture_scope") .cloned() @@ -279,12 +290,73 @@ impl SdkAdapter { .map(|_| ()) } - pub fn create_trusted_session( + pub fn end_transport_sessions(&self, transport_session: &str) -> usize { + let owner = format!("{}{}", self.runtime_prefix, transport_session); + cua_driver_core::session::end_sessions_for_owner( + &owner, + cua_driver_core::session::SessionEndReason::ProcessExit, + ) + } + + pub fn operator_sessions_json(&self) -> Value { + let sessions = cua_driver_core::session::list_session_snapshots_with_prefix( + &self.runtime_prefix, + cua_driver_core::session::DEFAULT_SESSION_IDLE_TTL, + ) + .into_iter() + .map(|session| { + let owner_short_id = session + .owner_transport + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .rev() + .take(8) + .collect::() + .chars() + .rev() + .collect::(); + let transport = match session.transport { + cua_driver_core::session::SessionTransport::Cli => "cli", + cua_driver_core::session::SessionTransport::Daemon => "daemon", + cua_driver_core::session::SessionTransport::McpStdio => "mcp_stdio", + cua_driver_core::session::SessionTransport::McpHttp => "mcp_http", + }; + let client_kind = match session.client_kind { + cua_driver_core::session::SessionClientKind::Cli => "cli", + cua_driver_core::session::SessionClientKind::Direct => "direct", + cua_driver_core::session::SessionClientKind::Mcp => "mcp", + cua_driver_core::session::SessionClientKind::PythonSdk => "python_sdk", + cua_driver_core::session::SessionClientKind::TypescriptSdk => "typescript_sdk", + }; + json!({ + "session": session.public_label.as_deref().and_then(cursor_overlay::sanitize_session_label), + "implicit": session.implicit, + "state": if session.ending { "ending" } else { "active" }, + "client_kind": client_kind, + "transport": transport, + "owner_short_id": owner_short_id, + "cursor_visible": cua_driver_core::session::cursor_visible(&session.runtime_id), + "recording_active": cua_driver_core::session::recording_active(&session.runtime_id), + "started_seconds_ago": session.started_for.as_secs(), + "idle_seconds": session.idle.as_secs(), + "expires_in_seconds": session.expires_in.as_secs(), + }) + }) + .collect::>(); + let count = sessions.len(); + json!({ + "sessions": sessions, + "count": count, + }) + } + + pub fn create_trusted_session_for_transport( &self, options: TrustedSessionOptions, + transport_session: &str, ) -> Result, String> { self.driver - .create_trusted_session(options) + .create_trusted_session_for_transport(options, transport_session) .map_err(|error| error.to_string()) } @@ -442,6 +514,45 @@ mod tests { sdk.shutdown().await.expect("shutdown"); } + #[tokio::test] + async fn public_observation_mirror_revives_an_implicit_transport_session() { + let _runtime_guard = crate::test_runtime_lock().lock().await; + let sdk = SdkAdapter::load(host_driver()).await.expect("SDK adapter"); + let transport_session = "adapter-implicit-transport"; + let implicit_args = || { + json!({ + "_session_id": transport_session, + "_transport_session_id": transport_session, + }) + }; + + let started = sdk + .invoke_raw("start_session", implicit_args()) + .await + .expect("start implicit session"); + assert_ne!(started["isError"], true); + + let ended = sdk + .invoke_raw("end_session", implicit_args()) + .await + .expect("end implicit session"); + assert_ne!(ended["isError"], true); + assert!(sdk.is_session_ended(transport_session)); + + let revived = sdk + .invoke_raw("start_session", implicit_args()) + .await + .expect("revive implicit session"); + assert_ne!(revived["isError"], true); + assert_eq!(revived["structuredContent"]["revived"], true); + assert!( + !sdk.is_session_ended(transport_session), + "successful implicit revival must clear the adapter's early tombstone" + ); + + sdk.shutdown().await.expect("shutdown"); + } + #[tokio::test] async fn runtime_owned_end_hook_updates_only_its_adapter() { let _runtime_guard = crate::test_runtime_lock().lock().await; diff --git a/packages/cua-driver/rust/crates/cua-driver/src/serve.rs b/packages/cua-driver/rust/crates/cua-driver/src/serve.rs index d48f8ba3af..5434eeff70 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/serve.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/serve.rs @@ -21,7 +21,7 @@ //! Source-installed local builds use the corresponding `qwen-cua-driver-local` //! namespace on every platform. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; pub use cua_driver_core::daemon::{ @@ -31,6 +31,111 @@ pub use cua_driver_core::daemon::{ static ACTIVE_PROXY_SESSIONS: OnceLock>> = OnceLock::new(); +#[derive(Clone, Debug)] +struct TrustedResumeRecord { + options: cua_driver_sdk::TrustedSessionOptions, + transport_session: String, + expires_at: std::time::Instant, +} + +struct TrustedConnectionSession { + session: std::sync::Arc, + options: cua_driver_sdk::TrustedSessionOptions, + transport_session: String, + resume_credential: String, +} + +type TrustedResumeRegistry = + std::sync::Arc>>; + +fn new_resume_credential() -> String { + format!("resume-{}", uuid::Uuid::new_v4()) +} + +fn resume_expiry(options: &cua_driver_sdk::TrustedSessionOptions) -> std::time::Instant { + std::time::Instant::now() + std::time::Duration::from_secs(options.idle_ttl_seconds.max(1)) +} + +fn bind_trusted_connection( + sdk: &crate::sdk_adapter::SdkAdapter, + options: cua_driver_sdk::TrustedSessionOptions, + transport_session: String, +) -> Result { + let session = sdk.create_trusted_session_for_transport(options.clone(), &transport_session)?; + Ok(TrustedConnectionSession { + session, + options, + transport_session, + resume_credential: new_resume_credential(), + }) +} + +async fn resume_trusted_connection( + sdk: &crate::sdk_adapter::SdkAdapter, + registry: &TrustedResumeRegistry, + credential: &str, +) -> Result { + let record = take_trusted_resume_record(registry, credential).await?; + bind_trusted_connection(sdk, record.options, record.transport_session) +} + +async fn take_trusted_resume_record( + registry: &TrustedResumeRegistry, + credential: &str, +) -> Result { + // Remove before validation so every presented credential is single use, + // including expired or otherwise invalid attempts. + let now = std::time::Instant::now(); + let record = { + let mut records = registry.lock().await; + let record = records.remove(credential); + records.retain(|_, candidate| candidate.expires_at > now); + record + } + .ok_or_else(|| "trusted session resume credential is unavailable or already used".to_owned())?; + if now >= record.expires_at { + return Err("trusted session resume credential expired".to_owned()); + } + Ok(record) +} + +async fn detach_trusted_connection( + registry: &TrustedResumeRegistry, + connection: TrustedConnectionSession, +) { + connection.session.close(); + let now = std::time::Instant::now(); + let mut records = registry.lock().await; + records.retain(|_, candidate| candidate.expires_at > now); + records.insert( + connection.resume_credential, + TrustedResumeRecord { + expires_at: resume_expiry(&connection.options), + options: connection.options, + transport_session: connection.transport_session, + }, + ); +} + +async fn end_trusted_connection(connection: &TrustedConnectionSession) -> Result<(), String> { + let result = connection + .session + .call_tool( + "end_session".to_owned(), + serde_json::json!({"session": connection.options.public_session}).to_string(), + ) + .await + .map_err(|error| error.to_string())?; + if result.is_error { + return Err(if result.text.is_empty() { + "trusted session cleanup did not complete".to_owned() + } else { + result.text + }); + } + Ok(()) +} + fn active_proxy_sessions() -> &'static Mutex> { ACTIVE_PROXY_SESSIONS.get_or_init(|| Mutex::new(HashSet::new())) } @@ -40,14 +145,6 @@ fn is_active_proxy_session(session: Option<&str>) -> bool { } fn inject_browser_approvals(tool_name: &str, args: &mut serde_json::Value, session: Option<&str>) { - if tool_name == "browser_prepare" && is_active_proxy_session(session) { - if let Some(arguments) = args.as_object_mut() { - arguments.insert( - cua_driver_core::browser::approval::MCP_HOST_APPROVAL_ARG.to_owned(), - serde_json::Value::Bool(true), - ); - } - } if tool_name == "browser_download" && is_active_proxy_session(session) { if let Some(arguments) = args.as_object_mut() { arguments.insert( @@ -60,15 +157,11 @@ fn inject_browser_approvals(tool_name: &str, args: &mut serde_json::Value, sessi /// Resolve + apply the session identity for a tool call at the daemon boundary. /// -/// A session is a **caller-declared** identity (the public `session` arg), not a -/// property of the MCP connection. We mirror an explicit `session` into the -/// reserved `_session_id` key that every session-aware tool already reads -/// (cursor key, per-session config override, recording owner). When no `session` -/// was declared we fall back to the per-connection minted id for `_session_id` -/// ONLY — that preserves connection-EOF cleanup of recording / config as before. -/// The cursor is deliberately NOT driven by that fallback: `resolve_cursor_key` -/// reads the explicit `session`/`cursor_id` arg only, so a cursor appears -/// exactly when a run declares its session (explicit-required). +/// Resolve optional public naming and the trusted transport's default lifecycle +/// identity independently. An explicit `session` is mirrored into the reserved +/// `_session_id` key; otherwise the per-connection lease id becomes the implicit +/// lifecycle key. Cursor, recording, configuration, and cleanup all use that +/// resolved identity, so ordinary callers do not need a setup call. /// /// The registry refreshes the runtime-private idle-TTL key after authorization; /// this transport helper only returns the effective `_session_id` for the @@ -78,7 +171,7 @@ fn apply_session_identity(args: &mut serde_json::Value, minted: &Option) .as_object() .and_then(|o| o.get("session")) .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) + .filter(|s| !s.is_empty() && *s != "default") .map(|s| s.to_owned()); if let Some(obj) = args.as_object_mut() { obj.remove("_session_id"); @@ -533,6 +626,8 @@ pub async fn run_serve( // Shutdown channel. let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let shutdown_tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))); + let trusted_resume_registry: TrustedResumeRegistry = + std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())); let parent_liveness = async { if cua_driver_core::parent_liveness_stdin_enabled() { wait_for_parent_stdin_eof().await; @@ -560,6 +655,7 @@ pub async fn run_serve( authenticate_embedded_host_connection(&stream).is_ok(); let reg = sdk.clone(); let shutdown_tx2 = shutdown_tx.clone(); + let trusted_resume_registry = trusted_resume_registry.clone(); tokio::spawn(async move { let (reader, mut writer) = stream.into_split(); @@ -572,9 +668,7 @@ pub async fn run_serve( // connection EOFs (graceful proxy exit OR kill -9, both // kernel-guaranteed), the post-loop block reaps the session. let mut control_session_id: Option = None; - let mut trusted_session: Option< - std::sync::Arc, - > = None; + let mut trusted_session: Option = None; while let Ok(Some(line)) = lines.next_line().await { let req: DaemonRequest = match serde_json::from_str(&line) { @@ -707,12 +801,21 @@ pub async fn run_serve( serde_json::from_value(value) .map_err(|error| format!("invalid trusted session options: {error}")) }); - match options.and_then(|options| reg.create_trusted_session(options)) { - Ok(session) => { - trusted_session = Some(session); + match options.and_then(|options| { + bind_trusted_connection( + ®, + options, + format!("trusted-{}", uuid::Uuid::new_v4()), + ) + }) { + Ok(connection) => { + let resume_credential = + connection.resume_credential.clone(); + trusted_session = Some(connection); DaemonResponse::ok(serde_json::json!({ "bound": true, "connection_bound": true, + "resume_credential": resume_credential, })) } Err(error) => DaemonResponse::err(error, 77), @@ -722,14 +825,58 @@ pub async fn run_serve( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; } + "trusted_session_resume" => { + let resp = if !trusted_host_connection { + DaemonResponse::err( + "trusted service sessions require the original authenticated embedded host connection", + 77, + ) + } else if trusted_session.is_some() { + DaemonResponse::err( + "this accepted connection already owns a trusted session", + 65, + ) + } else { + let credential = req.args + .as_ref() + .and_then(|value| value.get("resume_credential")) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()); + match credential { + Some(credential) => match resume_trusted_connection( + ®, + &trusted_resume_registry, + credential, + ).await { + Ok(connection) => { + let rotated = connection.resume_credential.clone(); + trusted_session = Some(connection); + DaemonResponse::ok(serde_json::json!({ + "bound": true, + "connection_bound": true, + "resume_credential": rotated, + })) + } + Err(error) => DaemonResponse::err(error, 77), + }, + None => DaemonResponse::err( + "trusted_session_resume omitted resume credential", + 65, + ), + } + }; + let _ = writer.write_all( + (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() + ).await; + } "trusted_session_call" => { let resp = match trusted_session.as_ref() { - Some(session) => { + Some(connection) => { let name = req.name.unwrap_or_default(); let arguments = req.args.unwrap_or_else(|| { serde_json::Value::Object(serde_json::Map::new()) }); - match session.call_tool(name, arguments.to_string()).await { + match connection.session.call_tool(name, arguments.to_string()).await { Ok(result) => match serde_json::from_str(&result.raw_json) { Ok(value) => DaemonResponse::ok(value), Err(error) => DaemonResponse::err( @@ -750,12 +897,24 @@ pub async fn run_serve( ).await; } "trusted_session_end" => { - if let Some(session) = trusted_session.take() { - session.close(); - } - let resp = DaemonResponse::ok( - serde_json::json!({"closed": true}) - ); + let resp = match trusted_session.as_ref() { + Some(connection) => match end_trusted_connection(connection).await { + Ok(()) => { + if let Some(connection) = trusted_session.take() { + connection.session.close(); + } + DaemonResponse::ok(serde_json::json!({"closed": true})) + } + Err(error) => DaemonResponse::err(error, 77), + }, + None => DaemonResponse::ok(serde_json::json!({"closed": true})), + }; + let _ = writer.write_all( + (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() + ).await; + } + "sessions_list" => { + let resp = DaemonResponse::ok(reg.operator_sessions_json()); let _ = writer.write_all( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; @@ -791,8 +950,10 @@ pub async fn run_serve( // proxies never send it — the control-connection // EOF is the single teardown path. Always ACK ok. if let Some(sid) = req.session_id.as_deref() { - if let Err(error) = reg.end_session(sid).await { - tracing::warn!("legacy session_end failed: {error}"); + if reg.end_transport_sessions(sid) == 0 { + if let Err(error) = reg.end_session(sid).await { + tracing::warn!("legacy session_end failed: {error}"); + } } } let resp = DaemonResponse::ok( @@ -824,12 +985,10 @@ pub async fn run_serve( // benign. if let Some(sid) = control_session_id { active_proxy_sessions().lock().unwrap().remove(&sid); - if let Err(error) = reg.end_session(&sid).await { - tracing::warn!("control-session cleanup failed: {error}"); - } + reg.end_transport_sessions(&sid); } - if let Some(session) = trusted_session { - session.close(); + if let Some(connection) = trusted_session { + detach_trusted_connection(&trusted_resume_registry, connection).await; } }); } @@ -1147,6 +1306,8 @@ pub async fn run_serve( let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let shutdown_tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(shutdown_tx))); + let trusted_resume_registry: TrustedResumeRegistry = + std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())); let parent_liveness = async { if cua_driver_core::parent_liveness_stdin_enabled() { wait_for_parent_stdin_eof().await; @@ -1211,6 +1372,7 @@ pub async fn run_serve( let reg = sdk.clone(); let shutdown_tx2 = shutdown_tx.clone(); + let trusted_resume_registry = trusted_resume_registry.clone(); tokio::spawn(async move { let (reader, mut writer) = tokio::io::split(server); @@ -1222,9 +1384,7 @@ pub async fn run_serve( // ERROR_BROKEN_PIPE on the next read, ending the while-let // loop equally reliably. let mut control_session_id: Option = None; - let mut trusted_session: Option< - std::sync::Arc, - > = None; + let mut trusted_session: Option = None; while let Ok(Some(line)) = lines.next_line().await { let req: DaemonRequest = match serde_json::from_str(&line) { @@ -1338,12 +1498,20 @@ pub async fn run_serve( serde_json::from_value(value) .map_err(|error| format!("invalid trusted session options: {error}")) }); - match options.and_then(|options| reg.create_trusted_session(options)) { - Ok(session) => { - trusted_session = Some(session); + match options.and_then(|options| { + bind_trusted_connection( + ®, + options, + format!("trusted-{}", uuid::Uuid::new_v4()), + ) + }) { + Ok(connection) => { + let resume_credential = connection.resume_credential.clone(); + trusted_session = Some(connection); DaemonResponse::ok(serde_json::json!({ "bound": true, "connection_bound": true, + "resume_credential": resume_credential, })) } Err(error) => DaemonResponse::err(error, 77), @@ -1353,14 +1521,58 @@ pub async fn run_serve( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; } + "trusted_session_resume" => { + let resp = if !trusted_host_connection { + DaemonResponse::err( + "trusted service sessions require the original authenticated embedded host connection", + 77, + ) + } else if trusted_session.is_some() { + DaemonResponse::err( + "this accepted connection already owns a trusted session", + 65, + ) + } else { + let credential = req.args + .as_ref() + .and_then(|value| value.get("resume_credential")) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()); + match credential { + Some(credential) => match resume_trusted_connection( + ®, + &trusted_resume_registry, + credential, + ).await { + Ok(connection) => { + let rotated = connection.resume_credential.clone(); + trusted_session = Some(connection); + DaemonResponse::ok(serde_json::json!({ + "bound": true, + "connection_bound": true, + "resume_credential": rotated, + })) + } + Err(error) => DaemonResponse::err(error, 77), + }, + None => DaemonResponse::err( + "trusted_session_resume omitted resume credential", + 65, + ), + } + }; + let _ = writer.write_all( + (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() + ).await; + } "trusted_session_call" => { let resp = match trusted_session.as_ref() { - Some(session) => { + Some(connection) => { let name = req.name.unwrap_or_default(); let arguments = req.args.unwrap_or_else(|| { serde_json::Value::Object(serde_json::Map::new()) }); - match session.call_tool(name, arguments.to_string()).await { + match connection.session.call_tool(name, arguments.to_string()).await { Ok(result) => match serde_json::from_str(&result.raw_json) { Ok(value) => DaemonResponse::ok(value), Err(error) => DaemonResponse::err( @@ -1381,12 +1593,24 @@ pub async fn run_serve( ).await; } "trusted_session_end" => { - if let Some(session) = trusted_session.take() { - session.close(); - } - let resp = DaemonResponse::ok( - serde_json::json!({"closed": true}) - ); + let resp = match trusted_session.as_ref() { + Some(connection) => match end_trusted_connection(connection).await { + Ok(()) => { + if let Some(connection) = trusted_session.take() { + connection.session.close(); + } + DaemonResponse::ok(serde_json::json!({"closed": true})) + } + Err(error) => DaemonResponse::err(error, 77), + }, + None => DaemonResponse::ok(serde_json::json!({"closed": true})), + }; + let _ = writer.write_all( + (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() + ).await; + } + "sessions_list" => { + let resp = DaemonResponse::ok(reg.operator_sessions_json()); let _ = writer.write_all( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; @@ -1415,8 +1639,10 @@ pub async fn run_serve( // connection; control_session_id stays None so no // EOF double-fire. fire_session_end is idempotent. if let Some(sid) = req.session_id.as_deref() { - if let Err(error) = reg.end_session(sid).await { - tracing::warn!("legacy session_end failed: {error}"); + if reg.end_transport_sessions(sid) == 0 { + if let Err(error) = reg.end_session(sid).await { + tracing::warn!("legacy session_end failed: {error}"); + } } } let resp = DaemonResponse::ok( @@ -1442,12 +1668,10 @@ pub async fn run_serve( // control_session_id None. if let Some(sid) = control_session_id { active_proxy_sessions().lock().unwrap().remove(&sid); - if let Err(error) = reg.end_session(&sid).await { - tracing::warn!("control-session cleanup failed: {error}"); - } + reg.end_transport_sessions(&sid); } - if let Some(session) = trusted_session { - session.close(); + if let Some(connection) = trusted_session { + detach_trusted_connection(&trusted_resume_registry, connection).await; } }); } @@ -1675,18 +1899,20 @@ pub fn run_status_cmd(socket_path: &str, pid_file_path: &str) { .unwrap_or("unavailable") ); println!( - " session policy: configured={}, approved_at_startup={}, valid={}", - status["session_policy_configured"] + " capability manifest: configured={}, approved_at_startup={}, valid={}", + status["capability_manifest_configured"] .as_bool() .unwrap_or(false), - status["session_policy_approved_at_startup"] + status["capability_manifest_approved_at_startup"] + .as_bool() + .unwrap_or(false), + status["capability_manifest_valid"] .as_bool() .unwrap_or(false), - status["session_policy_valid"].as_bool().unwrap_or(false), ); - if let Some(manifest) = status["session_policy"].as_object() { + if let Some(manifest) = status["capability_manifest"].as_object() { println!( - " session policy sha256: {}", + " capability manifest sha256: {}", manifest .get("sha256") .and_then(serde_json::Value::as_str) @@ -1701,6 +1927,65 @@ pub fn run_status_cmd(socket_path: &str, pid_file_path: &str) { } } +/// `cua-driver sessions list` implementation. This is an operator surface, +/// not an MCP tool: the daemon returns only content-free lifecycle metadata +/// and redacts transport ownership to a short correlation id. +pub fn run_sessions_list_cmd(socket_path: &str, json: bool) { + let request = DaemonRequest { + method: "sessions_list".to_owned(), + name: None, + args: None, + session_id: None, + observation_origin: Some(ToolObservationOrigin::Direct), + client_kind: None, + }; + match send_request(socket_path, &request) { + Ok(response) if response.ok => { + let result = response + .result + .unwrap_or_else(|| serde_json::json!({"sessions": [], "count": 0})); + if json { + println!("{}", serde_json::to_string(&result).unwrap()); + return; + } + let sessions = result + .get("sessions") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + if sessions.is_empty() { + println!("No live sessions."); + return; + } + println!("STATE\tTRANSPORT\tCLIENT\tSESSION\tOWNER\tIDLE"); + for session in sessions { + println!( + "{}\t{}\t{}\t{}\t{}\t{}s", + session["state"].as_str().unwrap_or("unknown"), + session["transport"].as_str().unwrap_or("unknown"), + session["client_kind"].as_str().unwrap_or("unknown"), + session["session"].as_str().unwrap_or("(implicit)"), + session["owner_short_id"].as_str().unwrap_or("unknown"), + session["idle_seconds"].as_u64().unwrap_or(0), + ); + } + } + Ok(response) => { + eprintln!( + "{}", + response + .error + .unwrap_or_else(|| "session listing failed".to_owned()) + ); + std::process::exit(response.exit_code.unwrap_or(1)); + } + Err(error) => { + eprintln!("session listing failed: {error}"); + std::process::exit(1); + } + } +} + /// Revoke one authorization/session scope or every live scope. This local /// control is intentionally deny-only and never accepts a grant token. pub fn run_revoke_cmd(socket_path: &str, session: Option<&str>, all: bool) { @@ -1963,8 +2248,8 @@ mod gate_tests { let start = DaemonRequest { method: "call".into(), name: Some("start_session".into()), - // Session policy is caller-declared. The transport session id is - // cleanup/ownership metadata and must not grant policy authority. + // The public lifecycle label is caller-declared. The transport + // session id is cleanup metadata and cannot grant authority. args: Some(serde_json::json!({ "session": s3b.clone() })), session_id: Some(s3b), observation_origin: None, @@ -2114,6 +2399,59 @@ mod telemetry_routing_tests { } } +#[cfg(test)] +mod trusted_resume_tests { + use super::{take_trusted_resume_record, TrustedResumeRecord, TrustedResumeRegistry}; + use cua_driver_sdk::{SessionPermissionMode, TrustedSessionOptions}; + use std::collections::HashMap; + use std::sync::Arc; + use std::time::{Duration, Instant}; + use tokio::sync::Mutex; + + fn record(expires_at: Instant, transport_session: &str) -> TrustedResumeRecord { + TrustedResumeRecord { + options: TrustedSessionOptions { + public_session: "public-test".into(), + mode: SessionPermissionMode::Standard, + ttl_seconds: 60, + idle_ttl_seconds: 30, + capability_manifest_path: None, + bounded_manifest_path: None, + }, + transport_session: transport_session.into(), + expires_at, + } + } + + #[tokio::test] + async fn resume_credentials_are_single_use_expiring_and_prune_orphans() { + let now = Instant::now(); + let registry: TrustedResumeRegistry = Arc::new(Mutex::new(HashMap::from([ + ("expired-target".into(), record(now, "expired-target-lease")), + ("expired-orphan".into(), record(now, "expired-orphan-lease")), + ( + "live".into(), + record(now + Duration::from_secs(60), "live-lease"), + ), + ]))); + + let error = take_trusted_resume_record(®istry, "expired-target") + .await + .expect_err("expired credential must fail closed"); + assert!(error.contains("expired")); + assert!(!registry.lock().await.contains_key("expired-orphan")); + + let live = take_trusted_resume_record(®istry, "live") + .await + .expect("live credential is consumed once"); + assert_eq!(live.transport_session, "live-lease"); + let replay = take_trusted_resume_record(®istry, "live") + .await + .expect_err("consumed credential must not replay"); + assert!(replay.contains("unavailable or already used")); + } +} + #[cfg(test)] mod service_authorization_status_tests { use super::service_authorization_status; @@ -2140,7 +2478,6 @@ mod service_authorization_status_tests { #[cfg(test)] mod session_boundary_tests { use super::{active_proxy_sessions, apply_session_identity, inject_browser_approvals}; - use cua_driver_core::browser::approval::MCP_HOST_APPROVAL_ARG; use cua_driver_core::browser::download::MCP_HOST_DOWNLOAD_APPROVAL_ARG; use serde_json::json; @@ -2164,9 +2501,9 @@ mod session_boundary_tests { #[test] fn no_session_falls_back_to_minted_for_session_id_only() { - // The minted per-connection id drives `_session_id` (recording / config - // lifecycle) but there is NO explicit `session`, so the cursor resolver - // — which reads `session`/`cursor_id`, not `_session_id` — sees nothing. + // The minted per-connection id drives the complete implicit lifecycle, + // including cursor, recording, configuration, and cleanup, without + // manufacturing a caller-visible public `session` label. let mut args = json!({ "x": 1 }); let eff = apply_session_identity(&mut args, &Some("mcp-123".to_owned())); assert_eq!(args["_session_id"], "mcp-123"); @@ -2175,6 +2512,16 @@ mod session_boundary_tests { assert!(args.get("session").is_none()); } + #[test] + fn explicit_default_uses_the_minted_lifecycle_identity() { + let mut args = json!({ "session": "default" }); + let eff = apply_session_identity(&mut args, &Some("mcp-default".to_owned())); + assert_eq!(args["session"], "default"); + assert_eq!(args["_session_id"], "mcp-default"); + assert_eq!(args["_transport_session_id"], "mcp-default"); + assert_eq!(eff.as_deref(), Some("mcp-default")); + } + #[test] fn anonymous_when_no_session_and_no_minted() { let mut args = json!({ "x": 1 }); @@ -2193,30 +2540,8 @@ mod session_boundary_tests { } #[test] - fn browser_prepare_approval_requires_a_live_proxy_session() { + fn browser_download_approval_requires_a_live_proxy_session() { let session = "approval-boundary-test"; - let mut raw_args = json!({"pid": 42}); - inject_browser_approvals("browser_prepare", &mut raw_args, Some(session)); - assert!(raw_args.get(MCP_HOST_APPROVAL_ARG).is_none()); - - active_proxy_sessions() - .lock() - .unwrap() - .insert(session.to_owned()); - let mut proxy_args = json!({"pid": 42}); - inject_browser_approvals("browser_prepare", &mut proxy_args, Some(session)); - active_proxy_sessions().lock().unwrap().remove(session); - assert_eq!(proxy_args[MCP_HOST_APPROVAL_ARG], true); - - let mut other_tool = json!({"pid": 42}); - active_proxy_sessions() - .lock() - .unwrap() - .insert(session.to_owned()); - inject_browser_approvals("get_browser_state", &mut other_tool, Some(session)); - active_proxy_sessions().lock().unwrap().remove(session); - assert!(other_tool.get(MCP_HOST_APPROVAL_ARG).is_none()); - let mut raw_download = json!({"destination_root": "/private/path"}); inject_browser_approvals("browser_download", &mut raw_download, Some(session)); assert!(raw_download.get(MCP_HOST_DOWNLOAD_APPROVAL_ARG).is_none()); diff --git a/packages/cua-driver/rust/crates/cua-driver/src/skills.rs b/packages/cua-driver/rust/crates/cua-driver/src/skills.rs index 34b8d25478..40398a2e6d 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/skills.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/skills.rs @@ -27,12 +27,11 @@ //! ## Fetch source //! //! The default fetch URL is the versioned release asset matched to the -//! binary's own version: `cua-driver-rs-v-skills.tar.gz` from -//! `https://github.com/QwenLM/qwen-code/releases/...`. This pins the skill +//! binary's own version: `cua-driver-rs-v-skills.tar.gz` from the +//! matching stable or nightly Qwen GitHub release tag. This pins the skill //! content to the binary release so an agent loading the doc knows //! every example matches the daemon it'll talk to. //! -//! `--from ` lets the user pin a different release tag. //! `--from main` fetches the latest from the `main` branch via the //! `Skills/cua-driver/` directory (one HTTP call per file — used //! for bleeding-edge dev validation; not the default). @@ -43,6 +42,7 @@ //! //! - Claude Code: `~/.claude/skills/` //! - Codex: `~/.agents/skills/` +//! - Prime Agent: `~/.prime/agent/skills/` //! - OpenClaw: `~/.openclaw/skills/` //! - OpenCode: `~/.config/opencode/skills/` (macOS / Linux), //! `%APPDATA%\opencode\skills\` (Windows) @@ -65,6 +65,8 @@ use std::fs; use std::path::{Path, PathBuf}; const SKILL_PACK_NAME: &str = "cua-driver"; +const STABLE_RELEASE_TAG_PREFIX: &str = "cua-driver-rs-v"; +const NIGHTLY_RELEASE_TAG_PREFIX: &str = "nightly-cua-driver-rs-v"; /// Pre-rename name. The skill pack used to install as `cua-driver-rs` /// (when the Rust port lived at `packages/cua-driver-rs/`). On install / /// uninstall we sweep this name out of every agent skills dir and the @@ -200,6 +202,10 @@ const AGENTS: &[Agent] = &[ label: "Codex", parent: AgentParent::Home(".agents/skills"), }, + Agent { + label: "Prime Agent", + parent: AgentParent::Home(".prime/agent/skills"), + }, Agent { label: "OpenClaw", parent: AgentParent::Home(".openclaw/skills"), @@ -331,7 +337,7 @@ fn install(flags: &[String], force: bool) -> Result<()> { } } if !linked_any { - println!("(No agent skills dirs present yet — install Claude Code / Codex / OpenClaw / OpenCode / Antigravity / Hermes then re-run.)"); + println!("(No agent skills dirs present yet — install Claude Code / Codex / Prime Agent / OpenClaw / OpenCode / Antigravity / Hermes then re-run.)"); } Ok(()) } @@ -536,14 +542,40 @@ fn fetch_into(dest: &Path, from_main: bool, all_platforms: bool) -> Result<()> { // Versioned release asset. let version = env!("CARGO_PKG_VERSION"); - let url = format!( - "https://github.com/QwenLM/qwen-code/releases/download/cua-driver-rs-v{version}/cua-driver-rs-v{version}-skills.tar.gz" - ); + let url = skill_release_url(version); let bytes = http_get_bytes(&url).with_context(|| format!("GET {url}"))?; extract_tar_gz(&bytes, dest, all_platforms)?; Ok(()) } +fn skill_release_url(version: &str) -> String { + let tag_prefix = if is_nightly_version(version) { + NIGHTLY_RELEASE_TAG_PREFIX + } else { + STABLE_RELEASE_TAG_PREFIX + }; + format!( + "https://github.com/QwenLM/qwen-code/releases/download/{tag_prefix}{version}/\ + {STABLE_RELEASE_TAG_PREFIX}{version}-skills.tar.gz" + ) +} + +fn is_nightly_version(version: &str) -> bool { + let Ok(version) = semver::Version::parse(version) else { + return false; + }; + if !version.build.is_empty() { + return false; + } + let parts = version.pre.as_str().split('.').collect::>(); + matches!(parts.as_slice(), ["nightly", date, run] + if date.len() == 8 + && date.bytes().all(|byte| byte.is_ascii_digit()) + && !run.is_empty() + && !run.starts_with('0') + && run.bytes().all(|byte| byte.is_ascii_digit())) +} + fn http_get_text(url: &str) -> Result { let resp = ureq::get(url) .call() @@ -810,10 +842,44 @@ fn print_path() -> Result<()> { #[cfg(test)] mod tests { - use super::{extract_tar_gz, link_points_to_any, SKILL_FILES}; + use super::{ + extract_tar_gz, link_points_to_any, skill_release_url, AgentParent, AGENTS, SKILL_FILES, + }; use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn prime_agent_target_matches_its_native_global_skill_directory() { + let target = AGENTS + .iter() + .find(|agent| agent.label == "Prime Agent") + .expect("Prime Agent must remain a supported skill target"); + + assert!(matches!( + target.parent, + AgentParent::Home(".prime/agent/skills") + )); + } + + #[test] + fn stable_skill_pack_uses_the_stable_release_tag() { + assert_eq!( + skill_release_url("0.19.3"), + "https://github.com/QwenLM/qwen-code/releases/download/\ + cua-driver-rs-v0.19.3/cua-driver-rs-v0.19.3-skills.tar.gz" + ); + } + + #[test] + fn nightly_skill_pack_uses_the_nightly_tag_and_compatible_asset_name() { + assert_eq!( + skill_release_url("0.19.4-nightly.20260812.3097"), + "https://github.com/QwenLM/qwen-code/releases/download/\ + nightly-cua-driver-rs-v0.19.4-nightly.20260812.3097/\ + cua-driver-rs-v0.19.4-nightly.20260812.3097-skills.tar.gz" + ); + } + /// Build a gzipped tarball with the entries given as /// `(path, contents)` pairs. Returns the raw `.tar.gz` bytes. fn build_tarball(entries: &[(&str, &[u8])]) -> Vec { @@ -916,6 +982,87 @@ mod tests { } } + #[test] + fn bundled_skill_keeps_semantic_clipboard_outcome_ladder() { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let skill = std::fs::read_to_string(crate_dir.join("../../Skills/cua-driver/SKILL.md")) + .expect("canonical skill must be readable"); + let browser = std::fs::read_to_string(crate_dir.join("../../Skills/cua-driver/BROWSER.md")) + .expect("canonical browser skill must be readable"); + + for required in [ + "exact value on the system clipboard", + "`clipboard_write`", + "`clipboard_read`", + "passive page-text ref", + ] { + assert!( + skill.contains(required), + "skill lost required clipboard outcome guidance: {required}" + ); + } + for required in [ + "exact page content on the system clipboard", + "passive headings and text nodes are evidence sources", + "foreground escalation rules in `SKILL.md`", + ] { + assert!( + browser.contains(required), + "browser skill lost required clipboard outcome guidance: {required}" + ); + } + } + + #[test] + fn bundled_skill_keeps_sessions_and_authorization_as_separate_concepts() { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let skill = std::fs::read_to_string(crate_dir.join("../../Skills/cua-driver/SKILL.md")) + .expect("canonical skill must be readable"); + let browser = std::fs::read_to_string(crate_dir.join("../../Skills/cua-driver/BROWSER.md")) + .expect("canonical browser skill must be readable"); + + for required in [ + "Choose the target on each action", + "transport's implicit session", + "prefer a short public", + "pass the same label on every call that accepts it", + "Passing it once is not sticky", + "revive a name after", + "There is no `deescalate_session`", + "--capability-manifest", + "--approve-capability-manifest", + "It can remove tools or typed resources", + "permission authority. A public session", + ] { + assert!( + skill.contains(required), + "skill lost required lifecycle/authorization guidance: {required}" + ); + } + for forbidden in [ + "one-way session phase", + "if session policy allows", + "Pass `session` on the first action", + ] { + assert!( + !skill.contains(forbidden), + "skill restored stale session-state guidance: {forbidden}" + ); + } + for required in [ + "start_session(session?)", + "optional; can name before acting", + "prefer a short `session` label", + "Passing it once is not sticky", + "one-shot CLI calls use disposable transports", + ] { + assert!( + browser.contains(required), + "browser skill lost required session guidance: {required}" + ); + } + } + #[test] fn extract_flat_tarball_v_0_2_20_plus() { // Post-fix shape: one wrapper dir, files directly under it. diff --git a/packages/cua-driver/rust/crates/cua-driver/src/telemetry.rs b/packages/cua-driver/rust/crates/cua-driver/src/telemetry.rs index 3bcafd9b78..8ca19d3ebb 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/telemetry.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/telemetry.rs @@ -503,6 +503,8 @@ struct AgentSessionState { used_cursor_tools: bool, used_recording: bool, used_config_write: bool, + used_window_modality: bool, + used_desktop_modality: bool, } impl AgentSessionState { @@ -530,6 +532,8 @@ impl AgentSessionState { used_cursor_tools: false, used_recording: false, used_config_write: false, + used_window_modality: false, + used_desktop_modality: false, } } @@ -537,10 +541,19 @@ impl AgentSessionState { &mut self, transport: Transport, computer_action: bool, + capture_modality: Option, escalation_reason: Option, outcome: &cua_driver_core::server::ToolCompletionObservation, ) { self.transport_bits |= transport_bit(transport); + self.used_window_modality |= matches!( + capture_modality, + Some(cua_driver_core::session::CaptureModality::Window) + ); + self.used_desktop_modality |= matches!( + capture_modality, + Some(cua_driver_core::session::CaptureModality::Desktop) + ); let tool_name = outcome.tool_name.as_str(); if tool_name == "escalate_session" && outcome.success @@ -609,6 +622,7 @@ impl AgentSessionState { let cursor = cursor.unwrap_or(cua_driver_core::session::CursorOutcomeObservation { observed: false, enabled: false, + visible: false, theme: CursorThemeCategory::Unknown, motion_customized: false, active_cursor_count: 0, @@ -650,6 +664,14 @@ impl AgentSessionState { ("used_cursor_tools", Value::Bool(self.used_cursor_tools)), ("used_recording", Value::Bool(self.used_recording)), ("used_config_write", Value::Bool(self.used_config_write)), + ( + "used_window_modality", + Value::Bool(self.used_window_modality), + ), + ( + "used_desktop_modality", + Value::Bool(self.used_desktop_modality), + ), ("cursor_outcome_observed", Value::Bool(cursor.observed)), ("cursor_overlay_enabled_at_end", Value::Bool(cursor.enabled)), ("cursor_theme", Value::String(cursor_theme.into())), @@ -848,6 +870,7 @@ impl cua_driver_core::session::SessionObserver for TelemetryObserver { session_id: &str, transport: cua_driver_core::session::SessionTransport, computer_action: bool, + capture_modality: Option, escalation_reason: Option, outcome: &cua_driver_core::server::ToolCompletionObservation, ) { @@ -861,6 +884,7 @@ impl cua_driver_core::session::SessionObserver for TelemetryObserver { state.observe( session_transport(transport), computer_action, + capture_modality, escalation_reason, outcome, ); @@ -3288,6 +3312,7 @@ mod tests { state.observe( Transport::McpHttp, true, + Some(cua_driver_core::session::CaptureModality::Window), None, &ToolCompletionObservation { tool_name: "click".into(), @@ -3304,6 +3329,7 @@ mod tests { state.observe( Transport::McpHttp, false, + None, Some(cua_driver_core::EscalationReason::NoWindowTarget), &ToolCompletionObservation { tool_name: "escalate_session".into(), @@ -3320,6 +3346,7 @@ mod tests { state.observe( Transport::McpHttp, false, + Some(cua_driver_core::session::CaptureModality::Desktop), None, &ToolCompletionObservation { tool_name: "page".into(), @@ -3338,6 +3365,7 @@ mod tests { Some(cua_driver_core::session::CursorOutcomeObservation { observed: true, enabled: true, + visible: true, theme: cua_driver_core::session::CursorThemeCategory::Custom, motion_customized: true, active_cursor_count: 3, @@ -3357,6 +3385,8 @@ mod tests { assert_eq!(properties["multi_cursor_bucket"], "3_5"); assert_eq!(properties["observed_multiple_transports"], true); assert_eq!(properties["client_kind"], "python_sdk"); + assert_eq!(properties["used_window_modality"], true); + assert_eq!(properties["used_desktop_modality"], true); assert_eq!(properties["capture_scope"], "auto"); assert_eq!(properties["auto_escalated_to_desktop"], true); assert_eq!(properties["escalation_reason"], "no_window_target"); @@ -3407,6 +3437,7 @@ mod tests { auto.observe( Transport::Daemon, false, + None, Some(cua_driver_core::EscalationReason::Other), &failed, ); @@ -3426,6 +3457,7 @@ mod tests { desktop.observe( Transport::Daemon, false, + None, Some(cua_driver_core::EscalationReason::Other), &successful, ); @@ -3467,7 +3499,7 @@ mod tests { cua_driver_core::session::SessionClientKind::Mcp, cua_driver_core::CaptureScope::Window, ); - state.observe(Transport::McpStdio, true, None, &outcome); + state.observe(Transport::McpStdio, true, None, None, &outcome); let session_properties = state.ended_properties(cua_driver_core::session::SessionEndReason::Explicit, None); assert_eq!(session_properties["computer_action_count_bucket"], "0"); @@ -3506,6 +3538,7 @@ mod tests { Transport::McpStdio, true, None, + None, &ToolCompletionObservation { tool_name: tool_name.into(), operation, diff --git a/packages/cua-driver/rust/crates/cua-driver/src/version_check.rs b/packages/cua-driver/rust/crates/cua-driver/src/version_check.rs index 18e4a7b596..72f3451542 100644 --- a/packages/cua-driver/rust/crates/cua-driver/src/version_check.rs +++ b/packages/cua-driver/rust/crates/cua-driver/src/version_check.rs @@ -38,8 +38,11 @@ use std::time::{SystemTime, UNIX_EPOCH}; /// of versions the user has actively dismissed. const CACHE_FILE_NAME: &str = "version_check.json"; -/// `~/.cua-driver/` — same subdirectory the telemetry client uses. -const HOME_SUBDIRECTORY: &str = ".cua-driver"; +/// The pre-rename home. [`migrate_legacy_cache`] moves a cache written +/// there by an older build into the canonical home and drops the directory +/// once it is empty — same shape as the telemetry and skill-pack +/// migrations. Nothing in this module writes here. +const LEGACY_HOME_SUBDIRECTORY: &str = ".cua-driver-rs"; /// Single-invocation opt-out env var. Recognised values mirror the /// telemetry opt-out: `0|false|no|off` disables the check, everything @@ -59,6 +62,7 @@ const HTTP_TIMEOUT_SECONDS: u64 = 4; /// Releases tagged with anything else (e.g. the Swift port's /// `cua-driver-v*`) are filtered out so we never recommend the wrong binary. pub const RELEASE_TAG_PREFIX: &str = "cua-driver-rs-v"; +pub const NIGHTLY_RELEASE_TAG_PREFIX: &str = "nightly-cua-driver-rs-v"; /// GitHub releases API endpoint. Paginates newest-first; 40 entries is /// plenty of headroom past the most recent stable release even when @@ -114,6 +118,8 @@ pub fn maybe_announce_update() { #[derive(serde::Serialize, Debug, Clone)] pub struct UpdateState { pub current_version: String, + pub current_channel: Option<&'static str>, + pub selected_channel: Option<&'static str>, /// `None` when the network fetch failed and no usable cache existed — /// the `error` field carries the human-readable reason. pub latest_version: Option, @@ -192,6 +198,28 @@ pub fn check_update_state(no_cache: bool) -> UpdateState { let now = unix_now(); let checked_at = iso8601(now); + let current_channel = crate::release_channel::ReleaseChannel::from_version(¤t); + let selected_channel = match crate::release_channel::selected() { + Ok(channel) => channel, + Err(error) => { + return UpdateState { + current_version: current, + current_channel: current_channel.map(|channel| channel.as_str()), + selected_channel: None, + latest_version: None, + update_available: false, + source: "github_releases", + checked_at, + cache_hit: false, + install_command: None, + release_notes_url: None, + error: Some(format!( + "{error}; run `qwen-cua-driver channel set stable` or `qwen-cua-driver channel set nightly` to repair it" + )), + }; + } + }; + let cached = read_cache().unwrap_or_default(); let cache_fresh = cached .last_checked_unix @@ -200,18 +228,22 @@ pub fn check_update_state(no_cache: bool) -> UpdateState { // Honour the cache unless caller explicitly asked us to bypass it. // Mirrors `npm outdated --no-cache` / `brew update --force` semantics. - let use_cache = !no_cache && cache_fresh && cached.latest_version.is_some(); + let cache_channel_matches = + cached.channel.as_deref().unwrap_or("stable") == selected_channel.as_str(); + let use_cache = + !no_cache && cache_fresh && cache_channel_matches && cached.latest_version.is_some(); let (latest, cache_hit, fetch_error) = if use_cache { (cached.latest_version.clone(), true, None) } else { - match fetch_latest_version() { + match fetch_latest_version_for(selected_channel) { Ok(v) => { // Persist on success so the next launch can reuse the answer. let new_cache = VersionCache { last_checked_unix: Some(now), last_checked_at: Some(checked_at.clone()), latest_version: Some(v.clone()), + channel: Some(selected_channel.as_str().to_owned()), dismissed_versions: cached.dismissed_versions.clone(), }; if let Err(e) = write_cache(&new_cache) { @@ -226,7 +258,11 @@ pub fn check_update_state(no_cache: bool) -> UpdateState { // surfaces only when no cache is available. tracing::debug!(target: "cua_driver::version_check", "fetch failed: {e}"); - match cached.latest_version.clone() { + match cached + .latest_version + .clone() + .filter(|_| cache_channel_matches) + { Some(v) => (Some(v), true, None), None => (None, false, Some(e)), } @@ -236,7 +272,7 @@ pub fn check_update_state(no_cache: bool) -> UpdateState { let update_available = latest .as_deref() - .map(|l| is_newer(l, ¤t)) + .map(|latest| update_is_available(latest, ¤t, current_channel, selected_channel)) .unwrap_or(false); let (install_command, release_notes_url) = if update_available { @@ -244,7 +280,8 @@ pub fn check_update_state(no_cache: bool) -> UpdateState { ( Some(install_one_liner()), Some(format!( - "https://github.com/QwenLM/qwen-code/releases/tag/{RELEASE_TAG_PREFIX}{l}" + "https://github.com/QwenLM/qwen-code/releases/tag/{}{l}", + tag_prefix(selected_channel) )), ) } else { @@ -253,6 +290,8 @@ pub fn check_update_state(no_cache: bool) -> UpdateState { UpdateState { current_version: current, + current_channel: current_channel.map(|channel| channel.as_str()), + selected_channel: Some(selected_channel.as_str()), latest_version: latest, update_available, source: "github_releases", @@ -301,13 +340,19 @@ where W: std::io::Write, { let now = unix_now(); + let Ok(selected_channel) = crate::release_channel::selected() else { + return; + }; // Decide whether the cache is still fresh enough to skip the network. let cached = read_cache().unwrap_or_default(); + let cache_channel_matches = + cached.channel.as_deref().unwrap_or("stable") == selected_channel.as_str(); let needs_refresh = cached .last_checked_unix .map(|t| now.saturating_sub(t) >= CACHE_REFRESH_SECONDS) - .unwrap_or(true); + .unwrap_or(true) + || !cache_channel_matches; let (latest, cache_hit) = if needs_refresh { match fetch() { @@ -317,6 +362,7 @@ where last_checked_unix: Some(now), last_checked_at: Some(iso8601(now)), latest_version: Some(v.clone()), + channel: Some(selected_channel.as_str().to_owned()), dismissed_versions: cached.dismissed_versions.clone(), }; if let Err(e) = write_cache(&new_cache) { @@ -330,14 +376,22 @@ where "fetch failed: {e}"); // Fall back to the cached value if any — better an old // banner than none on a brief network blip. - match cached.latest_version.clone() { + match cached + .latest_version + .clone() + .filter(|_| cache_channel_matches) + { Some(v) => (v, true), None => return, } } } } else { - match cached.latest_version.clone() { + match cached + .latest_version + .clone() + .filter(|_| cache_channel_matches) + { Some(v) => (v, true), None => return, } @@ -349,7 +403,8 @@ where .map(|c| c.dismissed_versions) .unwrap_or(cached.dismissed_versions); - if !is_newer(&latest, current) { + let current_channel = crate::release_channel::ReleaseChannel::from_version(current); + if !update_is_available(&latest, current, current_channel, selected_channel) { return; } if dismissed.iter().any(|v| v == &latest) { @@ -369,7 +424,7 @@ where ); } - let banner = format_banner(&latest, current); + let banner = format_banner(&latest, current, selected_channel); if let Err(e) = writer.write_all(banner.as_bytes()) { tracing::debug!(target: "cua_driver::version_check", "failed to print banner: {e}"); @@ -379,13 +434,17 @@ where /// Format the two-line banner. Plain text, no ANSI colours — terminals /// without UTF-8 still see the `✨` byte sequence but the text is /// readable either way. -fn format_banner(latest: &str, current: &str) -> String { +fn format_banner( + latest: &str, + current: &str, + channel: crate::release_channel::ReleaseChannel, +) -> String { format!( "\n\u{2728} Qwen Cua Driver v{latest} is available (you have v{current}).\n \ Update with: {cli} update\n \ Release notes: https://github.com/QwenLM/qwen-code/releases/tag/{prefix}{latest}\n\n", cli = crate::bundle::cli_name(), - prefix = RELEASE_TAG_PREFIX, + prefix = tag_prefix(channel), ) } @@ -404,7 +463,10 @@ fn is_enabled() -> bool { if let Some(false) = read_config_flag() { return false; } - if is_prerelease(env!("CARGO_PKG_VERSION")) { + if is_prerelease(env!("CARGO_PKG_VERSION")) + && crate::release_channel::ReleaseChannel::from_version(env!("CARGO_PKG_VERSION")) + != Some(crate::release_channel::ReleaseChannel::Nightly) + { return false; } true @@ -461,6 +523,18 @@ pub fn is_newer(latest: &str, current: &str) -> bool { l > c } +pub fn update_is_available( + latest: &str, + current: &str, + current_channel: Option, + selected_channel: crate::release_channel::ReleaseChannel, +) -> bool { + current_channel + .map(|channel| channel != selected_channel) + .unwrap_or(false) + || is_newer(latest, current) +} + // ── On-disk cache ──────────────────────────────────────────────────────── /// Serialised shape of `~/.cua-driver/version_check.json`. @@ -477,6 +551,8 @@ pub(crate) struct VersionCache { pub last_checked_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub latest_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel: Option, #[serde(default)] pub dismissed_versions: Vec, } @@ -484,6 +560,7 @@ pub(crate) struct VersionCache { /// Read the cache file. Returns `None` when missing / unreadable / not /// valid JSON — callers fall back to the default (empty) shape. fn read_cache() -> Option { + migrate_legacy_cache(); let path = cache_path()?; let raw = std::fs::read_to_string(&path).ok()?; serde_json::from_str(&raw).ok() @@ -507,19 +584,64 @@ fn write_cache(cache: &VersionCache) -> std::io::Result<()> { std::fs::write(&path, json) } -fn cache_path() -> Option { +fn home_root() -> Option { let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?; + Some(PathBuf::from(home)) +} + +fn cache_path() -> Option { Some( - PathBuf::from(home) - .join(if crate::bundle::is_local_installation() { - ".qwen-cua-driver-local" - } else { - HOME_SUBDIRECTORY - }) + home_root()? + .join(crate::bundle::user_home_subdirectory()) .join(CACHE_FILE_NAME), ) } +/// Pre-rename cache location. `None` for the source-build product, which +/// has always used its own `~/.cua-driver-local/` home and never wrote here. +fn legacy_cache_path() -> Option { + if crate::bundle::is_local_installation() { + return None; + } + Some( + home_root()? + .join(LEGACY_HOME_SUBDIRECTORY) + .join(CACHE_FILE_NAME), + ) +} + +/// Move a pre-rename cache into the canonical home, then remove the legacy +/// directory if nothing else is left in it. +/// +/// Best-effort and idempotent: every step is allowed to fail silently, and +/// the caller falls back to an empty cache exactly as it would for a missing +/// file. Keeping the dismissed-version list is the only reason to bother — +/// losing it would re-nag the user about a release they already dismissed. +fn migrate_legacy_cache() { + let (Some(legacy), Some(current)) = (legacy_cache_path(), cache_path()) else { + return; + }; + if !legacy.is_file() { + return; + } + if !current.is_file() { + if let Some(parent) = current.parent() { + if std::fs::create_dir_all(parent).is_err() { + return; + } + } + if std::fs::rename(&legacy, ¤t).is_err() { + return; + } + } + // The rename already took the source, or a canonical cache was already + // present and wins. Only the latter path still has a stale copy to remove. + let _ = std::fs::remove_file(&legacy); + if let Some(parent) = legacy.parent() { + let _ = std::fs::remove_dir(parent); + } +} + // ── HTTP fetch (shared with the `update` subcommand) ───────────────────── /// Fetch the highest `cua-driver-rs-v*` release tag from GitHub. @@ -539,6 +661,13 @@ fn cache_path() -> Option { /// human-readable error string on failure. The caller is expected to /// downgrade errors to `tracing::debug!`. pub fn fetch_latest_version() -> Result { + let channel = crate::release_channel::selected()?; + fetch_latest_version_for(channel) +} + +pub fn fetch_latest_version_for( + channel: crate::release_channel::ReleaseChannel, +) -> Result { let agent = ureq::Agent::config_builder() .timeout_global(Some(std::time::Duration::from_secs(HTTP_TIMEOUT_SECONDS))) .build() @@ -559,31 +688,65 @@ pub fn fetch_latest_version() -> Result { .read_json() .map_err(|e| format!("JSON parse error: {e}"))?; - pick_latest_release(&body) - .ok_or_else(|| "no matching cua-driver-rs-v* release in response".to_owned()) + pick_latest_release(&body, channel) + .ok_or_else(|| format!("no matching {}* release in response", tag_prefix(channel))) } /// Pull the highest non-draft `cua-driver-rs-v*` tag out of the parsed /// releases response. Split out so unit tests can feed in canned JSON /// without hitting the network. Pre-release flag is intentionally /// ignored — see `fetch_latest_version` doc-comment. -pub(crate) fn pick_latest_release(body: &serde_json::Value) -> Option { +pub(crate) fn pick_latest_release( + body: &serde_json::Value, + channel: crate::release_channel::ReleaseChannel, +) -> Option { let releases = body.as_array()?; let mut versions: Vec = releases .iter() .filter_map(|r| { let tag = r.get("tag_name")?.as_str()?; - let bare = tag.strip_prefix(RELEASE_TAG_PREFIX)?; + let bare = tag.strip_prefix(tag_prefix(channel))?; if r.get("draft").and_then(|d| d.as_bool()).unwrap_or(false) { return None; } - semver::Version::parse(bare).ok() + let version = semver::Version::parse(bare).ok()?; + match channel { + crate::release_channel::ReleaseChannel::Stable + if !version.pre.is_empty() || !version.build.is_empty() => + { + return None + } + crate::release_channel::ReleaseChannel::Nightly + if !is_canonical_nightly(&version) => + { + return None + } + _ => {} + } + Some(version) }) .collect(); versions.sort(); versions.last().map(|v| v.to_string()) } +fn tag_prefix(channel: crate::release_channel::ReleaseChannel) -> &'static str { + match channel { + crate::release_channel::ReleaseChannel::Stable => RELEASE_TAG_PREFIX, + crate::release_channel::ReleaseChannel::Nightly => NIGHTLY_RELEASE_TAG_PREFIX, + } +} + +fn is_canonical_nightly(version: &semver::Version) -> bool { + let parts: Vec<&str> = version.pre.as_str().split('.').collect(); + parts.len() == 3 + && parts[0] == "nightly" + && parts[1].len() == 8 + && parts[1].bytes().all(|byte| byte.is_ascii_digit()) + && parts[2].parse::().map(|run| run > 0).unwrap_or(false) + && version.build.is_empty() +} + // ── Time helpers ───────────────────────────────────────────────────────── fn unix_now() -> u64 { @@ -688,6 +851,23 @@ mod tests { assert!(is_newer("0.1.3", "0.1.3-dev")); } + #[test] + fn channel_mismatch_is_available_even_when_target_version_is_lower() { + use crate::release_channel::ReleaseChannel::{Nightly, Stable}; + assert!(update_is_available( + "0.19.3", + "0.19.4-nightly.20260812.42", + Some(Nightly), + Stable, + )); + assert!(!update_is_available( + "0.19.3", + "0.19.4", + Some(Stable), + Stable, + )); + } + #[test] fn is_newer_returns_false_for_equal_versions() { assert!(!is_newer("0.1.3", "0.1.3")); @@ -740,6 +920,7 @@ mod tests { last_checked_unix: Some(1_700_000_000), last_checked_at: Some("2023-11-14T22:13:20Z".into()), latest_version: Some("0.1.4".into()), + channel: Some("stable".into()), dismissed_versions: vec!["0.1.3".into()], }; write_cache(&original).expect("write_cache"); @@ -750,6 +931,87 @@ mod tests { }); } + #[test] + fn cache_lands_in_the_canonical_home() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|home| { + write_cache(&VersionCache::default()).expect("write_cache"); + assert!(home + .join(crate::bundle::user_home_subdirectory()) + .join(CACHE_FILE_NAME) + .is_file()); + // The pre-rename home must not be re-created behind the + // installer's back — sweeping it is what the installer does. + assert!(!home.join(LEGACY_HOME_SUBDIRECTORY).exists()); + }); + } + + #[test] + fn legacy_cache_is_migrated_and_its_home_removed() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|home| { + let legacy_home = home.join(LEGACY_HOME_SUBDIRECTORY); + std::fs::create_dir_all(&legacy_home).unwrap(); + std::fs::write( + legacy_home.join(CACHE_FILE_NAME), + r#"{"latest_version":"0.1.4","dismissed_versions":["0.1.4"]}"#, + ) + .unwrap(); + + let migrated = read_cache().expect("read_cache"); + assert_eq!(migrated.latest_version.as_deref(), Some("0.1.4")); + assert_eq!(migrated.dismissed_versions, vec!["0.1.4".to_owned()]); + assert!(home + .join(crate::bundle::user_home_subdirectory()) + .join(CACHE_FILE_NAME) + .is_file()); + assert!(!legacy_home.exists()); + }); + } + + #[test] + fn migration_leaves_a_legacy_home_that_still_holds_other_files() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|home| { + let legacy_home = home.join(LEGACY_HOME_SUBDIRECTORY); + std::fs::create_dir_all(&legacy_home).unwrap(); + std::fs::write(legacy_home.join(CACHE_FILE_NAME), "{}").unwrap(); + std::fs::write(legacy_home.join(".telemetry_id"), "not-ours").unwrap(); + + let _ = read_cache(); + + // Our file is gone, somebody else's is untouched: the empty-dir + // removal must never turn into a recursive delete. + assert!(!legacy_home.join(CACHE_FILE_NAME).exists()); + assert!(legacy_home.join(".telemetry_id").is_file()); + }); + } + + #[test] + fn failed_migration_preserves_the_legacy_cache() { + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|home| { + let legacy_home = home.join(LEGACY_HOME_SUBDIRECTORY); + let legacy_cache = legacy_home.join(CACHE_FILE_NAME); + let legacy_json = r#"{"latest_version":"0.1.4","dismissed_versions":["0.1.4"]}"#; + std::fs::create_dir_all(&legacy_home).unwrap(); + std::fs::write(&legacy_cache, legacy_json).unwrap(); + + // A regular file at the canonical home prevents parent-directory + // creation, making migration fail deterministically on every platform. + std::fs::write( + home.join(crate::bundle::user_home_subdirectory()), + "not a directory", + ) + .unwrap(); + + assert!(read_cache().is_none()); + assert!(legacy_cache.is_file()); + let preserved = std::fs::read_to_string(legacy_cache).unwrap(); + assert_eq!(preserved, legacy_json); + }); + } + #[test] fn dismissed_versions_persist_across_writes() { let _g = ENV_LOCK.lock().unwrap(); @@ -789,6 +1051,7 @@ mod tests { last_checked_unix: Some(now.saturating_sub(21 * 60 * 60)), last_checked_at: Some(iso8601(now.saturating_sub(21 * 60 * 60))), latest_version: Some("0.1.3".into()), // stale data + channel: Some("stable".into()), dismissed_versions: vec![], }; write_cache(&stale).unwrap(); @@ -826,6 +1089,7 @@ mod tests { last_checked_unix: Some(now.saturating_sub(60 * 60)), last_checked_at: Some(iso8601(now.saturating_sub(60 * 60))), latest_version: Some("0.1.4".into()), + channel: Some("stable".into()), dismissed_versions: vec![], }; write_cache(&fresh).unwrap(); @@ -864,6 +1128,7 @@ mod tests { last_checked_unix: Some(now), last_checked_at: Some(iso8601(now)), latest_version: Some("0.1.4".into()), + channel: Some("stable".into()), dismissed_versions: vec!["0.1.4".into()], }; write_cache(&cache).unwrap(); @@ -996,7 +1261,11 @@ mod tests { #[test] fn banner_contains_required_lines() { - let banner = format_banner("0.1.4", "0.1.3"); + let banner = format_banner( + "0.1.4", + "0.1.3", + crate::release_channel::ReleaseChannel::Stable, + ); // Headline. assert!( banner.contains("Qwen Cua Driver v0.1.4 is available"), @@ -1028,7 +1297,10 @@ mod tests { {"tag_name": "cua-driver-rs-v0.1.3", "draft": false, "prerelease": false}, {"tag_name": "cua-driver-v9.9.9", "draft": false, "prerelease": false}, ]); - assert_eq!(pick_latest_release(&body).as_deref(), Some("0.1.4")); + assert_eq!( + pick_latest_release(&body, crate::release_channel::ReleaseChannel::Stable).as_deref(), + Some("0.1.4") + ); } #[test] @@ -1042,12 +1314,56 @@ mod tests { {"tag_name": "cua-driver-rs-v0.1.5", "draft": false, "prerelease": true}, {"tag_name": "cua-driver-rs-v0.1.4", "draft": false, "prerelease": false}, ]); - assert_eq!(pick_latest_release(&body).as_deref(), Some("0.1.5")); + assert_eq!( + pick_latest_release(&body, crate::release_channel::ReleaseChannel::Stable).as_deref(), + Some("0.1.5") + ); + } + + #[test] + fn pick_latest_release_rejects_every_nightly_shape() { + let body = serde_json::json!([ + { + "tag_name": "nightly-cua-driver-rs-v0.2.1-nightly.20260812.42", + "draft": false, + "prerelease": true + }, + { + "tag_name": "cua-driver-rs-v0.2.1-nightly.20260812.42", + "draft": false, + "prerelease": true + }, + {"tag_name": "cua-driver-rs-v0.2.0", "draft": false, "prerelease": true}, + ]); + assert_eq!( + pick_latest_release(&body, crate::release_channel::ReleaseChannel::Stable).as_deref(), + Some("0.2.0") + ); + } + + #[test] + fn nightly_discovery_rejects_stable_and_wrong_prefix_tags() { + let body = serde_json::json!([ + {"tag_name": "cua-driver-rs-v9.9.9", "draft": false}, + {"tag_name": "cua-driver-rs-v0.20.0-nightly.20260812.99", "draft": false}, + {"tag_name": "nightly-cua-driver-rs-v0.20.0-nightly.20260812.42", "draft": false}, + {"tag_name": "nightly-cua-driver-rs-v0.20.0-nightly.20260812.7", "draft": false} + ]); + assert_eq!( + pick_latest_release(&body, crate::release_channel::ReleaseChannel::Nightly).as_deref(), + Some("0.20.0-nightly.20260812.42") + ); } #[test] fn pick_latest_release_returns_none_for_empty_array() { - assert_eq!(pick_latest_release(&serde_json::json!([])), None); + assert_eq!( + pick_latest_release( + &serde_json::json!([]), + crate::release_channel::ReleaseChannel::Stable + ), + None + ); } #[test] diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/agent_cursor_showcase_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/agent_cursor_showcase_test.rs index a1b34dd5ae..0e21737c1d 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/agent_cursor_showcase_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/agent_cursor_showcase_test.rs @@ -8,10 +8,14 @@ use cua_driver_testkit::e2e::{ OracleKind, Scope, Targeting, }; use cua_driver_testkit::{Driver, McpDriver}; +use cursor_overlay::{BADGE_CURSOR_GAP, BADGE_HEIGHT, BADGE_MAX_WIDTH}; use image::RgbaImage; const CELL_ID: &str = "desktop-agent-cursor-showcase-px"; const SESSION: &str = "Cursor showcase"; +// MoveTo offsets the cursor artwork by 16 points so its tip lands on the +// requested coordinate. The session badge follows that artwork anchor. +const MAX_CURSOR_ANCHOR_OFFSET: f64 = 16.0; #[test] #[ignore] @@ -31,15 +35,11 @@ fn semantic_cursor_showcase_records_session_and_action_states() { let mut driver = spawn_driver(); *evidence = recording_evidence(driver.recording_dir()); - call_ok( - &mut driver, - "start_session", - serde_json::json!({ - "session": SESSION, - "capture_scope": "auto" - }), - ); - + // Capture the empty desktop before declaring the explicit session. + // `start_session` may revive and materialize the session-owned overlay + // at the current pointer position, which can otherwise put the badge + // in both frames when a previous showcase left the pointer at this + // deterministic target. let (baseline_png, width, height) = capture_desktop_png(&mut driver); let baseline = image::load_from_memory(&baseline_png) .expect("decode baseline desktop screenshot") @@ -49,6 +49,14 @@ fn semantic_cursor_showcase_records_session_and_action_states() { "showcase requires a normal desktop, got {width}x{height}" ); + call_ok( + &mut driver, + "start_session", + serde_json::json!({ + "session": SESSION + }), + ); + call_ok( &mut driver, "set_agent_cursor_enabled", @@ -80,18 +88,17 @@ fn semantic_cursor_showcase_records_session_and_action_states() { "y": center_y - 80.0 }), ); + // move_cursor waits for the configured glide, but X11/Wayland capture + // still needs a compositor round-trip before the overlay is guaranteed + // to appear in the driver-owned screenshot. The badge remains fully + // visible for two seconds, so this settle stays inside that window. settle(900); - let (cursor_png, cursor_width, cursor_height) = capture_desktop_png(&mut driver); - assert_eq!( - (width, height), - (cursor_width, cursor_height), - "logical desktop dimensions changed while checking the cursor overlay" - ); + let cursor_png = capture_cursor_oracle_png(&mut driver); let cursor_frame = image::load_from_memory(&cursor_png) .expect("decode cursor desktop screenshot") .to_rgba8(); - assert_cursor_pixels_changed( + assert_cursor_and_badge_pixels_changed( &baseline, &cursor_frame, center_x - 180.0, @@ -106,22 +113,12 @@ fn semantic_cursor_showcase_records_session_and_action_states() { std::fs::write(&screenshot_path, cursor_png).expect("write cursor oracle screenshot"); evidence.screenshot = Some(screenshot_path.display().to_string()); - call_ok( - &mut driver, - "escalate_session", - serde_json::json!({ - "session": SESSION, - "reason": "foreground_ineffective", - "detail": "cursor showcase switches from overlay positioning to desktop actions" - }), - ); - call_ok( &mut driver, "click", serde_json::json!({ "session": SESSION, - "scope": "desktop", + "target": {"kind": "desktop", "display_id": "primary"}, "x": center_x, "y": center_y, "delivery_mode": "foreground" @@ -134,7 +131,7 @@ fn semantic_cursor_showcase_records_session_and_action_states() { "type_text", serde_json::json!({ "session": SESSION, - "scope": "desktop", + "target": {"kind": "desktop", "display_id": "primary"}, "text": "cua", "delivery_mode": "foreground" }), @@ -146,7 +143,7 @@ fn semantic_cursor_showcase_records_session_and_action_states() { "scroll", serde_json::json!({ "session": SESSION, - "scope": "desktop", + "target": {"kind": "desktop", "display_id": "primary"}, "x": center_x, "y": center_y, "direction": "down", @@ -161,7 +158,7 @@ fn semantic_cursor_showcase_records_session_and_action_states() { "drag", serde_json::json!({ "session": SESSION, - "scope": "desktop", + "target": {"kind": "desktop", "display_id": "primary"}, "from_x": center_x - 90.0, "from_y": center_y + 80.0, "to_x": center_x + 120.0, @@ -177,7 +174,7 @@ fn semantic_cursor_showcase_records_session_and_action_states() { }); } -fn assert_cursor_pixels_changed( +fn assert_cursor_and_badge_pixels_changed( baseline: &RgbaImage, cursor_frame: &RgbaImage, logical_x: f64, @@ -194,17 +191,69 @@ fn assert_cursor_pixels_changed( let scale_y = f64::from(baseline.height()) / logical_height; let center_x = (logical_x * scale_x).round() as i64; let center_y = (logical_y * scale_y).round() as i64; - let radius_x = (170.0 * scale_x).round() as i64; - let radius_y = (130.0 * scale_y).round() as i64; - let x0 = (center_x - radius_x).max(0) as u32; - let x1 = (center_x + radius_x) - .min(i64::from(baseline.width())) - .max(0) as u32; - let y0 = (center_y - radius_y).max(0) as u32; - let y1 = (center_y + radius_y) - .min(i64::from(baseline.height())) - .max(0) as u32; - let changed_pixels = (y0..y1) + + // The pointer is anchored at the requested coordinate (with at most the + // renderer's small click offset). The session badge is a separate pill + // centered below it. Keep the regions disjoint so a visible pointer alone + // cannot satisfy the badge oracle, which was possible with the previous + // single large region/count check. + let pointer_radius_x = (48.0 * scale_x).ceil() as i64; + let pointer_radius_y = (48.0 * scale_y).ceil() as i64; + let pointer_pixels = changed_pixels_in_rect( + baseline, + cursor_frame, + center_x - pointer_radius_x, + center_y - pointer_radius_y, + center_x + pointer_radius_x, + center_y + (f64::from(BADGE_CURSOR_GAP) * scale_y).floor() as i64, + ); + + let badge_half_width = (f64::from(BADGE_MAX_WIDTH) * 0.5 * scale_x).ceil() as i64; + let badge_cursor_exclusion = (34.0 * scale_x).ceil() as i64; + let badge_top = center_y + (f64::from(BADGE_CURSOR_GAP) * scale_y).floor() as i64; + let badge_bottom = center_y + + ((f64::from(BADGE_CURSOR_GAP + BADGE_HEIGHT) + MAX_CURSOR_ANCHOR_OFFSET) * scale_y).ceil() + as i64; + // Ignore the center corridor where the pointer's lower edge or glow could + // overlap the pill. Requiring changed pixels in the badge's outer wings + // makes this an independent badge assertion. + let badge_pixels = changed_pixels_in_rect( + baseline, + cursor_frame, + center_x - badge_half_width, + badge_top, + center_x - badge_cursor_exclusion, + badge_bottom, + ) + changed_pixels_in_rect( + baseline, + cursor_frame, + center_x + badge_cursor_exclusion, + badge_top, + center_x + badge_half_width, + badge_bottom, + ); + + assert!( + pointer_pixels >= 12 && badge_pixels >= 24, + "agent cursor overlay was incomplete near ({logical_x:.0},{logical_y:.0}): \ + pointer region changed {pointer_pixels} pixels (minimum 12), \ + badge region changed {badge_pixels} pixels (minimum 24)" + ); +} + +fn changed_pixels_in_rect( + baseline: &RgbaImage, + cursor_frame: &RgbaImage, + x0: i64, + y0: i64, + x1: i64, + y1: i64, +) -> usize { + let x0 = x0.clamp(0, i64::from(baseline.width())) as u32; + let x1 = x1.clamp(0, i64::from(baseline.width())) as u32; + let y0 = y0.clamp(0, i64::from(baseline.height())) as u32; + let y1 = y1.clamp(0, i64::from(baseline.height())) as u32; + (y0..y1) .flat_map(|pixel_y| (x0..x1).map(move |pixel_x| (pixel_x, pixel_y))) .filter(|(pixel_x, pixel_y)| { let before = baseline.get_pixel(*pixel_x, *pixel_y).0; @@ -216,12 +265,7 @@ fn assert_cursor_pixels_changed( .sum::() >= 80 }) - .count(); - assert!( - changed_pixels >= 24, - "agent cursor and session badge were not externally visible near \ - ({logical_x:.0},{logical_y:.0}): only {changed_pixels} pixels changed" - ); + .count() } fn capture_desktop_png(driver: &mut McpDriver) -> (Vec, f64, f64) { @@ -256,6 +300,38 @@ fn capture_desktop_png(driver: &mut McpDriver) -> (Vec, f64, f64) { (png, width, height) } +fn capture_cursor_oracle_png(driver: &mut McpDriver) -> Vec { + #[cfg(target_os = "linux")] + { + // A compositor-less X11 root read can omit the overlay client's own + // shaped window even while an independent display capture sees the + // complete cursor and badge. The action evidence recorder is the + // canonical external behavior oracle and has already captured the + // move_cursor after-frame before this call returns. + let path = driver + .recording_dir() + .expect("showcase recording directory") + .join("turn-00001/after.png"); + for _ in 0..20 { + if let Ok(png) = std::fs::read(&path) { + if !png.is_empty() { + return png; + } + } + settle(50); + } + panic!( + "action evidence did not produce the cursor after-frame at {}", + path.display() + ); + } + + #[cfg(not(target_os = "linux"))] + { + capture_desktop_png(driver).0 + } +} + fn call_ok(driver: &mut McpDriver, tool: &str, arguments: serde_json::Value) { let response = driver.call(tool, arguments); assert!(!response.is_error(), "{tool} failed: {}", response.text()); @@ -265,6 +341,76 @@ fn settle(milliseconds: u64) { std::thread::sleep(Duration::from_millis(milliseconds)); } +#[cfg(test)] +mod pixel_oracle_tests { + use super::*; + use image::Rgba; + + const WIDTH: u32 = 400; + const HEIGHT: u32 = 300; + const CURSOR_X: f64 = 200.0; + const CURSOR_Y: f64 = 150.0; + + #[test] + fn accepts_independent_pointer_and_badge_changes() { + let baseline = RgbaImage::new(WIDTH, HEIGHT); + let mut overlay = baseline.clone(); + paint_changed_rect(&mut overlay, 196, 146, 200, 150); + paint_changed_rect(&mut overlay, 150, 180, 156, 186); + + assert_cursor_and_badge_pixels_changed( + &baseline, + &overlay, + CURSOR_X, + CURSOR_Y, + f64::from(WIDTH), + f64::from(HEIGHT), + ); + } + + #[test] + fn accepts_badge_at_shifted_cursor_artwork_anchor() { + let baseline = RgbaImage::new(WIDTH, HEIGHT); + let mut overlay = baseline.clone(); + paint_changed_rect(&mut overlay, 196, 146, 200, 150); + paint_changed_rect(&mut overlay, 250, 204, 256, 210); + + assert_cursor_and_badge_pixels_changed( + &baseline, + &overlay, + CURSOR_X, + CURSOR_Y, + f64::from(WIDTH), + f64::from(HEIGHT), + ); + } + + #[test] + #[should_panic(expected = "badge region changed 0 pixels")] + fn rejects_pointer_without_badge() { + let baseline = RgbaImage::new(WIDTH, HEIGHT); + let mut pointer_only = baseline.clone(); + paint_changed_rect(&mut pointer_only, 196, 146, 200, 150); + + assert_cursor_and_badge_pixels_changed( + &baseline, + &pointer_only, + CURSOR_X, + CURSOR_Y, + f64::from(WIDTH), + f64::from(HEIGHT), + ); + } + + fn paint_changed_rect(image: &mut RgbaImage, x0: u32, y0: u32, x1: u32, y1: u32) { + for y in y0..y1 { + for x in x0..x1 { + image.put_pixel(x, y, Rgba([94, 192, 232, 255])); + } + } + } +} + #[cfg(target_os = "macos")] fn spawn_driver() -> McpDriver { McpDriver::spawn_macos_daemon_proxy_named(CELL_ID).expect("start installed macOS daemon proxy") diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/agent_cursor_windows_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/agent_cursor_windows_test.rs index 1e68e456cb..3ee683820a 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/agent_cursor_windows_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/agent_cursor_windows_test.rs @@ -2,18 +2,29 @@ #![cfg(target_os = "windows")] -use std::time::Duration; +use std::ffi::OsStr; +use std::net::TcpListener; +use std::os::windows::ffi::OsStrExt; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; use cua_driver_testkit::e2e::{ execute_case, recording_evidence, CaseSpec, Delivery, DriverRoute, Evidence, Observation, OracleKind, Scope, Targeting, }; +use cua_driver_testkit::observer::TargetWindow; use cua_driver_testkit::sentinel::ForegroundSentinel; -use cua_driver_testkit::{Driver, McpDriver}; +use cua_driver_testkit::{ax, harness_app, spawn_in_job, Driver, McpDriver}; +use windows::core::PCWSTR; +use windows::Win32::Foundation::{HWND, POINT, RECT}; +use windows::Win32::UI::WindowsAndMessaging::{ + FindWindowW, GetCursorPos, GetForegroundWindow, GetWindow, GetWindowLongPtrW, GetWindowRect, + GWL_EXSTYLE, GW_HWNDPREV, WS_EX_TOPMOST, +}; #[test] #[ignore] -fn agent_cursor_overlay_is_visible_without_moving_real_cursor() { +fn agent_cursor_overlay_obeys_untargeted_and_targeted_z_order() { let case = CaseSpec::delivered( "windows-desktop-agent-cursor-px", "desktop", @@ -28,57 +39,64 @@ fn agent_cursor_overlay_is_visible_without_moving_real_cursor() { OracleKind::Focus, OracleKind::ZOrder, OracleKind::Cursor, - OracleKind::NoLeakedInput, ], ); execute_case(case, |evidence| { - let mut driver = McpDriver::spawn_named("windows-desktop-agent-cursor-px") - .expect("required source-built driver did not start"); + let mut driver = McpDriver::spawn_named_with_overlay("windows-desktop-agent-cursor-px") + .expect("required source-built driver with cursor overlay did not start"); *evidence = recording_evidence(driver.recording_dir()); - let sentinel = ForegroundSentinel::launch(&mut driver); + let target = launch_wpf_window(&mut driver); + bring_to_front(&mut driver, target); driver.start_behavior_recording(); - let screen = driver.call("get_screen_size", serde_json::json!({})); - assert!( - !screen.is_error(), - "get_screen_size failed: {}", - screen.text() - ); - let width = screen.structured()["width"].as_f64().unwrap_or(0.0); - let height = screen.structured()["height"].as_f64().unwrap_or(0.0); - assert!( - width >= 80.0 && height >= 80.0, - "invalid screen size {width}x{height}" - ); - let (x, y) = (width / 2.0, height / 2.0); + let (x, y) = window_center(target.native_id); let cursor_id = "windows-agent-cursor-e2e"; + let foreground_before = unsafe { GetForegroundWindow() }; + assert_eq!(foreground_before.0 as u64, target.native_id); + let real_cursor_before = real_cursor_position(); - let (_, mut passed) = sentinel - .observe_desktop(|| { - for (tool, arguments) in [ - ( - "set_agent_cursor_enabled", - serde_json::json!({"enabled": true, "cursor_id": cursor_id}), - ), - ( - "set_agent_cursor_motion", - serde_json::json!({ - "cursor_id": cursor_id, - "glide_duration_ms": 100, - "idle_hide_ms": 0 - }), - ), - ( - "move_cursor", - serde_json::json!({"x": x, "y": y, "cursor_id": cursor_id}), - ), - ] { - let response = driver.call(tool, arguments); - assert!(!response.is_error(), "{tool} failed: {}", response.text()); - } - std::thread::sleep(Duration::from_millis(350)); - }) - .unwrap_or_else(|error| panic!("agent cursor disturbed the real desktop: {error}")); - assert_required_background_oracles(&passed); + for (tool, arguments) in [ + ( + "set_agent_cursor_enabled", + serde_json::json!({"enabled": true, "cursor_id": cursor_id}), + ), + ( + "set_agent_cursor_motion", + serde_json::json!({ + "cursor_id": cursor_id, + "glide_duration_ms": 100, + "idle_hide_ms": 0 + }), + ), + ( + "move_cursor", + serde_json::json!({ + "x": x, + "y": y, + "cursor_id": cursor_id + }), + ), + ] { + let response = driver.call(tool, arguments); + assert!(!response.is_error(), "{tool} failed: {}", response.text()); + } + std::thread::sleep(Duration::from_millis(350)); + + let overlay = wait_for_overlay(); + assert_eq!( + unsafe { GetForegroundWindow() }, + foreground_before, + "move-only cursor stole focus" + ); + assert_eq!( + real_cursor_position(), + real_cursor_before, + "move-only agent cursor moved the real pointer" + ); + assert_not_topmost(overlay); + assert!( + window_is_above(overlay, HWND(target.native_id as *mut _)), + "move-only overlay did not reach the top of the ordinary band" + ); let png = platform_windows::capture::screenshot_display_bytes() .expect("screenshot_display_bytes failed"); @@ -114,6 +132,75 @@ fn agent_cursor_overlay_is_visible_without_moving_real_cursor() { "agent cursor not visible at ({x:.0},{y:.0}): only {visible_pixels} qualifying pixels" ); + // Capture the target while it is still foreground. Electron may prune + // parts of its UIA tree once another full-size window covers it, but + // the snapshot remains a stable handle for the later background click. + let target_state = wait_for_window_text(&mut driver, target, "btn-increment"); + let button_index = ax::element_index_by_id(target_state.tree_text(), "btn-increment") + .unwrap_or_else(|| { + panic!( + "target increment button missing from foreground UIA snapshot: {}", + target_state.tree_text() + ) + }); + assert!(target_state.tree_text().contains("counter=0")); + + // A targeted cursor remains exactly above its target while an + // independent ordinary foreground window stays above both. This + // guards the pre-existing PinAbove semantics while exercising the new + // no-target branch in the same hosted desktop. + let (_foreground_profile, foreground) = launch_normal_window(&mut driver, "foreground"); + bring_to_front(&mut driver, foreground); + assert!( + window_is_above( + HWND(foreground.native_id as *mut _), + HWND(target.native_id as *mut _) + ), + "independent foreground window did not cover target" + ); + let targeted = driver.call( + "click", + serde_json::json!({ + "pid": target.pid, + "window_id": target.native_id, + "element_index": button_index, + "snapshot_id": target_state.snapshot_id(), + "delivery_mode": "background", + "cursor_id": cursor_id + }), + ); + assert!( + !targeted.is_error(), + "targeted background click failed: {}", + targeted.text() + ); + let target_state_after = wait_for_window_text(&mut driver, target, "counter=1"); + assert!( + target_state_after.tree_text().contains("counter=1"), + "targeted click did not change the background target: {}", + target_state_after.tree_text() + ); + let foreground_state_after = window_state(&mut driver, foreground); + assert!( + foreground_state_after.tree_text().contains("counter=0"), + "targeted click leaked into the independent foreground window: {}", + foreground_state_after.tree_text() + ); + wait_until( + Duration::from_secs(2), + "target-bound overlay z-order", + || { + window_is_above(overlay, HWND(target.native_id as *mut _)) + && window_is_above(HWND(foreground.native_id as *mut _), overlay) + }, + ); + assert_eq!( + unsafe { GetForegroundWindow().0 as u64 }, + foreground.native_id, + "target-bound overlay or action stole foreground" + ); + assert_not_topmost(overlay); + let disabled = driver.call( "set_agent_cursor_enabled", serde_json::json!({"enabled": false, "cursor_id": cursor_id}), @@ -123,11 +210,123 @@ fn agent_cursor_overlay_is_visible_without_moving_real_cursor() { "failed to disable agent cursor: {}", disabled.text() ); - passed.push(OracleKind::Pixels); + let passed = vec![ + OracleKind::Pixels, + OracleKind::Focus, + OracleKind::ZOrder, + OracleKind::Cursor, + ]; Observation::delivered(passed, Evidence::default()) }); } +#[test] +#[ignore] +fn agent_cursor_move_does_not_leak_input() { + let case = CaseSpec::delivered( + "windows-desktop-agent-cursor-no-input-leak", + "desktop", + "win32", + "agent_cursor", + Targeting::Px, + Delivery::NotApplicable, + Scope::Desktop, + DriverRoute::WindowsOverlay, + vec![ + OracleKind::Focus, + OracleKind::ZOrder, + OracleKind::Cursor, + OracleKind::NoLeakedInput, + ], + ); + execute_case(case, |evidence| { + let mut driver = + McpDriver::spawn_named_with_overlay("windows-desktop-agent-cursor-no-input-leak") + .expect("required source-built driver with cursor overlay did not start"); + *evidence = recording_evidence(driver.recording_dir()); + let sentinel = ForegroundSentinel::launch(&mut driver); + driver.start_behavior_recording(); + let screen = driver.call("get_screen_size", serde_json::json!({})); + assert!( + !screen.is_error(), + "get_screen_size failed: {}", + screen.text() + ); + let x = screen.structured()["width"].as_f64().unwrap_or(0.0) / 2.0; + let y = screen.structured()["height"].as_f64().unwrap_or(0.0) / 2.0; + let cursor_id = "windows-agent-cursor-no-input-leak"; + let (_, passed) = sentinel + .observe_desktop(|| { + for (tool, arguments) in [ + ( + "set_agent_cursor_enabled", + serde_json::json!({"enabled": true, "cursor_id": cursor_id}), + ), + ( + "set_agent_cursor_motion", + serde_json::json!({ + "cursor_id": cursor_id, + "glide_duration_ms": 100, + "idle_hide_ms": 0 + }), + ), + ( + "move_cursor", + serde_json::json!({ + "x": x, + "y": y, + "cursor_id": cursor_id + }), + ), + ] { + let response = driver.call(tool, arguments); + assert!(!response.is_error(), "{tool} failed: {}", response.text()); + } + std::thread::sleep(Duration::from_millis(350)); + }) + .unwrap_or_else(|error| panic!("agent cursor disturbed the real desktop: {error}")); + assert_required_background_oracles(&passed); + Observation::delivered(passed, Evidence::default()) + }); +} + +fn window_state(driver: &mut McpDriver, target: TargetWindow) -> cua_driver_testkit::ToolResponse { + let response = driver.call( + "get_window_state", + serde_json::json!({ + "pid": target.pid, + "window_id": target.native_id, + "capture_mode": "ax" + }), + ); + assert!( + !response.is_error(), + "window snapshot failed: {}", + response.text() + ); + response +} + +fn wait_for_window_text( + driver: &mut McpDriver, + target: TargetWindow, + expected: &str, +) -> cua_driver_testkit::ToolResponse { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let state = window_state(driver, target); + if state.tree_text().contains(expected) { + return state; + } + assert!( + Instant::now() < deadline, + "window state did not contain {expected:?}: {}", + state.tree_text() + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + fn assert_required_background_oracles(passed: &[OracleKind]) { for required in [ OracleKind::Focus, @@ -141,3 +340,134 @@ fn assert_required_background_oracles(passed: &[OracleKind]) { ); } } + +fn launch_normal_window(driver: &mut McpDriver, label: &str) -> (tempfile::TempDir, TargetWindow) { + let executable = harness_app("harness-electron", "CuaTestHarness.Electron.exe"); + assert!( + executable.exists(), + "Electron harness missing at {}; run the Windows harness build first", + executable.display() + ); + let profile = tempfile::Builder::new() + .prefix(&format!("cua-agent-cursor-{label}-")) + .tempdir() + .expect("create Electron profile"); + let port = TcpListener::bind(("127.0.0.1", 0)) + .and_then(|listener| listener.local_addr()) + .expect("allocate Electron CDP port") + .port(); + let mut command = Command::new(executable); + command + .env("CUA_ELECTRON_CDP_PORT", port.to_string()) + .env("CUA_E2E_USER_DATA_DIR", profile.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let child = spawn_in_job(&mut command).expect("launch ordinary Electron harness window"); + let pid = child.id(); + driver.reaper().push(child); + let (native_id, _) = driver + .find_window(pid as i64, "CuaTestHarness Electron") + .expect("ordinary Electron harness window did not appear"); + (profile, TargetWindow { pid, native_id }) +} + +fn launch_wpf_window(driver: &mut McpDriver) -> TargetWindow { + let executable = harness_app("harness-wpf", "CuaTestHarness.Wpf.exe"); + assert!( + executable.exists(), + "WPF harness missing at {}; run the Windows harness build first", + executable.display() + ); + let mut command = Command::new(executable); + command.stdout(Stdio::null()).stderr(Stdio::null()); + let child = spawn_in_job(&mut command).expect("launch ordinary WPF harness window"); + let pid = child.id(); + driver.reaper().push(child); + let (native_id, _) = driver + .find_window(pid as i64, "CuaTestHarness WPF") + .expect("ordinary WPF harness window did not appear"); + TargetWindow { pid, native_id } +} + +fn bring_to_front(driver: &mut McpDriver, target: TargetWindow) { + let response = driver.call( + "bring_to_front", + serde_json::json!({"pid": target.pid, "window_id": target.native_id}), + ); + assert!( + !response.is_error(), + "bring_to_front failed: {}", + response.text() + ); + wait_until( + Duration::from_secs(2), + "ordinary window foreground", + || unsafe { GetForegroundWindow().0 as u64 == target.native_id }, + ); +} + +fn wait_for_overlay() -> HWND { + let class_name: Vec = OsStr::new("Cua.AgentCursorOverlay\0") + .encode_wide() + .collect(); + let mut overlay = HWND::default(); + wait_until( + Duration::from_secs(2), + "agent cursor overlay window", + || { + overlay = unsafe { FindWindowW(PCWSTR(class_name.as_ptr()), PCWSTR::null()) } + .unwrap_or_default(); + !overlay.0.is_null() + }, + ); + overlay +} + +fn window_center(window_id: u64) -> (f64, f64) { + let mut rect = RECT::default(); + unsafe { GetWindowRect(HWND(window_id as *mut _), &mut rect) } + .expect("read ordinary foreground window bounds"); + ( + f64::from(rect.left + (rect.right - rect.left) / 2), + f64::from(rect.top + (rect.bottom - rect.top) / 2), + ) +} + +fn real_cursor_position() -> (i32, i32) { + let mut point = POINT::default(); + unsafe { GetCursorPos(&mut point) }.expect("read real cursor position"); + (point.x, point.y) +} + +fn assert_not_topmost(window: HWND) { + let ex_style = unsafe { GetWindowLongPtrW(window, GWL_EXSTYLE) } as u32; + assert_eq!( + ex_style & WS_EX_TOPMOST.0, + 0, + "agent cursor overlay remained persistently topmost" + ); +} + +fn window_is_above(upper: HWND, lower: HWND) -> bool { + let mut cursor = lower; + loop { + cursor = unsafe { GetWindow(cursor, GW_HWNDPREV) }.unwrap_or_default(); + if cursor.0.is_null() { + return false; + } + if cursor == upper { + return true; + } + } +} + +fn wait_until(timeout: Duration, description: &str, mut predicate: impl FnMut() -> bool) { + let deadline = Instant::now() + timeout; + while !predicate() { + assert!( + Instant::now() < deadline, + "timed out after {timeout:?} waiting for {description}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/atspi_listener_startup_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/atspi_listener_startup_test.rs new file mode 100644 index 0000000000..977ff948ce --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver/tests/atspi_listener_startup_test.rs @@ -0,0 +1,91 @@ +//! Linux process-level readiness when AT-SPI accepts a connection but stalls. + +#![cfg(target_os = "linux")] + +use std::os::unix::net::{UnixListener, UnixStream}; +use std::process::{Command, Stdio}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::time::{Duration, Instant}; + +use cua_driver_testkit::{spawn_in_job, ChildReaper}; + +#[test] +fn serve_binds_while_reachable_atspi_initialization_is_stalled() { + let directory = tempfile::Builder::new() + .prefix("cua-atspi-startup-") + .tempdir_in("/tmp") + .expect("temporary startup-test directory"); + let bus_path = directory.path().join("stalled-session-bus.sock"); + let daemon_socket = directory.path().join("driver.sock"); + let bus = UnixListener::bind(&bus_path).expect("bind reachable stalled bus"); + bus.set_nonblocking(true) + .expect("make stalled bus listener nonblocking"); + let accepted = Arc::new(AtomicBool::new(false)); + let accepted_in_server = accepted.clone(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let bus_server = std::thread::spawn(move || { + let accept_deadline = Instant::now() + Duration::from_secs(10); + let connection = loop { + match bus.accept() { + Ok((connection, _)) => break Some(connection), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= accept_deadline { + break None; + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept driver bus connection: {error}"), + } + }; + if connection.is_some() { + accepted_in_server.store(true, Ordering::SeqCst); + let _ = release_rx.recv_timeout(Duration::from_secs(10)); + } + }); + + let mut command = Command::new(env!("CARGO_BIN_EXE_qwen-cua-driver")); + command + .args([ + "serve", + "--socket", + daemon_socket.to_str().expect("UTF-8 daemon socket"), + "--no-overlay", + "--no-permissions-gate", + ]) + .env( + "DBUS_SESSION_BUS_ADDRESS", + format!("unix:path={}", bus_path.display()), + ) + .env("CUA_DRIVER_RS_DISABLE_A11Y_ADVERTISE", "1") + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let mut reaper = ChildReaper::new(); + let child = spawn_in_job(&mut command).expect("spawn cua-driver serve"); + reaper.push(child); + + let readiness_deadline = Instant::now() + Duration::from_secs(6); + while Instant::now() < readiness_deadline { + if UnixStream::connect(&daemon_socket).is_ok() { + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + + let ready = UnixStream::connect(&daemon_socket).is_ok(); + let _ = release_tx.send(()); + bus_server.join().expect("join stalled bus server"); + assert!( + accepted.load(Ordering::SeqCst), + "driver never reached the listening session-bus socket" + ); + assert!( + ready && daemon_socket.exists(), + "serve did not bind after the bounded AT-SPI readiness wait" + ); +} diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/bring_to_front_macos_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/bring_to_front_macos_test.rs new file mode 100644 index 0000000000..48ede776b6 --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver/tests/bring_to_front_macos_test.rs @@ -0,0 +1,421 @@ +//! Exact-window certification for macOS `bring_to_front`. +//! +//! The assertions deliberately do not consume cua-driver's `list_windows` or +//! internal verifier. System Events independently reports the frontmost process +//! and AX focused window, while a direct CoreGraphics query provides the +//! WindowServer front-to-back order. +//! +//! Run with: +//! `cargo test -p cua-driver --test bring_to_front_macos_test -- --ignored --nocapture --test-threads=1` + +#![cfg(target_os = "macos")] + +use std::collections::{HashMap, HashSet}; +use std::ffi::c_void; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use core_foundation::{ + array::{CFArray, CFArrayRef}, + base::{CFGetTypeID, CFTypeRef, TCFType}, + boolean::CFBoolean, + dictionary::CFDictionary, + number::CFNumber, + string::CFString, +}; +use cua_driver_testkit::{harness_app, Driver, McpDriver}; + +const MAIN_TITLE: &str = "CuaTestHarness AppKit"; +const SECONDARY_TITLE: &str = "CuaTestHarness AppKit Secondary"; +const OCCLUDER_TITLE: &str = "CuaTestHarness SwiftUI"; +#[link(name = "CoreGraphics", kind = "framework")] +extern "C" { + fn CGWindowListCopyWindowInfo(option: u32, relative_to_window: u32) -> CFArrayRef; +} + +#[derive(Debug, Clone)] +struct OracleWindow { + id: u32, + pid: u32, + layer: i32, +} + +struct Fixture { + pid: u32, + report: tempfile::NamedTempFile, +} + +struct Occluder { + pid: u32, +} + +impl Drop for Occluder { + fn drop(&mut self) { + let _ = Command::new("kill") + .args(["-TERM", &self.pid.to_string()]) + .status(); + } +} + +fn harness_exe() -> std::path::PathBuf { + std::env::var("HARNESS_APPKIT_APP") + .map(std::path::PathBuf::from) + .ok() + .filter(|path| path.exists()) + .unwrap_or_else(|| harness_app("harness-appkit", "CuaTestHarness.AppKit.app")) + .join("Contents/MacOS/CuaTestHarness.AppKit") +} + +fn occluder_exe() -> std::path::PathBuf { + std::env::var("HARNESS_SWIFTUI_APP") + .map(std::path::PathBuf::from) + .ok() + .filter(|path| path.exists()) + .unwrap_or_else(|| harness_app("harness-swiftui", "CuaTestHarness.SwiftUI.app")) + .join("Contents/MacOS/CuaTestHarness.SwiftUI") +} + +fn launch(driver: &mut McpDriver, mode: Option<&str>) -> Fixture { + let exe = harness_exe(); + assert!(exe.exists(), "required AppKit harness missing at {exe:?}"); + let report = tempfile::NamedTempFile::new().expect("create AppKit window report"); + let mut command = Command::new(exe); + command.stdout(Stdio::null()).stderr(Stdio::null()); + command.env("CUA_HARNESS_WINDOW_REPORT", report.path()); + if let Some(mode) = mode { + command.env("CUA_HARNESS_BRING_TO_FRONT_MODE", mode); + } + let child = cua_driver_testkit::spawn_in_job(&mut command).expect("launch AppKit harness"); + let pid = child.id(); + driver.reaper().push(child); + Fixture { pid, report } +} + +fn running_executable_pids(exe: &std::path::Path) -> HashSet { + let output = Command::new("pgrep") + .args([ + "-f", + "-x", + "--", + exe.to_str().expect("UTF-8 SwiftUI fixture path"), + ]) + .output() + .expect("query SwiftUI fixture processes"); + if !output.status.success() { + return HashSet::new(); + } + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.trim().parse().ok()) + .collect() +} + +fn launch_occluder(_driver: &mut McpDriver) -> Occluder { + let exe = occluder_exe(); + assert!( + exe.exists(), + "required distinct-bundle SwiftUI occluder missing at {exe:?}" + ); + let app = exe + .ancestors() + .nth(3) + .expect("SwiftUI executable is inside an app bundle"); + let previous = running_executable_pids(&exe); + let opened = Command::new("open") + .args(["-n", "-g"]) + .arg(app) + .status() + .expect("launch SwiftUI occluder through LaunchServices"); + assert!(opened.success(), "LaunchServices rejected SwiftUI occluder"); + + let deadline = Instant::now() + Duration::from_secs(12); + loop { + if let Some(pid) = running_executable_pids(&exe).difference(&previous).next() { + return Occluder { pid: *pid }; + } + assert!( + Instant::now() < deadline, + "LaunchServices SwiftUI process did not appear" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn wait_for_ordinary_window(pid: u32) -> u32 { + let deadline = Instant::now() + Duration::from_secs(12); + loop { + if let Some(window) = cg_windows() + .into_iter() + .find(|window| window.pid == pid && window.layer == 0) + { + return window.id; + } + assert!( + Instant::now() < deadline, + "ordinary window for pid {pid} did not appear" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn wait_for_window_ids(fixture: &Fixture, names: &[&str]) -> HashMap { + let deadline = Instant::now() + Duration::from_secs(12); + loop { + let report = std::fs::read_to_string(fixture.report.path()).unwrap_or_default(); + let ids: HashMap = report + .lines() + .filter_map(|line| line.split_once('=')) + .filter_map(|(name, id)| id.parse().ok().map(|id| (name.to_owned(), id))) + .collect(); + let windows = cg_windows(); + if names.iter().all(|name| { + ids.get(*name).is_some_and(|id| { + windows + .iter() + .any(|window| window.pid == fixture.pid && window.id == *id) + }) + }) { + return ids; + } + assert!( + Instant::now() < deadline, + "windows {names:?} for pid {} did not appear; report={report:?}; observed={windows:?}", + fixture.pid + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn cg_windows() -> Vec { + // On-screen only | exclude desktop elements. CoreGraphics returns front to back. + let raw = unsafe { CGWindowListCopyWindowInfo(1 | 16, 0) }; + assert!(!raw.is_null(), "CGWindowListCopyWindowInfo returned null"); + let array: CFArray = unsafe { CFArray::wrap_under_create_rule(raw) }; + let mut windows = Vec::new(); + for item in array.iter() { + let item = *item; + if unsafe { CFGetTypeID(item) } != CFDictionary::<*const c_void, *const c_void>::type_id() { + continue; + } + let dictionary: CFDictionary<*const c_void, *const c_void> = + unsafe { CFDictionary::wrap_under_get_rule(item as _) }; + let number = |key: &str| -> i64 { + let key = CFString::new(key); + dictionary + .find(key.as_concrete_TypeRef() as *const c_void) + .and_then(|value| unsafe { + let value = *value; + (CFGetTypeID(value) == CFNumber::type_id()) + .then(|| CFNumber::wrap_under_get_rule(value as _).to_i64()) + .flatten() + }) + .unwrap_or_default() + }; + let on_screen = { + let key = CFString::new("kCGWindowIsOnscreen"); + dictionary + .find(key.as_concrete_TypeRef() as *const c_void) + .is_some_and(|value| unsafe { + let value = *value; + CFGetTypeID(value) == CFBoolean::type_id() + && bool::from(CFBoolean::wrap_under_get_rule(value as _)) + }) + }; + if on_screen { + windows.push(OracleWindow { + id: number("kCGWindowNumber") as u32, + pid: number("kCGWindowOwnerPID") as u32, + layer: number("kCGWindowLayer") as i32, + }); + } + } + windows +} + +fn frontmost_pid() -> u32 { + let output = Command::new("osascript") + .args([ + "-e", + "tell application \"System Events\" to unix id of first process whose frontmost is true", + ]) + .output() + .expect("query frontmost process"); + assert!( + output.status.success(), + "frontmost oracle failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .expect("frontmost oracle pid") +} + +fn focused_window_title(pid: u32) -> String { + let script = format!( + "tell application \"System Events\" to tell (first process whose unix id is {pid}) to get name of value of attribute \"AXFocusedWindow\"" + ); + let output = Command::new("osascript") + .args(["-e", &script]) + .output() + .expect("query AX focused window"); + assert!( + output.status.success(), + "focused-window oracle failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() +} + +fn activate_and_raise(pid: u32, title: &str) { + let script = format!( + "tell application \"System Events\" to tell (first process whose unix id is {pid})\nset frontmost to true\nperform action \"AXRaise\" of window \"{title}\"\nend tell" + ); + let output = Command::new("osascript") + .args(["-e", &script]) + .output() + .expect("activate fixture window"); + assert!( + output.status.success(), + "fixture activation failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + std::thread::sleep(Duration::from_millis(250)); +} + +fn layer_zero_front() -> OracleWindow { + cg_windows() + .into_iter() + .find(|window| window.layer == 0) + .expect("at least one ordinary on-screen window") +} + +#[test] +#[ignore] +fn exact_secondary_window_is_verified_and_prior_app_can_recover_focus() { + let mut driver = McpDriver::spawn_macos_daemon_proxy_named("macos-bring-to-front-exact") + .expect("start installed macOS daemon proxy"); + let target = launch(&mut driver, Some("two-windows")); + let target_windows = wait_for_window_ids(&target, &["main", "secondary"]); + let secondary = target_windows["secondary"]; + // Use a distinct bundle identity for the prior app. Raw-launching a second + // copy of the AppKit fixture lets AX and WindowServer select that process's + // window while NSWorkspace continues to report the other same-bundle + // instance as frontmost, which is not representative app recovery. + let occluder = launch_occluder(&mut driver); + let occluder_main = wait_for_ordinary_window(occluder.pid); + + activate_and_raise(target.pid, MAIN_TITLE); + activate_and_raise(occluder.pid, OCCLUDER_TITLE); + assert_ne!( + layer_zero_front().id, + secondary, + "precondition: secondary already frontmost" + ); + + let result = driver.call( + "bring_to_front", + serde_json::json!({"pid": target.pid, "window_id": secondary}), + ); + assert!( + !result.is_error(), + "exact bring_to_front failed: {}; raw={}", + result.text(), + result.raw + ); + assert_eq!(result.structured()["activated"], true); + assert_eq!(result.structured()["request_accepted"], true); + assert_eq!(result.structured()["process_activated"], true); + assert_eq!(result.structured()["exact_window_effect"]["verified"], true); + assert_eq!( + frontmost_pid(), + target.pid, + "independent frontmost-process oracle" + ); + assert_eq!( + focused_window_title(target.pid), + SECONDARY_TITLE, + "independent AX key-window oracle" + ); + assert_eq!( + layer_zero_front().id, + secondary, + "independent CGWindow z-order oracle" + ); + + assert_ne!(focused_window_title(target.pid), MAIN_TITLE); + + let recovered = driver.call( + "bring_to_front", + serde_json::json!({"pid": occluder.pid, "window_id": occluder_main}), + ); + assert!( + !recovered.is_error(), + "prior app recovery failed: {}", + recovered.text() + ); + assert_eq!(frontmost_pid(), occluder.pid); + assert_eq!(layer_zero_front().id, occluder_main); +} + +#[test] +#[ignore] +fn modal_sheet_never_yields_false_parent_success() { + let mut driver = McpDriver::spawn_macos_daemon_proxy_named("macos-bring-to-front-sheet") + .expect("start installed macOS daemon proxy"); + let target = launch(&mut driver, Some("sheet")); + let windows = wait_for_window_ids(&target, &["main", "secondary", "sheet"]); + let parent = windows["main"]; + let result = driver.call( + "bring_to_front", + serde_json::json!({"pid": target.pid, "window_id": parent}), + ); + assert!( + result.is_error(), + "modal parent falsely reported success: {}", + result.raw + ); + assert_eq!(result.structured()["activated"], false); + assert_ne!(focused_window_title(target.pid), MAIN_TITLE); +} + +#[test] +#[ignore] +fn floating_panel_is_typed_nonordinary_without_focus_theft() { + let mut driver = McpDriver::spawn_macos_daemon_proxy_named("macos-bring-to-front-floating") + .expect("start installed macOS daemon proxy"); + let target = launch(&mut driver, Some("floating")); + let windows = wait_for_window_ids(&target, &["main", "secondary", "floating"]); + let floating_id = windows["floating"]; + let floating = cg_windows() + .into_iter() + .find(|window| window.pid == target.pid && window.id == floating_id) + .expect("floating panel in CoreGraphics oracle"); + assert_ne!( + floating.layer, 0, + "fixture floating panel unexpectedly ordinary" + ); + let occluder = launch(&mut driver, None); + wait_for_window_ids(&occluder, &["main"]); + activate_and_raise(occluder.pid, MAIN_TITLE); + + let result = driver.call( + "bring_to_front", + serde_json::json!({"pid": target.pid, "window_id": floating.id}), + ); + assert!( + result.is_error(), + "floating panel falsely reported success: {}", + result.raw + ); + assert_eq!( + result.structured()["code"], + "bring_to_front_window_not_ordinary" + ); + assert_eq!(result.structured()["request_accepted"], false); + assert_eq!( + frontmost_pid(), + occluder.pid, + "validation stole focus before refusing" + ); +} diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/compatibility_contract_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/compatibility_contract_test.rs index cd34139d72..862eed296f 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/compatibility_contract_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/compatibility_contract_test.rs @@ -84,6 +84,24 @@ fn released_cli_help_and_manifest_fields_remain_compatible() { } } +#[test] +fn prime_agent_connection_guidance_uses_the_skill_and_cli_path() { + let output = Command::new(env!("CARGO_BIN_EXE_qwen-cua-driver")) + .args(["mcp-config", "--client", "prime-agent"]) + .output() + .expect("run Prime Agent connection guidance"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 guidance"); + assert!(stdout.contains("skills install"), "{stdout}"); + assert!(stdout.contains("skills status"), "{stdout}"); + assert!( + stdout.contains("No MCP registration is required"), + "{stdout}" + ); + assert!(stdout.contains("snapshot/action/verify"), "{stdout}"); +} + #[test] fn released_mcp_initialize_tools_list_and_error_fields_remain_compatible() { let fixture: Value = serde_json::from_str(MCP_FIXTURE).expect("valid MCP fixture"); diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs index 6746c4fbb3..dc82e03304 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs @@ -795,6 +795,7 @@ fn run_text_action(fixture: &mut Fixture, addressing: &str, delivery: &str) -> O } thread::sleep(Duration::from_millis(250)); assert_fixture_contains(fixture, &format!("mirror={text}")); + assert_fixture_value(fixture, "number-input", "42"); Observation::delivered(passed, Evidence::default()) } @@ -818,6 +819,7 @@ fn run_type_submit_action(fixture: &mut Fixture, addressing: &str, delivery: &st type_response.text() ); assert_fixture_value(fixture, "keyboard-input", &text); + assert_fixture_value(fixture, "number-input", "42"); let post_type = snapshot(fixture); let mut enter_args = @@ -837,6 +839,7 @@ fn run_type_submit_action(fixture: &mut Fixture, addressing: &str, delivery: &st enter_response.text() ); assert_fixture_contains(fixture, &format!("key_state=submitted:{text}")); + assert_fixture_value(fixture, "number-input", "42"); let mut passed = vec![OracleKind::FixtureState]; if addressing == "px" { @@ -873,6 +876,7 @@ fn run_press_key_action(fixture: &mut Fixture, addressing: &str, delivery: &str) let mut passed = vec![OracleKind::FixtureState]; passed.extend(unverified_background_protocol_oracle(&response, delivery)); assert_fixture_contains(fixture, "key_state=enter"); + assert_fixture_value(fixture, "number-input", "42"); Observation::delivered(passed, Evidence::default()) } @@ -896,6 +900,7 @@ fn run_hotkey_action(fixture: &mut Fixture, addressing: &str, delivery: &str) -> response.text() ); assert_fixture_contains(fixture, "key_state=hotkey"); + assert_fixture_value(fixture, "number-input", "42"); #[cfg(target_os = "macos")] if fixture.name == "electron" && addressing == "px" && delivery == "foreground" { run_macos_selection_hotkeys(fixture); diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs index cf37c8e603..9e95853f80 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs @@ -149,6 +149,130 @@ fn cli_call_succeeds_through_test_owned_daemon() { assert!(response.structured().is_object()); } +#[test] +fn named_session_survives_across_one_shot_cli_calls() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + let session = format!("synthetic-cli-lifecycle-{}", std::process::id()); + + let started = driver.call( + "start_session", + serde_json::json!({"session": session, "capture_scope": "window"}), + ); + assert!(!started.is_error(), "start failed: {}", started.text()); + + let state = driver.call("get_session_state", serde_json::json!({"session": session})); + assert!( + !state.is_error(), + "named session did not survive the next CLI process: {}", + state.text() + ); + assert_eq!(state.structured()["session"], session); + + let sessions = Command::new(env!("CARGO_BIN_EXE_qwen-cua-driver")) + .args([ + "sessions", + "list", + "--json", + "--socket", + driver.daemon_socket().expect("test daemon socket"), + ]) + .output() + .expect("list live sessions"); + assert!( + sessions.status.success(), + "session list failed: {}", + String::from_utf8_lossy(&sessions.stderr) + ); + let sessions: serde_json::Value = + serde_json::from_slice(&sessions.stdout).expect("session list JSON"); + assert_eq!(sessions["count"], 1); + + let ended = driver.call("end_session", serde_json::json!({"session": session})); + assert!(!ended.is_error(), "end failed: {}", ended.text()); + + let sessions = Command::new(env!("CARGO_BIN_EXE_qwen-cua-driver")) + .args([ + "sessions", + "list", + "--json", + "--socket", + driver.daemon_socket().expect("test daemon socket"), + ]) + .output() + .expect("list ended sessions"); + assert!( + sessions.status.success(), + "session list failed: {}", + String::from_utf8_lossy(&sessions.stderr) + ); + let sessions: serde_json::Value = + serde_json::from_slice(&sessions.stdout).expect("session list JSON"); + assert_eq!(sessions["count"], 0); +} + +#[test] +fn implicitly_started_named_session_survives_across_one_shot_cli_calls() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + let session = format!("synthetic-cli-implicit-{}", std::process::id()); + + let first_action = driver.call("get_config", serde_json::json!({"session": session})); + assert!( + !first_action.is_error(), + "implicit first action failed: {}", + first_action.text() + ); + + let state = driver.call("get_session_state", serde_json::json!({"session": session})); + assert!( + !state.is_error(), + "implicitly started session did not survive the next CLI process: {}", + state.text() + ); + assert_eq!(state.structured()["session"], session); + + let ended = driver.call("end_session", serde_json::json!({"session": session})); + assert!(!ended.is_error(), "end failed: {}", ended.text()); +} + +#[test] +fn named_cli_session_cleanup_is_isolated() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + let first = format!("synthetic-cli-isolation-a-{}", std::process::id()); + let second = format!("synthetic-cli-isolation-b-{}", std::process::id()); + + for session in [&first, &second] { + let started = driver.call( + "start_session", + serde_json::json!({"session": session, "capture_scope": "window"}), + ); + assert!(!started.is_error(), "start failed: {}", started.text()); + } + + let ended = driver.call("end_session", serde_json::json!({"session": first})); + assert!(!ended.is_error(), "first end failed: {}", ended.text()); + + let anonymous = driver.call("get_config", serde_json::json!({})); + assert!( + !anonymous.is_error(), + "anonymous one-shot call failed: {}", + anonymous.text() + ); + + let state = driver.call("get_session_state", serde_json::json!({"session": second})); + assert!( + !state.is_error(), + "ending another named session or cleaning an anonymous call ended the survivor: {}", + state.text() + ); + assert_eq!(state.structured()["session"], second); + + let ended = driver.call("end_session", serde_json::json!({"session": second})); + assert!(!ended.is_error(), "second end failed: {}", ended.text()); +} + #[test] fn revoke_cli_ends_the_exact_live_session() { let mut driver = CliDriver::new(); @@ -181,56 +305,25 @@ fn revoke_cli_ends_the_exact_live_session() { } #[test] -fn forged_legacy_artifact_cannot_authorize_existing_profile_in_standard_mode() { +fn standard_mode_refuses_existing_profile_without_a_launch_grant() { let mut driver = CliDriver::new(); assert!(driver.available(), "test daemon failed to start"); - let token = uuid::Uuid::new_v4().to_string(); - let approval_root = std::env::temp_dir().join("cua-driver-browser-approvals-v1"); - std::fs::create_dir_all(&approval_root).expect("create legacy approval directory"); - let artifact_path = approval_root.join(format!("{token}.json")); - let expires_unix_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() - + 60_000; - std::fs::write( - &artifact_path, - serde_json::to_vec(&serde_json::json!({ - "schema": "cua-browser-existing-profile-approval-v1", - "token": token, - "scope": { - "pid": 42, - "window_id": 7, - "session": "forged-standard-attach" - }, - "expires_unix_ms": expires_unix_ms - })) - .unwrap(), - ) - .expect("write syntactically valid forged legacy artifact"); - let response = driver.call( "browser_prepare", serde_json::json!({ "pid": 42, "window_id": 7, "session": "forged-standard-attach", - "strategy": { "kind": "existing_profile" }, - "approval_token": token + "strategy": { "kind": "existing_profile" } }), ); - let _ = std::fs::remove_file(&artifact_path); assert_eq!(response.structured()["status"], "refused"); assert_eq!( response.structured()["refusal"]["code"], "browser_consent_required" ); - assert_eq!( - response.structured()["refusal"]["detail"]["legacy_approval_enabled"], - false - ); assert!( response.structured()["refusal"]["message"] .as_str() @@ -311,11 +404,64 @@ deny: assert!(denied.is_error()); assert!(denied .text() - .contains("bounded session policy denies tool 'list_apps'")); + .contains("capability manifest denies tool 'list_apps'")); let undeclared = driver.call("get_screen_size", serde_json::json!({})); assert!(undeclared.is_error()); assert!(undeclared .text() - .contains("outside the bounded session policy")); + .contains("outside the capability manifest")); +} + +#[test] +fn capability_manifest_narrows_standard_mode() { + let directory = tempfile::tempdir().expect("temporary capability manifest directory"); + let manifest_path = directory.path().join("standard-v3.yaml"); + std::fs::write( + &manifest_path, + "version: 3\nallow:\n tools: [get_config]\ndeny:\n tools: [list_apps]\n", + ) + .expect("write standard capability manifest"); + let manifest = manifest_path.display().to_string(); + let mut driver = CliDriver::with_daemon_env(&[ + ("CUA_DRIVER_PERMISSION_MODE", "standard"), + ("CUA_DRIVER_CAPABILITY_MANIFEST_FILE", &manifest), + ("CUA_DRIVER_CAPABILITY_MANIFEST_APPROVED", "1"), + ]); + assert!( + driver.available(), + "standard manifest daemon failed to start" + ); + assert!(!driver.call("get_config", serde_json::json!({})).is_error()); + assert!(driver.call("list_apps", serde_json::json!({})).is_error()); + assert!(driver + .call("get_screen_size", serde_json::json!({})) + .is_error()); +} + +#[test] +fn capability_manifest_narrows_unrestricted_mode() { + let directory = tempfile::tempdir().expect("temporary capability manifest directory"); + let manifest_path = directory.path().join("unrestricted-v3.yaml"); + std::fs::write( + &manifest_path, + "version: 3\nallow:\n tools: [get_config]\ndeny:\n tools: [list_apps]\n", + ) + .expect("write unrestricted capability manifest"); + let manifest = manifest_path.display().to_string(); + let mut driver = CliDriver::with_daemon_env(&[ + ("CUA_DRIVER_PERMISSION_MODE", "unrestricted"), + ("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS", "1"), + ("CUA_DRIVER_CAPABILITY_MANIFEST_FILE", &manifest), + ("CUA_DRIVER_CAPABILITY_MANIFEST_APPROVED", "1"), + ]); + assert!( + driver.available(), + "unrestricted manifest daemon failed to start" + ); + assert!(!driver.call("get_config", serde_json::json!({})).is_error()); + assert!(driver.call("list_apps", serde_json::json!({})).is_error()); + assert!(driver + .call("get_screen_size", serde_json::json!({})) + .is_error()); } diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/embedded_host_sdk_mcp_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/embedded_host_sdk_mcp_test.rs index 8a21528110..13f6c75ef3 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/embedded_host_sdk_mcp_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/embedded_host_sdk_mcp_test.rs @@ -9,7 +9,7 @@ use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; use cua_driver_contract::{CaptureScope, GetSessionStateInput, StartSessionInput}; -use cua_driver_sdk::{CuaDriver, EmbeddedCuaDriverHost, EmbeddedDriverHostState}; +use cua_driver_sdk::{CuaDriver, DriverError, EmbeddedCuaDriverHost, EmbeddedDriverHostState}; use cua_driver_testkit::{spawn_in_job, ChildReaper}; use serde_json::{json, Value}; @@ -140,7 +140,7 @@ async fn embedded_host_serves_sdk_and_mcp_with_one_contract() { let sdk_session = sdk .start_session(StartSessionInput { - session: "embedded-sdk-window".into(), + session: Some("embedded-sdk-window".into()), capture_scope: Some(CaptureScope::Window), cursor_theme: None, }) @@ -172,23 +172,47 @@ async fn embedded_host_serves_sdk_and_mcp_with_one_contract() { "desktop" ); - // Session policy belongs to the session, not the daemon or transport: - // one SDK session can remain strict-window while an MCP session sharing - // the same embedded host is strict-desktop. + // Session policy belongs to the session and ordinary lifecycle inspection + // is transport-scoped: the SDK and MCP connections can each inspect their + // own session without enumerating the other's label. let sdk_state = sdk .get_session_state(GetSessionStateInput { - session: "embedded-sdk-window".into(), + session: Some("embedded-sdk-window".into()), }) .await .expect("read SDK session"); - let mcp_state = sdk + let foreign = sdk .get_session_state(GetSessionStateInput { - session: "embedded-mcp-desktop".into(), + session: Some("embedded-mcp-desktop".into()), }) .await - .expect("read MCP-created session through SDK"); + .expect_err("SDK transport must not inspect an MCP-created session"); assert_eq!(sdk_state.capture_scope, CaptureScope::Window); - assert_eq!(mcp_state.capture_scope, CaptureScope::Desktop); + assert!(matches!( + foreign, + DriverError::Tool { error_code, .. } if error_code == "session_not_started" + )); + + writeln!( + stdin, + "{}", + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "get_session", + "arguments": {"session": "embedded-mcp-desktop"} + } + }) + ) + .unwrap(); + stdin.flush().unwrap(); + let mcp_state = read_json(&mut stdout); + assert_eq!( + mcp_state["result"]["structuredContent"]["session"], + "embedded-mcp-desktop" + ); drop(stdin); let mut reaper = ChildReaper::new(); @@ -301,10 +325,19 @@ async fn early_child_exit_resets_the_host_to_stopped() { "com.trycua.embedded-early-exit-test".into(), ) .expect("construct host"); - let error = host.clone().start().await.unwrap_err(); - assert!(matches!( - error, - cua_driver_sdk::EmbeddedDriverError::ExitedBeforeReady { code: Some(7) } - )); - assert_eq!(host.state(), EmbeddedDriverHostState::Stopped); + for attempt in 1..=64 { + let error = host.clone().start().await.unwrap_err(); + assert!( + matches!( + &error, + cua_driver_sdk::EmbeddedDriverError::ExitedBeforeReady { code: Some(7) } + ), + "attempt {attempt}/64 returned {error:?}" + ); + assert_eq!( + host.state(), + EmbeddedDriverHostState::Stopped, + "attempt {attempt}/64 did not reset the host after {error:?}" + ); + } } diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/harness_appkit_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/harness_appkit_test.rs index dfd2ef6545..548cc297cc 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/harness_appkit_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/harness_appkit_test.rs @@ -24,7 +24,7 @@ #![cfg(target_os = "macos")] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::Duration; @@ -62,6 +62,10 @@ struct Harness { impl Harness { fn launch() -> Self { + Self::launch_with_command_oracle(None) + } + + fn launch_with_command_oracle(command_oracle: Option<&Path>) -> Self { let exe = harness_exe(); assert!( exe.exists(), @@ -70,9 +74,12 @@ impl Harness { // Launch the binary directly (not via `open`) so we control the pid // and can kill it cleanly on Drop. The app still installs an AppKit // window via NSApp.run(). - let app = Command::new(&exe) - .stdout(Stdio::null()) - .stderr(Stdio::null()) + let mut command = Command::new(&exe); + command.stdout(Stdio::null()).stderr(Stdio::null()); + if let Some(path) = command_oracle { + command.env("CUA_APPKIT_COMMAND_ORACLE", path); + } + let app = command .spawn() .unwrap_or_else(|error| panic!("launch AppKit harness {exe:?}: {error}")); let pid = app.id(); @@ -534,6 +541,116 @@ fn harness_appkit_element_foreground_press_key_commits_edit() { ); } +#[test] +#[ignore] +fn harness_appkit_px_background_press_key_reports_honest_delivery_truth() { + let case = native_background_case( + "appkit", + "press_key_command", + Targeting::Px, + DriverRoute::MacosCgEventPid, + ); + let cell_id = case.cell_id.clone(); + execute_case(case, |evidence| { + let mut driver = McpDriver::spawn_macos_daemon_proxy_named(&cell_id) + .expect("start installed macOS daemon proxy"); + *evidence = recording_evidence(driver.recording_dir()); + let oracle_dir = tempfile::tempdir().expect("create command oracle directory"); + let oracle_path = oracle_dir.path().join("child-process-output.txt"); + let harness = Harness::launch_with_command_oracle(Some(&oracle_path)); + let (wid, _) = driver + .find_window(harness.pid as i64, "CuaTestHarness AppKit") + .expect("AppKit main window not found"); + + let (_, passed) = run_with_background_oracles( + &mut driver, + TargetWindow { + pid: harness.pid, + native_id: wid, + }, + |driver| { + let first = snapshot_elements(driver, harness.pid, wid); + let field = element_token_by_id(&first, "txt-input"); + let set = driver.call( + "set_value", + serde_json::json!({ + "pid": harness.pid as i64, + "window_id": wid, + "element_token": field, + "value": "printf cua-press-key" + }), + ); + assert!(!set.is_error(), "set command failed: {}", set.text()); + + let focused = snapshot_elements(driver, harness.pid, wid); + let (x, y, width, height) = element_pixel_frame(&focused, "txt-input"); + let pressed = driver.call( + "press_key", + serde_json::json!({ + "pid": harness.pid as i64, + "window_id": wid, + "x": x + width / 2.0, + "y": y + height / 2.0, + "key": "return", + "delivery_mode": "background" + }), + ); + assert!( + !pressed.is_error(), + "background Return failed: {}", + pressed.text() + ); + assert_eq!(pressed.action_route(), Some("synthetic_events")); + assert_eq!(pressed.action_delivery_mode(), Some("background")); + assert_eq!(pressed.action_effect(), Some("unverifiable")); + assert!( + pressed.structured()["escalation"].is_null(), + "accepted post without a positive oracle must not claim delivery_failed: {}", + pressed.raw + ); + + let deadline = std::time::Instant::now() + Duration::from_secs(3); + loop { + if std::fs::read_to_string(&oracle_path) + .is_ok_and(|value| value == "cua-press-key") + { + break; + } + assert!( + std::time::Instant::now() < deadline, + "background Return did not execute the controlled child process" + ); + std::thread::sleep(Duration::from_millis(25)); + } + + let mut exited = Command::new("/usr/bin/true") + .spawn() + .expect("spawn posting-failure fixture"); + let exited_pid = exited.id(); + exited.wait().expect("wait for posting-failure fixture"); + let failed = driver.call( + "press_key", + serde_json::json!({ + "pid": exited_pid, + // An explicit target bypasses the PID-only window resolver so + // this negative oracle reaches the posting preflight. Without + // one, the earlier and equally truthful result is + // window_target_not_found because /usr/bin/true owns no window. + "window_id": wid, + "key": "return", + "delivery_mode": "background" + }), + ); + assert!(failed.is_error(), "dead-pid post unexpectedly succeeded"); + assert_eq!(failed.structured()["code"], "delivery_failed"); + }, + ) + .unwrap_or_else(|error| panic!("background desktop contract failed: {error}")); + + Observation::delivered_with_fixture_state(passed) + }); +} + #[test] #[ignore] fn harness_appkit_modified_click_preserves_selection() { diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/harness_gtk3_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/harness_gtk3_test.rs index f612f27ad0..e753251960 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/harness_gtk3_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/harness_gtk3_test.rs @@ -238,6 +238,86 @@ fn run_case(case: CaseSpec, test: impl FnOnce(u32, u64, &mut McpDriver) -> Obser }); } +#[test] +#[ignore] +fn harness_gtk3_rejects_exited_target_and_recovers_with_fresh_app() { + let case: CaseSpec = native_readonly_case( + "gtk3", + "stale_target_recovery", + Targeting::Ax, + DriverRoute::AxRead, + vec![OracleKind::AxState], + ); + let cell_id = case.cell_id.clone(); + execute_case(case, |evidence| { + let mut driver = McpDriver::spawn_named(&cell_id).expect("start source-built Linux driver"); + *evidence = recording_evidence(driver.recording_dir()); + let (stale_pid, stale_window_id) = launch(&mut driver); + assert!( + !snapshot(&mut driver, stale_pid, stale_window_id).is_error(), + "initial GTK3 snapshot failed" + ); + + let killed = driver.call("kill_app", serde_json::json!({ "pid": stale_pid as i64 })); + assert!(!killed.is_error(), "kill_app failed: {}", killed.text()); + + let exit_deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < exit_deadline { + let windows = driver.call( + "list_windows", + serde_json::json!({ "pid": stale_pid as i64 }), + ); + let still_listed = windows.structured()["windows"] + .as_array() + .is_some_and(|windows| !windows.is_empty()); + if !still_listed { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + let started = Instant::now(); + let stale = driver.call( + "get_window_state", + serde_json::json!({ + "pid": stale_pid as i64, + "window_id": stale_window_id, + "include_screenshot": false + }), + ); + assert!( + stale.is_error(), + "exited target unexpectedly snapshot: {}", + stale.text() + ); + assert!( + stale.text().contains("stale or no longer running"), + "unexpected stale-target error: {}", + stale.text() + ); + assert!( + started.elapsed() < Duration::from_secs(1), + "stale target did not fail fast: {:?}", + started.elapsed() + ); + + let (fresh_pid, fresh_window_id) = launch(&mut driver); + driver.start_behavior_recording(); + let fresh = snapshot(&mut driver, fresh_pid, fresh_window_id); + assert!( + !fresh.is_error(), + "fresh GTK3 snapshot failed: {}", + fresh.text() + ); + assert!( + fresh.tree_text().contains("btn-increment"), + "fresh GTK3 accessibility tree did not recover:\n{}", + fresh.tree_text() + ); + Observation::delivered(vec![OracleKind::AxState], Evidence::default()) + }); +} + #[test] #[ignore] fn harness_gtk3_query_projects_structured_elements() { diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/harness_gtk4_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/harness_gtk4_test.rs new file mode 100644 index 0000000000..57022bb099 --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver/tests/harness_gtk4_test.rs @@ -0,0 +1,149 @@ +//! Focused GTK4 process-level AT-SPI selection coverage on Linux/X11. +//! +//! The GTK3 fixture starts first and proves a foreign registry application is +//! already accessible before the GTK4 target registers. The public driver must +//! still resolve the later target PID and return its actionable GTK4 subtree. + +#![cfg(target_os = "linux")] + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use cua_driver_testkit::e2e::{ + execute_case, native_readonly_case, recording_evidence, DriverRoute, Evidence, Observation, + OracleKind, Targeting, +}; +use cua_driver_testkit::{harness_app, Driver, McpDriver, ToolResponse}; + +fn launch_fixture( + driver: &mut McpDriver, + fixture: &str, + executable: &str, + title: &str, +) -> (u32, u64) { + let path = harness_app(fixture, executable); + assert!(path.exists(), "required fixture is missing: {path:?}"); + driver + .reaper() + .spawn( + Command::new(&path) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()), + ) + .unwrap_or_else(|error| panic!("launch fixture {path:?}: {error}")); + + let deadline = Instant::now() + Duration::from_secs(12); + while Instant::now() < deadline { + let response = driver.call("list_windows", serde_json::json!({})); + if let Some(window) = response.structured()["windows"] + .as_array() + .and_then(|windows| { + windows.iter().find(|window| { + window["title"] + .as_str() + .is_some_and(|value| value.contains(title)) + }) + }) + { + let pid = window["pid"].as_u64().unwrap_or(0) as u32; + let window_id = window["window_id"].as_u64().unwrap_or(0); + if pid != 0 && window_id != 0 { + driver.reaper().track_pid(pid); + return (pid, window_id); + } + } + std::thread::sleep(Duration::from_millis(300)); + } + panic!("fixture window {title:?} never appeared"); +} + +fn settled_snapshot( + driver: &mut McpDriver, + pid: u32, + window_id: u64, + marker: &str, +) -> ToolResponse { + let deadline = Instant::now() + Duration::from_secs(10); + let mut response = driver.call( + "get_window_state", + serde_json::json!({ + "pid": pid as i64, + "window_id": window_id, + "include_screenshot": false + }), + ); + while Instant::now() < deadline && !response.tree_text().contains(marker) { + std::thread::sleep(Duration::from_millis(300)); + response = driver.call( + "get_window_state", + serde_json::json!({ + "pid": pid as i64, + "window_id": window_id, + "include_screenshot": false + }), + ); + } + response +} + +#[test] +#[ignore] +fn gtk4_tree_resolves_after_foreign_gtk3_application() { + let case = native_readonly_case( + "gtk4", + "foreign_app_target_selection", + Targeting::Ax, + DriverRoute::AxRead, + vec![OracleKind::AxState], + ); + execute_case(case, |evidence| { + let mut driver = McpDriver::spawn_named("linux-gtk4-foreign-app-target-selection") + .expect("start source-built Linux driver"); + *evidence = recording_evidence(driver.recording_dir()); + + let (gtk3_pid, gtk3_window) = launch_fixture( + &mut driver, + "harness-gtk3", + "CuaTestHarness.Gtk3", + "CuaTestHarness GTK3", + ); + let gtk3 = settled_snapshot(&mut driver, gtk3_pid, gtk3_window, "btn-increment"); + assert!( + gtk3.tree_text().contains("btn-increment"), + "foreign GTK3 application never exposed its AT-SPI tree: {}", + gtk3.tree_text() + ); + + let (gtk4_pid, gtk4_window) = launch_fixture( + &mut driver, + "harness-gtk4", + "CuaTestHarness.Gtk4", + "CuaTestHarness GTK4", + ); + driver.start_behavior_recording(); + assert_ne!(gtk4_pid, gtk3_pid, "fixtures must be distinct processes"); + let gtk4 = settled_snapshot(&mut driver, gtk4_pid, gtk4_window, "GTK4 actionable target"); + assert!( + !gtk4.is_error(), + "GTK4 get_window_state failed: {}", + gtk4.raw + ); + assert_eq!( + gtk4.structured()["degraded"].as_bool(), + None, + "GTK4 snapshot degraded: {}", + gtk4.structured() + ); + assert!( + gtk4.tree_text().contains("GTK4 actionable target"), + "GTK4 actionable subtree missing: {}", + gtk4.tree_text() + ); + assert!( + gtk4.structured()["element_count"].as_u64().unwrap_or(0) > 0, + "GTK4 snapshot contained no actionable elements: {}", + gtk4.structured() + ); + Observation::delivered(vec![OracleKind::AxState], Evidence::default()) + }); +} diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/harness_web_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/harness_web_test.rs index 1be4e7870e..de06bf2537 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/harness_web_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/harness_web_test.rs @@ -305,7 +305,7 @@ fn harness_webview_left_click_px_background() { "webview2", "left_click", Targeting::Px, - DriverRoute::UiaInvoke, + DriverRoute::Composite, ); let point = Cell::new(None); run_web_case_with_preparation( @@ -376,13 +376,13 @@ fn harness_webview_left_click_px_background() { "WebView2 PX background click failed: {}", click.text() ); - assert_eq!( - click.action_route(), - Some("accessibility"), - "WebView2 PX background click used an unexpected driver route: {}", - click.text() - ); wait_for_journal_text(journal, "lbl-counter", "counter=3"); + let route = click.action_route(); + assert!( + matches!(route, Some("accessibility" | "synthetic_events")), + "WebView2 PX background click used an unsupported driver route {route:?}: {}", + click.text(), + ); }, ); } diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/harness_wpf_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/harness_wpf_test.rs index bbe9662a76..f7ae3d2c39 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/harness_wpf_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/harness_wpf_test.rs @@ -48,6 +48,8 @@ use cua_driver_testkit::e2e::{ use cua_driver_testkit::observer::TargetWindow; use cua_driver_testkit::sentinel::{run_with_background_oracles, ForegroundSentinel}; use cua_driver_testkit::{ax, harness_app, spawn_in_job, Driver, McpDriver, ToolResponse}; +use windows::Win32::Foundation::HWND; +use windows::Win32::UI::WindowsAndMessaging::{IsIconic, ShowWindow, SW_MINIMIZE}; // ── harness launcher ───────────────────────────────────────────────────────── @@ -549,6 +551,95 @@ fn harness_wpf_counter_invoke() { ); } +#[test] +#[ignore] +fn harness_wpf_minimized_element_click_refuses_without_side_effects() { + let case = CaseSpec::delivered( + "windows-wpf-minimized-element-click-refusal", + "wpf", + "wpf", + "left_click", + Targeting::Ax, + Delivery::Background, + Scope::Window, + DriverRoute::Composite, + vec![ + OracleKind::FixtureState, + OracleKind::Focus, + OracleKind::ZOrder, + OracleKind::Cursor, + OracleKind::NoLeakedInput, + OracleKind::Protocol, + ], + ) + .expecting_refusal(vec![RefusalCode::WindowMinimized]); + + execute_case(case, |evidence| { + let mut driver = McpDriver::spawn_named("windows-wpf-minimized-element-click-refusal") + .expect("required source-built driver did not start"); + *evidence = recording_evidence(driver.recording_dir()); + let state_dir = tempfile::tempdir().expect("create WPF fixture state directory"); + let state_path = state_dir.path().join("state.json"); + let pid = launch_harness_with_state_file(&mut driver, Some(&state_path)) + .expect("required WPF harness did not launch"); + let (wid, _) = driver + .find_window(pid as i64, "CuaTestHarness WPF") + .expect("main window"); + wait_for_fixture_file_text(&state_path, "lbl-counter", "counter=0"); + let ready = snapshot(&mut driver, pid, wid); + let idx = ax::element_index_by_id(ready.text(), "btn-increment") + .expect("btn-increment not in pre-minimize snapshot"); + + let sentinel = ForegroundSentinel::launch(&mut driver); + let hwnd = HWND(wid as *mut _); + let _ = unsafe { ShowWindow(hwnd, SW_MINIMIZE) }; + let deadline = Instant::now() + Duration::from_secs(2); + while !unsafe { IsIconic(hwnd) }.as_bool() { + assert!(Instant::now() < deadline, "WPF target did not minimize"); + std::thread::sleep(Duration::from_millis(25)); + } + driver.start_behavior_recording(); + + let (response, mut passed) = sentinel + .observe_desktop(|| { + driver.call( + "click", + serde_json::json!({ + "pid": pid as i64, + "window_id": wid, + "element_index": idx, + "snapshot_id": ready.snapshot_id(), + "delivery_mode": "background" + }), + ) + }) + .unwrap_or_else(|error| panic!("minimized refusal leaked desktop state: {error}")); + assert!( + response.is_error(), + "minimized click reported success: {}", + response.text() + ); + assert_eq!(response.structured()["status"], "refused"); + assert_eq!(response.structured()["refusal"]["code"], "window_minimized"); + assert!( + unsafe { IsIconic(hwnd) }.as_bool(), + "refused click restored or otherwise changed the minimized target" + ); + std::thread::sleep(Duration::from_millis(300)); + wait_for_fixture_file_text(&state_path, "lbl-counter", "counter=0"); + + passed.extend([OracleKind::FixtureState, OracleKind::Protocol]); + passed.sort(); + passed.dedup(); + Observation::refused( + RefusalCode::WindowMinimized, + passed, + response.text(), + Evidence::default(), + ) + }); +} + #[test] #[ignore] fn harness_wpf_left_click_px_background() { @@ -627,53 +718,24 @@ fn harness_wpf_type_text() { let snap = snapshot(driver, pid, wid); let idx = ax::element_index_by_id(snap.text(), "txt-input") .expect("txt-input not in snapshot"); - - // WPF's TextBox needs *keyboard focus* for WM_CHAR delivery — and - // PostMessage(WM_LBUTTONDOWN) doesn't reliably transfer keyboard - // focus (WPF's input system treats posted events differently from - // real ones). Use delivery_mode:"foreground" → SendInput synthesizes - // an OS-level click that WPF treats identically to a user mouse, - // landing actual keyboard focus on the TextBox. - let _ = driver.call( - "bring_to_front", - serde_json::json!({ - "pid": pid as i64, "window_id": wid - }), - ); - std::thread::sleep(Duration::from_millis(300)); driver.start_behavior_recording(); - let _ = driver.call( - "click", - serde_json::json!({ - "pid": pid as i64, "window_id": wid, "element_index": idx, - "snapshot_id": snap.snapshot_id(), - "delivery_mode": "foreground" - }), - ); - std::thread::sleep(Duration::from_millis(400)); - - // SendInput's restore_foreground_polling_best_effort may yank - // foreground back from the harness window between click and - // type_text. Re-assert foreground so PostMessage WM_CHAR finds - // the TextBox with keyboard focus. - let _ = driver.call( - "bring_to_front", - serde_json::json!({ - "pid": pid as i64, "window_id": wid - }), - ); - std::thread::sleep(Duration::from_millis(300)); - + // The fixture deliberately starts with txt-deferred-input focused. + // A single indexed type_text call must atomically activate the WPF + // window, focus txt-input, confirm that focus, and only then inject. let resp = driver.call( "type_text", serde_json::json!({ "pid": pid as i64, + "window_id": wid, + "element_index": idx, + "snapshot_id": snap.snapshot_id(), "text": "harness-typed", "delivery_mode": "foreground" }), ); println!("type_text: {}", resp.text()); + assert!(!resp.is_error(), "type_text failed: {}", resp.text()); std::thread::sleep(Duration::from_millis(700)); let post = snapshot(driver, pid, wid); @@ -687,6 +749,20 @@ fn harness_wpf_type_text() { "TextBox mirror did not reflect typed text. Mirror/input lines: {:?}", mirror_lines ); + let decoy_idx = ax::element_index_by_id(text, "txt-deferred-input") + .expect("txt-deferred-input not in post-action snapshot"); + let decoy = post.structured()["elements"] + .as_array() + .and_then(|elements| { + elements + .iter() + .find(|element| element["element_index"].as_u64() == Some(decoy_idx)) + }) + .expect("txt-deferred-input missing from structured elements"); + assert!( + decoy["value"].as_str().unwrap_or_default().is_empty(), + "foreground type_text leaked into the pre-focused decoy: {decoy}" + ); println!("✅ harness_wpf_type_text: TextBox mirror advanced to 'harness-typed'"); Vec::new() }, @@ -733,6 +809,60 @@ fn harness_wpf_set_value() { ); } +#[test] +#[ignore] +fn harness_wpf_deferred_type_text_requires_fresh_snapshot_before_retry() { + execute_case( + background_case("deferred-type-text", DriverRoute::UiaValue), + |evidence| { + with_named_session( + "windows-wpf-deferred-type-text-ax-background", + |pid, wid, driver| { + *evidence = recording_evidence(driver.recording_dir()); + let snap = snapshot(driver, pid, wid); + let idx = ax::element_index_by_id(snap.text(), "txt-deferred-input") + .expect("txt-deferred-input not in snapshot"); + let (response, passed) = observe_background(driver, pid, wid, |driver| { + driver.call( + "type_text", + serde_json::json!({ + "pid": pid as i64, + "window_id": wid, + "element_index": idx, + "snapshot_id": snap.snapshot_id(), + "text": "deferred-once", + "delivery_mode": "background" + }), + ) + }); + assert!( + !response.is_error(), + "type_text failed: {}", + response.text() + ); + assert_eq!(response.action_effect(), Some("unverifiable")); + assert_eq!(response.action_route(), Some("accessibility")); + assert!( + response.structured().get("escalation").is_none(), + "deferred publication must not recommend an immediate retry: {}", + response.structured() + ); + + std::thread::sleep(Duration::from_millis(700)); + let post = snapshot(driver, pid, wid); + assert!( + post.text().contains("deferred_mirror=deferred-once"), + "fresh snapshot did not observe exactly one deferred write: {}", + post.text().chars().take(700).collect::() + ); + delivered_with_fixture_state(passed) + }, + ) + .expect("required WPF session did not start") + }, + ); +} + // In test-batch mode (many harnesses launched/killed in sequence) the WPF // window's input pump occasionally misses background PostMessage events — // reproducibly passes in isolation, intermittently fails in batch. diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/permission_policy_startup_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/permission_policy_startup_test.rs index 589ad6be63..abe94f1cf2 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/permission_policy_startup_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/permission_policy_startup_test.rs @@ -1,7 +1,7 @@ //! Explicit permission-policy configuration must fail before a daemon exposes //! an action endpoint. -use std::process::Command; +use std::process::{Command, Stdio}; fn test_socket(directory: &tempfile::TempDir, suffix: &str) -> String { #[cfg(unix)] @@ -35,6 +35,74 @@ fn rejected_serve(socket: &str, extra_args: &[&str]) -> std::process::Output { .expect("run rejected cua-driver serve configuration") } +#[test] +fn mcp_rejects_serve_only_authorization_flags() { + let cases: &[(&[&str], &str)] = &[ + ( + &["mcp", "--direct", "--permission-mode", "bounded"], + "--permission-mode", + ), + ( + &["mcp", "--capability-manifest", "unused.yaml"], + "--capability-manifest", + ), + ( + &["mcp", "--approve-capability-manifest"], + "--approve-capability-manifest", + ), + ( + &["mcp", "--dangerously-bypass-approvals"], + "--dangerously-bypass-approvals", + ), + ( + &["mcp", "--session-policy", "unused.yaml"], + "--session-policy", + ), + ( + &["mcp", "--approve-session-policy"], + "--approve-session-policy", + ), + (&["--permission-mode=bounded"], "--permission-mode"), + ]; + + for (args, rejected_flag) in cases { + let output = Command::new(env!("CARGO_BIN_EXE_qwen-cua-driver")) + .args(*args) + .stdin(Stdio::null()) + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false") + .output() + .expect("run cua-driver mcp with a serve-only authorization flag"); + assert_eq!(output.status.code(), Some(64), "args={args:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!("does not accept {rejected_flag}")), + "args={args:?}, stderr={stderr}" + ); + assert!( + stderr.contains("CUA_DRIVER_PERMISSION_MODE"), + "args={args:?}, stderr={stderr}" + ); + assert!( + stderr.contains("mcp --socket"), + "args={args:?}, stderr={stderr}" + ); + } +} + +#[test] +fn direct_mcp_still_reads_permission_profile_from_environment() { + let output = Command::new(env!("CARGO_BIN_EXE_qwen-cua-driver")) + .args(["mcp", "--direct"]) + .stdin(Stdio::null()) + .env("CUA_DRIVER_PERMISSION_MODE", "bounded") + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "false") + .output() + .expect("run direct MCP with bounded profile from the environment"); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("requires --capability-manifest")); +} + #[test] fn missing_configured_policy_prevents_daemon_startup() { let directory = tempfile::tempdir().expect("temporary policy test directory"); @@ -167,13 +235,12 @@ fn danger_flag_alone_selects_unrestricted_and_managed_config_can_disable_it() { assert!(!std::path::Path::new(&socket).exists()); } -fn write_valid_session_policy(directory: &tempfile::TempDir) -> String { - let path = directory.path().join("session-policy.yaml"); +fn write_valid_capability_manifest(directory: &tempfile::TempDir) -> String { + let path = directory.path().join("capability-manifest.yaml"); std::fs::write( &path, r#" -version: 1 -mode: bounded +version: 3 expires_after: 1h idle_timeout: 10m resources: {} @@ -181,8 +248,6 @@ allow: tools: [start_session, get_session_state, end_session] deny: tools: [page] -ask: - tools: [browser_download] "#, ) .unwrap(); @@ -190,12 +255,12 @@ ask: } #[test] -fn bounded_mode_requires_a_session_policy_before_bind() { +fn bounded_mode_requires_a_capability_manifest_before_bind() { let directory = tempfile::tempdir().expect("temporary bounded test directory"); let socket = test_socket(&directory, "bounded-no-policy"); let output = rejected_serve(&socket, &["--permission-mode", "bounded"]); assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("requires --session-policy")); + assert!(String::from_utf8_lossy(&output.stderr).contains("requires --capability-manifest")); #[cfg(unix)] assert!(!std::path::Path::new(&socket).exists()); } @@ -203,58 +268,97 @@ fn bounded_mode_requires_a_session_policy_before_bind() { #[test] fn bounded_mode_requires_launch_time_manifest_approval_before_bind() { let directory = tempfile::tempdir().expect("temporary bounded test directory"); - let policy = write_valid_session_policy(&directory); + let policy = write_valid_capability_manifest(&directory); let socket = test_socket(&directory, "bounded-no-approval"); - let output = rejected_serve( - &socket, - &["--permission-mode", "bounded", "--session-policy", &policy], - ); - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("requires --approve-session-policy")); - #[cfg(unix)] - assert!(!std::path::Path::new(&socket).exists()); -} - -#[test] -fn session_policy_cannot_be_smuggled_into_standard_mode() { - let directory = tempfile::tempdir().expect("temporary standard test directory"); - let policy = write_valid_session_policy(&directory); - let socket = test_socket(&directory, "standard-session-policy"); let output = rejected_serve( &socket, &[ "--permission-mode", - "standard", - "--session-policy", + "bounded", + "--capability-manifest", &policy, - "--approve-session-policy", ], ); assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("valid only with")); + assert!( + String::from_utf8_lossy(&output.stderr).contains("requires --approve-capability-manifest") + ); #[cfg(unix)] assert!(!std::path::Path::new(&socket).exists()); } #[test] -fn legacy_existing_profile_approval_cannot_bypass_bounded_indicator() { - let directory = tempfile::tempdir().expect("temporary bounded test directory"); - let policy = write_valid_session_policy(&directory); - let socket = test_socket(&directory, "bounded-legacy-approval"); +fn deprecated_session_policy_flags_remain_startup_aliases() { + let directory = tempfile::tempdir().expect("temporary bounded alias test directory"); + let policy = directory.path().join("legacy-session-policy.yaml"); + std::fs::write( + &policy, + r#" +version: 3 +resources: {} +allow: + tools: [start_session] +"#, + ) + .expect("write v3 manifest without bounded lifetime fields"); + let socket = test_socket(&directory, "bounded-deprecated-aliases"); let output = rejected_serve( &socket, &[ "--permission-mode", "bounded", "--session-policy", - &policy, + &policy.display().to_string(), "--approve-session-policy", - "--allow-legacy-existing-profile-approval", + ], + ); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("requires capability manifest expires_after and idle_timeout"), + "deprecated aliases did not reach bounded manifest validation: {stderr}" + ); + #[cfg(unix)] + assert!(!std::path::Path::new(&socket).exists()); +} + +#[test] +fn capability_manifest_without_acknowledgement_fails_in_standard_mode() { + let directory = tempfile::tempdir().expect("temporary standard test directory"); + let policy = write_valid_capability_manifest(&directory); + let socket = test_socket(&directory, "standard-session-policy"); + let output = rejected_serve( + &socket, + &[ + "--permission-mode", + "standard", + "--capability-manifest", + &policy, ], ); assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr) - .contains("valid only with --permission-mode standard")); + assert!( + String::from_utf8_lossy(&output.stderr).contains("requires --approve-capability-manifest") + ); + #[cfg(unix)] + assert!(!std::path::Path::new(&socket).exists()); +} + +#[test] +fn capability_manifest_acknowledgement_without_manifest_fails_closed() { + let directory = tempfile::tempdir().expect("temporary standard test directory"); + let socket = test_socket(&directory, "standard-manifest-ack-only"); + let output = rejected_serve( + &socket, + &[ + "--permission-mode", + "standard", + "--approve-capability-manifest", + ], + ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("requires --capability-manifest")); #[cfg(unix)] assert!(!std::path::Path::new(&socket).exists()); } @@ -266,7 +370,7 @@ fn autonomous_mode_name_remains_a_bounded_compatibility_alias() { let output = rejected_serve(&socket, &["--permission-mode", "autonomous"]); assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr) - .contains("permission mode bounded requires --session-policy")); + .contains("permission mode bounded requires --capability-manifest")); #[cfg(unix)] assert!(!std::path::Path::new(&socket).exists()); } diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/policy_tools_list_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/policy_tools_list_test.rs new file mode 100644 index 0000000000..aa1a036cff --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver/tests/policy_tools_list_test.rs @@ -0,0 +1,70 @@ +//! Permission policy must shape the MCP tool roster as well as invocation. + +#![cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + +use std::collections::HashSet; + +use cua_driver_testkit::{driver_binary, RawDriver}; +use serde_json::json; + +#[test] +fn tools_list_hides_policy_denied_tools_and_calls_stay_denied() { + let directory = tempfile::tempdir().expect("temporary policy directory"); + let policy_path = directory.path().join("policy.yaml"); + // `get_config` is unconditionally allowed via `allow.tools`. + // `get_window_state` is conditionally allowed via `allow.rules` with a + // constraint. Both must appear in `tools/list` because `tools/list` + // should not hide tools that are *potentially* allowed. + // `list_apps` is explicitly denied and must be absent. + std::fs::write( + &policy_path, + "allow:\n tools: [get_config]\n rules:\n - tool: get_window_state\n constraints:\n pid: {allowed: [0]}\ndeny:\n tools: [list_apps]\n", + ) + .expect("write permission policy"); + let policy = policy_path.display().to_string(); + + if !driver_binary().exists() { + return; + } + let mut driver = RawDriver::spawn_with_env(&[("CUA_DRIVER_POLICY_FILE", &policy)]) + .expect("a built driver must start with the test policy"); + + driver.send(&json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}})); + driver.recv(); + + driver.send(&json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})); + let response = driver.recv(); + let names: HashSet<&str> = response["result"]["tools"] + .as_array() + .expect("tools/list tools array") + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect(); + assert!( + names.contains("get_config"), + "unconditionally allowed tool must remain listed: {response}" + ); + assert!( + names.contains("get_window_state"), + "rule-conditionally allowed tool must remain listed even though empty-arg evaluation would deny it: {response}" + ); + assert!( + !names.contains("list_apps"), + "policy-denied tool must not be advertised: {response}" + ); + + driver.send(&json!({ + "jsonrpc":"2.0", + "id":3, + "method":"tools/call", + "params":{"name":"list_apps","arguments":{}} + })); + let response = driver.recv(); + assert_eq!(response["result"]["isError"], true); + assert!( + response["result"]["content"][0]["text"] + .as_str() + .is_some_and(|message| message.contains("Permission denied")), + "policy-denied invocation must remain rejected: {response}" + ); +} diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs index e91739091e..c3128c421b 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs @@ -18,6 +18,7 @@ fn worker_options() -> PrivateWorkerOptions { SessionPermissionMode::Unrestricted, ], compatibility_mode: SessionPermissionMode::Standard, + compatibility_capability_manifest_path: None, compatibility_bounded_manifest_path: None, unrestricted_acknowledged: true, max_session_ttl_seconds: 60, @@ -50,6 +51,7 @@ async fn private_worker_owns_one_runtime_without_a_reconnect_endpoint() { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -127,6 +129,7 @@ async fn private_worker_inherits_the_interactive_linux_display_scope() { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -147,6 +150,7 @@ async fn private_worker_inherits_the_interactive_linux_display_scope() { mode: SessionPermissionMode::Unrestricted, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -241,6 +245,8 @@ async fn embedded_service_binds_authority_to_the_original_host_connection() { startup_timeout_ms: Some(10_000), shutdown_timeout_ms: Some(2_000), permission_mode: Some(EmbeddedPermissionMode::Standard), + capability_manifest_path: None, + approve_capability_manifest: false, session_policy_path: None, approve_session_policy: false, dangerously_bypass_approvals: false, @@ -257,6 +263,7 @@ async fn embedded_service_binds_authority_to_the_original_host_connection() { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }) .unwrap(); @@ -298,6 +305,7 @@ fn service_untrusted_child_probe() { mode: SessionPermissionMode::Standard, ttl_seconds: 60, idle_ttl_seconds: 30, + capability_manifest_path: None, bounded_manifest_path: None, }); assert!( diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/protocol_schema_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/protocol_schema_test.rs index 3d850724c6..a2ee5c604b 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/protocol_schema_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/protocol_schema_test.rs @@ -157,9 +157,8 @@ fn tools_list_schema_shape() { list_resp["result"]["capability_version"], "1", "adding one capability token is additive and must not bump the vocabulary version" ); - // Capture scope belongs to the session lifecycle on every platform. The - // action-level `scope` selects a coordinate/transport form but cannot - // override the session policy enforced by the registry. + // Session capture scope remains advertised only for compatibility. New + // clients choose one typed window or desktop target on each action. let capture_scope = &properties("start_session")["capture_scope"]; for expected in ["auto", "window", "desktop"] { assert!( diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/protocol_tools_call_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/protocol_tools_call_test.rs index fd43fd0aea..df76760fa2 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/protocol_tools_call_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/protocol_tools_call_test.rs @@ -12,6 +12,8 @@ #![cfg(any(target_os = "macos", target_os = "windows"))] use cua_driver_testkit::RawDriver; +#[cfg(target_os = "windows")] +use cua_driver_testkit::{Driver, McpDriver}; fn spawn_unrestricted() -> Option { RawDriver::spawn_with_env(&[ @@ -38,7 +40,13 @@ fn tools_call_list_apps() { "method": "tools/call", "params": { "name": "list_apps", "arguments": {} } })); + let started = std::time::Instant::now(); let resp = d.recv(); + assert!( + started.elapsed() < std::time::Duration::from_secs(8), + "list_apps exceeded its optional installed-app discovery budget: {:?}", + started.elapsed() + ); assert_eq!(resp["id"], 2); assert!(resp["result"]["content"].is_array()); if cfg!(target_os = "windows") { @@ -67,6 +75,89 @@ fn tools_call_list_apps() { } } +#[test] +#[cfg(target_os = "windows")] +fn launch_unknown_app_is_bounded_and_keeps_mcp_session_responsive() { + let Some(mut driver) = McpDriver::spawn_with_env(&[ + ("CUA_DRIVER_PERMISSION_MODE", "unrestricted"), + ("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS", "1"), + ]) else { + return; + }; + let missing_name = format!("CuaMissingAppIssue2856_{}.exe", std::process::id()); + + let launch_started = std::time::Instant::now(); + let launch = driver.call("launch_app", serde_json::json!({ "name": missing_name })); + let launch_elapsed = launch_started.elapsed(); + assert!( + launch_elapsed < std::time::Duration::from_secs(10), + "unknown-app launch exceeded its hard response budget: {launch_elapsed:?}; response={:?}", + launch.raw + ); + assert!( + launch.is_error(), + "unknown app should fail: {:?}", + launch.raw + ); + let error = launch.text().to_ascii_lowercase(); + assert!( + error.contains("not found") || error.contains("lookup") && error.contains("unavailable"), + "expected an explicit not-found or lookup-unavailable error, got: {}", + launch.text() + ); + + let follow_up_started = std::time::Instant::now(); + let windows = driver.call("list_windows", serde_json::json!({})); + let follow_up_elapsed = follow_up_started.elapsed(); + assert!( + follow_up_elapsed < std::time::Duration::from_secs(5), + "follow-up list_windows call was not responsive: {follow_up_elapsed:?}; response={:?}", + windows.raw + ); + assert!( + !windows.is_error(), + "follow-up list_windows failed after unknown launch: {:?}", + windows.raw + ); + assert!( + windows.structured()["windows"].is_array(), + "follow-up list_windows returned no windows array: {:?}", + windows.raw + ); +} + +#[test] +#[cfg(target_os = "windows")] +#[ignore = "requires an interactive Windows desktop with Microsoft Edge installed"] +fn launch_edge_from_apps_folder_registration() { + let Some(mut driver) = McpDriver::spawn_with_env(&[ + ("CUA_DRIVER_PERMISSION_MODE", "unrestricted"), + ("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS", "1"), + ]) else { + return; + }; + + let launch = driver.call( + "launch_app", + serde_json::json!({ + "name": "Microsoft Edge", + "urls": ["https://example.com"] + }), + ); + assert!( + !launch.is_error(), + "Edge AppsFolder launch failed: {:?}", + launch.raw + ); + assert!( + launch.structured()["pid"] + .as_u64() + .is_some_and(|pid| pid > 0), + "Edge AppsFolder launch returned no process id: {:?}", + launch.raw + ); +} + #[test] #[cfg(any(target_os = "macos", target_os = "windows"))] fn get_config_and_check_permissions() { @@ -611,14 +702,41 @@ fn type_text_chars_tool() { return; }; - // type_text_chars with delay_ms=5 — just verify the tool invocation is accepted. + // Resolve an exact on-screen window. PID-only targeting is deliberately + // refused when an app owns multiple eligible windows. d.send(&serde_json::json!({ "jsonrpc":"2.0","id":3,"method":"tools/call", + "params":{"name":"list_windows","arguments":{"pid":pid,"on_screen_only":true}} + })); + let resp = d.recv(); + let windows = resp["result"]["structuredContent"]["windows"].as_array(); + let Some(window_id) = windows + .and_then(|windows| windows.first()) + .and_then(|window| window["window_id"].as_u64()) + else { + eprintln!("TextEdit has no on-screen windows — skipping type_text_chars test"); + return; + }; + + // type_text_chars with delay_ms=5 — just verify the tool invocation is accepted. + d.send(&serde_json::json!({ + "jsonrpc":"2.0","id":4,"method":"tools/call", "params":{"name":"type_text_chars","arguments":{ - "pid": pid, "text": "hi", "delay_ms": 5 + "pid": pid, "window_id": window_id, "text": "hi", "delay_ms": 5 }} })); let resp = d.recv(); + if resp["result"]["isError"].as_bool().unwrap_or(false) + && matches!( + resp["result"]["structuredContent"]["code"].as_str(), + Some("off_space_or_ax_unresolved" | "window_target_not_found") + ) + { + eprintln!( + "TextEdit has no safe AX-resolved input target in this desktop session — skipping type_text_chars test" + ); + return; + } assert!( !resp["result"]["isError"].as_bool().unwrap_or(false), "type_text_chars returned error: {resp:?}" @@ -943,14 +1061,14 @@ fn set_value_via_element_index() { let element_count = resp["result"]["structuredContent"]["element_count"] .as_u64() .unwrap_or(0); - let snapshot_id = resp["result"]["structuredContent"]["snapshot_id"] - .as_str() - .expect("get_window_state snapshot_id") - .to_owned(); if element_count == 0 { eprintln!("No AX elements — skipping set_value test"); return; } + let snapshot_id = resp["result"]["structuredContent"]["snapshot_id"] + .as_str() + .expect("get_window_state snapshot_id") + .to_owned(); // set_value on element 0 — this is the document / text area in a new TextEdit document. d.send(&serde_json::json!({ diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/release_channel_cli_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/release_channel_cli_test.rs new file mode 100644 index 0000000000..e12e793a40 --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver/tests/release_channel_cli_test.rs @@ -0,0 +1,59 @@ +use std::process::Command; + +fn run(home: &std::path::Path, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_qwen-cua-driver")) + .args(args) + .env("CUA_DRIVER_RS_HOME", home) + .env("CUA_DRIVER_RS_TELEMETRY_ENABLED", "0") + .output() + .expect("run cua-driver") +} + +#[test] +fn channel_cli_persists_and_reports_the_selected_channel() { + let home = tempfile::tempdir().expect("temp home"); + + let initial = run(home.path(), &["channel", "status", "--json"]); + assert!( + initial.status.success(), + "{}", + String::from_utf8_lossy(&initial.stderr) + ); + let initial: serde_json::Value = serde_json::from_slice(&initial.stdout).expect("initial json"); + assert_eq!(initial["selected_channel"], "stable"); + + let changed = run(home.path(), &["channel", "set", "nightly", "--json"]); + assert!( + changed.status.success(), + "{}", + String::from_utf8_lossy(&changed.stderr) + ); + assert_eq!( + std::fs::read_to_string(home.path().join("release-channel")).expect("saved preference"), + "nightly\n" + ); + + let status = run(home.path(), &["channel", "status", "--json"]); + assert!( + status.status.success(), + "{}", + String::from_utf8_lossy(&status.stderr) + ); + let status: serde_json::Value = serde_json::from_slice(&status.stdout).expect("status json"); + assert_eq!(status["selected_channel"], "nightly"); +} + +#[test] +fn channel_cli_fails_closed_on_invalid_saved_state() { + let home = tempfile::tempdir().expect("temp home"); + std::fs::write(home.path().join("release-channel"), "broken\n").expect("invalid state"); + + let output = run(home.path(), &["channel", "status", "--json"]); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("expected `stable` or `nightly`"), + "{stderr}" + ); + assert!(stderr.contains("channel set stable"), "{stderr}"); +} diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/schema_consistency_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/schema_consistency_test.rs index 41ea9e7f10..8183b7b9ea 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/schema_consistency_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/schema_consistency_test.rs @@ -67,6 +67,12 @@ fn registered_tool_contracts_match_on_active_backend() { .unwrap_or_else(|| json!({})); violations.extend(shared_schema_violations(name, &schema)); + if let Some(output_schema) = tool.get("outputSchema") { + if output_schema.get("type") != Some(&json!("object")) { + violations.push(format!("{name}: outputSchema root must have type=object")); + } + } + let schema_accepts_delivery_mode = schema .pointer("/properties/delivery_mode") .is_some_and(Value::is_object); @@ -84,12 +90,17 @@ fn registered_tool_contracts_match_on_active_backend() { } if is_action_result_tool(name) { - let expected = ActionResult::output_schema(); + // The live surface advertises the success shape beside the refusal + // envelope, because MCP holds every `structuredContent` — refusals + // included — to the advertised schema. The success variant must + // still be the shared ActionResult schema, byte for byte. + let expected = + cua_driver_contract::advertised_output_schema(ActionResult::output_schema()); let actual = tool.get("outputSchema"); if actual != Some(&expected) { violations.push(format!( - "{name}: live outputSchema does not equal the shared ActionResult schema; \ - actual={} expected={expected}", + "{name}: live outputSchema does not equal the advertised ActionResult schema \ + (success variant + refusal envelope); actual={} expected={expected}", actual.unwrap_or(&Value::Null) )); } diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/session_capture_scope_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/session_capture_scope_test.rs index cd469ab0ba..21acfde0d9 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/session_capture_scope_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/session_capture_scope_test.rs @@ -112,6 +112,22 @@ fn policies_are_isolated_immutable_and_enforced_over_mcp() { json!({"session": "scope-auto"}), ); assert_eq!(code(&auto_window), Some("window_scope_disabled")); + assert_eq!( + auto_window["result"]["structuredContent"], + json!({ + "session": "scope-auto", + "capture_scope": "auto", + "effective_scope": "desktop", + "desktop_unlocked": true, + "escalation_reason": "foreground_ineffective", + "escalation_detail": "window ladder exhausted", + "code": "window_scope_disabled" + }) + ); + assert_eq!( + auto_window["result"]["content"][0]["text"], + "window-scope tool 'get_window_state' is disabled because escalation to desktop scope is permanent for session 'scope-auto'; to recover, call end_session for session 'scope-auto', then call start_session with a new session id" + ); let conflict = call( &mut driver, @@ -155,6 +171,6 @@ fn persistent_capture_scope_key_is_retired() { assert_eq!(code(&response), Some("config_key_retired")); assert_eq!( response["result"]["structuredContent"]["replacement"], - "start_session.capture_scope" + "action.target" ); } diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/standalone_browser_behavior_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/standalone_browser_behavior_test.rs index 9ff876b384..df332bdaa2 100644 --- a/packages/cua-driver/rust/crates/cua-driver/tests/standalone_browser_behavior_test.rs +++ b/packages/cua-driver/rust/crates/cua-driver/tests/standalone_browser_behavior_test.rs @@ -45,6 +45,31 @@ fn standalone_fixture_html() -> String { ) } +/// Synthetic control that records event delivery but deliberately rejects the +/// application action unless the browser marks the click as trusted. +fn standalone_trust_gated_click_html() -> String { + standalone_fixture_html().replace( + "", + r#"
+ trust-gated click + + + activation=idle + +
+ +"#, + ) +} + #[cfg(target_os = "macos")] fn standalone_generic_type_text_html() -> String { standalone_fixture_html().replace( @@ -1579,6 +1604,68 @@ fn run_roundtrip(spec: &BrowserSpec) { }); } +fn run_trust_gated_dom_click(spec: &BrowserSpec) { + let scenario = format!( + "{}-{}-standalone-trust-gated-dom-click", + std::env::consts::OS, + spec.name + ); + execute_case(case(&spec.name, "trust_gated_dom_click"), |evidence| { + let mut fixture = + launch_browser_with_html(spec, &scenario, standalone_trust_gated_click_html()); + *evidence = recording_evidence(fixture.driver.recording_dir()); + run_with_background_oracles(&mut fixture, |fixture| { + let session = format!("standalone-trust-gated-dom-click-{}", fixture.pid); + let (target, tab, snapshot) = bind(fixture, &session); + let click_ref = ref_by_label(&snapshot, "id=standalone-trust-gated"); + let click = fixture.driver.call( + "browser_click", + serde_json::json!({ + "target_id": target, + "tab_id": tab, + "ref": click_ref, + "input_route": "dom_event", + "session": session, + }), + ); + + assert_eq!(click.action_effect(), Some("unverifiable"), "{}", click.raw); + assert_eq!(click.action_route(), Some("dom"), "{}", click.raw); + assert_eq!( + click.action_delivery_mode(), + Some("background"), + "{}", + click.raw + ); + assert_eq!( + click.structured()["escalation"]["target"], + "page", + "{}", + click.raw + ); + assert_eq!( + click.structured()["escalation"]["reason"], + "effect_unconfirmed", + "{}", + click.raw + ); + assert!( + click.text().contains("application effect not verified") + && click.text().contains("trust-gated controls"), + "{}", + click.raw + ); + wait_for_text( + &fixture.server, + "standalone-trust-gated-state", + "activation=ignored-untrusted", + ); + + Observation::delivered(vec![OracleKind::FixtureState], Evidence::default()) + }) + }); +} + fn run_semantic_state(spec: &BrowserSpec) { let scenario = format!( "{}-{}-standalone-semantic-state", @@ -2173,11 +2260,6 @@ fn run_prepare_isolated_launch(spec: &BrowserSpec) { "browser_prepare disclosed its private profile path: {}", prepared.raw ); - assert!( - !prepared_json.contains("approval_token"), - "{}", - prepared.raw - ); let prepared_pid = prepared.structured()["prepared_pid"] .as_u64() @@ -2415,11 +2497,6 @@ fn run_existing_profile_attach(spec: &BrowserSpec) { "{}", prepared.raw ); - assert!( - !public_result.contains("approval_token"), - "{}", - prepared.raw - ); assert!( !public_result.contains(&fixture._profile.path().display().to_string()), "{}", @@ -2568,11 +2645,6 @@ fn run_existing_profile_setup(spec: &BrowserSpec) { let public_result = prepared.raw.to_string(); assert!(!public_result.contains("ws://"), "{}", prepared.raw); - assert!( - !public_result.contains("approval_token"), - "{}", - prepared.raw - ); assert!( !public_result.contains(&fixture._profile.path().display().to_string()), "{}", @@ -3554,6 +3626,13 @@ fn run_browser_owned_permission_prompt(spec: &BrowserSpec) { "Linux window capture already includes the browser-owned prompt surface: {}", before.raw ); + } else if cfg!(target_os = "macos") { + assert_eq!( + before.structured()["capture_coverage"]["browser_chrome"]["status"], + "may_be_incomplete_in_window_scope", + "{}", + before.raw + ); } else { assert_eq!( before.structured()["capture_coverage"]["browser_chrome"]["status"], @@ -3561,6 +3640,8 @@ fn run_browser_owned_permission_prompt(spec: &BrowserSpec) { "{}", before.raw ); + } + if !cfg!(target_os = "linux") { assert_eq!( before.structured()["capture_coverage"]["recovery"]["when"], "verified_window_action_ineffective", @@ -3568,34 +3649,22 @@ fn run_browser_owned_permission_prompt(spec: &BrowserSpec) { before.raw ); assert_eq!( - before.structured()["capture_coverage"]["recovery"]["escalate"], + before.structured()["capture_coverage"]["recovery"]["act_target"], serde_json::json!({ - "tool": "escalate_session", - "reason": "foreground_ineffective", + "kind": "desktop", + "display_id": "primary", }), "{}", before.raw ); + assert!( + before.structured()["capture_coverage"]["recovery"] + .get("escalate") + .is_none(), + "{}", + before.raw + ); } - let escalated = fixture.driver.call( - "escalate_session", - serde_json::json!({ - "session": window_session, - "reason": "foreground_ineffective", - "detail": "browser chrome may be outside window capture", - }), - ); - assert!( - !escalated.is_error(), - "desktop inspection escalation failed: {}", - escalated.raw - ); - assert_eq!( - escalated.structured()["effective_scope"], - "desktop", - "{}", - escalated.raw - ); let desktop_before = fixture.driver.call( "get_desktop_state", serde_json::json!({ @@ -3656,6 +3725,13 @@ fn run_browser_owned_permission_prompt(spec: &BrowserSpec) { "Linux window capture already includes the browser-owned prompt surface: {}", window.raw ); + } else if cfg!(target_os = "macos") { + assert_eq!( + window.structured()["capture_coverage"]["browser_chrome"]["status"], + "may_be_incomplete_in_window_scope", + "{}", + window.raw + ); } else { assert_eq!( window.structured()["capture_coverage"]["browser_chrome"]["status"], @@ -3725,7 +3801,7 @@ fn run_browser_owned_permission_prompt(spec: &BrowserSpec) { "click", serde_json::json!({ "session": window_session, - "scope": "desktop", + "target": {"kind": "desktop", "display_id": "primary"}, "x": x, "y": y, }), @@ -3837,6 +3913,11 @@ fn run_browser_owned_permission_prompt(spec: &BrowserSpec) { !desktop_has_materially_more_prompt_pixels, "Linux desktop capture unexpectedly contained materially more permission UI than window capture: {metrics}" ); + } else if cfg!(target_os = "macos") { + assert!( + window_changed_pixels >= minimum_prompt_pixels, + "macOS window capture omitted the tested notification permission surface: {metrics}" + ); } else { assert!( desktop_has_materially_more_prompt_pixels, @@ -4544,6 +4625,10 @@ macro_rules! standalone_browser_test { } standalone_browser_test!(standalone_browser_roundtrip, run_roundtrip); +standalone_browser_test!( + standalone_browser_trust_gated_dom_click, + run_trust_gated_dom_click +); standalone_browser_test!(standalone_browser_semantic_state, run_semantic_state); standalone_browser_test!(standalone_browser_background_type, run_background_type); standalone_browser_test!(standalone_browser_type_replace, run_type_replace); diff --git a/packages/cua-driver/rust/crates/cua-driver/tests/wayland_overlay_idle_test.rs b/packages/cua-driver/rust/crates/cua-driver/tests/wayland_overlay_idle_test.rs new file mode 100644 index 0000000000..d8583cd7c9 --- /dev/null +++ b/packages/cua-driver/rust/crates/cua-driver/tests/wayland_overlay_idle_test.rs @@ -0,0 +1,178 @@ +//! Hosted native-Wayland lifecycle and CPU certification for the layer-shell +//! cursor overlay. Run inside the repository's isolated Sway session. + +#![cfg(target_os = "linux")] + +use std::fs; +use std::thread; +use std::time::{Duration, Instant}; + +use cua_driver_testkit::RawDriver; + +const OVERLAY_THREAD: &str = "cua-overlay-wl"; + +fn call(driver: &mut RawDriver, id: u64, name: &str, arguments: serde_json::Value) { + driver.send(&serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + })); + let response = driver.recv(); + assert_eq!(response["id"], id, "unexpected response: {response:?}"); + assert!( + !response["result"]["isError"].as_bool().unwrap_or(false), + "{name} failed: {response:?}" + ); +} + +fn initialize(driver: &mut RawDriver) { + driver.send(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} + })); + let response = driver.recv(); + assert_eq!(response["id"], 1, "initialize failed: {response:?}"); +} + +fn overlay_tid(pid: u32) -> Option { + let tasks = fs::read_dir(format!("/proc/{pid}/task")).ok()?; + tasks.filter_map(Result::ok).find_map(|task| { + let comm = fs::read_to_string(task.path().join("comm")).ok()?; + (comm.trim() == OVERLAY_THREAD).then(|| { + task.file_name() + .to_string_lossy() + .parse::() + .expect("numeric Linux task id") + }) + }) +} + +fn wait_for_overlay_tid(pid: u32) -> u32 { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(tid) = overlay_tid(pid) { + return tid; + } + thread::sleep(Duration::from_millis(25)); + } + panic!("{OVERLAY_THREAD} did not start in daemon {pid}"); +} + +fn cpu_ticks(pid: u32, tid: u32) -> u64 { + let stat = + fs::read_to_string(format!("/proc/{pid}/task/{tid}/stat")).expect("read overlay task stat"); + let suffix = stat.rsplit_once(") ").expect("parse task comm").1; + let fields: Vec<&str> = suffix.split_whitespace().collect(); + let utime = fields[11].parse::().expect("parse task utime"); + let stime = fields[12].parse::().expect("parse task stime"); + utime + stime +} + +fn assert_idle_tick_bound(pid: u32, tid: u32, window: Duration, bound: u64) { + let before = cpu_ticks(pid, tid); + thread::sleep(window); + let after = cpu_ticks(pid, tid); + let delta = after.saturating_sub(before); + eprintln!( + "wayland overlay idle evidence: pid={pid} tid={tid} window_ms={} tick_delta={delta} bound={bound}", + window.as_millis() + ); + assert!( + delta <= bound, + "idle overlay used {delta} ticks (bound {bound})" + ); +} + +#[test] +#[ignore] +fn no_overlay_flag_never_starts_wayland_overlay_thread() { + let Some(mut driver) = RawDriver::spawn_with_env(&[ + ("CUA_DRIVER_PERMISSION_MODE", "unrestricted"), + ("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS", "1"), + ]) else { + panic!("source-built driver is required"); + }; + let pid = driver.daemon_pid().expect("daemon-backed driver pid"); + initialize(&mut driver); + call( + &mut driver, + 2, + "move_cursor", + serde_json::json!({"x": 300.0, "y": 240.0}), + ); + thread::sleep(Duration::from_millis(300)); + assert_eq!( + overlay_tid(pid), + None, + "--no-overlay daemon unexpectedly started {OVERLAY_THREAD}" + ); +} + +#[test] +#[ignore] +fn wayland_overlay_quiesces_and_recovers_after_capture_and_cursor_activity() { + let Some(mut driver) = RawDriver::spawn_with_overlay_and_env(&[ + ("CUA_DRIVER_PERMISSION_MODE", "unrestricted"), + ("CUA_DRIVER_DANGEROUSLY_BYPASS_APPROVALS", "1"), + ]) else { + panic!("source-built driver is required"); + }; + let pid = driver.daemon_pid().expect("daemon-backed driver pid"); + initialize(&mut driver); + + // Exercise the reported trigger class: native compositor capture followed + // by daemon-owned cursor motion. Call the Linux capture backend directly: + // `screenshot` is intentionally denied at the MCP authorization boundary + // until that tool has a reviewed risk classification. + let capture = platform_linux::wayland::screenshot_display_dispatch() + .expect("capture native Wayland display before overlay activity"); + assert!(!capture.is_empty(), "native Wayland capture was empty"); + call( + &mut driver, + 3, + "set_agent_cursor_motion", + serde_json::json!({"glide_duration_ms": 700, "idle_hide_ms": 0}), + ); + call( + &mut driver, + 4, + "move_cursor", + serde_json::json!({"x": 500.0, "y": 360.0}), + ); + let tid = wait_for_overlay_tid(pid); + thread::sleep(Duration::from_secs(2)); + assert_idle_tick_bound(pid, tid, Duration::from_secs(2), 1); + + call( + &mut driver, + 5, + "set_agent_cursor_enabled", + serde_json::json!({"enabled": false}), + ); + thread::sleep(Duration::from_millis(250)); + assert_idle_tick_bound(pid, tid, Duration::from_secs(1), 1); + + call( + &mut driver, + 6, + "set_agent_cursor_enabled", + serde_json::json!({"enabled": true}), + ); + let recovery_before = cpu_ticks(pid, tid); + call( + &mut driver, + 7, + "move_cursor", + serde_json::json!({"x": 900.0, "y": 600.0}), + ); + thread::sleep(Duration::from_millis(500)); + let recovery_delta = cpu_ticks(pid, tid).saturating_sub(recovery_before); + eprintln!("wayland overlay recovery evidence: tick_delta={recovery_delta}"); + assert!( + recovery_delta > 0, + "re-enabled overlay did not resume rendering" + ); + + thread::sleep(Duration::from_secs(2)); + assert_idle_tick_bound(pid, tid, Duration::from_secs(2), 1); +} diff --git a/packages/cua-driver/rust/crates/cursor-overlay/examples/export_gallery_frames.rs b/packages/cua-driver/rust/crates/cursor-overlay/examples/export_gallery_frames.rs index 51657088ea..e0300b06a4 100644 --- a/packages/cua-driver/rust/crates/cursor-overlay/examples/export_gallery_frames.rs +++ b/packages/cua-driver/rust/crates/cursor-overlay/examples/export_gallery_frames.rs @@ -2,12 +2,111 @@ use cursor_overlay::{ render_frame, CursorAction, CursorConfig, DeliveryModifier, OverlayCommand, RenderStateCore, TargetModifier, }; -use std::{fs, path::Path}; +use std::{ + fs, + path::{Path, PathBuf}, +}; const SIZE: u32 = 256; const FPS: u32 = 30; const DURATION_SECS: u32 = 4; const PREVIEW_BACKING_SCALE: f32 = 1.5; +const RUNTIME_SESSION_LABEL: &str = "Research"; + +#[derive(Clone, Copy)] +struct GalleryState { + action: CursorAction, + delivery: Option, + target: Option, + session_label: Option<&'static str>, +} + +#[cfg(test)] +fn runtime_state() -> GalleryState { + GalleryState { + action: CursorAction::Observe, + delivery: Some(DeliveryModifier::Background), + target: Some(TargetModifier::Browser), + session_label: Some(RUNTIME_SESSION_LABEL), + } +} + +const DELIVERIES: [Option; 3] = [ + None, + Some(DeliveryModifier::Background), + Some(DeliveryModifier::Foreground), +]; + +const TARGETS: [Option; 5] = [ + None, + Some(TargetModifier::Ax), + Some(TargetModifier::Pixel), + Some(TargetModifier::Browser), + Some(TargetModifier::Desktop), +]; + +fn delivery_slug(delivery: Option) -> &'static str { + match delivery { + None => "none", + Some(DeliveryModifier::Background) => "background", + Some(DeliveryModifier::Foreground) => "foreground", + } +} + +fn target_slug(target: Option) -> &'static str { + match target { + None => "none", + Some(TargetModifier::Ax) => "ax", + Some(TargetModifier::Pixel) => "pixel", + Some(TargetModifier::Browser) => "browser", + Some(TargetModifier::Desktop) => "desktop", + } +} + +fn preview_slug(state: GalleryState) -> String { + format!( + "{}--{}--{}", + state.action.as_str(), + delivery_slug(state.delivery), + target_slug(state.target), + ) +} + +fn preview_states(output: &Path) -> Vec<(PathBuf, GalleryState)> { + let mut states = Vec::with_capacity(CursorAction::ALL.len() * DELIVERIES.len() * TARGETS.len()); + for action in CursorAction::ALL { + for delivery in DELIVERIES { + for target in TARGETS { + let state = GalleryState { + action, + delivery, + target, + session_label: Some(RUNTIME_SESSION_LABEL), + }; + states.push((output.join("previews").join(preview_slug(state)), state)); + } + } + } + states +} + +fn export_states(states: Vec<(PathBuf, GalleryState)>) { + let worker_count = std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(4) + .min(12) + .min(states.len().max(1)); + let chunk_size = states.len().div_ceil(worker_count); + std::thread::scope(|scope| { + for chunk in states.chunks(chunk_size) { + scope.spawn(move || { + for (path, state) in chunk { + export_state(path, *state); + } + }); + } + }); +} fn main() { let output = std::env::args() @@ -19,89 +118,19 @@ fn main() { for action in CursorAction::ALL { states.push(( output.join("actions").join(action.as_str()), - action, - None, - None, - )); - } - - for (name, delivery, target) in [ - ("background", Some(DeliveryModifier::Background), None), - ("foreground", Some(DeliveryModifier::Foreground), None), - ("ax", None, Some(TargetModifier::Ax)), - ("pixel", None, Some(TargetModifier::Pixel)), - ("browser", None, Some(TargetModifier::Browser)), - ("desktop", None, Some(TargetModifier::Desktop)), - ] { - states.push(( - output.join("modifiers").join(name), - CursorAction::Idle, - delivery, - target, - )); - } - - states.push(( - output.join("combined").join("foreground-pixel-click"), - CursorAction::Click, - Some(DeliveryModifier::Foreground), - Some(TargetModifier::Pixel), - )); - - std::thread::scope(|scope| { - for (path, action, delivery, target) in states { - scope.spawn(move || export_state(&path, action, delivery, target)); - } - }); - export_session_badge(&output.join("session").join("badge")); -} - -fn export_session_badge(output: &Path) { - fs::create_dir_all(output).expect("create session badge frame output"); - for frame in 0..FPS * DURATION_SECS { - let mut config = CursorConfig::default(); - config.cursor_id = "gallery-session".into(); - let mut core = RenderStateCore::new(config); - core.motion.idle_hide_ms = 0.0; - core.pos = ( - f64::from(SIZE) / (2.0 * f64::from(PREVIEW_BACKING_SCALE)), - 64.0, - ); - core.apply_command_base( - OverlayCommand::SetSessionLabel("Research".into()), - false, - false, - ); - core.apply_command_base( - OverlayCommand::BeginAction { - action: CursorAction::Observe, - delivery: Some(DeliveryModifier::Background), - target: Some(TargetModifier::Browser), + GalleryState { + action, + delivery: None, + target: None, + session_label: None, }, - false, - false, - ); - core.tick_motion(f64::from(frame) / f64::from(FPS)); - let pixmap = render_frame(&core, SIZE, SIZE, 0.0, 0.0, None, PREVIEW_BACKING_SCALE); - let pixels = unpremultiply_rgba(pixmap.data().to_vec()); - image::save_buffer_with_format( - output.join(format!("{frame:04}.png")), - &pixels, - SIZE, - SIZE, - image::ColorType::Rgba8, - image::ImageFormat::Png, - ) - .expect("write session badge frame"); + )); } + export_states(states); + export_states(preview_states(output)); } -fn export_state( - output: &Path, - action: CursorAction, - delivery: Option, - target: Option, -) { +fn export_state(output: &Path, state: GalleryState) { fs::create_dir_all(output).expect("create frame output"); for frame in 0..FPS * DURATION_SECS { let mut config = CursorConfig::default(); @@ -113,11 +142,18 @@ fn export_state( f64::from(SIZE) / (2.0 * f64::from(PREVIEW_BACKING_SCALE)), ); core.heading = f64::from(std::f32::consts::FRAC_PI_4); + if let Some(session_label) = state.session_label { + core.apply_command_base( + OverlayCommand::SetSessionLabel(session_label.into()), + false, + false, + ); + } core.apply_command_base( OverlayCommand::BeginAction { - action, - delivery, - target, + action: state.action, + delivery: state.delivery, + target: state.target, }, false, false, @@ -137,6 +173,36 @@ fn export_state( } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_preview_uses_the_complete_production_composition() { + let state = runtime_state(); + assert_eq!(state.action, CursorAction::Observe); + assert_eq!(state.delivery, Some(DeliveryModifier::Background)); + assert_eq!(state.target, Some(TargetModifier::Browser)); + assert_eq!(state.session_label, Some(RUNTIME_SESSION_LABEL)); + } + + #[test] + fn preview_inventory_covers_every_runtime_combination_once() { + let root = Path::new("gallery"); + let states = preview_states(root); + assert_eq!(states.len(), 12 * 3 * 5); + + let slugs = states + .iter() + .map(|(_, state)| preview_slug(*state)) + .collect::>(); + assert_eq!(slugs.len(), states.len()); + assert!(slugs.contains("observe--background--browser")); + assert!(slugs.contains("idle--none--none")); + assert!(slugs.contains("system--foreground--desktop")); + } +} + fn unpremultiply_rgba(mut pixels: Vec) -> Vec { for pixel in pixels.chunks_exact_mut(4) { let alpha = u16::from(pixel[3]); diff --git a/packages/cua-driver/rust/crates/cursor-overlay/src/lib.rs b/packages/cua-driver/rust/crates/cursor-overlay/src/lib.rs index f5ab60eb1c..185c7f341b 100644 --- a/packages/cua-driver/rust/crates/cursor-overlay/src/lib.rs +++ b/packages/cua-driver/rust/crates/cursor-overlay/src/lib.rs @@ -314,14 +314,18 @@ pub struct KeyedOverlayCommand { pub cmd: OverlayCommand, } -/// Message carried over the macOS overlay channel. Either a keyed render -/// command or a lifecycle removal. A separate lifecycle enum (rather than an -/// `OverlayCommand::Remove` variant) keeps `OverlayCommand` render-only and -/// avoids forcing a no-op arm onto the Windows/Linux match. +/// Message carried over a platform overlay channel. Either a keyed render +/// command or an explicit session-lifecycle transition. A separate lifecycle +/// enum (rather than `OverlayCommand` variants) keeps render commands +/// render-only. #[derive(Debug, Clone)] pub enum OverlayMsg { Cmd(KeyedOverlayCommand), Remove(CursorKey), + /// Clear the render-side tombstone for an explicitly revived session. + /// This deliberately does not recreate a cursor; the next command does so + /// lazily after the successful `start_session` boundary. + Revive(CursorKey), } /// Commands sent from MCP tool handlers to the overlay's render thread. diff --git a/packages/cua-driver/rust/crates/cursor-overlay/src/session_badge.rs b/packages/cua-driver/rust/crates/cursor-overlay/src/session_badge.rs index 65f301c86e..b37a2c8576 100644 --- a/packages/cua-driver/rust/crates/cursor-overlay/src/session_badge.rs +++ b/packages/cua-driver/rust/crates/cursor-overlay/src/session_badge.rs @@ -29,8 +29,6 @@ pub const BADGE_CHIP_GROUP_GAP: f32 = 7.0; const FONT_BYTES: &[u8] = include_bytes!("../assets/Inter.ttf"); const FONT_SIZE: f32 = 11.5; const HORIZONTAL_PADDING: f32 = 10.0; -const ORB_SIZE: f32 = 10.0; -const ORB_GAP: f32 = 7.0; const TEXT_OPTICAL_Y_OFFSET: f32 = 1.0; #[derive(Debug, Clone, Copy, PartialEq)] @@ -50,7 +48,6 @@ pub struct BadgeLabelLayout { pub struct SessionBadgeLayout { pub rect: Rect, pub corner_radius: f32, - pub orb_center: (f32, f32), pub label: Option, pub delivery_chip: Option, pub target_chip: Option, @@ -337,7 +334,7 @@ pub fn session_badge_layout(input: SessionBadgeInput<'_>) -> Option BADGE_CHIP_SIZE, _ => BADGE_CHIP_SIZE * 2.0 + BADGE_CHIP_GAP, } * scale; - let fixed_width = (HORIZONTAL_PADDING * 2.0 + ORB_SIZE + ORB_GAP) * scale + let fixed_width = HORIZONTAL_PADDING * 2.0 * scale + chip_width + if show_label && chip_count > 0 { BADGE_CHIP_GROUP_GAP * scale @@ -354,7 +351,7 @@ pub fn session_badge_layout(input: SessionBadgeInput<'_>) -> Option) -> Option) -> Option) -> AdvertiseMode { - let launches_screen_reader = desktop.is_some_and(|desktop| { - desktop - .split([':', ';']) - .map(str::trim) - .any(|part| part.eq_ignore_ascii_case("gnome") || part.eq_ignore_ascii_case("cosmic")) - }); - if launches_screen_reader { + let has_desktop = |candidate: &str| { + desktop.is_some_and(|desktop| { + desktop + .split([':', ';']) + .map(str::trim) + .any(|part| part.eq_ignore_ascii_case(candidate)) + }) + }; + + // Cinnamon mirrors its GNOME and Cinnamon accessibility schemas and + // recomputes toolkit accessibility from the assistive-technology toggles. + // Advertising only IsEnabled can therefore livelock the settings daemons, + // while advertising ScreenReaderEnabled launches Orca. Fail closed. + if has_desktop("cinnamon") || has_desktop("x-cinnamon") { + AdvertiseMode::None + } else if has_desktop("gnome") || has_desktop("cosmic") { AdvertiseMode::IsEnabledOnly } else { AdvertiseMode::All @@ -196,6 +208,25 @@ mod tests { ); } + #[test] + fn cinnamon_default_leaves_accessibility_status_untouched() { + for desktop in ["Cinnamon", "X-Cinnamon", "LinuxMint:X-Cinnamon"] { + assert_eq!( + advertise_mode_from(false, None, Some(desktop)), + AdvertiseMode::None, + "desktop={desktop}" + ); + } + } + + #[test] + fn cinnamon_safety_wins_in_composite_desktop_names() { + assert_eq!( + advertise_mode_from(false, None, Some("GNOME:X-Cinnamon")), + AdvertiseMode::None + ); + } + #[test] fn non_gnome_default_preserves_chromium_compatibility() { assert_eq!( diff --git a/packages/cua-driver/rust/crates/platform-linux/src/atspi/cache.rs b/packages/cua-driver/rust/crates/platform-linux/src/atspi/cache.rs index 78807c6ae9..360482d5f0 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/atspi/cache.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/atspi/cache.rs @@ -6,7 +6,7 @@ //! `CacheKey` and `CachedSnapshot` (no Drop needed — `Vec` frees //! itself). -use super::AtspiNode; +use super::{AtspiIdentity, AtspiNode}; use cua_driver_core::element_cache::ElementCacheCore; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -16,8 +16,14 @@ pub struct CacheKey { } pub struct CachedSnapshot { - /// element_index → element_key (opaque AT-SPI path hash). - pub elements: Vec, + pub elements: Vec, +} + +#[derive(Clone)] +pub struct CachedElement { + pub element_key: u64, + pub identity: Option, + pub actions: Vec, } pub struct ElementCache { @@ -32,10 +38,14 @@ impl ElementCache { } pub fn update(&self, pid: u32, xid: u64, nodes: &[AtspiNode]) { - let elements: Vec = nodes + let elements = nodes .iter() .filter(|n| n.element_index.is_some()) - .map(|n| n.element_key) + .map(|node| CachedElement { + element_key: node.element_key, + identity: node.identity.clone(), + actions: node.actions.clone(), + }) .collect(); self.core .insert(CacheKey { pid, xid }, CachedSnapshot { elements }); @@ -43,7 +53,31 @@ impl ElementCache { pub fn get_element_key(&self, pid: u32, xid: u64, idx: usize) -> Option { self.core - .with_snapshot(&CacheKey { pid, xid }, |s| s.elements.get(idx).copied()) + .with_snapshot(&CacheKey { pid, xid }, |s| { + s.elements.get(idx).map(|element| element.element_key) + }) + .flatten() + } + + pub fn get_element_identity(&self, pid: u32, xid: u64, idx: usize) -> Option { + self.core + .with_snapshot(&CacheKey { pid, xid }, |snapshot| { + snapshot + .elements + .get(idx) + .and_then(|element| element.identity.clone()) + }) + .flatten() + } + + pub fn get_element_actions(&self, pid: u32, xid: u64, idx: usize) -> Option> { + self.core + .with_snapshot(&CacheKey { pid, xid }, |snapshot| { + snapshot + .elements + .get(idx) + .map(|element| element.actions.clone()) + }) .flatten() } @@ -52,6 +86,10 @@ impl ElementCache { .with_snapshot(&CacheKey { pid, xid }, |s| s.elements.len()) .unwrap_or(0) } + + pub fn clear_target(&self, pid: u32, xid: u64) { + self.core.remove(&CacheKey { pid, xid }); + } } impl Default for ElementCache { diff --git a/packages/cua-driver/rust/crates/platform-linux/src/atspi/mod.rs b/packages/cua-driver/rust/crates/platform-linux/src/atspi/mod.rs index 5336a8dc2b..5bfc88b850 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/atspi/mod.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/atspi/mod.rs @@ -13,9 +13,16 @@ use anyhow::Result; pub mod cache; pub mod native; +pub mod revision; pub use cache::ElementCache; pub use native::ensure_listener_active; +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct AtspiIdentity { + pub unique_owner: String, + pub object_path: String, +} + #[derive(Clone, Debug)] pub struct AtspiNode { pub element_index: Option, @@ -42,6 +49,13 @@ pub struct AtspiNode { /// True when the native AT-SPI walker observed this node below renderer /// web content. Browser-owned consent UI must never match such nodes. pub in_web_content: bool, + pub identity: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AtspiBackend { + Atspi, + X11, } pub struct AtspiTreeResult { @@ -51,6 +65,19 @@ pub struct AtspiTreeResult { /// True only for a native AT-SPI walk. The X11 property fallback is a /// partial discovery aid and must not prove verification predicates. pub trusted: bool, + /// Machine-readable reason when a native walk failed in a way that is more + /// specific than ordinary AT-SPI unavailability. + pub degraded_reason: Option, + /// True when a caller-supplied `xid` was proven to correspond to exactly one + /// of the application's top-levels, so these nodes are that window's and no + /// other's. AT-SPI publishes one tree per process, so an application-scoped + /// snapshot of a multi-window app carries every window's controls; callers + /// that act on behalf of an exact native window must require this. + pub window_scoped: bool, + pub backend: AtspiBackend, + pub complete: bool, + pub truncated: bool, + pub incomplete_notes: Vec, } /// Walk the AT-SPI tree for a window identified by (pid, xid). @@ -70,15 +97,20 @@ pub(crate) fn walk_tree_for_recording( xid: u64, timeout: std::time::Duration, ) -> AtspiTreeResult { - if let Ok(Some((tree_markdown, nodes, bounds))) = - native::walk_tree_bounded_with_timeout(pid, xid, None, None, timeout) + if let Ok(Some(walked)) = native::walk_tree_bounded_with_timeout(pid, xid, None, None, timeout) { - if !tree_markdown.is_empty() { + if !walked.markdown.is_empty() { return AtspiTreeResult { - tree_markdown, - nodes, - bounds, + tree_markdown: walked.markdown, + nodes: walked.nodes, + bounds: walked.bounds, trusted: true, + degraded_reason: None, + window_scoped: walked.window_scoped, + backend: AtspiBackend::Atspi, + complete: walked.complete && walked.window_scoped, + truncated: walked.truncated, + incomplete_notes: walked.incomplete_notes, }; } } @@ -104,27 +136,38 @@ pub fn walk_tree_bounded( // while the tree is suspiciously root-only, so the first get_window_state // after launch returns the real tree instead of an empty one. See #1927. const MAX_ATTEMPTS: usize = 4; + let mut native_failure = None; for attempt in 0..MAX_ATTEMPTS { - if let Ok(Some((raw_md, nodes, bounds))) = - native::walk_tree_bounded(pid, xid, max_elements, max_depth) - { - // `nodes.len() <= 1` == only the root window resolved: the - // cold-registry symptom. Accept any real tree immediately; only - // keep waiting on the degenerate case, and accept it anyway on the - // final attempt rather than discarding a (minimal) valid result. - if !raw_md.is_empty() && (nodes.len() > 1 || attempt == MAX_ATTEMPTS - 1) { - let md = if let Some(q) = query { - filter_tree(&raw_md, q) - } else { - raw_md - }; - return AtspiTreeResult { - tree_markdown: md, - nodes, - bounds, - trusted: true, - }; + match native::walk_tree_bounded(pid, xid, max_elements, max_depth) { + Ok(Some(walked)) => { + // `nodes.len() <= 1` == only the root window resolved: the + // cold-registry symptom. Accept any real tree immediately; only + // keep waiting on the degenerate case, and accept it anyway on the + // final attempt rather than discarding a (minimal) valid result. + if !walked.markdown.is_empty() + && (walked.nodes.len() > 1 || attempt == MAX_ATTEMPTS - 1) + { + let md = if let Some(q) = query { + filter_tree(&walked.markdown, q) + } else { + walked.markdown + }; + return AtspiTreeResult { + tree_markdown: md, + nodes: walked.nodes, + bounds: walked.bounds, + trusted: true, + degraded_reason: None, + window_scoped: walked.window_scoped, + backend: AtspiBackend::Atspi, + complete: walked.complete && walked.window_scoped, + truncated: walked.truncated, + incomplete_notes: walked.incomplete_notes, + }; + } } + Ok(None) => {} + Err(error) => native_failure = Some(error.to_string()), } if attempt < MAX_ATTEMPTS - 1 { std::thread::sleep(std::time::Duration::from_millis(150)); @@ -132,7 +175,9 @@ pub fn walk_tree_bounded( } // Fallback: X11 window properties as minimal tree. - walk_via_x11_properties(xid, query) + let mut fallback = walk_via_x11_properties(xid, query); + fallback.degraded_reason = native_failure.map(|error| format!("atspi_walk_failed: {error}")); + fallback } /// Perform the first advertised action on element `idx` within pid's app tree. @@ -141,16 +186,47 @@ pub fn walk_tree_bounded( /// display role, or no advertised action), so the caller can surface /// `effect: "suspected_noop"`. pub fn perform_action(pid: u32, idx: usize) -> Result<(String, bool)> { - native::perform_action(pid, idx) + native::perform_action(pid, idx, None) +} + +pub fn perform_action_exact( + pid: u32, + idx: usize, + identity: AtspiIdentity, +) -> Result<(String, bool)> { + native::perform_action(pid, idx, Some(identity)) +} + +pub fn perform_secondary_action( + pid: u32, + idx: usize, + identity: AtspiIdentity, + action: &str, +) -> Result { + native::perform_secondary_action(pid, idx, identity, action) } /// Give an indexed AT-SPI element keyboard focus without activating its window. pub fn focus_element(pid: u32, idx: usize) -> Result { - native::focus_element(pid, idx) + native::focus_element(pid, idx, None) +} + +pub fn focus_element_exact(pid: u32, idx: usize, identity: AtspiIdentity) -> Result { + native::focus_element(pid, idx, Some(identity)) } pub fn scroll_element(pid: u32, idx: usize, direction: &str, amount: usize) -> Result<()> { - native::scroll_element(pid, idx, direction, amount) + native::scroll_element(pid, idx, None, direction, amount) +} + +pub fn scroll_element_exact( + pid: u32, + idx: usize, + identity: AtspiIdentity, + direction: &str, + amount: usize, +) -> Result<()> { + native::scroll_element(pid, idx, Some(identity), direction, amount) } /// Enumerate top-level windows from the AT-SPI registry. The window-listing @@ -195,14 +271,27 @@ pub fn type_into_editable(pid: u32, text: &str) -> Result<()> { /// Type into the exact indexed editable from the caller's accessibility snapshot. pub fn type_into_editable_at(pid: u32, idx: usize, text: &str) -> Result<()> { - native::type_into_editable_at(pid, idx, text) + native::type_into_editable_at(pid, idx, None, text) +} + +pub fn type_into_editable_exact( + pid: u32, + idx: usize, + identity: AtspiIdentity, + text: &str, +) -> Result<()> { + native::type_into_editable_at(pid, idx, Some(identity), text) } /// Set the text value of element `idx` within pid's app tree via AT-SPI. /// Tries `EditableText.set_text_contents(value)` first, then /// `Value.set_current_value(float)`. pub fn set_value(pid: u32, idx: usize, value: &str) -> Result<()> { - native::set_value(pid, idx, value) + native::set_value(pid, idx, None, value) +} + +pub fn set_value_exact(pid: u32, idx: usize, identity: AtspiIdentity, value: &str) -> Result<()> { + native::set_value(pid, idx, Some(identity), value) } /// Insert `text` into a GUI app's editable field via AT-SPI EditableText — @@ -224,7 +313,15 @@ pub fn focused_is_editable(pid: u32) -> Result> { } pub fn get_element_bounds(pid: u32, idx: usize) -> Result<(i32, i32, u32, u32)> { - native::get_element_bounds(pid, idx) + native::get_element_bounds(pid, idx, None) +} + +pub fn get_element_bounds_exact( + pid: u32, + idx: usize, + identity: AtspiIdentity, +) -> Result<(i32, i32, u32, u32)> { + native::get_element_bounds(pid, idx, Some(identity)) } // ── Internal helpers ───────────────────────────────────────────────────────── @@ -241,6 +338,12 @@ fn walk_via_x11_properties(xid: u64, query: Option<&str>) -> AtspiTreeResult { nodes: vec![], bounds: vec![], trusted: false, + degraded_reason: None, + window_scoped: false, + backend: AtspiBackend::X11, + complete: false, + truncated: false, + incomplete_notes: vec!["x11_property_fallback".into()], } } }; @@ -278,6 +381,7 @@ fn walk_via_x11_properties(xid: u64, query: Option<&str>) -> AtspiTreeResult { depth: 0, parent_element_index: None, in_web_content: false, + identity: None, }; md.push_str(&format!( "- [0] window \"{}\" [actions=[activate]]\n", @@ -297,9 +401,53 @@ fn walk_via_x11_properties(xid: u64, query: Option<&str>) -> AtspiTreeResult { nodes, bounds: vec![], trusted: false, + // Built by reading this exact window's X11 properties, so it describes + // one window by construction — but `trusted: false` still bars it from + // proving anything a caller acts on. + window_scoped: true, + degraded_reason: None, + backend: AtspiBackend::X11, + complete: false, + truncated: false, + incomplete_notes: vec!["x11_property_fallback".into()], } } +pub(crate) fn format_revision_body(node: &AtspiNode) -> String { + let label = node + .name + .as_deref() + .or(node.value.as_deref()) + .or(node.description.as_deref()) + .unwrap_or_default(); + let mut fields = vec![ + format!("<{}>", node.role), + serde_json::to_string(label).expect("string labels serialize"), + ]; + if let Some(value) = node.value.as_deref().filter(|value| !value.is_empty()) { + fields.push(format!( + "value={}", + serde_json::to_string(value).expect("string values serialize") + )); + } + if let Some(enabled) = node.enabled { + fields.push(format!("enabled={enabled}")); + } + if let Some(selected) = node.selected.or(node.checked) { + fields.push(format!("selected={selected}")); + } + if !node.actions.is_empty() { + fields.push(format!( + "actions={}", + serde_json::to_string(&node.actions).expect("string actions serialize") + )); + } + if node.in_web_content { + fields.push("in_web_content=true".into()); + } + fields.join(" ") +} + fn get_x11_title(conn: &x11rb::rust_connection::RustConnection, window: u32) -> Option { use x11rb::protocol::xproto::*; // Try _NET_WM_NAME first. diff --git a/packages/cua-driver/rust/crates/platform-linux/src/atspi/native.rs b/packages/cua-driver/rust/crates/platform-linux/src/atspi/native.rs index 0c4d30d75e..677c5b0ec0 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/atspi/native.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/atspi/native.rs @@ -8,9 +8,11 @@ //! //! Element indices match the markdown produced by [`walk_tree`]: a depth-first, //! pre-order traversal of the target application's windows, numbering the -//! nodes that advertise AT-SPI actions OR a Value interface (see is_indexable). `perform_action`, `set_value`, and -//! `get_element_bounds` index into that same ordered set. +//! nodes accepted by the shared [`is_indexable`] capability predicate. +//! `perform_action`, `set_value`, and `get_element_bounds` index into that same +//! ordered set. +use std::collections::HashMap; use std::sync::OnceLock; use std::time::Duration; @@ -18,15 +20,21 @@ use anyhow::{anyhow, Result}; use atspi::connection::{AccessibilityConnection, P2P}; use atspi::proxy::accessible::AccessibleProxy; use atspi::proxy::proxy_ext::ProxyExt; -use atspi::{CoordType, Interface, State}; +use atspi::{CoordType, Interface, State, StateSet}; -use super::AtspiNode; +use super::{AtspiIdentity, AtspiNode}; /// Per-call D-Bus timeout: a single unresponsive accessible (common in large, /// lazily-built trees like Chromium's) must not stall the whole walk. const CALL_TIMEOUT: Duration = Duration::from_secs(3); /// Overall budget for one tree walk / operation. const OP_TIMEOUT: Duration = Duration::from_secs(25); +/// Startup may run before `serve` binds its socket or MCP reads stdin. A +/// reachable but wedged accessibility bus must not hold either entry point +/// forever. The worker is deliberately left running after this readiness +/// budget so a late registry reply can still establish the process-lifetime +/// listener. +const LISTENER_STARTUP_TIMEOUT: Duration = Duration::from_secs(3); /// Run `fut` with [`CALL_TIMEOUT`]; `None` on timeout so the caller can skip /// the node and keep walking rather than blocking forever. @@ -34,6 +42,13 @@ async fn call(fut: impl std::future::Future) -> Option { tokio::time::timeout(CALL_TIMEOUT, fut).await.ok() } +async fn before_snapshot_deadline( + deadline: tokio::time::Instant, + work: impl std::future::Future, +) -> std::result::Result { + tokio::time::timeout_at(deadline, work).await +} + /// Drive an AT-SPI op `work` on the runtime, bounded by [`OP_TIMEOUT`]. /// /// Individual interface calls are each bounded by [`call`], and `app_for_pid` / @@ -105,17 +120,109 @@ async fn shared_connection() -> Result<&'static AccessibilityConnection> { /// Establish the process-lifetime listener before accessibility-aware apps are /// launched. Idempotent; later calls reuse the same connection. pub fn ensure_listener_active() -> Result<()> { - let connect = || runtime().block_on(async { shared_connection().await.map(|_| ()) }); - if tokio::runtime::Handle::try_current().is_ok() { - // The daemon builds its registry from its Tokio entry-point. Calling - // Runtime::block_on there panics even though this module owns a separate - // runtime, so initialize the AT-SPI connection on a plain thread and - // wait for it before accessibility-aware apps can launch. - std::thread::spawn(connect) - .join() - .map_err(|_| anyhow!("AT-SPI listener initialization thread panicked"))? - } else { - connect() + wait_for_listener_startup(LISTENER_STARTUP_TIMEOUT, || { + runtime().block_on(async { shared_connection().await.map(|_| ()) }) + }) +} + +fn wait_for_listener_startup( + timeout: Duration, + connect: impl FnOnce() -> Result<()> + Send + 'static, +) -> Result<()> { + let (completed_tx, completed_rx) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("cua-atspi-listener".into()) + .spawn(move || { + let _ = completed_tx.send(connect()); + }) + .map_err(|error| anyhow!("could not spawn AT-SPI listener initialization: {error}"))?; + + match completed_rx.recv_timeout(timeout) { + Ok(result) => result, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(anyhow!( + "AT-SPI listener initialization did not complete within {} ms; continuing in the background", + timeout.as_millis() + )), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + Err(anyhow!("AT-SPI listener initialization thread panicked")) + } + } +} + +#[cfg(test)] +mod listener_startup_tests { + use super::wait_for_listener_startup; + use anyhow::anyhow; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Condvar, Mutex, + }; + use std::time::{Duration, Instant}; + + #[test] + fn healthy_listener_initialization_completes_before_readiness_returns() { + let initialized = Arc::new(AtomicBool::new(false)); + let initialized_in_worker = initialized.clone(); + + wait_for_listener_startup(Duration::from_secs(1), move || { + initialized_in_worker.store(true, Ordering::SeqCst); + Ok(()) + }) + .expect("healthy listener startup"); + + assert!(initialized.load(Ordering::SeqCst)); + } + + #[test] + fn unreachable_listener_initialization_returns_its_error() { + let error = wait_for_listener_startup(Duration::from_secs(1), || { + Err(anyhow!("synthetic AT-SPI connection failure")) + }) + .expect_err("unreachable listener must fail"); + + assert!(error + .to_string() + .contains("synthetic AT-SPI connection failure")); + } + + #[test] + fn stalled_listener_is_bounded_and_keeps_initializing_in_background() { + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let gate_in_worker = gate.clone(); + let completed = Arc::new(AtomicBool::new(false)); + let completed_in_worker = completed.clone(); + let started_at = Instant::now(); + + let error = wait_for_listener_startup(Duration::from_millis(50), move || { + let (lock, ready) = &*gate_in_worker; + let released = lock.lock().expect("listener gate lock"); + drop( + ready + .wait_while(released, |released| !*released) + .expect("listener gate wait"), + ); + completed_in_worker.store(true, Ordering::SeqCst); + Ok(()) + }) + .expect_err("stalled listener must exceed the readiness budget"); + + assert!(error.to_string().contains("continuing in the background")); + assert!( + started_at.elapsed() < Duration::from_secs(1), + "stalled initialization exceeded its bounded wait" + ); + let (lock, ready) = &*gate; + *lock.lock().expect("release listener gate") = true; + ready.notify_one(); + + let completion_deadline = Instant::now() + Duration::from_secs(1); + while !completed.load(Ordering::SeqCst) && Instant::now() < completion_deadline { + std::thread::yield_now(); + } + assert!( + completed.load(Ordering::SeqCst), + "timed-out initialization worker was not allowed to finish" + ); } } @@ -144,9 +251,49 @@ struct Visited<'a> { /// Chromium keeps its document on the application's ordinary AT-SPI bus, /// where descendant Window extents already include the document origin. on_web_process_bus: bool, + /// Position of the application top-level (frame/window) this node descends + /// from, in `app.get_children()` order. AT-SPI exposes one application per + /// process, so a multi-window app publishes every window's controls in one + /// tree; this is what lets a caller that named an exact native window prove + /// which of those windows a node actually lives in. + frame_ordinal: usize, + identity: Option, acc: AccessibleProxy<'a>, } +#[derive(Debug)] +struct WalkStatus { + complete: bool, + truncated: bool, + incomplete_notes: Vec, +} + +impl WalkStatus { + fn complete() -> Self { + Self { + complete: true, + truncated: false, + incomplete_notes: Vec::new(), + } + } + + fn incomplete(&mut self, note: &'static str) { + self.complete = false; + if !self + .incomplete_notes + .iter() + .any(|existing| existing == note) + { + self.incomplete_notes.push(note.into()); + } + } + + fn truncate(&mut self, note: &'static str) { + self.truncated = true; + self.incomplete(note); + } +} + /// Role names that denote embedded web/document content. An editable beneath /// one of these is page content (the field a user means when typing into a /// background browser) rather than browser chrome like the address bar. @@ -232,6 +379,20 @@ impl RawObjectRef { } } +async fn canonical_unique_owner( + dbus: &atspi::zbus::fdo::DBusProxy<'_>, + name: &str, +) -> Option { + if name.starts_with(':') { + return Some(name.to_owned()); + } + let bus_name = atspi::zbus::names::BusName::try_from(name.to_owned()).ok()?; + call(dbus.get_name_owner(bus_name)) + .await + .and_then(Result::ok) + .map(|owner| owner.to_string()) +} + /// Read Accessible.GetChildren without deserializing the bus-name field as a /// `UniqueName`. WebKitGTK's embedded WebProcess exposes a well-known name /// containing a UUID; D-Bus can address it, but the stricter AT-SPI wrapper @@ -270,6 +431,50 @@ async fn pid_of( dbus.get_connection_unix_process_id(bus).await.ok() } +/// Keep the first matching application as a compatibility fallback, but allow +/// a later registration with a real child tree to win. Some Qt processes +/// publish an empty application object before their populated one (#2678, +/// #2706). +struct ApplicationSelection { + target_pid: u32, + fallback: Option, + populated: Vec, +} + +impl ApplicationSelection { + fn new(target_pid: u32) -> Self { + Self { + target_pid, + fallback: None, + populated: Vec::new(), + } + } + + fn matches_pid(&self, candidate_pid: Option) -> bool { + candidate_pid == Some(self.target_pid) + } + + /// Retain every populated exact-PID candidate so resolution can reject an + /// ambiguous registry instead of silently choosing whichever entry sorted + /// first. The first childless candidate remains the compatibility fallback + /// for applications that genuinely expose no top-level accessibles. + fn consider_matching(&mut self, candidate: T, has_children: bool) { + if has_children { + self.populated.push(candidate); + } else if self.fallback.is_none() { + self.fallback = Some(candidate); + } + } + + fn into_selected(mut self) -> std::result::Result, usize> { + match self.populated.len() { + 0 => Ok(self.fallback), + 1 => Ok(self.populated.pop()), + count => Err(count), + } + } +} + /// Locate the application accessible whose backing process is `pid`. async fn app_for_pid<'a>( conn: &'a AccessibilityConnection, @@ -306,6 +511,7 @@ async fn app_for_pid<'a>( "registry root has {} application(s); seeking pid {pid}", apps.len() ); + let mut selection = ApplicationSelection::new(pid); for child in apps { // A modal-grabbed app can't answer the pid query; skip it after // CALL_TIMEOUT rather than blocking the whole walk on it. @@ -320,22 +526,49 @@ async fn app_for_pid<'a>( } }; dlog!(" app bus={:?} pid={:?}", child.name_as_str(), cpid); - if cpid == Some(pid) { - let child = match RawObjectRef::from_atspi(&child) { - Some(child) => child, - None => continue, - }; - return match call(accessible_for(conn, &child)).await { - Some(r) => r.map(Some), - None => { - dlog!(" accessible_for timed out for pid {pid}"); - Ok(None) - } - }; + if !selection.matches_pid(cpid) { + continue; } + let child = match RawObjectRef::from_atspi(&child) { + Some(child) => child, + None => continue, + }; + let app = match call(accessible_for(conn, &child)).await { + Some(Ok(app)) => app, + Some(Err(error)) => { + dlog!(" accessible_for failed for pid {pid}: {error:#}"); + continue; + } + None => { + dlog!(" accessible_for timed out for pid {pid}"); + continue; + } + }; + let has_children = match call(app.get_children()).await { + Some(Ok(children)) => !children.is_empty(), + Some(Err(error)) => { + dlog!(" get_children failed for pid {pid}: {error:#}"); + false + } + None => { + dlog!(" get_children timed out for pid {pid}"); + false + } + }; + dlog!(" matching app has_children={has_children}"); + selection.consider_matching(app, has_children); + } + match selection.into_selected() { + Ok(Some(app)) => Ok(Some(app)), + Ok(None) => { + dlog!("no application accessible matched pid {pid}"); + Ok(None) + } + Err(count) => Err(anyhow!( + "ambiguous AT-SPI application selection for pid {pid}: \ + {count} populated application accessibles matched" + )), } - dlog!("no application accessible matched pid {pid}"); - Ok(None) } /// Depth-first, pre-order walk of an application's windows. Mirrors the old @@ -345,7 +578,128 @@ async fn collect_visited<'a>( conn: &'a AccessibilityConnection, pid: u32, ) -> Result>>> { - collect_visited_bounded(conn, pid, None, None).await + collect_visited_bounded(conn, pid, 0, None, None) + .await + .map(|walked| walked.map(|(visited, _, _)| visited)) +} + +/// Screen-space distance between an AT-SPI frame's extents and a native +/// window's geometry. Lower is a better correspondence; `None` when the frame +/// reports no usable extents. +fn frame_geometry_distance( + frame: (i32, i32, i32, i32), + window: &crate::x11::WindowInfo, +) -> Option { + let (fx, fy, fw, fh) = frame; + if fw <= 0 || fh <= 0 { + return None; + } + let dx = i64::from(fx) - i64::from(window.x); + let dy = i64::from(fy) - i64::from(window.y); + let dw = i64::from(fw) - i64::from(window.width); + let dh = i64::from(fh) - i64::from(window.height); + Some(dx.unsigned_abs() + dy.unsigned_abs() + dw.unsigned_abs() + dh.unsigned_abs()) +} + +/// Server-side decorations offset a frame's reported origin from the native +/// window's outer geometry, so an exact match is not required. The correlation +/// must still be unambiguous: the best candidate has to be within this budget +/// AND beat the runner-up by [`FRAME_MATCH_MARGIN_PX`]. +const FRAME_MATCH_TOLERANCE_PX: u64 = 160; + +/// How decisively the best frame must beat the second-best. Two windows of +/// genuinely similar geometry are not disambiguated by this heuristic, and a +/// caller that needs proof of window identity must get a refusal instead of a +/// coin flip. +const FRAME_MATCH_MARGIN_PX: u64 = 24; + +/// Pick the unique application top-level that corresponds to native window +/// `xid`, or `None` when the correspondence cannot be proven. +/// +/// AT-SPI publishes one application per process: every window of a multi-window +/// app shares a single tree, and the protocol exposes no window handle to join +/// on. Geometry is the available bridge — `Component.GetExtents` in screen +/// coordinates against the X11 outer geometry the caller already named. This +/// refuses ties rather than guessing, because callers use the result to decide +/// which window they are about to act inside. +fn correlate_frame_to_window( + candidates: &[(usize, (i32, i32, i32, i32))], + window: &crate::x11::WindowInfo, +) -> Option { + let mut scored: Vec<(u64, usize)> = candidates + .iter() + .filter_map(|(ordinal, extents)| { + frame_geometry_distance(*extents, window).map(|distance| (distance, *ordinal)) + }) + .collect(); + scored.sort_by_key(|(distance, ordinal)| (*distance, *ordinal)); + let (best_distance, best_ordinal) = *scored.first()?; + if best_distance > FRAME_MATCH_TOLERANCE_PX { + return None; + } + if let Some((runner_up, _)) = scored.get(1) { + if runner_up.saturating_sub(best_distance) < FRAME_MATCH_MARGIN_PX { + return None; + } + } + Some(best_ordinal) +} + +/// Resolve native window `xid` to the ordinal of the application top-level that +/// renders it, or `None` when that cannot be proven. `None` means the walk stays +/// application-wide: callers that merely want a tree carry on, and callers that +/// need window identity must refuse. +async fn resolve_window_frame( + conn: &AccessibilityConnection, + pid: u32, + xid: u64, + seeds: &[RawObjectRef], +) -> Option { + if seeds.len() == 1 { + // One top-level: the caller's window is the only thing this + // application could be showing, and no geometry round-trip can make + // that more certain. + return Some(0); + } + let window = crate::x11::list_windows(Some(pid)) + .into_iter() + .find(|candidate| candidate.xid == xid)?; + let mut candidates: Vec<(usize, (i32, i32, i32, i32))> = Vec::new(); + for (ordinal, oref) in seeds.iter().enumerate() { + let Some(Ok(acc)) = call(accessible_for(conn, oref)).await else { + continue; + }; + // Menus, tooltips and other transients are top-level accessibles too; + // only real windows can correspond to a native window id. + let role = match call(acc.get_role_name()).await { + Some(Ok(role)) => role, + _ => continue, + }; + if !matches!( + role.as_str(), + "frame" | "window" | "dialog" | "alert" | "file chooser" + ) { + continue; + } + let Some(Ok(proxies)) = call(acc.proxies()).await else { + continue; + }; + let Some(Ok(component)) = call(proxies.component()).await else { + continue; + }; + if let Some(Ok(extents)) = call(component.get_extents(CoordType::Screen)).await { + candidates.push((ordinal, extents)); + } + } + let resolved = correlate_frame_to_window(&candidates, &window); + if resolved.is_none() { + dlog!( + "could not correlate xid {xid} to one of pid {pid}'s {} top-level frame(s); \ + walk stays application-scoped", + candidates.len() + ); + } + resolved } /// `collect_visited` with caller-supplied caps. @@ -357,29 +711,58 @@ async fn collect_visited<'a>( async fn collect_visited_bounded<'a>( conn: &'a AccessibilityConnection, pid: u32, + xid: u64, max_elements: Option, max_depth: Option, -) -> Result>>> { +) -> Result>, Option, WalkStatus)>> { let app = match app_for_pid(conn, pid).await? { Some(a) => a, None => return Ok(None), }; let zconn = conn.connection(); + let dbus = atspi::zbus::fdo::DBusProxy::new(zconn) + .await + .map_err(|error| anyhow!("DBus proxy unavailable: {error}"))?; + let mut status = WalkStatus::complete(); - // Stack of (object ref, depth, in_web_doc). Seed with the app's windows; - // push children reversed so siblings pop left-to-right and each subtree - // completes before the next sibling (pre-order). `in_web_doc` is inherited - // from ancestors so editables in page content can be told from chrome. - let mut stack: Vec<(RawObjectRef, usize, bool)> = match call(app.get_children()).await { + // Stack of (object ref, depth, in_web_doc, frame_ordinal). Seed with the + // app's windows; push children reversed so siblings pop left-to-right and + // each subtree completes before the next sibling (pre-order). `in_web_doc` + // is inherited from ancestors so editables in page content can be told from + // chrome. `frame_ordinal` is the seed's position in `get_children()` order + // and is likewise inherited, so every node carries the identity of the + // top-level window it belongs to. + let seeds: Vec = match call(app.get_children()).await { Some(Ok(children)) => children .into_iter() .filter_map(|child| RawObjectRef::from_atspi(&child)) - .rev() - .map(|r| (r, 0usize, false)) .collect(), - _ => Vec::new(), + _ => { + status.incomplete("application_children_unavailable"); + Vec::new() + } }; + // Resolve which seed is the caller's window before walking, from the same + // child list the walk is about to seed from. Re-reading `get_children()` + // later could observe a different window set, and an ordinal resolved + // against one list but applied to another names the wrong window. + let scoped_frame = if xid == 0 { + None + } else { + resolve_window_frame(conn, pid, xid, &seeds).await + }; + if xid != 0 && scoped_frame.is_none() { + status.incomplete("window_scope_unresolved"); + } + + let mut stack: Vec<(RawObjectRef, usize, bool, usize)> = seeds + .into_iter() + .enumerate() + .map(|(ordinal, r)| (r, 0usize, false, ordinal)) + .rev() + .collect(); + let mut visited: Vec> = Vec::new(); // Guard against pathological/looping trees. Defaults to 5 000 (the // historical hard-coded budget); callers can override via max_elements. @@ -399,14 +782,17 @@ async fn collect_visited_bounded<'a>( // time out too. Give up after a few so type_text falls back to XTEST in a // few seconds rather than ~25s. let mut consecutive_timeouts = 0u32; + let mut owner_cache: HashMap> = HashMap::new(); - while let Some((oref, depth, inherited_web_doc)) = stack.pop() { + while let Some((oref, depth, inherited_web_doc, frame_ordinal)) = stack.pop() { if budget == 0 { dlog!("node budget exhausted; truncating walk"); + status.truncate("max_elements_reached"); break; } if std::time::Instant::now() >= deadline { dlog!("collect_visited time budget exhausted; returning partial walk"); + status.truncate("walk_deadline_reached"); break; } budget -= 1; @@ -426,15 +812,18 @@ async fn collect_visited_bounded<'a>( Some(Ok(a)) => a, Some(Err(error)) => { dlog!(" accessible_for failed: {error:#}"); + status.incomplete("accessible_proxy_unavailable"); continue; } None => { + status.incomplete("accessible_proxy_timeout"); consecutive_timeouts += 1; if consecutive_timeouts >= 3 { dlog!( "{} consecutive AT-SPI timeouts (accessible_for); app unresponsive, bailing walk", consecutive_timeouts ); + status.truncate("provider_unresponsive"); break; } continue; @@ -451,17 +840,20 @@ async fn collect_visited_bounded<'a>( // A completed-but-errored call is node-specific; keep walking. Some(Err(error)) => { dlog!(" get_interfaces failed: {error:#}"); + status.incomplete("interfaces_unavailable"); continue; } // A timeout means the app didn't answer in CALL_TIMEOUT. A run of // these means the whole app is wedged — bail so callers fall back. None => { + status.incomplete("interfaces_timeout"); consecutive_timeouts += 1; if consecutive_timeouts >= 3 { dlog!( "{} consecutive AT-SPI timeouts; app unresponsive, bailing walk", consecutive_timeouts ); + status.truncate("provider_unresponsive"); break; } continue; @@ -482,6 +874,18 @@ async fn collect_visited_bounded<'a>( call(acc.get_state()), call(raw_children(zconn, &oref)), ); + if !matches!(&role_r, Some(Ok(_))) { + status.incomplete("role_unavailable"); + } + if !matches!(&name_r, Some(Ok(_))) { + status.incomplete("name_unavailable"); + } + if !matches!(&state_r, Some(Ok(_))) { + status.incomplete("state_unavailable"); + } + if !matches!(&children_r, Some(Ok(_))) { + status.incomplete("children_unavailable"); + } let role = match role_r { Some(Ok(r)) => r, _ => String::new(), @@ -503,7 +907,7 @@ async fn collect_visited_bounded<'a>( let enabled = state_r .as_ref() .and_then(|state| state.as_ref().ok()) - .map(|state| state.contains(State::Enabled) && state.contains(State::Sensitive)); + .map(is_enabled_state); let selectable = state_r .as_ref() .and_then(|state| state.as_ref().ok()) @@ -573,6 +977,8 @@ async fn collect_visited_bounded<'a>( } } } + } else { + status.incomplete("interface_proxies_unavailable"); } } @@ -589,17 +995,35 @@ async fn collect_visited_bounded<'a>( // would exceed the cap. let descend = max_depth.map(|d| depth + 1 <= d).unwrap_or(true); if descend { - match children_r { + match &children_r { Some(Ok(children)) => { - for c in children.into_iter().rev() { - stack.push((c, depth + 1, child_in_web_doc)); + for c in children.iter().rev().cloned() { + stack.push((c, depth + 1, child_in_web_doc, frame_ordinal)); } } Some(Err(error)) => dlog!(" get_children failed: {error:#}"), None => dlog!(" get_children timed out"), } + } else if matches!(&children_r, Some(Ok(children)) if !children.is_empty()) { + status.truncate("max_depth_reached"); } + let unique_owner = match owner_cache.get(&oref.name) { + Some(owner) => owner.clone(), + None => { + let owner = canonical_unique_owner(&dbus, &oref.name).await; + owner_cache.insert(oref.name.clone(), owner.clone()); + owner + } + }; + if unique_owner.is_none() { + status.incomplete("unique_owner_unavailable"); + } + let identity = unique_owner.map(|unique_owner| AtspiIdentity { + unique_owner, + object_path: oref.path.clone(), + }); + visited.push(Visited { depth, role, @@ -616,12 +1040,14 @@ async fn collect_visited_bounded<'a>( focused, in_web_doc, on_web_process_bus: is_web_process_bus(&oref.name), + frame_ordinal, + identity, acc, }); } dlog!("walked pid {pid}: {} node(s)", visited.len()); - Ok(Some(visited)) + Ok(Some((visited, scoped_frame, status))) } /// Render visited nodes into the markdown + node list `walk_tree` returns. @@ -631,10 +1057,18 @@ async fn collect_visited_bounded<'a>( /// `parent_at_depth` tracks the most recently emitted actionable index at /// each depth, so descendants can look up their parent_element_index without /// a second pass. -fn render(visited: &[Visited<'_>]) -> (String, Vec) { +/// +/// `only_frame` restricts what is *emitted* to one application top-level while +/// leaving the index space application-wide. Element indices are the contract +/// between a snapshot and every actuator that later takes one +/// (`perform_action`, `focus_element`, `set_value`, …), and those resolve an +/// index against the whole application. Renumbering per window would make a +/// window-scoped snapshot's indices name different elements at actuation time. +fn render(visited: &[Visited<'_>], only_frame: Option) -> (String, Vec) { let mut md = String::new(); let mut nodes = Vec::new(); let mut idx = 0usize; + let mut current_frame: Option = None; // Sparse stack: parent_at_depth[d] = Some(idx) for the actionable node // most recently emitted at depth d. When a new node appears at depth d, // its parent_element_index is the closest ancestor at depth < d that has @@ -643,6 +1077,14 @@ fn render(visited: &[Visited<'_>]) -> (String, Vec) { let mut parent_at_depth: Vec> = Vec::new(); for v in visited { + // Ancestry never spans two top-levels, so a frame change retires every + // recorded parent. Without this a window's first descendants could + // inherit a parent index from the previous window's subtree. + if current_frame != Some(v.frame_ordinal) { + current_frame = Some(v.frame_ordinal); + parent_at_depth.clear(); + } + let emit = only_frame.is_none_or(|frame| frame == v.frame_ordinal); let indent = " ".repeat(v.depth); // Resolve parent: walk parent_at_depth from v.depth-1 down to 0. let parent_element_index = if v.depth == 0 { @@ -654,6 +1096,12 @@ fn render(visited: &[Visited<'_>]) -> (String, Vec) { }; if is_indexable(v) { + if !emit { + // Consume the index without emitting: indices stay aligned with + // the application-wide walk the actuators perform. + idx += 1; + continue; + } let act_str = v.actions.join(","); let val_part = match &v.value { Some(val) if !val.is_empty() => format!(" value=\"{val}\""), @@ -682,6 +1130,7 @@ fn render(visited: &[Visited<'_>]) -> (String, Vec) { depth: v.depth, parent_element_index, in_web_content: v.in_web_doc, + identity: v.identity.clone(), }); // Record this actionable index at its depth, and invalidate any // deeper entries from a previous subtree. @@ -693,7 +1142,7 @@ fn render(visited: &[Visited<'_>]) -> (String, Vec) { parent_at_depth[deeper] = None; } idx += 1; - } else if !v.name.is_empty() { + } else if emit && !v.name.is_empty() { md.push_str(&format!( "{indent}- {role} = \"{name}\"\n", role = v.role, @@ -711,6 +1160,16 @@ fn format_value(v: f64) -> String { format!("{v:?}") } +/// Interpret the positive AT-SPI states that establish user operability. +/// +/// GTK3 commonly publishes both `Enabled` and `Sensitive`. GTK4's native +/// exporter derives widget operability from its `disabled` accessibility state +/// and publishes `Sensitive` alone for an enabled widget. Either positive state +/// therefore establishes operability; an empty set still means disabled. +fn is_enabled_state(state: &StateSet) -> bool { + state.contains(State::Enabled) || state.contains(State::Sensitive) +} + /// Whether a walked node is exposed as an indexed, usable element. /// /// Historically this was "the node advertises AT-SPI Actions" (buttons, menu @@ -718,7 +1177,10 @@ fn format_value(v: f64) -> String { /// selectable list rows. GTK list rows expose Component + Selectable state but /// no Action even though a coordinate click on their bounds is operable. Keep /// every such control in the shared index space so physical-input fallbacks can -/// address it without inventing pixels in the caller. +/// address it without inventing pixels in the caller. Some GTK4 buttons expose +/// only Component plus their control role; include those only when the state set +/// positively verifies that they are enabled. Passive component-backed labels +/// and containers remain outside the index. /// /// This predicate is the single source of truth for the element-index space and /// MUST be applied identically in `render` and in every `action_nodes` filter @@ -726,29 +1188,93 @@ fn format_value(v: f64) -> String { /// any divergence would desync indices between the snapshot and the operations. fn is_indexable(v: &Visited) -> bool { is_indexable_capabilities( + &v.role, !v.actions.is_empty(), v.has_editable, v.has_value, v.selectable, + v.has_component, v.enabled, ) } +fn select_indexable_target<'v, 'a>( + visited: &'v [Visited<'a>], + idx: usize, + identity: Option<&AtspiIdentity>, +) -> Result<&'v Visited<'a>> { + if let Some(identity) = identity { + let mut matches = visited + .iter() + .filter(|node| is_indexable(node) && node.identity.as_ref() == Some(identity)); + let target = matches.next().ok_or_else(|| { + anyhow!( + "stale AT-SPI identity {}{}: owner disappeared or object was removed", + identity.unique_owner, + identity.object_path + ) + })?; + if matches.next().is_some() { + return Err(anyhow!( + "ambiguous AT-SPI identity {}{}", + identity.unique_owner, + identity.object_path + )); + } + return Ok(target); + } + let action_nodes = visited + .iter() + .filter(|node| is_indexable(node)) + .collect::>(); + action_nodes + .get(idx) + .copied() + .ok_or_else(|| anyhow!("element {idx} not found (total: {})", action_nodes.len())) +} + fn is_indexable_capabilities( + role: &str, has_action: bool, has_editable: bool, has_value: bool, has_selectable_state: bool, + has_component: bool, enabled: Option, ) -> bool { - (has_action || has_editable || has_value || has_selectable_state) && enabled != Some(false) + let normalized_role = role.trim().to_ascii_lowercase(); + let pixel_addressable_control = has_component + && enabled == Some(true) + && matches!(normalized_role.as_str(), "button" | "push button"); + !is_passive_role(&normalized_role) + && (has_action + || has_editable + || has_value + || has_selectable_state + || pixel_addressable_control) + && enabled == Some(true) } // ── Public (sync) entry points ─────────────────────────────────────────────── pub fn walk_tree(pid: u32) -> Result)>> { walk_tree_bounded(pid, 0, None, None) - .map(|snapshot| snapshot.map(|(markdown, nodes, _)| (markdown, nodes))) + .map(|snapshot| snapshot.map(|walked| (walked.markdown, walked.nodes))) +} + +/// One accessibility snapshot, plus whether it was provably narrowed to the +/// caller's window. +pub struct WalkedTree { + pub markdown: String, + pub nodes: Vec, + pub bounds: Vec<(usize, i32, i32, u32, u32)>, + /// True when a non-zero `xid` was resolved to exactly one application + /// top-level and the snapshot contains only that window's nodes. False + /// means the snapshot spans every window the application publishes. + pub window_scoped: bool, + pub complete: bool, + pub truncated: bool, + pub incomplete_notes: Vec, } /// Walk the AT-SPI tree with caller-supplied node + depth caps. @@ -759,7 +1285,7 @@ pub fn walk_tree_bounded( xid: u64, max_elements: Option, max_depth: Option, -) -> Result, Vec<(usize, i32, i32, u32, u32)>)>> { +) -> Result> { walk_tree_bounded_with_timeout(pid, xid, max_elements, max_depth, OP_TIMEOUT) } @@ -769,25 +1295,61 @@ pub(super) fn walk_tree_bounded_with_timeout( max_elements: Option, max_depth: Option, timeout: Duration, -) -> Result, Vec<(usize, i32, i32, u32, u32)>)>> { +) -> Result> { runtime().block_on(async { + // Tree traversal and bounds collection form one snapshot. Keep one + // deadline for both phases so a dead AT-SPI peer cannot outlive the + // operation timeout while resolving geometry. + let deadline = tokio::time::Instant::now() + timeout; let walk = async { let conn = shared_connection().await?; - collect_visited_bounded(conn, pid, max_elements, max_depth).await + collect_visited_bounded(conn, pid, xid, max_elements, max_depth).await }; - let visited = match tokio::time::timeout(timeout, walk).await { + let walked = match before_snapshot_deadline(deadline, walk).await { Ok(result) => result?, Err(_) => { dlog!("walk_tree timed out for pid {pid}"); return Ok(None); } }; - let Some(visited) = visited else { + let Some((visited, scoped_frame, mut status)) = walked else { return Ok(None); }; - let (markdown, nodes) = render(&visited); - let bounds = element_bounds_for_visited(&visited, pid, xid).await; - Ok(Some((markdown, nodes, bounds))) + let (markdown, nodes) = render(&visited, scoped_frame); + let bounds = match before_snapshot_deadline( + deadline, + element_bounds_for_visited(&visited, pid, xid), + ) + .await + { + Ok(bounds) => bounds, + Err(_) => { + dlog!("element bounds timed out for pid {pid}"); + status.incomplete("element_bounds_timeout"); + Vec::new() + } + }; + // Bounds are keyed by the application-wide element index, so drop the + // entries for windows this snapshot no longer shows. + let bounds = if scoped_frame.is_some() { + let emitted: std::collections::HashSet = + nodes.iter().filter_map(|node| node.element_index).collect(); + bounds + .into_iter() + .filter(|(index, ..)| emitted.contains(index)) + .collect() + } else { + bounds + }; + Ok(Some(WalkedTree { + markdown, + nodes, + bounds, + window_scoped: scoped_frame.is_some(), + complete: status.complete, + truncated: status.truncated, + incomplete_notes: status.incomplete_notes, + })) }) } @@ -1071,18 +1633,19 @@ pub fn type_into_editable(pid: u32, text: &str) -> Result<()> { } /// Write into the exact indexed editable exposed by the caller's snapshot. -pub fn type_into_editable_at(pid: u32, idx: usize, text: &str) -> Result<()> { +pub fn type_into_editable_at( + pid: u32, + idx: usize, + identity: Option, + text: &str, +) -> Result<()> { bounded( async { let conn = shared_connection().await?; let visited = collect_visited(conn, pid) .await? .ok_or_else(|| anyhow!("no AT-SPI application for pid {pid}"))?; - let target = visited - .iter() - .filter(|node| is_indexable(node)) - .nth(idx) - .ok_or_else(|| anyhow!("element {idx} not found (total: {})", visited.len()))?; + let target = select_indexable_target(&visited, idx, identity.as_ref())?; if write_into_editable_target(target, text).await? { Ok(()) } else { @@ -1092,13 +1655,8 @@ pub fn type_into_editable_at(pid: u32, idx: usize, text: &str) -> Result<()> { let refreshed = collect_visited(conn, pid) .await? .ok_or_else(|| anyhow!("no AT-SPI application for pid {pid}"))?; - let refreshed_target = refreshed - .iter() - .filter(|node| is_indexable(node)) - .nth(idx) - .ok_or_else(|| { - anyhow!("element {idx} disappeared after AT-SPI focus refresh") - })?; + let refreshed_target = select_indexable_target(&refreshed, idx, identity.as_ref()) + .map_err(|_| anyhow!("element {idx} disappeared after AT-SPI focus refresh"))?; if write_into_editable_target(refreshed_target, text).await? { Ok(()) } else { @@ -1439,17 +1997,18 @@ pub fn invoke_menu_path(pid: u32, path: &[String]) -> Result<()> { ) } -pub fn perform_action(pid: u32, idx: usize) -> Result<(String, bool)> { +pub fn perform_action( + pid: u32, + idx: usize, + identity: Option, +) -> Result<(String, bool)> { bounded( async { let conn = shared_connection().await?; let visited = collect_visited(conn, pid) .await? .ok_or_else(|| anyhow!("no AT-SPI application for pid {pid}"))?; - let action_nodes: Vec<&Visited> = visited.iter().filter(|v| is_indexable(v)).collect(); - let target = action_nodes.get(idx).ok_or_else(|| { - anyhow!("element {idx} not found (total: {})", action_nodes.len()) - })?; + let target = select_indexable_target(&visited, idx, identity.as_ref())?; // Suspected no-op: actuating `do_action(0)` on a passive display role // (a `label`/`static`/`image` indexed only for its Value interface) or a @@ -1497,23 +2056,76 @@ pub fn perform_action(pid: u32, idx: usize) -> Result<(String, bool)> { ) } -/// Invoke an indexed scroll target's directional AT-SPI action. -/// -/// Chromium exposes scrollable web regions as named actions such as -/// `scrollDown`/`scrollForward`; using that accessibility route avoids the -/// X11 `Button5` event path that Chromium silently drops in background mode. -pub fn scroll_element(pid: u32, idx: usize, direction: &str, amount: usize) -> Result<()> { +pub fn perform_secondary_action( + pid: u32, + idx: usize, + identity: AtspiIdentity, + requested: &str, +) -> Result { + let requested = requested.to_owned(); bounded( async { let conn = shared_connection().await?; let visited = collect_visited(conn, pid) .await? .ok_or_else(|| anyhow!("no AT-SPI application for pid {pid}"))?; - let target = visited + let target = select_indexable_target(&visited, idx, Some(&identity))?; + if target.enabled == Some(false) { + return Err(anyhow!("element {idx} is disabled")); + } + if requested.is_empty() { + return Err(anyhow!("secondary action must not be empty")); + } + let matches = target + .actions .iter() - .filter(|v| is_indexable(v)) - .nth(idx) - .ok_or_else(|| anyhow!("element {idx} not found (total: {})", visited.len()))?; + .enumerate() + .filter(|(_, action)| action.as_str() == requested) + .collect::>(); + let [(action_index, action_name)] = matches.as_slice() else { + return Err(anyhow!( + "secondary action '{requested}' is unavailable or ambiguous; advertised actions: {}", + target.actions.join(", ") + )); + }; + let action = target + .acc + .proxies() + .await + .map_err(|error| anyhow!("interface proxies unavailable: {error}"))? + .action() + .await + .map_err(|error| anyhow!("Action unavailable: {error}"))?; + match call(action.do_action(*action_index as i32)).await { + Some(Ok(true)) => Ok((*action_name).clone()), + Some(Ok(false)) => Err(anyhow!("secondary action returned false")), + Some(Err(error)) => Err(anyhow!("secondary action failed: {error}")), + None => Err(anyhow!("secondary action timed out")), + } + }, + || Err(anyhow!("secondary action timed out for pid {pid}")), + ) +} + +/// Invoke an indexed scroll target's directional AT-SPI action. +/// +/// Chromium exposes scrollable web regions as named actions such as +/// `scrollDown`/`scrollForward`; using that accessibility route avoids the +/// X11 `Button5` event path that Chromium silently drops in background mode. +pub fn scroll_element( + pid: u32, + idx: usize, + identity: Option, + direction: &str, + amount: usize, +) -> Result<()> { + bounded( + async { + let conn = shared_connection().await?; + let visited = collect_visited(conn, pid) + .await? + .ok_or_else(|| anyhow!("no AT-SPI application for pid {pid}"))?; + let target = select_indexable_target(&visited, idx, identity.as_ref())?; let proxies = target .acc .proxies() @@ -1611,20 +2223,15 @@ pub fn scroll_element(pid: u32, idx: usize, direction: &str, amount: usize) -> R /// updates its renderer-owned focused control. Sending key events immediately /// after the acknowledgement can therefore split one string between the old /// and new controls. Wait for the target's Focused state to become observable; -/// if a toolkit does not publish that state, retain the historical successful -/// result after a bounded settling interval. -pub fn focus_element(pid: u32, idx: usize) -> Result { +/// an acknowledgement without read-back is not sufficient for global input. +pub fn focus_element(pid: u32, idx: usize, identity: Option) -> Result { bounded( async { let conn = shared_connection().await?; let visited = collect_visited(conn, pid) .await? .ok_or_else(|| anyhow!("no AT-SPI application for pid {pid}"))?; - let target = visited - .iter() - .filter(|v| is_indexable(v)) - .nth(idx) - .ok_or_else(|| anyhow!("element {idx} not found (total: {})", visited.len()))?; + let target = select_indexable_target(&visited, idx, identity.as_ref())?; let proxies = target .acc .proxies() @@ -1660,7 +2267,7 @@ pub fn focus_element(pid: u32, idx: usize) -> Result { } } } - Ok(true) + Ok(false) }, || Err(anyhow!("focus_element timed out for pid {pid}")), ) @@ -1911,17 +2518,14 @@ fn select_click_target( best_active.or(best_passive).map(|(_, idx)| idx) } -pub fn set_value(pid: u32, idx: usize, value: &str) -> Result<()> { +pub fn set_value(pid: u32, idx: usize, identity: Option, value: &str) -> Result<()> { bounded( async { let conn = shared_connection().await?; let visited = collect_visited(conn, pid) .await? .ok_or_else(|| anyhow!("no AT-SPI application for pid {pid}"))?; - let action_nodes: Vec<&Visited> = visited.iter().filter(|v| is_indexable(v)).collect(); - let target = action_nodes.get(idx).ok_or_else(|| { - anyhow!("element {idx} not found (total: {})", action_nodes.len()) - })?; + let target = select_indexable_target(&visited, idx, identity.as_ref())?; let proxies = target .acc @@ -1976,7 +2580,11 @@ pub fn set_value(pid: u32, idx: usize, value: &str) -> Result<()> { ) } -pub fn get_element_bounds(pid: u32, idx: usize) -> Result<(i32, i32, u32, u32)> { +pub fn get_element_bounds( + pid: u32, + idx: usize, + identity: Option, +) -> Result<(i32, i32, u32, u32)> { bounded( async { let conn = shared_connection().await?; @@ -1986,10 +2594,7 @@ pub fn get_element_bounds(pid: u32, idx: usize) -> Result<(i32, i32, u32, u32)> let web_document_origin = web_document_origin_for_visited(&visited, pid) .await .unwrap_or((0, 0)); - let action_nodes: Vec<&Visited> = visited.iter().filter(|v| is_indexable(v)).collect(); - let target = action_nodes - .get(idx) - .ok_or_else(|| anyhow!("element {idx} not found"))?; + let target = select_indexable_target(&visited, idx, identity.as_ref())?; if !target.has_component { return Err(anyhow!("element {idx} exposes no Component interface")); } @@ -2483,34 +3088,230 @@ async fn element_bounds_for_visited( out } +#[cfg(test)] +mod frame_correlation_tests { + use super::{correlate_frame_to_window, FRAME_MATCH_TOLERANCE_PX}; + use crate::x11::WindowInfo; + + fn window(x: i32, y: i32, width: u32, height: u32) -> WindowInfo { + WindowInfo { + xid: 4242, + pid: Some(99), + app_name: "Google-chrome".to_owned(), + title: "Cua - Google Chrome".to_owned(), + is_on_screen: true, + z_index: Some(3), + x, + y, + width, + height, + } + } + + /// The configuration that made the existing-profile route unreachable: one + /// browser process publishing three windows. + #[test] + fn picks_the_frame_matching_the_named_window_among_siblings() { + let candidates = [ + (0usize, (144, 51, 1244, 953)), + (1, (438, 80, 1050, 953)), + (2, (550, 225, 500, 584)), + ]; + assert_eq!( + correlate_frame_to_window(&candidates, &window(438, 80, 1050, 953)), + Some(1) + ); + assert_eq!( + correlate_frame_to_window(&candidates, &window(550, 225, 500, 584)), + Some(2) + ); + } + + /// Server-side decorations shift a frame's reported origin; a small offset + /// must still resolve rather than fall back to an application-wide walk. + #[test] + fn tolerates_decoration_offsets() { + let candidates = [(0usize, (440, 108, 1050, 925)), (1, (144, 51, 1244, 953))]; + assert_eq!( + correlate_frame_to_window(&candidates, &window(438, 80, 1050, 953)), + Some(0) + ); + } + + /// Two windows of the same geometry cannot be told apart this way, and the + /// caller needs a refusal rather than a coin flip. + #[test] + fn refuses_when_two_frames_are_equally_plausible() { + let candidates = [(0usize, (438, 80, 1050, 953)), (1, (438, 80, 1050, 953))]; + assert_eq!( + correlate_frame_to_window(&candidates, &window(438, 80, 1050, 953)), + None + ); + } + + #[test] + fn refuses_when_no_frame_is_close_enough() { + let candidates = [(0usize, (0, 0, 200, 200))]; + assert_eq!( + correlate_frame_to_window(&candidates, &window(438, 80, 1050, 953)), + None + ); + } + + #[test] + fn refuses_when_the_application_publishes_no_frame_extents() { + assert_eq!( + correlate_frame_to_window(&[], &window(438, 80, 1050, 953)), + None + ); + } + + /// Zero-area extents are what a frame reports before it has been mapped; + /// they must never be treated as a match for a real window. + #[test] + fn ignores_frames_without_usable_extents() { + let candidates = [(0usize, (0, 0, 0, 0)), (1, (438, 80, 1050, 953))]; + assert_eq!( + correlate_frame_to_window(&candidates, &window(438, 80, 1050, 953)), + Some(1) + ); + } + + /// Being the only candidate is not evidence of correspondence. (The walk + /// does short-circuit a genuinely single-top-level application before it + /// reaches this function — see `resolve_window_frame`.) + #[test] + fn a_sole_candidate_still_has_to_be_close_enough() { + let far_away = i32::try_from(FRAME_MATCH_TOLERANCE_PX).unwrap() + 500; + let candidates = [(0usize, (far_away, far_away, 1050, 953))]; + assert_eq!( + correlate_frame_to_window(&candidates, &window(438, 80, 1050, 953)), + None + ); + } +} + #[cfg(test)] mod coord_tests { use super::parse_gtk_frame_extents; use super::{ - activation_index, combine_wayland_content_offsets, is_activation_action, - is_indexable_capabilities, is_passive_role, is_web_process_bus, - prefer_authoritative_wayland_origin, rebase_renderer_window_offset, screen_extent_rebase, - select_click_target, + activation_index, before_snapshot_deadline, combine_wayland_content_offsets, + is_activation_action, is_enabled_state, is_indexable_capabilities, is_passive_role, + is_web_process_bus, prefer_authoritative_wayland_origin, rebase_renderer_window_offset, + screen_extent_rebase, select_click_target, ApplicationSelection, }; + use atspi::{State, StateSet}; + use std::time::Duration; + + #[test] + fn duplicate_pid_prefers_populated_application_after_empty_registration() { + let target_pid = 4242; + let candidates = [ + (Some(9000), "other-process", true), + (Some(target_pid), "empty-root", false), + (Some(target_pid), "live-tree", true), + ]; + let mut selection = ApplicationSelection::new(target_pid); + for (pid, app, has_children) in candidates { + if selection.matches_pid(pid) { + selection.consider_matching(app, has_children); + } + } + + assert_eq!(selection.into_selected(), Ok(Some("live-tree"))); + } + + #[test] + fn foreign_empty_application_before_target_is_ignored() { + let target_pid = 4242; + let candidates = [ + (Some(9000), "foreign-empty", false), + (Some(target_pid), "target-live-tree", true), + ]; + let mut selection = ApplicationSelection::new(target_pid); + + for (pid, app, has_children) in candidates { + if selection.matches_pid(pid) { + selection.consider_matching(app, has_children); + } + } + + assert_eq!(selection.into_selected(), Ok(Some("target-live-tree"))); + } + + #[test] + fn childless_exact_pid_application_remains_the_fallback() { + let mut selection = ApplicationSelection::new(4242); + selection.consider_matching("first-empty", false); + selection.consider_matching("second-empty", false); + + assert_eq!(selection.into_selected(), Ok(Some("first-empty"))); + } + + #[test] + fn multiple_populated_exact_pid_applications_are_ambiguous() { + let mut selection = ApplicationSelection::new(4242); + selection.consider_matching("first-live-tree", true); + selection.consider_matching("second-live-tree", true); + + assert_eq!(selection.into_selected(), Err(2)); + } + + #[tokio::test(start_paused = true)] + async fn one_absolute_deadline_spans_traversal_and_bounds() { + let deadline = tokio::time::Instant::now() + Duration::from_millis(100); + + before_snapshot_deadline(deadline, tokio::time::sleep(Duration::from_millis(60))) + .await + .expect("traversal should fit the shared budget"); + before_snapshot_deadline(deadline, tokio::time::sleep(Duration::from_millis(60))) + .await + .expect_err("bounds must receive only the traversal's remaining budget"); + + assert_eq!(tokio::time::Instant::now(), deadline); + } #[test] fn operable_nodes_are_addressable() { assert!(is_indexable_capabilities( + "entry", false, true, false, false, + true, Some(true) )); - assert!(is_indexable_capabilities(true, false, false, false, None)); assert!(is_indexable_capabilities( + "button", + true, + false, + false, + false, + false, + Some(true) + )); + assert!(is_indexable_capabilities( + "slider", false, false, true, false, + true, Some(true) )); assert!(is_indexable_capabilities( + "list item", + false, + false, + false, + true, + true, + Some(true) + )); + assert!(!is_indexable_capabilities( + "label", + false, false, false, false, @@ -2518,19 +3319,80 @@ mod coord_tests { Some(true) )); assert!(!is_indexable_capabilities( - false, - false, - false, - false, - Some(true) - )); - assert!(!is_indexable_capabilities( + "button", true, false, false, false, + true, Some(false) )); + assert!(!is_indexable_capabilities( + "button", true, false, false, false, true, None + )); + assert!(!is_indexable_capabilities( + "label", + true, + false, + false, + false, + true, + Some(true) + )); + } + + #[test] + fn gtk_state_sets_establish_operability_from_enabled_or_sensitive() { + assert!(is_enabled_state(&StateSet::new(State::Enabled))); + assert!(is_enabled_state(&StateSet::new(State::Sensitive))); + assert!(is_enabled_state(&StateSet::new( + State::Enabled | State::Sensitive + ))); + assert!(!is_enabled_state(&StateSet::empty())); + } + + #[test] + fn enabled_component_backed_buttons_are_pixel_addressable() { + for role in ["button", "push button", " Button "] { + assert!(is_indexable_capabilities( + role, + false, + false, + false, + false, + true, + Some(true) + )); + } + } + + #[test] + fn component_role_fallback_rejects_unverified_or_passive_nodes() { + for enabled in [None, Some(false)] { + assert!(!is_indexable_capabilities( + "button", false, false, false, false, true, enabled + )); + } + assert!(!is_indexable_capabilities( + "button", + false, + false, + false, + false, + false, + Some(true) + )); + for role in ["label", "application", "panel", "frame", "window"] { + assert!(!is_indexable_capabilities( + role, + false, + false, + false, + false, + true, + Some(true) + )); + } } #[test] diff --git a/packages/cua-driver/rust/crates/platform-linux/src/atspi/revision.rs b/packages/cua-driver/rust/crates/platform-linux/src/atspi/revision.rs new file mode 100644 index 0000000000..55210be9f6 --- /dev/null +++ b/packages/cua-driver/rust/crates/platform-linux/src/atspi/revision.rs @@ -0,0 +1,437 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Mutex; + +use cua_driver_core::observation_revision::{ + CapturedNode, FullResyncReason, ObservationLineage, ObservationRevisionError, + ObservationRevisionRequest, ObservationRevisionResult, ObservationSessionIdentity, +}; + +use super::{format_revision_body, AtspiBackend, AtspiIdentity, AtspiNode, AtspiTreeResult}; + +const RETAINED_REVISIONS: usize = 8; +const MAX_LINEAGES: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RevisionKey { + session: ObservationSessionIdentity, + pid: u32, + xid: u64, + max_elements: usize, + max_depth: usize, + serializer_version: String, + projection_version: String, +} + +struct LinuxLineage { + revision: ObservationLineage, + owners: HashSet, +} + +impl LinuxLineage { + fn new() -> Result { + Ok(Self { + revision: ObservationLineage::new( + format!("l_{}", uuid::Uuid::new_v4().simple()), + RETAINED_REVISIONS, + ) + .map_err(|error| error.to_string())?, + owners: HashSet::new(), + }) + } +} + +#[derive(Default)] +struct RevisionStore { + lineages: HashMap, + lru: VecDeque, +} + +impl RevisionStore { + fn touch(&mut self, key: &RevisionKey) { + self.lru.retain(|candidate| candidate != key); + self.lru.push_back(key.clone()); + } + + fn ensure_capacity(&mut self) { + while self.lineages.len() >= MAX_LINEAGES { + let Some(key) = self.lru.pop_front() else { + break; + }; + self.remove(&key); + } + } + + fn remove(&mut self, key: &RevisionKey) { + self.lru.retain(|candidate| candidate != key); + if let Some(lineage) = self.lineages.remove(key) { + cua_driver_core::observation_revision::revision_tokens() + .clear_lineage(lineage.revision.lineage_id()); + } + } +} + +pub struct LinuxObservationRevisions { + store: Mutex, +} + +impl LinuxObservationRevisions { + pub fn new() -> Self { + Self { + store: Mutex::new(RevisionStore::default()), + } + } + + #[allow(clippy::too_many_arguments)] + pub fn observe( + &self, + session: ObservationSessionIdentity, + pid: u32, + xid: u64, + max_elements: usize, + max_depth: usize, + tree: &AtspiTreeResult, + request: &ObservationRevisionRequest, + ) -> Result { + let key = RevisionKey { + session, + pid, + xid, + max_elements, + max_depth, + serializer_version: request.serializer_version.clone(), + projection_version: request.projection_version.clone(), + }; + if tree.backend == AtspiBackend::X11 { + self.store.lock().unwrap().remove(&key); + return transient_full(&tree.nodes, FullResyncReason::UnsupportedBackend); + } + if !tree.complete { + self.store.lock().unwrap().remove(&key); + return transient_full(&tree.nodes, FullResyncReason::CaptureIncomplete); + } + let identities = tree + .nodes + .iter() + .map(|node| node.identity.clone()) + .collect::>>(); + let Some(identities) = identities else { + self.store.lock().unwrap().remove(&key); + return transient_full(&tree.nodes, FullResyncReason::IdentityUnavailable); + }; + if identities.iter().collect::>().len() != identities.len() { + self.store.lock().unwrap().remove(&key); + return transient_full(&tree.nodes, FullResyncReason::IdentityUnavailable); + } + let owners = identities + .iter() + .map(|identity| identity.unique_owner.clone()) + .collect::>(); + + let mut store = self.store.lock().unwrap(); + if store + .lineages + .get(&key) + .is_some_and(|lineage| !lineage.owners.is_empty() && lineage.owners != owners) + { + store.remove(&key); + return transient_full(&tree.nodes, FullResyncReason::ProviderInvalidated); + } + if !store.lineages.contains_key(&key) { + store.ensure_capacity(); + store.lineages.insert(key.clone(), LinuxLineage::new()?); + } + store.touch(&key); + let lineage = store.lineages.get_mut(&key).expect("inserted above"); + let captured = tree + .nodes + .iter() + .zip(identities) + .map(|(node, identity)| CapturedNode { + identity, + depth: node.depth, + body: format_revision_body(node), + actionable_index: node.element_index, + }) + .collect::>(); + let forced_reason = + cua_driver_core::observation_revision::requested_format_resync_reason(request) + .or_else(|| request.force_full.then_some(FullResyncReason::Requested)); + let result = lineage + .revision + .observe_with_reason(captured, request.base_revision_id.as_deref(), forced_reason) + .map_err(|error: ObservationRevisionError| error.to_string())?; + lineage.owners = owners; + Ok(result) + } + + pub fn clear_session(&self, session_id: &str) { + self.clear_where(|key| key.session.session_id == session_id); + } + + pub fn clear_runtime(&self, runtime_scope: &str) { + self.clear_where(|key| key.session.runtime_scope == runtime_scope); + } + + pub fn clear_target(&self, pid: u32, xid: u64) { + self.clear_where(|key| key.pid == pid && key.xid == xid); + } + + fn clear_where(&self, predicate: impl Fn(&RevisionKey) -> bool) { + let mut store = self.store.lock().unwrap(); + let keys = store + .lineages + .keys() + .filter(|key| predicate(key)) + .cloned() + .collect::>(); + for key in keys { + store.remove(&key); + } + } +} + +impl Default for LinuxObservationRevisions { + fn default() -> Self { + Self::new() + } +} + +fn transient_full( + nodes: &[AtspiNode], + reason: FullResyncReason, +) -> Result { + let captured = nodes + .iter() + .enumerate() + .map(|(identity, node)| CapturedNode { + identity, + depth: node.depth, + body: format_revision_body(node), + actionable_index: node.element_index, + }) + .collect::>(); + let mut lineage = ObservationLineage::new( + format!("l_{}", uuid::Uuid::new_v4().simple()), + RETAINED_REVISIONS, + ) + .map_err(|error| error.to_string())?; + lineage + .observe_unretained_full(captured, reason) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cua_driver_core::observation_revision::{ + ObservationMode, ACCESSIBILITY_PROJECTION_VERSION, ACCESSIBILITY_SERIALIZER_VERSION, + OBSERVATION_REVISION_VERSION, + }; + + fn session() -> ObservationSessionIdentity { + ObservationSessionIdentity { + runtime_scope: "runtime".into(), + session_id: "session".into(), + transport_session_id: "transport".into(), + } + } + + fn request(base_revision_id: Option) -> ObservationRevisionRequest { + ObservationRevisionRequest { + version: OBSERVATION_REVISION_VERSION, + serializer_version: ACCESSIBILITY_SERIALIZER_VERSION.into(), + projection_version: ACCESSIBILITY_PROJECTION_VERSION.into(), + base_revision_id, + force_full: false, + } + } + + fn node(path: &str, name: &str) -> AtspiNode { + AtspiNode { + element_index: Some(0), + role: "button".into(), + name: Some(name.into()), + value: None, + checked: None, + enabled: Some(true), + selected: None, + description: None, + actions: vec!["click".into()], + element_key: 0, + depth: 0, + parent_element_index: None, + in_web_content: false, + identity: Some(AtspiIdentity { + unique_owner: ":1.5".into(), + object_path: path.into(), + }), + } + } + + fn tree(nodes: Vec) -> AtspiTreeResult { + AtspiTreeResult { + tree_markdown: String::new(), + nodes, + bounds: Vec::new(), + trusted: true, + degraded_reason: None, + window_scoped: true, + backend: AtspiBackend::Atspi, + complete: true, + truncated: false, + incomplete_notes: Vec::new(), + } + } + + #[test] + fn stable_owner_and_path_retain_no_change_and_diff_lineage() { + let revisions = LinuxObservationRevisions::new(); + let initial = revisions + .observe( + session(), + 10, + 20, + 5000, + usize::MAX, + &tree(vec![node("/button", "Before")]), + &request(None), + ) + .unwrap(); + assert_eq!(initial.mode, ObservationMode::Full); + assert!(initial.stable_element_ids); + + let unchanged = revisions + .observe( + session(), + 10, + 20, + 5000, + usize::MAX, + &tree(vec![node("/button", "Before")]), + &request(Some(initial.revision_id.clone())), + ) + .unwrap(); + assert_eq!(unchanged.mode, ObservationMode::NoChange); + + let changed = revisions + .observe( + session(), + 10, + 20, + 5000, + usize::MAX, + &tree(vec![node("/button", "After")]), + &request(Some(unchanged.revision_id)), + ) + .unwrap(); + assert_eq!(changed.mode, ObservationMode::Diff); + assert_eq!(changed.lineage_id, initial.lineage_id); + assert_eq!(changed.nodes[0].element_id, initial.nodes[0].element_id); + } + + #[test] + fn owner_change_and_incomplete_capture_fail_closed() { + let revisions = LinuxObservationRevisions::new(); + let initial = revisions + .observe( + session(), + 10, + 20, + 5000, + usize::MAX, + &tree(vec![node("/button", "Before")]), + &request(None), + ) + .unwrap(); + + let mut restarted = node("/button", "After"); + restarted.identity.as_mut().unwrap().unique_owner = ":1.9".into(); + let provider_changed = revisions + .observe( + session(), + 10, + 20, + 5000, + usize::MAX, + &tree(vec![restarted]), + &request(Some(initial.revision_id.clone())), + ) + .unwrap(); + assert_eq!(provider_changed.mode, ObservationMode::Full); + assert_eq!( + provider_changed.full_resync_reason, + Some(FullResyncReason::ProviderInvalidated) + ); + assert!(!provider_changed.stable_element_ids); + + let mut incomplete = tree(vec![node("/button", "After")]); + incomplete.complete = false; + incomplete.truncated = true; + let partial = revisions + .observe( + session(), + 10, + 20, + 5000, + usize::MAX, + &incomplete, + &request(Some(initial.revision_id)), + ) + .unwrap(); + assert_eq!(partial.mode, ObservationMode::Full); + assert_eq!( + partial.full_resync_reason, + Some(FullResyncReason::CaptureIncomplete) + ); + assert!(!partial.stable_element_ids); + } + + #[test] + fn x11_fallback_retires_the_previous_atspi_lineage() { + let revisions = LinuxObservationRevisions::new(); + let initial = revisions + .observe( + session(), + 10, + 20, + 5000, + usize::MAX, + &tree(vec![node("/button", "Before")]), + &request(None), + ) + .unwrap(); + + let mut x11 = tree(vec![node("/button", "Fallback")]); + x11.backend = AtspiBackend::X11; + let fallback = revisions + .observe( + session(), + 10, + 20, + 5000, + usize::MAX, + &x11, + &request(Some(initial.revision_id.clone())), + ) + .unwrap(); + assert_eq!( + fallback.full_resync_reason, + Some(FullResyncReason::UnsupportedBackend) + ); + assert!(!fallback.stable_element_ids); + + let recovered = revisions + .observe( + session(), + 10, + 20, + 5000, + usize::MAX, + &tree(vec![node("/button", "Before")]), + &request(Some(initial.revision_id)), + ) + .unwrap(); + assert_eq!(recovered.mode, ObservationMode::Full); + assert_ne!(recovered.lineage_id, initial.lineage_id); + } +} diff --git a/packages/cua-driver/rust/crates/platform-linux/src/browser_consent_ui.rs b/packages/cua-driver/rust/crates/platform-linux/src/browser_consent_ui.rs index 100862b4ab..9957585b41 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/browser_consent_ui.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/browser_consent_ui.rs @@ -325,6 +325,7 @@ mod tests { depth: 0, parent_element_index: None, in_web_content: false, + identity: None, } } diff --git a/packages/cua-driver/rust/crates/platform-linux/src/browser_platform.rs b/packages/cua-driver/rust/crates/platform-linux/src/browser_platform.rs index 9e29eaa6a6..6808992d6f 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/browser_platform.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/browser_platform.rs @@ -762,16 +762,16 @@ impl BrowserPlatform for LinuxBrowserPlatform { "the approved browser pid is outside the Linux process-id range", ) })?; - if self - .is_only_exact_native_window(request.pid, request.window_id) - .await? - != Some(true) - { - return Err(refusal( - BrowserRefusalCode::BrowserBindingAmbiguous, - "existing-profile setup on Linux requires exactly one PID-owned native window; generic Wayland and multi-window processes are refused", - )); - } + // Window cardinality is deliberately NOT a precondition here. Setup acts + // inside one named window, and `browser_setup_ui` proves that window's + // identity directly by resolving the native window to exactly one + // accessibility top-level before it matches or actuates any control. A + // process owning several windows is the ordinary case for a browser and + // says nothing about whether the driver can act precisely; refusing on + // it made the whole existing-profile route unreachable for most real + // sessions. Where identity genuinely cannot be established — a generic + // Wayland session with no compositor window list — that same proof fails + // and setup refuses with a reason that names the cause. let window_id = request.window_id; let listeners_before = tokio::task::spawn_blocking(move || loopback_ports_for_pid(request.pid)) @@ -892,6 +892,54 @@ impl BrowserPlatform for LinuxBrowserPlatform { }; let endpoint = match endpoint_result { Ok(endpoint) => endpoint, + // The toggle took, but no endpoint appeared. That is Chromium's + // documented-by-behaviour semantics rather than a failure: "Allow + // remote debugging for this browser instance" persists a preference + // that the browser acts on at its NEXT launch — verified on Chrome + // 151 by enabling it and observing no listener for 25s, then a + // listener on 127.0.0.1 plus a DevToolsActivePort file immediately + // after a restart with no --remote-debugging-port flag. + // + // Rolling back here erased the setting moments before the restart + // that would activate it, so this path could never succeed. Keep the + // preference, close the temporary tab, and tell the caller the one + // thing it needs to do. The profile is armed, not broken. + Err(error) + if enabled_remote_debugging + && error.code == BrowserRefusalCode::BrowserRequiresSetup => + { + let closed = tokio::task::spawn_blocking(move || handle.close_for_success()) + .await + .map_err(|join_error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not finish browser setup cleanup: {join_error}"), + ) + })??; + return Err(refusal( + BrowserRefusalCode::BrowserRequiresSetup, + format!( + "remote debugging is now enabled for this {} profile, but {} only opens \ + its debugging endpoint at startup. Restart the browser, then call \ + browser_prepare again — the setting persists and does not need to be \ + applied twice.", + descriptor.product_name, descriptor.product_name + ), + ) + .with_detail(serde_json::json!({ + "restart_required": true, + "remote_debugging_enabled": true, + "setup_side_effects": { + "opened_setup_page": opened_setup_page, + "closed_setup_page": closed.unwrap_or(false), + "enabled_remote_debugging": true, + "restored_remote_debugging": false, + "focused_setup_address_field": focused_setup_address_field, + "foregrounded_window": foregrounded_window, + "injected_global_input": injected_global_input, + }, + }))); + } Err(error) => { let error = tokio::task::spawn_blocking(move || handle.abort(error)) .await diff --git a/packages/cua-driver/rust/crates/platform-linux/src/browser_setup_ui.rs b/packages/cua-driver/rust/crates/platform-linux/src/browser_setup_ui.rs index 171240b152..db36c28e9f 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/browser_setup_ui.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/browser_setup_ui.rs @@ -97,39 +97,6 @@ fn exact_setup_checkbox<'a>( } } -fn exact_setup_navigation<'a>( - nodes: &'a [AtspiNode], - descriptor: &BrowserSetupDescriptor, -) -> Result, BrowserRefusal> { - let exact_page = nodes.iter().any(|node| { - role_is(node, &["document web", "document frame"]) - && descriptor - .page_titles - .iter() - .any(|title| field_equals(node, title)) - }); - if !exact_page { - return Ok(None); - } - let matches = nodes - .iter() - .filter(|node| { - role_is(node, &["push button", "button"]) - && field_equals(node, descriptor.page_heading) - && !node.actions.is_empty() - && node.element_index.is_some() - }) - .collect::>(); - match matches.as_slice() { - [] => Ok(None), - [node] => Ok(Some(*node)), - _ => Err(refusal( - BrowserRefusalCode::BrowserWrongTargetRefused, - "multiple exact remote-debugging navigation controls were exposed", - )), - } -} - fn setup_not_ready_message(descriptor: &BrowserSetupDescriptor) -> String { format!( "the exact {} remote-debugging setup page did not become ready; on Linux, existing-profile setup requires the browser's complete AT-SPI tree (launch Chromium-family browsers with --force-renderer-accessibility, or use a screen reader that enables full renderer accessibility)", @@ -165,58 +132,226 @@ fn close_tab(pid: u32, window_id: u64) -> anyhow::Result<()> { }) } -fn trusted_keyboard_setup_navigation( +/// The exact address-and-search field of the approved window, or `None` while +/// the freshly created tab has not exposed one yet. More than one is refused: +/// the field is where the setup URL is about to be written, so the wrong pick +/// navigates a surface the caller never approved. +fn exact_omnibox<'a>( + nodes: &'a [AtspiNode], + descriptor: &BrowserSetupDescriptor, +) -> Result, BrowserRefusal> { + let matches = nodes + .iter() + .filter(|node| { + role_is(node, &["entry", "text"]) + && field_equals(node, "Address and search bar") + && node.element_index.is_some() + }) + .collect::>(); + match matches.as_slice() { + [] => Ok(None), + [node] => Ok(Some(*node)), + _ => Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "{} exposed multiple exact address-and-search fields", + descriptor.product_name + ), + )), + } +} + +/// Whether the omnibox currently holds exactly the fixed setup URL. +fn omnibox_holds_setup_url(node: &AtspiNode, descriptor: &BrowserSetupDescriptor) -> bool { + node.value + .as_deref() + .or(node.name.as_deref()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case(descriptor.setup_url)) +} + +/// Navigate the approved window to its fixed setup page. +/// +/// This mirrors the macOS and Windows adapters rather than synthesizing the URL +/// keystroke by keystroke: write the whole URL into the address field through +/// the accessibility API, read it back to prove it landed, and only then commit +/// with a single Enter. `set_text_contents` is the AT-SPI counterpart of UIA's +/// `ValuePattern::SetValue` and AppKit's `AXValue`. +/// +/// Per-character synthesis was the wrong primitive here. XTEST keysym lookup is +/// keyboard-layout dependent and wlroots virtual-keyboard seats drop +/// punctuation, so `chrome://inspect` could arrive as `inspect` — which the +/// omnibox treats as a search term, silently navigating to a search-engine +/// results page. Nothing downstream could tell that apart from a slow-loading +/// setup page, so the flow reported a readiness timeout while leaving the user +/// on someone else's website. Writing the value whole removes the layout +/// dependency, and the read-back turns any residual mangling into an immediate, +/// accurate refusal. +fn trusted_setup_navigation( pid: u32, window_id: u64, descriptor: &BrowserSetupDescriptor, ) -> anyhow::Result<()> { - let (base, _fragment) = descriptor - .setup_url - .split_once("/#") - .ok_or_else(|| anyhow::anyhow!("the fixed setup URL has no /# delimiter"))?; let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some(); + + // A fresh tab, so the setup page never displaces a page the user was on. + // `ctrl+l` then focuses the address field. Both are single letters, so + // neither depends on the keyboard layout the way punctuation does. + let focus_omnibox = || -> anyhow::Result<()> { + with_target_foreground(pid, window_id, || { + if wayland { + crate::wayland::hotkey_focused(&["ctrl".to_owned(), "l".to_owned()]) + } else { + crate::input::send_key_xtest("l", &["ctrl"]) + } + }) + }; + with_target_foreground(pid, window_id, || { if wayland { - crate::wayland::hotkey_focused(&["ctrl".to_owned(), "t".to_owned()])?; - std::thread::sleep(Duration::from_millis(100)); - crate::wayland::hotkey_focused(&["ctrl".to_owned(), "l".to_owned()])?; - crate::wayland::type_text_then_key_focused(base, "enter") + crate::wayland::hotkey_focused(&["ctrl".to_owned(), "t".to_owned()]) + } else { + crate::input::send_key_xtest("t", &["ctrl"]) + } + })?; + std::thread::sleep(Duration::from_millis(100)); + focus_omnibox()?; + + // Wait for the new tab to publish its address field before writing to it. + let deadline = Instant::now() + EXISTING_PROFILE_SETUP_READY_TIMEOUT; + loop { + let tree = + window_scoped_tree(pid, window_id).map_err(|error| anyhow::anyhow!(error.message))?; + if exact_omnibox(&tree.nodes, descriptor) + .map_err(|error| anyhow::anyhow!(error.message))? + .is_some() + { + break; + } + if Instant::now() >= deadline { + anyhow::bail!( + "the approved {} window never exposed an exact address-and-search field", + descriptor.product_name + ); + } + std::thread::sleep(Duration::from_millis(150)); + } + + // Transfer the URL through the clipboard rather than the keyboard. + // + // Chromium's AT-SPI bridge does not honour EditableText writes on the + // omnibox, so the accessibility set-value that macOS (`AXValue`) and + // Windows (`ValuePattern::SetValue`) rely on has no working counterpart + // here. A paste keeps the property that actually matters: the exact string + // arrives in one operation, with no per-keysym synthesis to be mistranslated + // by the active layout or dropped by a virtual-keyboard seat. `ctrl+a` and + // `ctrl+v` are plain letters, so they carry no layout dependency of their own. + use cua_driver_core::clipboard::ClipboardBackend; + let clipboard = crate::clipboard::LinuxClipboard::new(); + let restore = clipboard.read_text().ok().flatten(); + clipboard + .write_text(descriptor.setup_url.to_owned()) + .map_err(|error| anyhow::anyhow!("could not stage the fixed setup URL: {error}"))?; + let paste = with_target_foreground(pid, window_id, || { + if wayland { + crate::wayland::hotkey_focused(&["ctrl".to_owned(), "a".to_owned()])?; + std::thread::sleep(Duration::from_millis(60)); + crate::wayland::hotkey_focused(&["ctrl".to_owned(), "v".to_owned()]) + } else { + crate::input::send_key_xtest("a", &["ctrl"])?; + std::thread::sleep(Duration::from_millis(60)); + crate::input::send_key_xtest("v", &["ctrl"]) + } + }); + // The user's clipboard is theirs; put it back whether or not the paste took. + std::thread::sleep(Duration::from_millis(120)); + if let Some(previous) = restore { + let _ = clipboard.write_text(previous); + } + paste?; + + // Commit. Enter is the one synthesized keystroke left, and it carries no + // layout dependency. + with_target_foreground(pid, window_id, || { + if wayland { + crate::wayland::hotkey_focused(&["enter".to_owned()]) } else { - crate::input::send_key_xtest("t", &["ctrl"])?; - std::thread::sleep(Duration::from_millis(100)); - crate::input::send_key_xtest("l", &["ctrl"])?; - crate::input::send_type_text_xtest(base)?; crate::input::send_key_xtest("enter", &[]) } })?; - // Avoid keyboard-layout-dependent `/#` synthesis on X11 and punctuation - // loss on fresh wlroots virtual-keyboard seats. Open the fixed base page, - // then invoke its unique semantic navigation control in the exact PID. + // Verify the destination, not the input. Chromium exposes no readable text + // on its omnibox over AT-SPI — no Value interface and no Text content even + // while the field holds a URL — so the read-back that the Windows and macOS + // adapters perform against the address field has no counterpart here. + // Proving the tab actually arrived at the fixed setup page is the stronger + // check anyway: it fails for a mistyped URL, a hijacked search, and a + // redirect alike, and it names what was reached instead of timing out. let deadline = Instant::now() + EXISTING_PROFILE_SETUP_READY_TIMEOUT; loop { - let tree = crate::atspi::walk_tree(pid, window_id, None); - let navigation = exact_setup_navigation(&tree.nodes, descriptor) - .map_err(|error| anyhow::anyhow!(error.message))?; - match navigation { - Some(node) => { - return crate::atspi::perform_action( - pid, - node.element_index.expect("actionable navigation index"), - ) - .map(|_| ()) - } - None if Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(100)); - } - None => anyhow::bail!( - "the exact {} setup navigation control did not become ready", - descriptor.product_name - ), + let tree = + window_scoped_tree(pid, window_id).map_err(|error| anyhow::anyhow!(error.message))?; + if tree.nodes.iter().any(|node| { + role_is(node, &["document web", "document frame"]) + && descriptor + .page_titles + .iter() + .any(|title| field_equals(node, title)) + }) { + return Ok(()); } + if Instant::now() >= deadline { + let landed = tree + .nodes + .iter() + .find(|node| role_is(node, &["document web", "document frame"])) + .and_then(|node| node.name.clone()) + .unwrap_or_else(|| "no document".to_owned()); + anyhow::bail!( + "the approved {} window did not reach its fixed setup page; it is showing {:?}. \ + The address field is written through the clipboard, so this means the browser \ + rejected or redirected the URL rather than that a keystroke was dropped", + descriptor.product_name, + landed + ); + } + std::thread::sleep(Duration::from_millis(150)); } } +/// Walk the target window's accessibility tree, refusing unless the snapshot is +/// provably confined to that one window. +/// +/// AT-SPI publishes a single tree per process, so a browser showing several +/// windows exposes all of their controls together — including one "Allow remote +/// debugging for this browser instance" checkbox per open setup page. Matching a +/// control by label across that tree can therefore find a control the caller did +/// not name. Requiring proven window scope is what makes the exact-window +/// contract real rather than assumed. +fn window_scoped_tree( + pid: u32, + window_id: u64, +) -> Result { + let tree = crate::atspi::walk_tree(pid, window_id, None); + if !tree.trusted { + return Err(refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + "no trusted AT-SPI tree for the approved browser window; \ + the accessibility bus must be reachable to prove which window a control belongs to", + )); + } + if !tree.window_scoped { + return Err(refusal( + BrowserRefusalCode::BrowserBindingAmbiguous, + format!( + "could not prove which of pid {pid}'s accessibility top-levels renders window \ + {window_id}, so a matched control cannot be attributed to the approved window; \ + relaunch the browser with --remote-debugging-port to skip setup entirely" + ), + )); + } + Ok(tree) +} + pub struct SetupUiHandle { pid: u32, window_id: u64, @@ -361,7 +496,7 @@ pub fn enable( window_id: u64, descriptor: &'static BrowserSetupDescriptor, ) -> Result { - let initial = crate::atspi::walk_tree(pid, window_id, None); + let initial = window_scoped_tree(pid, window_id)?; let initial_checkbox = exact_setup_checkbox(&initial.nodes, descriptor, false)?; let mut handle = if initial_checkbox.is_some() { SetupUiHandle { @@ -391,7 +526,7 @@ pub fn enable( foregrounded_window: true, injected_global_input: true, }; - if let Err(error) = trusted_keyboard_setup_navigation(pid, window_id, descriptor) { + if let Err(error) = trusted_setup_navigation(pid, window_id, descriptor) { return Err(handle.abort(refusal( BrowserRefusalCode::BrowserWrongTargetRefused, format!( @@ -405,7 +540,10 @@ pub fn enable( let deadline = Instant::now() + EXISTING_PROFILE_SETUP_READY_TIMEOUT; loop { - let tree = crate::atspi::walk_tree(pid, window_id, None); + let tree = match window_scoped_tree(pid, window_id) { + Ok(tree) => tree, + Err(error) => return Err(handle.abort(error)), + }; match exact_setup_checkbox(&tree.nodes, descriptor, handle.trusted_setup_navigation) { Ok(Some(node)) => match node.checked { Some(true) => { @@ -530,6 +668,7 @@ mod tests { depth: 0, parent_element_index: None, in_web_content: false, + identity: None, } } @@ -604,27 +743,58 @@ mod tests { assert!(message.contains("--force-renderer-accessibility")); } - #[test] - fn setup_navigation_requires_one_actionable_control_on_the_exact_page() { - let nodes = vec![ - node("document web", descriptor().page_titles[0], None, &[]), - node("push button", descriptor().page_heading, None, &["press"]), - node("heading", descriptor().page_heading, None, &[]), - ]; - assert!(exact_setup_navigation(&nodes, descriptor()) - .unwrap() - .is_some()); - assert!(exact_setup_navigation(&nodes[1..], descriptor()) - .unwrap() - .is_none()); + fn omnibox(value: Option<&str>) -> AtspiNode { + node("entry", "Address and search bar", value, &["activate"]) + } + #[test] + fn omnibox_selection_is_exact_or_refused() { + let nodes = vec![ + node("push button", "Reload", None, &["press"]), + omnibox(Some("about:blank")), + ]; + assert!(exact_omnibox(&nodes, descriptor()).unwrap().is_some()); + assert!(exact_omnibox(&nodes[..1], descriptor()).unwrap().is_none()); + + // Two address fields means two candidate destinations; writing the + // setup URL into a guess could navigate a surface nobody approved. let mut ambiguous = nodes; - ambiguous.push(node( - "push button", - descriptor().page_heading, - None, - &["press"], + ambiguous.push(omnibox(None)); + assert!(exact_omnibox(&ambiguous, descriptor()).is_err()); + } + + /// The regression that motivated the rewrite: XTEST dropped characters and + /// `chrome://inspect` reached the omnibox as `inspect`, which Chrome + /// submitted as a search query. The read-back has to reject that before it + /// is ever committed. + #[test] + fn partially_applied_setup_url_is_not_accepted() { + assert!(!omnibox_holds_setup_url( + &omnibox(Some("inspect")), + descriptor() + )); + assert!(!omnibox_holds_setup_url( + &omnibox(Some("chrome://inspect")), + descriptor() + )); + assert!(!omnibox_holds_setup_url(&omnibox(None), descriptor())); + assert!(!omnibox_holds_setup_url( + &omnibox(Some("https://www.google.com/search?q=inspect")), + descriptor() + )); + } + + #[test] + fn fully_applied_setup_url_is_accepted() { + assert!(omnibox_holds_setup_url( + &omnibox(Some(descriptor().setup_url)), + descriptor() + )); + // Chromium reports the omnibox value with surrounding whitespace on + // some toolkit versions, and case is not significant in a scheme. + assert!(omnibox_holds_setup_url( + &omnibox(Some(" CHROME://inspect/#remote-debugging ")), + descriptor() )); - assert!(exact_setup_navigation(&ambiguous, descriptor()).is_err()); } } diff --git a/packages/cua-driver/rust/crates/platform-linux/src/capture.rs b/packages/cua-driver/rust/crates/platform-linux/src/capture.rs index e7d7b97121..e8b414d4bb 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/capture.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/capture.rs @@ -1,92 +1,831 @@ //! Window screenshot on Linux. //! -//! Strategy (in order of preference): -//! 1. `xwd -id -silent | xwdtopnm | pnmtopng` (X11, no focus change) -//! 2. `import -window png:-` (ImageMagick, widely available) -//! 3. `scrot -u ` (focused window fallback) -//! 4. XGetImage via x11rb (pure Rust, no subprocess) +//! Strategy (in order of preference) for per-window X11 capture: +//! 1. Persistent MIT-SHM (`shm_get_image`) via x11rb — warm path, no subprocess +//! 2. Persistent plain `XGetImage` via x11rb — still in-process +//! 3. `import -window png:-` (ImageMagick compatibility fallback) +//! +//! Main-display capture keeps its own dispatch (Wayland cascade → ImageMagick → +//! root `XGetImage`). Wayland-native per-window paths are not routed into XShm. +//! +//! x11rb MIT-SHM API (pinned 0.13.2): +//! - https://docs.rs/x11rb/0.13.2/x11rb/protocol/shm/trait.ConnectionExt.html +//! - https://docs.rs/x11rb/0.13.2/x11rb/protocol/shm/struct.GetImageReply.html +//! - https://docs.rs/x11rb/0.13.2/x11rb/protocol/shm/struct.CreateSegmentReply.html +//! +//! Request order: `shm_query_version().reply()` before any other SHM call; +//! `generate_id`; `shm_create_segment(seg, size, false).reply()` → `OwnedFd`; +//! map FD; `shm_get_image(..., seg, 0).reply()` writes into mapped memory; +//! `shm_detach(seg)` on replacement/drop. -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use std::process::Command; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; +use x11rb::connection::Connection as _; +use x11rb::protocol::xproto::{Format, ImageOrder, Setup, VisualClass, Visualtype}; + +/// Max edge length accepted for a single SHM/XGetImage capture (px). +const MAX_CAPTURE_DIM: u32 = 16_384; +/// Max SHM segment size (1 GiB). +const MAX_CAPTURE_BYTES: usize = 1 << 30; +/// After MIT-SHM init/extension failure, same-DISPLAY may be probed again only +/// after this backoff. Request/capture failures never use this path. +const XSHM_INIT_RETRY_BACKOFF: Duration = Duration::from_secs(30); /// Capture a window by X11 XID. Returns raw PNG bytes. pub fn screenshot_window_bytes(xid: u64) -> Result> { - // Try `import -window png:-` (ImageMagick). - if let Ok(bytes) = capture_via_import(xid) { - return Ok(bytes); - } - // Fallback: x11rb XGetImage → returns (b64, w, h); decode the b64 back. - let (b64, _, _) = capture_via_xgetimage(xid)?; - use base64::Engine as _; - let bytes = base64::engine::general_purpose::STANDARD.decode(&b64)?; - Ok(bytes) + capture_window_with_backends( + xid, + capture_via_xshm, + capture_via_persistent_xgetimage, + capture_via_import, + ) } /// Capture a window by X11 XID. Returns (base64_png, width, height). pub fn screenshot_window(xid: u64) -> Result<(String, u32, u32)> { - // Try `import -window png:-` (ImageMagick). - if let Ok(bytes) = capture_via_import(xid) { - let (w, h) = cua_driver_core::image_utils::png_dimensions(&bytes)?; - return Ok((BASE64.encode(&bytes), w, h)); - } + let bytes = screenshot_window_bytes(xid)?; + let (w, h) = cua_driver_core::image_utils::png_dimensions(&bytes)?; + Ok((BASE64.encode(&bytes), w, h)) +} - // Fallback: x11rb XGetImage. - capture_via_xgetimage(xid) +/// Ordered window-capture backend cascade. +/// +/// Non-empty XShm success returns immediately; otherwise try non-empty +/// XGetImage; otherwise ImageMagick. If all fail or return empty, the final +/// error preserves all three contexts. Closures are `FnOnce` only (no +/// `Send`/`Sync` bounds) so unit tests can drive them with `Rc`/`Cell`. +fn capture_window_with_backends( + xid: u64, + xshm: impl FnOnce(u64) -> Result>, + xgetimage: impl FnOnce(u64) -> Result>, + imagemagick: impl FnOnce(u64) -> Result>, +) -> Result> { + let xshm_err = match xshm(xid) { + Ok(bytes) if !bytes.is_empty() => return Ok(bytes), + Ok(_) => "XShm returned empty image".to_string(), + Err(e) => format!("{e:#}"), + }; + + let xgetimage_err = match xgetimage(xid) { + Ok(bytes) if !bytes.is_empty() => return Ok(bytes), + Ok(_) => "XGetImage returned empty image".to_string(), + Err(e) => format!("{e:#}"), + }; + + let imagemagick_err = match imagemagick(xid) { + Ok(bytes) if !bytes.is_empty() => return Ok(bytes), + Ok(_) => "ImageMagick returned empty image".to_string(), + Err(e) => format!("{e:#}"), + }; + + Err(anyhow!( + "all Linux window capture backends failed\n- XShm: {xshm_err}\n- XGetImage: {xgetimage_err}\n- ImageMagick: {imagemagick_err}" + )) } fn capture_via_import(xid: u64) -> Result> { let out = Command::new("import") .args(["-window", &xid.to_string(), "png:-"]) - .output()?; - if !out.status.success() || out.stdout.is_empty() { - bail!("import failed"); + .output() + .map_err(|e| anyhow!("failed to launch ImageMagick import: {e}"))?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let detail = stderr.trim().chars().take(512).collect::(); + bail!("ImageMagick import exited {}: {detail}", out.status); + } + if out.stdout.is_empty() { + bail!("ImageMagick import returned empty stdout"); } Ok(out.stdout) } -fn capture_via_xgetimage(xid: u64) -> Result<(String, u32, u32)> { - use x11rb::protocol::xproto::*; - use x11rb::rust_connection::RustConnection; +// ── shared pixel conversion ─────────────────────────────────────────────── - let (conn, _) = RustConnection::connect(None)?; - let window = xid as u32; +#[derive(Clone)] +struct PixelCatalog { + image_byte_order: ImageOrder, + formats: Vec, + visuals: Vec, +} - let geom = conn.get_geometry(window)?.reply()?; - let w = geom.width as u32; - let h = geom.height as u32; +#[derive(Clone, Copy)] +struct PackedLayout { + byte_order: ImageOrder, + bytes_per_pixel: usize, + stride: usize, + len: usize, +} - let img = conn - .get_image( - ImageFormat::Z_PIXMAP, - window, - 0, - 0, - w as u16, - h as u16, - !0u32, - )? - .reply()?; +#[derive(Clone, Copy)] +struct PixelDecoder { + layout: PackedLayout, + red_mask: u32, + green_mask: u32, + blue_mask: u32, +} - // The raw data is BGRA or BGRX depending on depth. - // Encode as a minimal PNG. - let bytes = img.data; - let (bpp, has_alpha) = match img.depth { - 32 => (4usize, true), - 24 => (4usize, false), - _ => bail!("Unsupported depth: {}", img.depth), - }; - - // Convert to RGBA. - let mut rgba = Vec::with_capacity((w * h * 4) as usize); - for chunk in bytes.chunks_exact(bpp) { - let (b, g, r) = (chunk[0], chunk[1], chunk[2]); - let a = if has_alpha { chunk[3] } else { 255 }; - rgba.extend_from_slice(&[r, g, b, a]); +impl PixelCatalog { + fn from_setup(setup: &Setup) -> Self { + Self { + image_byte_order: setup.image_byte_order, + formats: setup.pixmap_formats.clone(), + visuals: setup + .roots + .iter() + .flat_map(|screen| screen.allowed_depths.iter()) + .flat_map(|depth| depth.visuals.iter().copied()) + .collect(), + } } - let png = cua_driver_core::image_utils::encode_rgba_to_png(&rgba, w, h)?; - Ok((BASE64.encode(&png), w, h)) + fn packed_layout(&self, w: u32, h: u32, depth: u8) -> Result { + checked_capture_dimensions(w, h)?; + let format = self + .formats + .iter() + .find(|format| format.depth == depth) + .ok_or_else(|| anyhow!("no X11 pixmap format for depth {depth}"))?; + if !matches!(format.bits_per_pixel, 16 | 24 | 32) { + bail!( + "unsupported X11 bits-per-pixel {} for depth {depth}", + format.bits_per_pixel + ); + } + if !matches!(format.scanline_pad, 8 | 16 | 32) { + bail!( + "unsupported X11 scanline pad {} for depth {depth}", + format.scanline_pad + ); + } + + let row_bits = u64::from(w) + .checked_mul(u64::from(format.bits_per_pixel)) + .ok_or_else(|| anyhow!("window geometry {w}x{h} overflows row length"))?; + let pad = u64::from(format.scanline_pad); + let stride_bits = row_bits + .checked_add(pad - 1) + .map(|bits| (bits / pad) * pad) + .ok_or_else(|| anyhow!("window geometry {w}x{h} overflows padded row length"))?; + let stride = usize::try_from(stride_bits / 8) + .map_err(|_| anyhow!("window geometry {w}x{h} stride does not fit usize"))?; + let len = stride + .checked_mul(h as usize) + .ok_or_else(|| anyhow!("window geometry {w}x{h} overflows byte length"))?; + if len == 0 || len > MAX_CAPTURE_BYTES { + bail!("window capture size {len} out of bounds (max {MAX_CAPTURE_BYTES})"); + } + Ok(PackedLayout { + byte_order: self.image_byte_order, + bytes_per_pixel: usize::from(format.bits_per_pixel / 8), + stride, + len, + }) + } + + fn decoder(&self, w: u32, h: u32, depth: u8, visual_id: u32) -> Result { + let layout = self.packed_layout(w, h, depth)?; + let visual = self + .visuals + .iter() + .find(|visual| visual.visual_id == visual_id) + .ok_or_else(|| anyhow!("unknown X11 visual 0x{visual_id:x} for depth {depth}"))?; + if visual.class != VisualClass::TRUE_COLOR { + bail!( + "unsupported X11 visual class {:?} for visual 0x{visual_id:x}", + visual.class + ); + } + for (name, mask) in [ + ("red", visual.red_mask), + ("green", visual.green_mask), + ("blue", visual.blue_mask), + ] { + validate_component_mask(name, mask)?; + } + if visual.red_mask & visual.green_mask != 0 + || visual.red_mask & visual.blue_mask != 0 + || visual.green_mask & visual.blue_mask != 0 + { + bail!("overlapping RGB masks for X11 visual 0x{visual_id:x}"); + } + Ok(PixelDecoder { + layout, + red_mask: visual.red_mask, + green_mask: visual.green_mask, + blue_mask: visual.blue_mask, + }) + } +} + +fn checked_capture_dimensions(w: u32, h: u32) -> Result<()> { + if w == 0 || h == 0 { + bail!("window geometry is 0x0"); + } + if w > MAX_CAPTURE_DIM || h > MAX_CAPTURE_DIM { + bail!("window geometry {w}x{h} exceeds max {MAX_CAPTURE_DIM}px edge"); + } + Ok(()) +} + +fn validate_component_mask(name: &str, mask: u32) -> Result<()> { + if mask == 0 { + bail!("X11 {name} mask is zero"); + } + let normalized = mask >> mask.trailing_zeros(); + if normalized & normalized.wrapping_add(1) != 0 { + bail!("X11 {name} mask 0x{mask:x} is not contiguous"); + } + Ok(()) +} + +fn component_to_u8(pixel: u32, mask: u32) -> u8 { + let shifted_mask = mask >> mask.trailing_zeros(); + let value = (pixel & mask) >> mask.trailing_zeros(); + ((u64::from(value) * 255 + u64::from(shifted_mask) / 2) / u64::from(shifted_mask)) as u8 +} + +fn packed_zpixmap_to_png(data: &[u8], w: u32, h: u32, decoder: PixelDecoder) -> Result> { + if data.len() != decoder.layout.len { + bail!( + "pixel buffer length {} != expected {} ({}x{})", + data.len(), + decoder.layout.len, + w, + h + ); + } + let rgba_len = (w as usize) + .checked_mul(h as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| anyhow!("window geometry {w}x{h} overflows RGBA length"))?; + let mut rgba = Vec::with_capacity(rgba_len); + for y in 0..h as usize { + let row = &data[y * decoder.layout.stride..(y + 1) * decoder.layout.stride]; + for x in 0..w as usize { + let start = x * decoder.layout.bytes_per_pixel; + let bytes = &row[start..start + decoder.layout.bytes_per_pixel]; + let pixel = if decoder.layout.byte_order == ImageOrder::LSB_FIRST { + bytes.iter().enumerate().fold(0u32, |value, (index, byte)| { + value | (u32::from(*byte) << (index * 8)) + }) + } else if decoder.layout.byte_order == ImageOrder::MSB_FIRST { + bytes + .iter() + .fold(0u32, |value, byte| (value << 8) | u32::from(*byte)) + } else { + bail!("unsupported X11 image byte order"); + }; + rgba.extend_from_slice(&[ + component_to_u8(pixel, decoder.red_mask), + component_to_u8(pixel, decoder.green_mask), + component_to_u8(pixel, decoder.blue_mask), + 255, + ]); + } + } + cua_driver_core::image_utils::encode_rgba_to_png(&rgba, w, h) +} + +fn xid_to_window(xid: u64) -> Result { + u32::try_from(xid).map_err(|_| anyhow!("X11 window id {xid} does not fit u32")) +} + +fn current_display() -> String { + std::env::var("DISPLAY").unwrap_or_default() +} + +fn lock_mutex(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +// ── persistent MIT-SHM session ──────────────────────────────────────────── + +struct ShmBuffer { + seg: u32, + map: memmap2::MmapMut, + capacity: usize, +} + +struct XShmSession { + display: String, + conn: x11rb::rust_connection::RustConnection, + pixels: PixelCatalog, + #[allow(dead_code)] + screen_num: usize, + buffer: Option, +} + +enum XShmState { + /// No live session yet (or after a full reset). + Uninit, + /// MIT-SHM init/extension unavailable for `display` until `retry_after`. + /// Do not reprob each frame while the backoff is active. Request/capture + /// failures must never land here — they reset to `Uninit` instead. + Unsupported { + display: String, + reason: String, + retry_after: Instant, + }, + Ready(XShmSession), +} + +impl XShmState { + /// Build `Unsupported` from an initialization/extension failure. + /// `now` is injected so pure policy tests need not sleep. + fn unsupported_after_init_failure(display: String, reason: String, now: Instant) -> Self { + Self::Unsupported { + display, + reason, + retry_after: now + XSHM_INIT_RETRY_BACKOFF, + } + } + + /// State after a failed capture request even after reconnect+retry. + /// Never caches as `Unsupported` — stale windows and transport blips must + /// remain recoverable on the next call. + fn after_capture_retry_failure() -> Self { + Self::Uninit + } + + /// Consume init backoff for `display` at caller-supplied `now`. + /// + /// - Same-DISPLAY `Unsupported` before `retry_after`: leave state, `Err(reason)`. + /// - Same-DISPLAY `Unsupported` at/after deadline: reset to `Uninit`, `Ok(())`. + /// - Different-DISPLAY `Unsupported`: reset to `Uninit`, `Ok(())`. + /// - `Ready` / `Uninit`: leave state, `Ok(())`. + fn consume_init_backoff( + &mut self, + display: &str, + now: Instant, + ) -> std::result::Result<(), String> { + match self { + Self::Unsupported { + display: d, + reason, + retry_after, + } if d.as_str() == display => { + if now < *retry_after { + Err(reason.clone()) + } else { + *self = Self::Uninit; + Ok(()) + } + } + Self::Unsupported { .. } => { + *self = Self::Uninit; + Ok(()) + } + _ => Ok(()), + } + } +} + +impl Drop for XShmSession { + fn drop(&mut self) { + self.detach_buffer_best_effort(); + } +} + +/// Run `map` after a server SHM segment has been created. On map error, +/// invoke `cleanup` exactly once (best-effort detach) and return the +/// original map error unchanged. Success path never calls cleanup. +fn map_created_segment_with_cleanup( + map: impl FnOnce() -> Result, + cleanup: impl FnOnce(), +) -> Result { + match map() { + Ok(v) => Ok(v), + Err(e) => { + cleanup(); + Err(e) + } + } +} + +impl XShmSession { + fn connect(display: String) -> Result { + use x11rb::protocol::shm::ConnectionExt as _; + + let (conn, screen_num) = x11rb::rust_connection::RustConnection::connect(Some(&display)) + .map_err(|e| anyhow!("X11 connect for SHM: {e}"))?; + let pixels = PixelCatalog::from_setup(conn.setup()); + + // MUST query version before any other SHM request. + let ver = conn + .shm_query_version() + .map_err(|e| anyhow!("shm_query_version request: {e}"))? + .reply() + .map_err(|e| anyhow!("shm_query_version reply: {e}"))?; + + // CreateSegment requires MIT-SHM >= 1.2. + if ver.major_version < 1 || (ver.major_version == 1 && ver.minor_version < 2) { + bail!( + "MIT-SHM {}.{} < 1.2 (CreateSegment unsupported)", + ver.major_version, + ver.minor_version + ); + } + + Ok(Self { + display, + conn, + pixels, + screen_num, + buffer: None, + }) + } + + fn detach_buffer_best_effort(&mut self) { + let _ = self.detach_buffer(); + } + + fn detach_buffer(&mut self) -> Result<()> { + use x11rb::protocol::shm::ConnectionExt as _; + if let Some(buf) = self.buffer.take() { + let seg = buf.seg; + let detach = self + .conn + .shm_detach(seg) + .map_err(|e| anyhow!("shm_detach request for segment 0x{seg:x}: {e}"))? + .check() + .map_err(|e| anyhow!("shm_detach reply for segment 0x{seg:x}: {e}")); + // Drop the mapping even when the connection is already broken. + drop(buf); + detach?; + } + Ok(()) + } + + fn ensure_buffer(&mut self, need: usize) -> Result<()> { + use x11rb::connection::Connection; + use x11rb::protocol::shm::ConnectionExt as _; + + if need == 0 { + bail!("SHM buffer size must be nonzero"); + } + if need > MAX_CAPTURE_BYTES { + bail!("SHM buffer size {need} exceeds max {MAX_CAPTURE_BYTES}"); + } + if let Some(buf) = &self.buffer { + if buf.capacity >= need { + return Ok(()); + } + } + + // Grow: detach old segment and drop old mapping before allocating. + self.detach_buffer()?; + + let size_u32 = + u32::try_from(need).map_err(|_| anyhow!("SHM buffer size {need} does not fit u32"))?; + let seg = self + .conn + .generate_id() + .map_err(|e| anyhow!("generate_id for SHM segment: {e}"))?; + let reply = self + .conn + .shm_create_segment(seg, size_u32, false) + .map_err(|e| anyhow!("shm_create_segment request: {e}"))? + .reply() + .map_err(|e| anyhow!("shm_create_segment reply: {e}"))?; + + let fd = reply.shm_fd; + // SAFETY: we own the server-returned FD for a segment of exactly + // `need` bytes (fixed nonzero size from CreateSegment). We never + // truncate the underlying object while the mapping is live. + // On map failure, detach the just-created server segment before + // returning so it is not leaked until connection teardown. + let map = map_created_segment_with_cleanup( + || unsafe { + memmap2::MmapOptions::new() + .len(need) + .map_mut(&fd) + .map_err(|e| anyhow!("mmap MIT-SHM CreateSegment FD: {e}")) + }, + || { + if let Ok(cookie) = self.conn.shm_detach(seg) { + let _ = cookie.check(); + } + }, + )?; + // Mapping retains the pages; FD can close. + drop(fd); + + self.buffer = Some(ShmBuffer { + seg, + map, + capacity: need, + }); + Ok(()) + } + + /// Geometry + SHM request/reply + copy into owned Vec. Caller encodes off-lock. + fn capture_raw(&mut self, xid: u64) -> Result { + use x11rb::protocol::shm::ConnectionExt as _; + use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat}; + + let window = xid_to_window(xid)?; + let geom = self + .conn + .get_geometry(window) + .map_err(|e| anyhow!("get_geometry request: {e}"))? + .reply() + .map_err(|e| anyhow!("get_geometry reply: {e}"))?; + let w = u32::from(geom.width); + let h = u32::from(geom.height); + let layout = self.pixels.packed_layout(w, h, geom.depth)?; + let need = layout.len; + + self.ensure_buffer(need)?; + let (seg, map_len) = { + let buf = self + .buffer + .as_ref() + .ok_or_else(|| anyhow!("SHM buffer missing after ensure"))?; + if buf.map.len() < need { + bail!("mapped SHM length {} < required {need}", buf.map.len()); + } + (buf.seg, buf.map.len()) + }; + + let reply = self + .conn + .shm_get_image( + window, + 0, + 0, + geom.width, + geom.height, + !0u32, + u8::from(ImageFormat::Z_PIXMAP), + seg, + 0, + ) + .map_err(|e| anyhow!("shm_get_image request: {e}"))? + .reply() + .map_err(|e| anyhow!("shm_get_image reply: {e}"))?; + + match reply.depth { + 16 | 24 | 32 => {} + other => bail!("Unsupported depth: {other}"), + } + + let size = reply.size as usize; + if size != need { + bail!( + "shm_get_image size {size} != expected {need} ({}x{}, depth {}, stride {})", + w, + h, + reply.depth, + layout.stride + ); + } + if size > map_len { + bail!("shm_get_image size {size} exceeds mapped {map_len}"); + } + + let data = { + let buf = self + .buffer + .as_ref() + .ok_or_else(|| anyhow!("SHM buffer missing after get_image"))?; + buf.map[..size].to_vec() + }; + Ok(RawFrame { + data, + w, + h, + decoder: self.pixels.decoder(w, h, reply.depth, reply.visual)?, + }) + } +} + +struct RawFrame { + data: Vec, + w: u32, + h: u32, + decoder: PixelDecoder, +} + +fn xshm_state() -> &'static Mutex { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(XShmState::Uninit)) +} + +fn capture_via_xshm(xid: u64) -> Result> { + let display = current_display(); + let mut guard = lock_mutex(xshm_state()); + let frame = capture_raw_via_xshm_state(&mut guard, &display, xid)?; + + // Release the session mutex before pixel conversion and PNG encode. + drop(guard); + packed_zpixmap_to_png(&frame.data, frame.w, frame.h, frame.decoder) +} + +fn capture_raw_via_xshm_state(guard: &mut XShmState, display: &str, xid: u64) -> Result { + // Init backoff for this DISPLAY — no per-frame reprobe while active. + // Expired same-DISPLAY / different-DISPLAY Unsupported → Uninit (probeable). + if let Err(reason) = guard.consume_init_backoff(display, Instant::now()) { + bail!("MIT-SHM disabled for DISPLAY={display}: {reason}"); + } + + // Ensure Ready session for current DISPLAY (connect only on init/recovery). + match ensure_xshm_ready(guard, display) { + Ok(()) => {} + Err(e) => { + let reason = format!("{e:#}"); + *guard = XShmState::unsupported_after_init_failure( + display.to_string(), + reason.clone(), + Instant::now(), + ); + bail!("MIT-SHM init failed for DISPLAY={display}: {reason}"); + } + } + + // Warm capture under lock (geometry + SHM + copy only). + let first = { + let session = match &mut *guard { + XShmState::Ready(session) => session, + _ => bail!("internal: XShm state not Ready after ensure"), + }; + session.capture_raw(xid) + }; + + match first { + Ok(frame) => Ok(frame), + Err(first_err) => { + // Cached session failure: discard/detach and reconnect+retry once. + *guard = XShmState::Uninit; + let retry_init = ensure_xshm_ready(guard, display); + let second = match retry_init { + Ok(()) => { + let session = match &mut *guard { + XShmState::Ready(session) => session, + _ => { + return Err(anyhow!("internal: XShm not Ready after reconnect")); + } + }; + session.capture_raw(xid) + } + Err(e) => Err(e), + }; + match second { + Ok(frame) => Ok(frame), + Err(second_err) => { + let combined = format!("first: {first_err:#}; retry: {second_err:#}"); + // Request/capture failures never enter Unsupported. + *guard = XShmState::after_capture_retry_failure(); + bail!( + "MIT-SHM capture failed after reconnect for DISPLAY={display}: {combined}" + ); + } + } + } + } +} + +fn ensure_xshm_ready(guard: &mut XShmState, display: &str) -> Result<()> { + match guard { + XShmState::Ready(session) if session.display == display => Ok(()), + XShmState::Unsupported { + display: d, reason, .. + } if d == display => { + // Defensive: call sites should consume backoff first. + bail!("MIT-SHM disabled for DISPLAY={display}: {reason}"); + } + _ => { + // DISPLAY change or Uninit: drop old session (Detach on Drop) and reconnect. + *guard = XShmState::Uninit; + let session = XShmSession::connect(display.to_string())?; + *guard = XShmState::Ready(session); + Ok(()) + } + } +} + +// ── persistent plain XGetImage session ──────────────────────────────────── + +struct XGetImageSession { + display: String, + conn: x11rb::rust_connection::RustConnection, + pixels: PixelCatalog, +} + +fn xgetimage_state() -> &'static Mutex> { + static STATE: OnceLock>> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(None)) +} + +fn capture_via_persistent_xgetimage(xid: u64) -> Result> { + let display = current_display(); + let mut guard = lock_mutex(xgetimage_state()); + + ensure_xgetimage_ready(&mut guard, &display) + .map_err(|e| anyhow!("XGetImage connect: {e:#}"))?; + + let first = match guard.as_mut() { + Some(session) => session.capture_raw(xid), + None => Err(anyhow!("internal: XGetImage session missing after ensure")), + }; + + let frame = match first { + Ok(frame) => frame, + Err(first_err) => { + // Cached connection failure → reconnect once. + *guard = None; + ensure_xgetimage_ready(&mut guard, &display) + .map_err(|e| anyhow!("XGetImage reconnect after error ({first_err:#}): {e:#}"))?; + match guard.as_mut() { + Some(session) => session.capture_raw(xid).map_err(|e| { + anyhow!("XGetImage failed after reconnect (first: {first_err:#}): {e:#}") + })?, + None => { + bail!("XGetImage session missing after reconnect (first: {first_err:#})") + } + } + } + }; + + drop(guard); + packed_zpixmap_to_png(&frame.data, frame.w, frame.h, frame.decoder) +} + +fn ensure_xgetimage_ready(guard: &mut Option, display: &str) -> Result<()> { + if let Some(session) = guard.as_ref() { + if session.display == display { + return Ok(()); + } + } + *guard = None; + *guard = Some(XGetImageSession::connect(display.to_string())?); + Ok(()) +} + +impl XGetImageSession { + fn connect(display: String) -> Result { + let (conn, _screen) = x11rb::rust_connection::RustConnection::connect(Some(&display)) + .map_err(|e| anyhow!("{e}"))?; + let pixels = PixelCatalog::from_setup(conn.setup()); + Ok(Self { + display, + conn, + pixels, + }) + } + + fn capture_raw(&mut self, xid: u64) -> Result { + use x11rb::protocol::xproto::{ConnectionExt as _, ImageFormat}; + + let window = xid_to_window(xid)?; + let geom = self + .conn + .get_geometry(window) + .map_err(|e| anyhow!("get_geometry request: {e}"))? + .reply() + .map_err(|e| anyhow!("get_geometry reply: {e}"))?; + let w = u32::from(geom.width); + let h = u32::from(geom.height); + let layout = self.pixels.packed_layout(w, h, geom.depth)?; + let need = layout.len; + + let img = self + .conn + .get_image( + ImageFormat::Z_PIXMAP, + window, + 0, + 0, + geom.width, + geom.height, + !0u32, + ) + .map_err(|e| anyhow!("get_image request: {e}"))? + .reply() + .map_err(|e| anyhow!("get_image reply: {e}"))?; + + match img.depth { + 16 | 24 | 32 => {} + other => bail!("Unsupported depth: {other}"), + } + if img.data.len() != need { + bail!( + "XGetImage data length {} != expected {need} ({}x{})", + img.data.len(), + w, + h + ); + } + + Ok(RawFrame { + data: img.data, + w, + h, + decoder: self.pixels.decoder(w, h, img.depth, img.visual)?, + }) + } } /// Public version of png_dimensions for use in tool code. @@ -217,6 +956,219 @@ pub fn crosshair_png_bytes(png_bytes: &[u8], cx: f64, cy: f64) -> Result mod tests { use super::*; use std::cell::Cell; + use std::rc::Rc; + use std::time::{Duration, Instant}; + + fn decode_png_rgba(png: &[u8]) -> Vec { + image::load_from_memory_with_format(png, image::ImageFormat::Png) + .expect("decode PNG") + .to_rgba8() + .into_raw() + } + + #[test] + fn zpixmap_decoder_honors_little_endian_24bpp_stride_and_masks() { + let decoder = PixelDecoder { + layout: PackedLayout { + byte_order: ImageOrder::LSB_FIRST, + bytes_per_pixel: 3, + stride: 12, + len: 24, + }, + red_mask: 0x00ff_0000, + green_mask: 0x0000_ff00, + blue_mask: 0x0000_00ff, + }; + // Width 3 at 24bpp with 32-bit scanline padding: 9 payload bytes + + // 3 ignored padding bytes per row. Colors are deliberately varied. + let packed = [ + 0x33, 0x22, 0x11, 0x66, 0x55, 0x44, 0x99, 0x88, 0x77, 0xde, 0xad, 0xbe, 0xcc, 0xbb, + 0xaa, 0xff, 0xee, 0xdd, 0x03, 0x02, 0x01, 0xef, 0xca, 0xfe, + ]; + let png = packed_zpixmap_to_png(&packed, 3, 2, decoder).expect("decode little-endian"); + assert_eq!( + decode_png_rgba(&png), + vec![ + 0x11, 0x22, 0x33, 0xff, 0x44, 0x55, 0x66, 0xff, 0x77, 0x88, 0x99, 0xff, 0xaa, 0xbb, + 0xcc, 0xff, 0xdd, 0xee, 0xff, 0xff, 0x01, 0x02, 0x03, 0xff, + ] + ); + } + + #[test] + fn zpixmap_decoder_honors_big_endian_rgb565_masks() { + let decoder = PixelDecoder { + layout: PackedLayout { + byte_order: ImageOrder::MSB_FIRST, + bytes_per_pixel: 2, + stride: 4, + len: 4, + }, + red_mask: 0xf800, + green_mask: 0x07e0, + blue_mask: 0x001f, + }; + let packed = [0xf8, 0x00, 0x07, 0xe0]; + let png = packed_zpixmap_to_png(&packed, 2, 1, decoder).expect("decode big-endian"); + assert_eq!(decode_png_rgba(&png), vec![255, 0, 0, 255, 0, 255, 0, 255]); + } + + #[test] + fn pixel_catalog_uses_server_bpp_padding_and_rejects_bad_masks() { + let valid = Visualtype { + visual_id: 0x21, + class: VisualClass::TRUE_COLOR, + bits_per_rgb_value: 8, + colormap_entries: 256, + red_mask: 0x00ff_0000, + green_mask: 0x0000_ff00, + blue_mask: 0x0000_00ff, + }; + let mut catalog = PixelCatalog { + image_byte_order: ImageOrder::LSB_FIRST, + formats: vec![Format { + depth: 24, + bits_per_pixel: 24, + scanline_pad: 32, + }], + visuals: vec![valid], + }; + let decoder = catalog.decoder(3, 2, 24, 0x21).expect("valid decoder"); + assert_eq!(decoder.layout.bytes_per_pixel, 3); + assert_eq!(decoder.layout.stride, 12); + assert_eq!(decoder.layout.len, 24); + + catalog.visuals[0].red_mask = 0x00f0_f000; + let err = catalog + .decoder(3, 2, 24, 0x21) + .err() + .expect("non-contiguous mask must fail"); + assert!(format!("{err:#}").contains("not contiguous")); + } + + #[test] + fn xid_narrowing_rejects_values_above_x11_range() { + assert_eq!(xid_to_window(u64::from(u32::MAX)).unwrap(), u32::MAX); + let err = xid_to_window(u64::from(u32::MAX) + 1).expect_err("overflow must fail"); + assert!(format!("{err:#}").contains("does not fit u32")); + } + + /// Mapping failure after a server SHM segment exists must run cleanup + /// exactly once and still surface the original map error. + #[test] + fn xshm_mapping_failure_detaches_created_segment() { + let cleanups = Rc::new(Cell::new(0u32)); + let cleanups_c = Rc::clone(&cleanups); + + let err = map_created_segment_with_cleanup( + || Err::<(), _>(anyhow!("mmap failed")), + || { + cleanups_c.set(cleanups_c.get() + 1); + }, + ) + .expect_err("map failure must propagate"); + + assert_eq!( + cleanups.get(), + 1, + "cleanup must run exactly once on map error" + ); + let msg = format!("{err:#}"); + assert!( + msg.contains("mmap failed"), + "original map error must be preserved, got: {msg}" + ); + } + + #[test] + fn xshm_same_display_unsupported_blocked_before_retry_deadline() { + let t0 = Instant::now(); + let mut state = + XShmState::unsupported_after_init_failure(":0".into(), "connect refused".into(), t0); + let before = t0 + Duration::from_secs(29); + let err = state + .consume_init_backoff(":0", before) + .expect_err("must stay blocked before deadline"); + assert_eq!(err, "connect refused"); + match &state { + XShmState::Unsupported { + display, + reason, + retry_after, + } => { + assert_eq!(display, ":0"); + assert_eq!(reason, "connect refused"); + assert_eq!(*retry_after, t0 + XSHM_INIT_RETRY_BACKOFF); + } + XShmState::Uninit | XShmState::Ready(_) => { + panic!("expected Unsupported still cached before deadline") + } + } + } + + #[test] + fn xshm_same_display_unsupported_becomes_uninit_at_retry_deadline() { + let t0 = Instant::now(); + let mut state = XShmState::unsupported_after_init_failure( + ":0".into(), + "shm_query_version failed".into(), + t0, + ); + let at_deadline = t0 + XSHM_INIT_RETRY_BACKOFF; + state + .consume_init_backoff(":0", at_deadline) + .expect("deadline must make same DISPLAY probeable"); + assert!( + matches!(state, XShmState::Uninit), + "expired backoff must reset to Uninit" + ); + } + + #[test] + fn xshm_same_display_unsupported_becomes_uninit_after_retry_deadline() { + let t0 = Instant::now(); + let mut state = + XShmState::unsupported_after_init_failure(":1".into(), "extension missing".into(), t0); + let after = t0 + XSHM_INIT_RETRY_BACKOFF + Duration::from_millis(1); + state + .consume_init_backoff(":1", after) + .expect("past deadline must make same DISPLAY probeable"); + assert!(matches!(state, XShmState::Uninit)); + } + + #[test] + fn xshm_different_display_unsupported_is_immediately_probeable() { + let t0 = Instant::now(); + let mut state = + XShmState::unsupported_after_init_failure(":0".into(), "init failed on :0".into(), t0); + // Still well inside the 30s window for :0. + let now = t0 + Duration::from_secs(1); + state + .consume_init_backoff(":1", now) + .expect("DISPLAY change must ignore prior backoff"); + assert!( + matches!(state, XShmState::Uninit), + "different DISPLAY must reset Unsupported to Uninit" + ); + } + + #[test] + fn xshm_capture_retry_failure_yields_uninit_not_unsupported() { + let state = XShmState::after_capture_retry_failure(); + assert!( + matches!(state, XShmState::Uninit), + "request/capture retry failure must never enter Unsupported" + ); + // Explicit counter-check: constructing init-failure Unsupported is a + // different path and must remain distinct from capture retry policy. + let init_fail = + XShmState::unsupported_after_init_failure(":0".into(), "init".into(), Instant::now()); + assert!(matches!(init_fail, XShmState::Unsupported { .. })); + assert!(!matches!( + XShmState::after_capture_retry_failure(), + XShmState::Unsupported { .. } + )); + } #[test] fn available_gnome_helper_failure_is_terminal_at_public_boundary() { @@ -254,4 +1206,491 @@ mod tests { assert_eq!(result.unwrap(), vec![1, 2, 3]); assert!(!wayland_called.get()); } + + #[test] + fn xshm_success_short_circuits_other_linux_capture_backends() { + use std::rc::Rc; + + let png = cua_driver_core::image_utils::encode_rgba_to_png(&[255, 0, 0, 255], 1, 1) + .expect("1x1 PNG"); + assert!(!png.is_empty()); + + let xshm_calls = Rc::new(Cell::new(0u32)); + let xgetimage_calls = Rc::new(Cell::new(0u32)); + let imagemagick_calls = Rc::new(Cell::new(0u32)); + + let xshm_calls_c = Rc::clone(&xshm_calls); + let xgetimage_calls_c = Rc::clone(&xgetimage_calls); + let imagemagick_calls_c = Rc::clone(&imagemagick_calls); + let png_ret = png.clone(); + + let result = capture_window_with_backends( + 42, + move |xid| { + xshm_calls_c.set(xshm_calls_c.get() + 1); + assert_eq!(xid, 42); + Ok(png_ret) + }, + move |_xid| { + xgetimage_calls_c.set(xgetimage_calls_c.get() + 1); + Err(anyhow::anyhow!("XGetImage must not be invoked")) + }, + move |_xid| { + imagemagick_calls_c.set(imagemagick_calls_c.get() + 1); + Err(anyhow::anyhow!("ImageMagick must not be invoked")) + }, + ); + + assert_eq!(result.expect("xshm success"), png); + assert_eq!(xshm_calls.get(), 1); + assert_eq!(xgetimage_calls.get(), 0); + assert_eq!(imagemagick_calls.get(), 0); + } + + #[test] + fn capture_cascade_preserves_every_backend_error() { + let err = capture_window_with_backends( + 42, + |_| Err(anyhow!("shm transport disconnected")), + |_| Err(anyhow!("get-image bad drawable")), + |_| Err(anyhow!("import executable unavailable")), + ) + .expect_err("all failures must be returned"); + let message = format!("{err:#}"); + assert!(message.contains("shm transport disconnected")); + assert!(message.contains("get-image bad drawable")); + assert!(message.contains("import executable unavailable")); + } + + #[test] + fn empty_fast_paths_fall_through_to_imagemagick() { + let png = vec![1, 2, 3]; + let result = capture_window_with_backends( + 42, + |_| Ok(Vec::new()), + |_| Ok(Vec::new()), + |_| Ok(png.clone()), + ) + .expect("fallback succeeds"); + assert_eq!(result, png); + } + + struct LiveFixture { + conn: x11rb::rust_connection::RustConnection, + window: u32, + } + + struct XvfbServer { + child: Option, + } + + impl XvfbServer { + fn start(display: &str) -> Self { + let mut child = Command::new("Xvfb") + .args([ + display, + "-screen", + "0", + "640x480x24", + "-ac", + "-nolisten", + "tcp", + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("start restart-test Xvfb"); + for _ in 0..100 { + if x11rb::rust_connection::RustConnection::connect(Some(display)).is_ok() { + return Self { child: Some(child) }; + } + if let Some(status) = child.try_wait().expect("poll restart-test Xvfb") { + panic!("restart-test Xvfb exited before ready: {status}"); + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = child.kill(); + let _ = child.wait(); + panic!("restart-test Xvfb did not become ready on {display}"); + } + + fn stop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } + + impl Drop for XvfbServer { + fn drop(&mut self) { + self.stop(); + } + } + + fn create_live_fixture(display: &str, width: u16, height: u16) -> LiveFixture { + use x11rb::protocol::xproto::{ConnectionExt as _, CreateWindowAux, WindowClass}; + use x11rb::rust_connection::RustConnection; + + let (conn, screen_num) = RustConnection::connect(Some(display)).expect("connect fixture"); + let screen = &conn.setup().roots[screen_num]; + let window = conn.generate_id().expect("generate window id"); + conn.create_window( + screen.root_depth, + window, + screen.root, + 0, + 0, + width, + height, + 0, + WindowClass::INPUT_OUTPUT, + screen.root_visual, + &CreateWindowAux::new().background_pixel(0x0011_2233), + ) + .expect("create_window request") + .check() + .expect("create_window sync check"); + conn.map_window(window) + .expect("map_window request") + .check() + .expect("map_window sync check"); + let fixture = LiveFixture { conn, window }; + paint_live_fixture(&fixture, width, height); + fixture + } + + fn paint_live_fixture(fixture: &LiveFixture, width: u16, height: u16) { + use x11rb::protocol::xproto::{ConnectionExt as _, CreateGCAux, Rectangle}; + + let split1 = width / 3; + let split2 = width.saturating_mul(2) / 3; + for (pixel, rectangle) in [ + ( + 0x0017_5b_a8, + Rectangle { + x: 0, + y: 0, + width: split1, + height, + }, + ), + ( + 0x00c4_3d_52, + Rectangle { + x: split1 as i16, + y: 0, + width: split2 - split1, + height, + }, + ), + ( + 0x002d_b8_71, + Rectangle { + x: split2 as i16, + y: 0, + width: width - split2, + height, + }, + ), + ] { + let gc = fixture.conn.generate_id().expect("generate GC id"); + fixture + .conn + .create_gc(gc, fixture.window, &CreateGCAux::new().foreground(pixel)) + .expect("create_gc request") + .check() + .expect("create_gc sync check"); + fixture + .conn + .poly_fill_rectangle(fixture.window, gc, &[rectangle]) + .expect("poly_fill_rectangle request") + .check() + .expect("poly_fill_rectangle sync check"); + fixture.conn.free_gc(gc).expect("free_gc request"); + } + fixture.conn.flush().expect("flush fixture paint"); + fixture + .conn + .get_input_focus() + .expect("fixture sync request") + .reply() + .expect("fixture sync reply"); + } + + fn raw_frame_png(frame: RawFrame) -> Vec { + packed_zpixmap_to_png(&frame.data, frame.w, frame.h, frame.decoder) + .expect("encode raw frame") + } + + fn percentile_micros(samples: &mut [u128], percentile: usize) -> u128 { + samples.sort_unstable(); + samples[(samples.len() - 1) * percentile / 100] + } + + /// Live correctness and bounded-performance evidence against three Xvfb + /// servers. The fixture uses nontrivial colors and a non-power-of-two row + /// width, then proves MIT-SHM and XGetImage decode to identical pixels, + /// survive resize and DISPLAY switches, reuse one warm segment, and detach + /// it before replacement. CreateSegment is FD-backed MIT-SHM 1.2, so no + /// SysV `IPC_RMID` lifecycle exists in this implementation. + #[test] + #[ignore = "requires two live X11 servers with MIT-SHM 1.2"] + fn live_xshm_matches_xgetimage_across_resize_reconnect_and_repetition() { + use x11rb::connection::Connection; + use x11rb::protocol::shm::ConnectionExt as _; + use x11rb::protocol::xproto::{ConfigureWindowAux, ConnectionExt as _, ImageFormat}; + + const W1: u16 = 67; + const H1: u16 = 43; + const W2: u16 = 131; + const H2: u16 = 79; + const REPEATS: usize = 40; + const IMPORT_REPEATS: usize = 10; + + let display = std::env::var("DISPLAY").expect("DISPLAY must be set"); + let second_display = + std::env::var("CUA_X11_SECOND_DISPLAY").expect("CUA_X11_SECOND_DISPLAY must be set"); + let restart_display = + std::env::var("CUA_X11_RESTART_DISPLAY").expect("CUA_X11_RESTART_DISPLAY must be set"); + assert_ne!( + display, second_display, + "tests require distinct DISPLAY values" + ); + assert_ne!(display, restart_display, "restart DISPLAY must be distinct"); + assert_ne!( + second_display, restart_display, + "restart DISPLAY must be distinct" + ); + let fixture = create_live_fixture(&display, W1, H1); + let second_fixture = create_live_fixture(&second_display, W1, H1); + + let mut xshm = XShmSession::connect(display.clone()).expect("connect XShm session"); + let ver = xshm + .conn + .shm_query_version() + .expect("shm_query_version request") + .reply() + .expect("shm_query_version reply"); + assert!( + ver.major_version > 1 || (ver.major_version == 1 && ver.minor_version >= 2), + "MIT-SHM {}.{} < 1.2", + ver.major_version, + ver.minor_version + ); + + let mut xget = XGetImageSession::connect(display.clone()).expect("connect XGetImage"); + let shm_png = raw_frame_png( + xshm.capture_raw(u64::from(fixture.window)) + .expect("initial XShm capture"), + ); + let xget_png = raw_frame_png( + xget.capture_raw(u64::from(fixture.window)) + .expect("initial XGetImage capture"), + ); + let import_png = capture_via_import(u64::from(fixture.window)) + .expect("initial ImageMagick capture oracle"); + let shm_rgba = decode_png_rgba(&shm_png); + assert_eq!(shm_rgba, decode_png_rgba(&xget_png)); + assert_eq!(shm_rgba, decode_png_rgba(&import_png)); + assert_eq!(&shm_rgba[0..4], &[0x17, 0x5b, 0xa8, 0xff]); + let middle = ((usize::from(H1 / 2) * usize::from(W1) + usize::from(W1 / 2)) * 4) + ..((usize::from(H1 / 2) * usize::from(W1) + usize::from(W1 / 2)) * 4 + 4); + assert_eq!(&shm_rgba[middle], &[0xc4, 0x3d, 0x52, 0xff]); + + let warm_seg = xshm.buffer.as_ref().expect("warm SHM buffer").seg; + let mut shm_micros = Vec::with_capacity(REPEATS); + let mut xget_micros = Vec::with_capacity(REPEATS); + let mut import_micros = Vec::with_capacity(IMPORT_REPEATS); + for _ in 0..REPEATS { + let started = Instant::now(); + let frame = xshm + .capture_raw(u64::from(fixture.window)) + .expect("repeated XShm capture"); + let _ = raw_frame_png(frame); + shm_micros.push(started.elapsed().as_micros()); + assert_eq!(xshm.buffer.as_ref().expect("reused buffer").seg, warm_seg); + + let started = Instant::now(); + let frame = xget + .capture_raw(u64::from(fixture.window)) + .expect("repeated XGetImage capture"); + let _ = raw_frame_png(frame); + xget_micros.push(started.elapsed().as_micros()); + } + for _ in 0..IMPORT_REPEATS { + let started = Instant::now(); + let _ = capture_via_import(u64::from(fixture.window)) + .expect("repeated ImageMagick capture"); + import_micros.push(started.elapsed().as_micros()); + } + + fixture + .conn + .configure_window( + fixture.window, + &ConfigureWindowAux::new() + .width(u32::from(W2)) + .height(u32::from(H2)), + ) + .expect("resize request") + .check() + .expect("resize reply"); + paint_live_fixture(&fixture, W2, H2); + let resized_shm = raw_frame_png( + xshm.capture_raw(u64::from(fixture.window)) + .expect("resized XShm capture"), + ); + let resized_xget = raw_frame_png( + xget.capture_raw(u64::from(fixture.window)) + .expect("resized XGetImage capture"), + ); + let resized_import = capture_via_import(u64::from(fixture.window)) + .expect("resized ImageMagick capture oracle"); + assert_eq!( + decode_png_rgba(&resized_shm), + decode_png_rgba(&resized_xget) + ); + assert_eq!( + decode_png_rgba(&resized_shm), + decode_png_rgba(&resized_import) + ); + assert_eq!( + cua_driver_core::image_utils::png_dimensions(&resized_shm).unwrap(), + (u32::from(W2), u32::from(H2)) + ); + let resized_seg = xshm.buffer.as_ref().expect("resized SHM buffer").seg; + assert_ne!(resized_seg, warm_seg, "growth must replace the segment"); + + // Explicit detach is synchronous. Reusing the old XID must be rejected + // by the server while the connection remains healthy. + xshm.detach_buffer().expect("detach resized segment"); + let detached = xshm + .conn + .shm_get_image( + fixture.window, + 0, + 0, + W2, + H2, + !0u32, + u8::from(ImageFormat::Z_PIXMAP), + resized_seg, + 0, + ) + .expect("detached-segment request") + .reply(); + assert!(detached.is_err(), "server accepted a detached SHM segment"); + xshm.capture_raw(u64::from(fixture.window)) + .expect("capture allocates replacement after detach"); + + let mut shm_state = XShmState::Ready(xshm); + ensure_xshm_ready(&mut shm_state, &second_display).expect("switch XShm DISPLAY"); + let second_shm = match &mut shm_state { + XShmState::Ready(session) => { + assert_eq!(session.display, second_display); + raw_frame_png( + session + .capture_raw(u64::from(second_fixture.window)) + .expect("capture on second XShm DISPLAY"), + ) + } + _ => panic!("XShm state not ready after DISPLAY switch"), + }; + let mut xget_state = Some(xget); + ensure_xgetimage_ready(&mut xget_state, &second_display).expect("switch XGetImage DISPLAY"); + let second_xget = raw_frame_png( + xget_state + .as_mut() + .expect("second XGetImage session") + .capture_raw(u64::from(second_fixture.window)) + .expect("capture on second XGetImage DISPLAY"), + ); + assert_eq!(decode_png_rgba(&second_shm), decode_png_rgba(&second_xget)); + + // Prove that a cached connection failure retries once, returns the + // state to Uninit when the server remains unavailable, then recovers + // after a server restart at the exact same DISPLAY value. + let mut restart_server = XvfbServer::start(&restart_display); + let restart_fixture = create_live_fixture(&restart_display, W1, H1); + let mut restart_state = XShmState::Uninit; + capture_raw_via_xshm_state( + &mut restart_state, + &restart_display, + u64::from(restart_fixture.window), + ) + .expect("initial capture on restart DISPLAY"); + restart_server.stop(); + let disconnected = match capture_raw_via_xshm_state( + &mut restart_state, + &restart_display, + u64::from(restart_fixture.window), + ) { + Ok(_) => panic!("capture must fail while restart DISPLAY is down"), + Err(error) => error, + }; + assert!( + format!("{disconnected:#}").contains("capture failed after reconnect"), + "unexpected disconnect error: {disconnected:#}" + ); + assert!(matches!(restart_state, XShmState::Uninit)); + drop(restart_fixture); + + restart_server = XvfbServer::start(&restart_display); + let restarted_fixture = create_live_fixture(&restart_display, W1, H1); + let restarted_shm = raw_frame_png( + capture_raw_via_xshm_state( + &mut restart_state, + &restart_display, + u64::from(restarted_fixture.window), + ) + .expect("capture after same-DISPLAY server restart"), + ); + let mut restarted_xget = XGetImageSession::connect(restart_display.clone()) + .expect("connect restarted XGetImage"); + let restarted_xget = raw_frame_png( + restarted_xget + .capture_raw(u64::from(restarted_fixture.window)) + .expect("XGetImage capture after server restart"), + ); + assert_eq!( + decode_png_rgba(&restarted_shm), + decode_png_rgba(&restarted_xget) + ); + drop(restart_state); + drop(restarted_fixture); + restart_server.stop(); + + let shm_p50 = percentile_micros(&mut shm_micros, 50); + let shm_p95 = percentile_micros(&mut shm_micros, 95); + let xget_p50 = percentile_micros(&mut xget_micros, 50); + let xget_p95 = percentile_micros(&mut xget_micros, 95); + let import_p50 = percentile_micros(&mut import_micros, 50); + let import_p95 = percentile_micros(&mut import_micros, 95); + assert!( + shm_p95.saturating_mul(2) < import_p95, + "MIT-SHM p95 ({shm_p95}us) must be less than half the ImageMagick p95 ({import_p95}us)" + ); + println!( + "CAPTURE_EVIDENCE {{\"display\":\"{}\",\"second_display\":\"{}\",\"restart_display\":\"{}\",\"fast_samples\":{},\"import_samples\":{},\"width\":{},\"height\":{},\"xshm_p50_us\":{},\"xshm_p95_us\":{},\"xgetimage_p50_us\":{},\"xgetimage_p95_us\":{},\"imagemagick_p50_us\":{},\"imagemagick_p95_us\":{},\"performance_bound\":\"xshm_p95_lt_half_imagemagick_p95\",\"pixel_equivalent\":true,\"imagemagick_equivalent\":true,\"resize_equivalent\":true,\"segment_reused\":true,\"detach_verified\":true,\"display_switch_verified\":true,\"server_restart_verified\":true}}", + display, + second_display, + restart_display, + REPEATS, + IMPORT_REPEATS, + W1, + H1, + shm_p50, + shm_p95, + xget_p50, + xget_p95, + import_p50, + import_p95 + ); + + let _ = fixture.conn.destroy_window(fixture.window); + let _ = fixture.conn.flush(); + let _ = second_fixture.conn.destroy_window(second_fixture.window); + let _ = second_fixture.conn.flush(); + } } diff --git a/packages/cua-driver/rust/crates/platform-linux/src/input/mod.rs b/packages/cua-driver/rust/crates/platform-linux/src/input/mod.rs index 4725ad77cf..288636d030 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/input/mod.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/input/mod.rs @@ -21,6 +21,7 @@ use evdev::{AttributeSet, EventType, InputEvent, Key, RelativeAxisType}; use std::collections::HashMap; use std::ffi::{CStr, CString}; use std::fs; +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::ptr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; @@ -126,6 +127,17 @@ static UINPUT_POINTERS: OnceLock> OnceLock::new(); static XLIB_THREADS_READY: OnceLock> = OnceLock::new(); static MPX_NAME_COUNTER: AtomicU64 = AtomicU64::new(1); +// evdev 0.12.2 asserts `name.len() + 1 < UINPUT_MAX_NAME_SIZE` while building +// a device. Linux defines UINPUT_MAX_NAME_SIZE as 80, leaving 78 usable bytes. +const EVDEV_UINPUT_NAME_MAX_BYTES: usize = 78; +const UINPUT_POINTER_SUFFIX: &str = " uinput pointer"; +pub const UINPUT_UNAVAILABLE_CODE: &str = "uinput_unavailable"; + +#[derive(Debug, thiserror::Error)] +#[error("Linux uinput pointer unavailable: {reason}")] +struct UinputUnavailable { + reason: String, +} fn mpx_pointers() -> &'static Mutex> { MPX_POINTERS.get_or_init(|| Mutex::new(HashMap::new())) @@ -137,11 +149,70 @@ fn uinput_pointers() -> &'static Mutex> fn master_pointer_name(cursor_id: &str) -> String { let nonce = MPX_NAME_COUNTER.fetch_add(1, Ordering::Relaxed); - format!("CUA {cursor_id} mp-{}-{nonce}", std::process::id()) + let prefix = "CUA "; + let suffix = format!(" mp-{}-{nonce}", std::process::id()); + let max_cursor_bytes = EVDEV_UINPUT_NAME_MAX_BYTES + .saturating_sub(prefix.len() + suffix.len() + UINPUT_POINTER_SUFFIX.len()); + let cursor_id = sanitize_device_name(cursor_id); + format!( + "{prefix}{}{suffix}", + truncate_utf8(&cursor_id, max_cursor_bytes) + ) } fn slave_pointer_name(master_name: &str) -> String { - format!("{master_name} uinput pointer") + format!("{master_name}{UINPUT_POINTER_SUFFIX}") +} + +fn truncate_utf8(value: &str, max_bytes: usize) -> &str { + let mut end = value.len().min(max_bytes); + while !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +fn sanitize_device_name(value: &str) -> String { + value + .chars() + .map(|ch| if ch.is_control() { '_' } else { ch }) + .collect() +} + +fn normalize_uinput_device_name(name: &str) -> String { + let sanitized = sanitize_device_name(name); + truncate_utf8(&sanitized, EVDEV_UINPUT_NAME_MAX_BYTES).to_owned() +} + +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> &str { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("unknown panic") +} + +pub(crate) fn uinput_unavailable(reason: impl Into) -> anyhow::Error { + UinputUnavailable { + reason: reason.into(), + } + .into() +} + +fn guarded_uinput_creation(name: &str, create: impl FnOnce(&str) -> Result) -> Result { + let name = normalize_uinput_device_name(name); + match catch_unwind(AssertUnwindSafe(|| create(&name))) { + Ok(Ok(device)) => Ok(device), + Ok(Err(error)) => Err(uinput_unavailable(error.to_string())), + Err(payload) => Err(uinput_unavailable(format!( + "device creation panicked: {}", + panic_payload_message(payload.as_ref()) + ))), + } +} + +pub fn is_uinput_unavailable(error: &anyhow::Error) -> bool { + error.downcast_ref::().is_some() } fn master_pointer_device_name(master_name: &str) -> String { @@ -372,7 +443,13 @@ fn ensure_master_pointer(cursor_id: &str) -> Result { // inexpensive availability probe above handles the normal permission // denial; this ordering also prevents a race or late open failure from // leaking a newly created master pair. - let uinput_device = create_uinput_pointer(&device_name)?; + let uinput_device = match create_uinput_pointer(&device_name) { + Ok(device) => device, + Err(error) => { + unsafe { x11::xlib::XCloseDisplay(display) }; + return Err(error); + } + }; let mut change = x11::xinput2::XIAnyHierarchyChangeInfo::default(); let name = CString::new(base.clone())?; unsafe { @@ -481,25 +558,28 @@ pub fn forget_master_pointer(cursor_id: &str) { } fn create_uinput_pointer(name: &str) -> Result { - let mut keys = AttributeSet::::new(); - keys.insert(Key::BTN_LEFT); - keys.insert(Key::BTN_RIGHT); - keys.insert(Key::BTN_MIDDLE); + guarded_uinput_creation(name, |name| { + let mut keys = AttributeSet::::new(); + keys.insert(Key::BTN_LEFT); + keys.insert(Key::BTN_RIGHT); + keys.insert(Key::BTN_MIDDLE); - let mut rel_axes = AttributeSet::::new(); - rel_axes.insert(RelativeAxisType::REL_X); - rel_axes.insert(RelativeAxisType::REL_Y); - // REL_WHEEL (vertical) and REL_HWHEEL (horizontal) so the same uinput slave - // can also drive scroll: libinput turns these into the XI2 smooth-scroll - // events GTK consumes, where synthetic Button4-7 XSendEvents are dropped. - rel_axes.insert(RelativeAxisType::REL_WHEEL); - rel_axes.insert(RelativeAxisType::REL_HWHEEL); + let mut rel_axes = AttributeSet::::new(); + rel_axes.insert(RelativeAxisType::REL_X); + rel_axes.insert(RelativeAxisType::REL_Y); + // REL_WHEEL (vertical) and REL_HWHEEL (horizontal) so the same uinput + // slave can also drive scroll: libinput turns these into the XI2 + // smooth-scroll events GTK consumes, where synthetic Button4-7 + // XSendEvents are dropped. + rel_axes.insert(RelativeAxisType::REL_WHEEL); + rel_axes.insert(RelativeAxisType::REL_HWHEEL); - Ok(evdev::uinput::VirtualDeviceBuilder::new()? - .name(name) - .with_keys(&keys)? - .with_relative_axes(&rel_axes)? - .build()?) + Ok(evdev::uinput::VirtualDeviceBuilder::new()? + .name(name) + .with_keys(&keys)? + .with_relative_axes(&rel_axes)? + .build()?) + }) } fn wait_for_slave_pointer_id(display: *mut x11::xlib::Display, device_name: &str) -> Result { @@ -887,10 +967,11 @@ fn ewmh_activate_window( /// primitives (proper `x_server_time` stamping beats the WM's focus-stealing /// prevention). /// -/// Best-effort: if no X display can be opened the body still runs (without -/// activation) so a headless/Wayland path degrades rather than hard-fails. -/// `settle_ms` is the pause after activation before the first injected event — -/// the WM needs a moment to complete the focus swap (mirrors the macOS settle). +/// The transition is confirmed from both EWMH active-window state and the X11 +/// core input-focus tree before `body` runs. A fixed delay or a successful +/// `XSetInputFocus` return is not evidence that global XTest input is safe. +/// `settle_ms` is retained as a compatibility hint and folded into the bounded +/// confirmation timeout; it is no longer an unconditional sleep. pub fn with_x11_foreground( xid: u64, settle_ms: u64, @@ -898,16 +979,18 @@ pub fn with_x11_foreground( ) -> Result { let display = unsafe { x11::xlib::XOpenDisplay(ptr::null()) }; if display.is_null() { - return body(); + bail!("foreground_unavailable: cannot open DISPLAY to verify exact X11 input focus"); } let prior = ewmh_active_window(display); + let mut prior_core_focus: x11::xlib::Window = 0; + let mut prior_revert = 0; + unsafe { + x11::xlib::XGetInputFocus(display, &mut prior_core_focus, &mut prior_revert); + } ewmh_activate_window(display, xid as x11::xlib::Window, prior.unwrap_or(0)); unsafe { x11::xlib::XSync(display, 0); } - if settle_ms > 0 { - std::thread::sleep(std::time::Duration::from_millis(settle_ms)); - } // EWMH `_NET_ACTIVE_WINDOW` is honored as *raise-only* by WMs with // focus-stealing prevention (e.g. KWin): the window reaches the top of the // stack — enough for a coordinate click, which lands by stacking — but the X @@ -928,12 +1011,45 @@ pub fn with_x11_foreground( x11::xlib::XSync(display, 0); x11::xlib::XSetErrorHandler(prev_handler); } - let result = body(); - // Restore the prior active window (brief swap, like macOS/Windows). + let timeout = std::time::Duration::from_millis(settle_ms.max(400)); + let deadline = std::time::Instant::now() + timeout; + let target = xid as x11::xlib::Window; + let focused = loop { + let active = ewmh_active_window(display) == Some(target); + if active && x11_focus_is_within(display, target) { + break true; + } + if std::time::Instant::now() >= deadline { + break false; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + let result = if focused { + body() + } else { + let active = ewmh_active_window(display).unwrap_or(0); + Err(anyhow::anyhow!( + "foreground_unavailable: X11 did not confirm active window and input focus within \ + exact target 0x{xid:x} before the {:?} deadline (active=0x{active:x}); no input was sent", + timeout + )) + }; + + // Restore both the EWMH active toplevel and the exact prior core focus. if let Some(p) = prior { ewmh_activate_window(display, p, xid as x11::xlib::Window); + } + if prior_core_focus != 0 { unsafe { + let previous_handler = x11::xlib::XSetErrorHandler(Some(ignore_x_error)); + x11::xlib::XSetInputFocus( + display, + prior_core_focus, + prior_revert, + x11::xlib::CurrentTime, + ); x11::xlib::XSync(display, 0); + x11::xlib::XSetErrorHandler(previous_handler); } } unsafe { @@ -942,6 +1058,55 @@ pub fn with_x11_foreground( result } +fn x11_focus_is_within(display: *mut x11::xlib::Display, target: x11::xlib::Window) -> bool { + let previous_handler = unsafe { x11::xlib::XSetErrorHandler(Some(ignore_x_error)) }; + let result = (|| { + let mut focused: x11::xlib::Window = 0; + let mut revert_to = 0; + unsafe { + x11::xlib::XGetInputFocus(display, &mut focused, &mut revert_to); + } + if focused == target { + return true; + } + let root = unsafe { x11::xlib::XDefaultRootWindow(display) }; + while focused != 0 && focused != root { + let mut query_root = 0; + let mut parent = 0; + let mut children: *mut x11::xlib::Window = ptr::null_mut(); + let mut child_count = 0; + let status = unsafe { + x11::xlib::XQueryTree( + display, + focused, + &mut query_root, + &mut parent, + &mut children, + &mut child_count, + ) + }; + if !children.is_null() { + unsafe { + x11::xlib::XFree(children.cast()); + } + } + if status == 0 || parent == 0 || parent == focused { + return false; + } + if parent == target { + return true; + } + focused = parent; + } + false + })(); + unsafe { + x11::xlib::XSync(display, 0); + x11::xlib::XSetErrorHandler(previous_handler); + } + result +} + /// Activate `xid` and LEAVE it active (no restore) — the persistent foreground /// swap behind `bring_to_front`. Returns the window that was active before, so /// the caller can report/inspect it. Best-effort; returns `None` prior on a @@ -2061,7 +2226,7 @@ pub fn send_key_xtest(key: &str, modifiers: &[&str]) -> Result<()> { Ok(()) } -/// Screen-absolute click via the XTest extension — the `capture_scope="desktop"` +/// Screen-absolute click via the XTest extension — the desktop-target /// foreground click. It warps the real pointer to `(x, y)` and injects a true /// button press/release there, so the event lands on whatever window owns that /// screen pixel (the Linux peer of the Windows `WindowFromPoint` + macOS @@ -2660,8 +2825,10 @@ exit 0"#, #[cfg(test)] mod path_tests { use super::{ - modifiers_to_state, path_cumulative, point_on_path, real_pointer_capabilities_available, - sample_function, + create_uinput_pointer, guarded_uinput_creation, is_uinput_unavailable, master_pointer_name, + modifiers_to_state, normalize_uinput_device_name, path_cumulative, point_on_path, + real_pointer_capabilities_available, sample_function, slave_pointer_name, + EVDEV_UINPUT_NAME_MAX_BYTES, UINPUT_POINTER_SUFFIX, }; use x11rb::protocol::xproto::KeyButMask; @@ -2679,6 +2846,95 @@ mod path_tests { ); } + #[test] + fn slave_pointer_name_fits_evdev_uinput_limit() { + for cursor_id in ["m".repeat(200), "cursor-鼠".repeat(50)] { + let name = slave_pointer_name(&master_pointer_name(&cursor_id)); + assert!( + name.len() <= 78, + "evdev 0.12 requires uinput names to be at most 78 bytes, got {}", + name.len() + ); + } + + let cursor_id = "same-long-cursor".repeat(20); + let first = slave_pointer_name(&master_pointer_name(&cursor_id)); + let second = slave_pointer_name(&master_pointer_name(&cursor_id)); + assert_ne!(first, second, "truncation must retain the unique nonce"); + assert!(first.ends_with(UINPUT_POINTER_SUFFIX)); + assert!(second.ends_with(UINPUT_POINTER_SUFFIX)); + } + + #[test] + fn uinput_name_normalization_covers_byte_boundaries_and_multibyte_text() { + let exact = "a".repeat(EVDEV_UINPUT_NAME_MAX_BYTES); + assert_eq!(normalize_uinput_device_name(&exact), exact); + + let overlong_ascii = "a".repeat(EVDEV_UINPUT_NAME_MAX_BYTES + 1); + assert_eq!( + normalize_uinput_device_name(&overlong_ascii), + "a".repeat(EVDEV_UINPUT_NAME_MAX_BYTES) + ); + + let exact_multibyte = format!("{}鼠", "a".repeat(75)); + assert_eq!(exact_multibyte.len(), EVDEV_UINPUT_NAME_MAX_BYTES); + assert_eq!( + normalize_uinput_device_name(&exact_multibyte), + exact_multibyte + ); + + let split_multibyte = format!("{}鼠", "a".repeat(77)); + let normalized = normalize_uinput_device_name(&split_multibyte); + assert_eq!(normalized, "a".repeat(77)); + assert!(normalized.is_char_boundary(normalized.len())); + + assert_eq!( + normalize_uinput_device_name("CUA\0pointer\n"), + "CUA_pointer_" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn uinput_creation_panic_is_contained_and_daemon_worker_remains_usable() { + let failed = tokio::task::spawn_blocking(|| { + guarded_uinput_creation::<()>("panic", |_| panic!("synthetic evdev panic")) + }) + .await + .expect("the blocking worker must not unwind"); + let error = failed.expect_err("the panic must become an error"); + assert!(is_uinput_unavailable(&error)); + assert!(error.to_string().contains("device creation panicked")); + + let subsequent = tokio::task::spawn_blocking(|| { + guarded_uinput_creation("subsequent", |name| Ok(name.to_owned())) + }) + .await + .expect("the runtime must remain usable after the contained panic") + .expect("a subsequent device operation must succeed"); + assert_eq!(subsequent, "subsequent"); + } + + #[test] + fn uinput_creation_error_is_stably_typed() { + let error = + guarded_uinput_creation::<()>("failure", |_| anyhow::bail!("permission denied")) + .expect_err("the injected builder error must be returned"); + assert!(is_uinput_unavailable(&error)); + assert_eq!( + error.to_string(), + "Linux uinput pointer unavailable: permission denied" + ); + } + + #[test] + #[ignore = "requires a writable /dev/uinput device"] + fn real_uinput_accepts_normalized_overlong_multibyte_name() { + let overlong_name = format!("CUA {}{UINPUT_POINTER_SUFFIX}", "鼠".repeat(100)); + let device = create_uinput_pointer(&overlong_name) + .expect("normalized device name should create a real uinput pointer"); + drop(device); + } + #[test] fn real_pointer_capabilities_require_uinput_access() { assert!(real_pointer_capabilities_available(true, false, true)); diff --git a/packages/cua-driver/rust/crates/platform-linux/src/lib.rs b/packages/cua-driver/rust/crates/platform-linux/src/lib.rs index ae2523898f..75334a31f8 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/lib.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/lib.rs @@ -145,6 +145,8 @@ pub fn register_tools_with_cursor_and_provider( ) -> ToolRegistry { #[cfg(target_os = "linux")] wayland::ensure_nested_session(); + #[cfg(target_os = "linux")] + wayland::overlay::set_config_enabled(cfg.enabled); if cfg.enabled { overlay::init(cfg.clone()); overlay::run_on_thread(); diff --git a/packages/cua-driver/rust/crates/platform-linux/src/overlay.rs b/packages/cua-driver/rust/crates/platform-linux/src/overlay.rs index 54eecdac3e..710aa22e01 100644 --- a/packages/cua-driver/rust/crates/platform-linux/src/overlay.rs +++ b/packages/cua-driver/rust/crates/platform-linux/src/overlay.rs @@ -5,9 +5,25 @@ //! from XComposite. The window covers the full display area. //! - A background thread renders cursor-local tiles at ~60 Hz while animation //! is active and uploads them with XPutImage + XShape clipping. -//! - XShape clips both input and visible pixels. On bare X11, the visible shape -//! quantizes the rendered alpha mask so translucent bloom pixels do not turn -//! into an opaque black disk when no compositor is present. +//! - XShape clips both input and visible pixels. On bare X11 the server never +//! blends our alpha, so the tile is software-composited over a save-under +//! copy of the real desktop backdrop and uploaded opaque; the visible shape +//! stays the alpha≠0 runs, exactly as under a compositor. When the backdrop +//! cannot be read the frame is deferred, and if that persists the shape falls +//! back to quantizing the alpha mask for the session, which keeps the cursor +//! visible at the cost of the translucent bloom. +//! - Software compositing reads the root under each tile. Our own painted +//! pixels would come back with it, so every painted rect is recorded with +//! both the desktop that was under it and the pixels we put on top; a later +//! read of that rect is only treated as desktop once it stops matching what +//! we uploaded. Whenever that record cannot be trusted — a RandR change, a +//! compositing manager arriving or leaving — the overlay blanks itself for a +//! short grace so the windows underneath repaint before it reads again. +//! - Known cost of compositing without a compositor: the alpha≠0 footprint is +//! opaque on screen, so whatever ends up under a *resting* cursor cannot be +//! observed (the server clips those pixels away from their owner) and stays +//! as it was at the last paint until the cursor moves or fades. The cutoff +//! path had the same limitation over the smaller alpha≥128 silhouette. //! - Z-ordering: `XRaiseWindow` every 80ms to stay above normal windows. //! - Wayland: when WAYLAND_DISPLAY is set but DISPLAY is also available (XWayland), //! the X11 path is used. Pure Wayland support is a TODO. @@ -19,6 +35,8 @@ //! What stays here is the X11 window plumbing: connection setup, //! override-redirect visual, ShapeInput passthrough, and the XPutImage paint. +#[cfg(target_os = "linux")] +use std::collections::VecDeque; use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; #[cfg(target_os = "linux")] @@ -26,6 +44,14 @@ use std::time::{Duration, Instant}; #[cfg(all(test, target_os = "linux"))] use cursor_overlay::CursorAction; +#[cfg(target_os = "linux")] +const X11_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(50); +/// How often the `_NET_WM_CM_S{n}` owner is re-sampled. A compositing manager +/// starting or stopping emits no event we subscribe to, and it decides whether +/// the server blends our alpha, so the sample cannot be startup-only. +#[cfg(target_os = "linux")] +const X11_COMPOSITOR_POLL_INTERVAL: Duration = Duration::from_secs(1); + #[cfg(target_os = "linux")] use cursor_overlay::ZOrderEnforcer; use cursor_overlay::{ @@ -39,6 +65,9 @@ static CMD_RX_CELL: Mutex>> = Mutex static RENDER: Mutex> = Mutex::new(None); static ARRIVAL_TX: Mutex>>> = Mutex::new(None); +#[cfg(all(test, target_os = "linux"))] +static X11_RANDR_REPAIR_COUNT: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); fn arrival_register(key: CursorKey, tx: tokio::sync::oneshot::Sender<()>) { let mut guard = ARRIVAL_TX.lock().unwrap(); @@ -58,6 +87,87 @@ fn arrival_fire(key: &CursorKey) { } } +fn arrival_cancel(key: &CursorKey) { + if let Ok(mut guard) = ARRIVAL_TX.lock() { + if let Some(map) = guard.as_mut() { + map.remove(key); + } + } +} + +fn release_all_arrivals() { + if let Ok(mut guard) = ARRIVAL_TX.lock() { + clear_arrivals(&mut guard); + } +} + +fn clear_arrivals(arrivals: &mut Option>>) { + if let Some(map) = arrivals.as_mut() { + map.clear(); + } +} + +fn try_send_x11_message( + sender: Option<&std::sync::mpsc::SyncSender>, + msg: OverlayMsg, +) -> bool { + sender.is_some_and(|tx| tx.try_send(msg).is_ok()) +} + +#[cfg(target_os = "linux")] +struct X11OverlayThreadCleanup { + receiver: Option>, + disable_render_state: bool, +} + +#[cfg(target_os = "linux")] +impl X11OverlayThreadCleanup { + fn receiver(&self) -> &std::sync::mpsc::Receiver { + self.receiver + .as_ref() + .expect("X11 overlay receiver is available before teardown") + } + + fn disconnect_receiver(&mut self) { + drop(self.receiver.take()); + } + + fn finish_cleanup(&self) { + if self.disable_render_state { + if let Ok(mut guard) = RENDER.lock() { + disable_render_map(&mut guard); + } + } + release_all_arrivals(); + } + + /// Run a hook in the only teardown interval where registration can race: + /// after channel disconnection but before renderer/waiter cleanup. + fn teardown_with_after_disconnect(&mut self, after_disconnect: impl FnOnce()) { + self.disconnect_receiver(); + after_disconnect(); + self.finish_cleanup(); + } +} + +#[cfg(target_os = "linux")] +impl Drop for X11OverlayThreadCleanup { + fn drop(&mut self) { + // Disconnect first so an animator racing teardown cannot enqueue after + // the waiter sweep. Registrations before release are swept below; + // registrations after release observe a disconnected channel and cancel + // themselves. On X11, also make future calls observe the renderer as + // unavailable. A Wayland session may still use its native forwarding + // path even when the optional XWayland owner thread cannot start. + self.teardown_with_after_disconnect(|| {}); + } +} + +#[cfg(target_os = "linux")] +fn disable_render_map(render: &mut Option) { + *render = None; +} + struct RenderMap { cursors: HashMap, scr_w: u32, @@ -90,8 +200,15 @@ fn apply_msg(map: &mut RenderMap, msg: OverlayMsg) -> Option { } None } + OverlayMsg::Revive(key) => { + if key != "default" { + map.ended.remove(&key); + } + None + } OverlayMsg::Cmd(KeyedOverlayCommand { key, cmd }) => { if map.ended.contains(&key) { + tracing::debug!(key = %key, cmd = ?cmd, "overlay: command dropped — key was ended"); return None; } let template = map.template.clone(); @@ -168,15 +285,27 @@ pub fn send_command(cmd: OverlayCommand) { } pub fn send_command_for(key: CursorKey, cmd: OverlayCommand) { + let _ = try_send_command_for(key, cmd); +} + +/// Dispatch to every active Linux overlay backend. The result reports only +/// whether the X11 owner accepted the command and can fire `ARRIVAL_TX`; the +/// Wayland layer-shell path does not currently publish arrival notifications. +fn try_send_command_for(key: CursorKey, cmd: OverlayCommand) -> bool { if key.is_empty() { - return; + return false; } let msg = OverlayMsg::Cmd(KeyedOverlayCommand { key: key.clone(), cmd: cmd.clone(), }); - if let Some(tx) = CMD_TX.get() { - let _ = tx.try_send(msg.clone()); + let x11_queued = try_send_x11_message(CMD_TX.get(), msg.clone()); + if !x11_queued { + tracing::warn!( + key = %key, + sender_missing = CMD_TX.get().is_none(), + "overlay: X11 channel rejected command (no sender or queue full)" + ); } // Also forward to the native-Wayland layer-shell overlay when Wayland // is opted in. The wayland overlay's `forward` is a no-op when its @@ -238,6 +367,7 @@ pub fn send_command_for(key: CursorKey, cmd: OverlayCommand) { } } } + x11_queued } pub fn is_enabled() -> bool { @@ -259,6 +389,26 @@ pub fn is_enabled_for(key: &str) -> bool { .unwrap_or(false) } +/// Truthful render acknowledgement for lifecycle inspection. This checks the +/// exact session key and never inherits the seeded default cursor. +pub fn is_visible_for_session(key: &str) -> bool { + RENDER + .lock() + .ok() + .and_then(|guard| { + guard + .as_ref() + .and_then(|map| map.cursors.get(key)) + .map(|rs| { + rs.core.cfg.enabled + && rs.core.visible + && rs.core.idle_alpha >= 0.004 + && rs.core.pos.0 >= -100.0 + }) + }) + .unwrap_or(false) +} + pub fn current_position() -> (f64, f64) { current_position_for("default") } @@ -362,14 +512,19 @@ pub async fn animate_cursor_to_for(key: CursorKey, x: f64, y: f64) { let (tx, rx) = tokio::sync::oneshot::channel::<()>(); arrival_register(key.clone(), tx); - send_command_for( - key, + if !try_send_command_for( + key.clone(), OverlayCommand::MoveTo { x, y, end_heading_radians: std::f64::consts::FRAC_PI_4, }, - ); + ) { + // A full or disconnected channel cannot ever produce an arrival. Drop + // the registered sender now so this async operation cannot hang. + arrival_cancel(&key); + return; + } let _ = rx.await; } @@ -378,8 +533,30 @@ pub fn remove_cursor(key: CursorKey) { if key.is_empty() { return; } + let msg = OverlayMsg::Remove(key); if let Some(tx) = CMD_TX.get() { - let _ = tx.try_send(OverlayMsg::Remove(key)); + let _ = tx.try_send(msg.clone()); + } + #[cfg(target_os = "linux")] + if crate::wayland::is_wayland() && !crate::wayland::shell_helper::available() { + let _ = crate::wayland::overlay::forward(&msg); + } +} + +/// Clear the X11 render-side tombstone after a successful explicit session +/// revival. Wayland has no keyed tombstone, so forwarding this lifecycle +/// signal there is an accepted no-op. +pub fn revive_cursor(key: CursorKey) { + if key.is_empty() { + return; + } + let msg = OverlayMsg::Revive(key); + if let Some(tx) = CMD_TX.get() { + let _ = tx.try_send(msg.clone()); + } + #[cfg(target_os = "linux")] + if crate::wayland::is_wayland() && !crate::wayland::shell_helper::available() { + let _ = crate::wayland::overlay::forward(&msg); } } @@ -454,8 +631,8 @@ impl RenderState { /// True while the render loop must wake at frame cadence because the next /// tick can change pixels. A brand-new sentinel cursor is deliberately - /// quiescent, so an idle MCP server can block on the command channel - /// instead of repainting a full-screen X11 pixmap at 60 fps. + /// quiescent, so an idle MCP server can park on bounded maintenance waits + /// instead of rebuilding and repainting X11 cursor tiles at 60 fps. #[cfg(target_os = "linux")] fn needs_frame_tick(&self) -> bool { let fade_start = self.core.motion.idle_hide_ms / 1000.0; @@ -463,6 +640,17 @@ impl RenderState { || self.core.spring.is_some() || self.core.click_t.is_some() || self.core.session_badge_needs_frame_tick() + // The resting float bob (`shared_float_motion`) is part of the + // cursor's visual identity, not a transient animation: it runs + // whenever the cursor is on screen and reduced motion is off, so + // a settled cursor must keep receiving frames or the bob freezes + // mid-swing on Linux while macOS keeps levitating. The term dies + // with `idle_alpha` once the idle fade completes, returning the + // parked-overlay fast path to the fully hidden cursor. + || (self.core.visible + && self.core.pos.0 >= -100.0 + && self.core.idle_alpha >= 0.004 + && self.core.visual.reduced_motion != cursor_overlay::ReducedMotion::On) || (self.core.motion.idle_hide_ms > 0.0 && self.core.visible && self.core.pos.0 >= -100.0 @@ -573,12 +761,29 @@ fn next_maintenance_deadline( last_tick: Instant, last_z_order_tick: Instant, z_order_interval: Duration, + z_order_tick_needed: bool, idle_wait_interval: Option, + x11_event_poll_interval: Duration, ) -> Instant { - let z_order_deadline = last_z_order_tick + z_order_interval; - idle_wait_interval - .map(|interval| (last_tick + interval).min(z_order_deadline)) - .unwrap_or(z_order_deadline) + let mut deadline = last_tick + x11_event_poll_interval; + if z_order_tick_needed { + deadline = deadline.min(last_z_order_tick + z_order_interval); + } + if let Some(interval) = idle_wait_interval { + deadline = deadline.min(last_tick + interval); + } + deadline +} + +#[cfg(target_os = "linux")] +fn z_order_reassertion_needed( + had_msg: bool, + screen_changed: bool, + pin_changed: bool, + periodic_tick_needed: bool, + periodic_tick_due: bool, +) -> bool { + had_msg || screen_changed || pin_changed || (periodic_tick_needed && periodic_tick_due) } #[cfg(target_os = "linux")] @@ -593,40 +798,216 @@ enum OverlayWake { fn wait_for_overlay_work( rx: &std::sync::mpsc::Receiver, frame_tick_needed: bool, - z_order_tick_needed: bool, maintenance_deadline: Instant, ) -> OverlayWake { if frame_tick_needed { return OverlayWake::Frame; } - if z_order_tick_needed { - let timeout = maintenance_deadline.saturating_duration_since(Instant::now()); - return match rx.recv_timeout(timeout) { - Ok(msg) => OverlayWake::Message(msg), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => OverlayWake::MaintenanceTimeout, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => OverlayWake::Disconnected, - }; - } - - match rx.recv() { + let timeout = maintenance_deadline.saturating_duration_since(Instant::now()); + match rx.recv_timeout(timeout) { Ok(msg) => OverlayWake::Message(msg), - Err(_) => OverlayWake::Disconnected, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => OverlayWake::MaintenanceTimeout, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => OverlayWake::Disconnected, } } +#[cfg(target_os = "linux")] +fn recoverable_x11_z_order_error(error: &x11rb::x11_utils::X11Error, overlay_win: u32) -> bool { + // BadWindow: the sibling died between the liveness probe and the server + // processing the restack. BadMatch: the sibling is not a sibling — a + // reparenting WM moved the target under a frame window (or reparented it + // between our ancestor resolution and the restack), so it no longer + // shares the overlay's parent. Both mean only "this z-order nudge did + // not land"; the next eligible reassertion retries with fresh state. + matches!( + error.error_kind, + x11rb::protocol::ErrorKind::Window | x11rb::protocol::ErrorKind::Match + ) && error.major_opcode == x11rb::protocol::xproto::CONFIGURE_WINDOW_REQUEST + && error.extension_name.is_none() + && error.bad_value != overlay_win +} + +#[cfg(target_os = "linux")] +/// Return `Ok(true)` for display changes, `Ok(false)` for unrelated or safely +/// recoverable events, and `Err` for protocol failures that disable the overlay. +fn classify_x11_overlay_event( + event: &x11rb::protocol::Event, + overlay_win: u32, +) -> anyhow::Result { + match event { + // The pinned target can disappear (BadWindow) or be reparented under a + // WM frame (BadMatch) after the synchronous probe in + // X11ZOrderEnforcer::reassert but before its unchecked ConfigureWindow + // request reaches the server; x11rb then delivers the error here after + // the VoidCookie is dropped. This owner connection's only other + // ConfigureWindow path is checked synchronously, so a non-overlay bad + // value is the stale sibling and is safe to retry with fresh state on + // the next eligible z-order reassertion. + x11rb::protocol::Event::Error(error) + if recoverable_x11_z_order_error(error, overlay_win) => + { + tracing::debug!( + stale_target = error.bad_value, + "X11 overlay z-order sibling went stale during reassertion" + ); + Ok(false) + } + x11rb::protocol::Event::Error(error) => { + anyhow::bail!("X11 server rejected an overlay request: {error:?}") + } + x11rb::protocol::Event::RandrScreenChangeNotify(_) + | x11rb::protocol::Event::RandrNotify(_) => Ok(true), + _ => Ok(false), + } +} + +#[cfg(target_os = "linux")] +fn update_render_map_geometry(map: &mut RenderMap, width: u16, height: u16) { + map.scr_w = u32::from(width); + map.scr_h = u32::from(height); +} + +#[cfg(target_os = "linux")] +fn drain_x11_overlay_events( + conn: &impl x11rb::connection::Connection, + overlay_win: u32, +) -> anyhow::Result { + let mut display_changed = false; + while let Some(event) = conn.poll_for_event()? { + display_changed |= classify_x11_overlay_event(&event, overlay_win)?; + } + Ok(display_changed) +} + +#[cfg(target_os = "linux")] +fn subscribe_x11_display_changes( + conn: &impl x11rb::connection::Connection, + root: u32, +) -> anyhow::Result<()> { + use x11rb::protocol::randr::{ConnectionExt as RandrConnectionExt, NotifyMask}; + + let notify_mask = + NotifyMask::SCREEN_CHANGE | NotifyMask::CRTC_CHANGE | NotifyMask::OUTPUT_CHANGE; + conn.randr_select_input(root, notify_mask)?.check()?; + conn.flush()?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn current_x11_root_geometry( + conn: &impl x11rb::connection::Connection, + root: u32, +) -> anyhow::Result<(u16, u16)> { + use x11rb::protocol::xproto::ConnectionExt as XprotoConnectionExt; + + let geometry = conn.get_geometry(root)?.reply()?; + anyhow::ensure!( + geometry.width > 0 && geometry.height > 0, + "X11 root reported invalid geometry {}x{}", + geometry.width, + geometry.height + ); + Ok((geometry.width, geometry.height)) +} + +#[cfg(target_os = "linux")] +fn prepare_x11_overlay_geometry( + conn: &impl x11rb::connection::Connection, + win: u32, + width: u16, + height: u16, +) -> anyhow::Result<()> { + use x11rb::protocol::shape::{ConnectionExt as ShapeConnectionExt, SK, SO}; + use x11rb::protocol::xproto::{ + ClipOrdering, ConfigureWindowAux, ConnectionExt as XprotoConnectionExt, + }; + + // Hide the backing window before resizing it. If the server reset or + // invalidated an old bounding shape during RandR reconfiguration, this + // prevents a zero-filled full-root frame from becoming visible between the + // ConfigureWindow request and the next cursor-local paint. + conn.shape_rectangles( + SO::SET, + SK::BOUNDING, + ClipOrdering::UNSORTED, + win, + 0, + 0, + &[], + )? + .check()?; + clear_x11_overlay_input_shape(conn, win)?; + conn.configure_window( + win, + &ConfigureWindowAux::new() + .x(0) + .y(0) + .width(u32::from(width)) + .height(u32::from(height)), + )? + .check()?; + conn.flush()?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn clear_x11_overlay_input_shape( + conn: &impl x11rb::connection::Connection, + win: u32, +) -> anyhow::Result<()> { + use x11rb::protocol::shape::{ConnectionExt as ShapeConnectionExt, SK, SO}; + use x11rb::protocol::xproto::ClipOrdering; + + conn.shape_rectangles(SO::SET, SK::INPUT, ClipOrdering::UNSORTED, win, 0, 0, &[])? + .check()?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn map_x11_overlay_with_empty_input( + conn: &impl x11rb::connection::Connection, + win: u32, +) -> anyhow::Result<()> { + use x11rb::protocol::shape::{ConnectionExt as ShapeConnectionExt, SK, SO}; + use x11rb::protocol::xproto::{ClipOrdering, ConnectionExt as XprotoConnectionExt}; + + // Check both safety-critical shapes before mapping. If either request is + // rejected, the full-root overlay must remain unmapped rather than falling + // back to the server's default full-window input region. + clear_x11_overlay_input_shape(conn, win)?; + conn.shape_rectangles( + SO::SET, + SK::BOUNDING, + ClipOrdering::UNSORTED, + win, + 0, + 0, + &[], + )? + .check()?; + conn.map_window(win)?.check()?; + conn.flush()?; + Ok(()) +} + // ── X11 thread ──────────────────────────────────────────────────────────── #[cfg(target_os = "linux")] fn run_overlay_thread(cfg: CursorConfig, rx: std::sync::mpsc::Receiver) { use x11rb::connection::Connection; - use x11rb::protocol::shape::{ConnectionExt as ShapeConnectionExt, SK, SO}; use x11rb::protocol::xproto::ConnectionExt as XprotoConnectionExt; use x11rb::protocol::xproto::{ AtomEnum, ColormapAlloc, CreateGCAux, CreateWindowAux, EventMask, PropMode, WindowClass, }; use x11rb::wrapper::ConnectionExt as WrapperConnectionExt; + let cleanup = X11OverlayThreadCleanup { + receiver: Some(rx), + disable_render_state: !crate::wayland::is_wayland(), + }; + let rx = cleanup.receiver(); + // Connect to X11. let (conn, screen_num) = match x11rb::connect(None) { Ok(c) => c, @@ -638,9 +1019,26 @@ fn run_overlay_thread(cfg: CursorConfig, rx: std::sync::mpsc::Receiver (u32::from(width), u32::from(height)), + Err(e) => { + tracing::warn!("X11 overlay: cannot query initial root geometry: {e}"); + return; + } + }; + let mut compositor_present = x11_compositor_present(&conn, screen_num); tracing::debug!(compositor_present, "X11 overlay compositor state"); // Update render state with screen size. @@ -670,6 +1068,49 @@ fn run_overlay_thread(cfg: CursorConfig, rx: std::sync::mpsc::Receiver = None; - let z_enforcer = X11ZOrderEnforcer { conn: &conn, win }; + let mut last_compositor_poll = last_tick; + // Constructed after the geometry query and the window map, so the cache can + // never be primed against a placeholder geometry. This window has painted + // nothing yet and its bounding shape is still empty, so its first root read + // sees no pixels of ours. (A previous overlay instance torn down moments + // earlier can still be on screen; that resolves itself as soon as the + // cursor vacates the rect and its owner repaints.) + let mut backdrop = X11BackdropCache::default(); + // Startup probe result; a property of the server, not of the compositor, + // so it is never re-sampled when a compositing manager comes or goes. + backdrop.readback_untrusted = readback_untrusted; + if readback_untrusted { + tracing::warn!( + "X11 overlay: root reads cannot see this window's own pixels; \ + save-unders will be served without readback confirmation" + ); + } + // Arrivals resolve callers waiting for the destination frame to be visible, + // so they are held back across a deferred paint instead of firing early. + let mut pending_arrivals: Vec = Vec::new(); + let z_enforcer = X11ZOrderEnforcer { + conn: &conn, + win, + root, + }; loop { - // Idle fast path: no full-screen Pixmap allocation, RGBA→BGRA copy, - // XShape update, or XPutImage until a command arrives. A resting visible - // cursor still wakes cheaply every 80 ms to preserve the documented - // X11 z-order contract without repainting. - let (first_msg, maintenance_timeout) = match wait_for_overlay_work( - &rx, - frame_tick_needed, - z_order_tick_needed, - maintenance_deadline, - ) { - OverlayWake::Frame => (None, false), - OverlayWake::Message(msg) => (Some(msg), false), - OverlayWake::MaintenanceTimeout => (None, true), - OverlayWake::Disconnected => break, + // Idle fast path: no Pixmap allocation, RGBA→BGRA copy, XShape update, + // or XPutImage until pixels can change. The 50 ms maintenance bound + // services X11/RandR events; a resting visible cursor also reasserts + // z-order at most every 80 ms. Neither maintenance path authorizes paint. + let (first_msg, maintenance_timeout) = + match wait_for_overlay_work(rx, frame_tick_needed, maintenance_deadline) { + OverlayWake::Frame => (None, false), + OverlayWake::Message(msg) => (Some(msg), false), + OverlayWake::MaintenanceTimeout => (None, true), + OverlayWake::Disconnected => break, + }; + + // X11 events do not wake std::mpsc, so quiescent overlays use the + // bounded maintenance timeout above to service this queue. Detect + // RandR changes before touching render state; unrelated X events remain + // cheap and do not authorize a repaint. + let screen_changed = match drain_x11_overlay_events(&conn, win) { + Ok(changed) => changed, + Err(e) => { + tracing::warn!("X11 overlay event drain failed; disabling overlay: {e}"); + break; + } + }; + let screen_geometry = if screen_changed { + let geometry = match current_x11_root_geometry(&conn, root) { + Ok(geometry) => geometry, + Err(e) => { + tracing::warn!( + "X11 overlay root geometry refresh failed; disabling overlay: {e}" + ); + break; + } + }; + if let Err(e) = prepare_x11_overlay_geometry(&conn, win, geometry.0, geometry.1) { + tracing::warn!("X11 overlay geometry repair failed; disabling overlay: {e}"); + break; + } + // Every saved backdrop describes the pre-resize screen. The repair + // above emptied our bounding shape, but that only queues Expose: + // the framebuffer still holds our last frame until the windows + // underneath repaint it, so stay blank for the resync grace instead + // of reading our own pixels back as desktop. + backdrop.resync(Instant::now()); + #[cfg(test)] + X11_RANDR_REPAIR_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + compositor_present = x11_compositor_present(&conn, screen_num); + last_compositor_poll = Instant::now(); + tracing::debug!( + width = geometry.0, + height = geometry.1, + compositor_present, + "X11 overlay repaired after RandR display change" + ); + Some(geometry) + } else { + None + }; + + // A compositing manager can start or stop without any RandR event, and + // it decides which pixel policy is correct: with one present the server + // blends our alpha and a root read no longer sees the windows below us. + // Poll the selection owner rather than let a stale sample pick the path. + let compositor_changed = if last_compositor_poll.elapsed() >= X11_COMPOSITOR_POLL_INTERVAL { + last_compositor_poll = Instant::now(); + let present = x11_compositor_present(&conn, screen_num); + let changed = present != compositor_present; + if changed { + compositor_present = present; + // Same reasoning as the RandR repair: what we already painted is + // still on screen, so blank and let it be repainted before the + // next root read (and repaint now that the policy changed). + backdrop.resync(Instant::now()); + tracing::debug!(compositor_present, "X11 overlay compositor state changed"); + } + changed + } else { + false }; let now = Instant::now(); @@ -796,10 +1321,13 @@ fn run_overlay_thread(cfg: CursorConfig, rx: std::sync::mpsc::Receiver