diff --git a/.github/workflows/cd-rust-cua-driver.yml b/.github/workflows/cd-rust-cua-driver.yml index 9efe3dd81f..60054464d0 100644 --- a/.github/workflows/cd-rust-cua-driver.yml +++ b/.github/workflows/cd-rust-cua-driver.yml @@ -44,6 +44,8 @@ on: required: false TEAM_ID: required: false + CUA_DRIVER_PROVISIONING_PROFILE_BASE64: + required: false RELEASE_APP_ID: required: false RELEASE_APP_PRIVATE_KEY: @@ -484,6 +486,42 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ inputs.source_ref || github.event_name == 'workflow_dispatch' && inputs.publish && format('refs/tags/cua-driver-rs-v{0}', inputs.version) || github.ref }} + - name: Preflight Computer History provisioning profile + if: env.DO_NOTARIZE == 'true' + env: + CUA_DRIVER_PROVISIONING_PROFILE_BASE64: ${{ secrets.CUA_DRIVER_PROVISIONING_PROFILE_BASE64 }} + TEAM_ID: ${{ secrets.TEAM_ID }} + run: | + test "$TEAM_ID" = 'YCK386LBJ7' || { + echo "Cua Driver releases must use the pinned YCK386LBJ7 signing team" >&2 + exit 1 + } + test -n "$CUA_DRIVER_PROVISIONING_PROFILE_BASE64" || { + echo "Missing CUA_DRIVER_PROVISIONING_PROFILE_BASE64; restricted Keychain entitlement cannot be released" >&2 + exit 1 + } + printf '%s' "$CUA_DRIVER_PROVISIONING_PROFILE_BASE64" | base64 --decode \ + > "$RUNNER_TEMP/CuaDriver.provisionprofile" + security cms -D -i "$RUNNER_TEMP/CuaDriver.provisionprofile" \ + > "$RUNNER_TEMP/CuaDriver.provisioning-profile.plist" + python3 - <<'PY' + import datetime + import os + import plistlib + + path = os.path.join(os.environ["RUNNER_TEMP"], "CuaDriver.provisioning-profile.plist") + with open(path, "rb") as handle: + profile = plistlib.load(handle) + if "YCK386LBJ7" not in profile.get("TeamIdentifier", []): + raise SystemExit("Cua Driver provisioning profile does not match the pinned release team") + expiry = profile.get("ExpirationDate") + now = datetime.datetime.now(datetime.timezone.utc) + if expiry is None or expiry.replace(tzinfo=datetime.timezone.utc) <= now: + raise SystemExit("Cua Driver provisioning profile is missing an active expiration date") + remaining = expiry.replace(tzinfo=datetime.timezone.utc) - now + if remaining < datetime.timedelta(days=30): + print(f"::warning::Cua Driver provisioning profile expires in {remaining.days} day(s)") + PY - name: Stage nightly artifact version if: inputs.channel == 'nightly' run: python3 .github/scripts/release_channels.py apply-version --component cua-driver-rs --version "${{ inputs.version }}" @@ -598,13 +636,27 @@ jobs: grep -A2 LC_RPATH | grep -Fq 'path @loader_path' test "$(otool -l release/universal/libcua_driver_sdk.dylib | \ awk '/LC_BUILD_VERSION/{seen=1} seen && $1 == "minos" {print $2; exit}')" = '13.0' - - name: Codesign universal binary (hardened runtime) + - name: Codesign bare universal binary (hardened runtime) if: env.DO_NOTARIZE == 'true' working-directory: libs/cua-driver/rust env: DEVELOPER_NAME: ${{ secrets.DEVELOPER_NAME }} TEAM_ID: ${{ secrets.TEAM_ID }} run: | + python3 - <<'PY' + import os + import plistlib + + source = "scripts/CuaDriver.entitlements" + output = "release/CuaDriver.release.entitlements" + with open(source, "rb") as handle: + entitlements = plistlib.load(handle) + application_identifier = f"{os.environ['TEAM_ID']}.com.trycua.driver" + entitlements["com.apple.application-identifier"] = application_identifier + entitlements["keychain-access-groups"] = [application_identifier] + with open(output, "wb") as handle: + plistlib.dump(entitlements, handle, sort_keys=True) + PY IDENTITY="Developer ID Application: ${DEVELOPER_NAME} (${TEAM_ID})" codesign --force --timestamp --options runtime \ --entitlements scripts/CuaDriver.entitlements \ @@ -614,6 +666,19 @@ jobs: codesign --force --timestamp --options runtime \ --sign "$IDENTITY" release/universal/libcua_driver_sdk.dylib codesign --verify --strict --verbose=2 release/universal/cua-driver + codesign -d --entitlements - --xml release/universal/cua-driver \ + > release/actual-cua-driver-entitlements.plist + python3 - <<'PY' + import os + import plistlib + + with open("release/actual-cua-driver-entitlements.plist", "rb") as handle: + actual = plistlib.load(handle) + assert "com.apple.application-identifier" not in actual + assert "keychain-access-groups" not in actual + assert actual.get("com.apple.security.automation.apple-events") is True + assert actual.get("com.apple.security.device.screen-capture") is True + PY codesign --verify --strict --verbose=2 release/universal/cua-cursor-theme codesign --verify --strict --verbose=2 release/universal/libcua_driver_sdk.dylib - name: Assemble CuaDriver.app bundle @@ -664,17 +729,70 @@ jobs: TEAM_ID: ${{ secrets.TEAM_ID }} APPLE_ID: ${{ secrets.APPLE_ID }} APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }} + CUA_DRIVER_PROVISIONING_PROFILE_BASE64: ${{ secrets.CUA_DRIVER_PROVISIONING_PROFILE_BASE64 }} run: | IDENTITY="Developer ID Application: ${DEVELOPER_NAME} (${TEAM_ID})" - # Sign the .app — `--deep` covers the embedded binary too, but - # since we already pre-signed the binary explicitly above this - # is essentially a no-op on the binary and a fresh signature - # on the bundle wrapper. - codesign --force --deep --timestamp --options runtime \ - --entitlements scripts/CuaDriver.entitlements \ + test -n "$CUA_DRIVER_PROVISIONING_PROFILE_BASE64" || { + echo "Missing CUA_DRIVER_PROVISIONING_PROFILE_BASE64; restricted Keychain entitlement cannot be released" >&2 + exit 1 + } + printf '%s' "$CUA_DRIVER_PROVISIONING_PROFILE_BASE64" | base64 --decode \ + > release/CuaDriver.app/Contents/embedded.provisionprofile + security cms -D -i release/CuaDriver.app/Contents/embedded.provisionprofile \ + > release/CuaDriver.provisioning-profile.plist + python3 - <<'PY' + import datetime + import os + import plistlib + + with open("release/CuaDriver.provisioning-profile.plist", "rb") as handle: + profile = plistlib.load(handle) + expected = f"{os.environ['TEAM_ID']}.com.trycua.driver" + entitlements = profile.get("Entitlements", {}) + assert os.environ["TEAM_ID"] in profile.get("TeamIdentifier", []) + assert entitlements.get("com.apple.application-identifier") == expected + assert entitlements.get("keychain-access-groups") == [expected] + expiry = profile.get("ExpirationDate") + assert expiry is not None and expiry.replace(tzinfo=datetime.timezone.utc) > datetime.datetime.now(datetime.timezone.utc) + PY + + # Sign the bundle/main executable without --deep. Nested code was + # pre-signed above and must not inherit the main executable's + # restricted application/keychain entitlements. + codesign --force --timestamp --options runtime \ + --entitlements release/CuaDriver.release.entitlements \ --sign "$IDENTITY" release/CuaDriver.app codesign --verify --strict --verbose=2 release/CuaDriver.app + codesign -d --entitlements - --xml \ + release/CuaDriver.app/Contents/MacOS/cua-driver \ + > release/actual-packaged-cua-driver-entitlements.plist + python3 - <<'PY' + import os + import plistlib + + with open("release/actual-packaged-cua-driver-entitlements.plist", "rb") as handle: + actual = plistlib.load(handle) + expected = f"{os.environ['TEAM_ID']}.com.trycua.driver" + assert actual.get("com.apple.application-identifier") == expected + assert actual.get("keychain-access-groups") == [expected] + with open("release/CuaDriver.provisioning-profile.plist", "rb") as handle: + profile_entitlements = plistlib.load(handle).get("Entitlements", {}) + assert actual.get("com.apple.application-identifier") == profile_entitlements.get("com.apple.application-identifier") + assert actual.get("keychain-access-groups") == profile_entitlements.get("keychain-access-groups") + PY + codesign -d --entitlements - --xml \ + release/CuaDriver.app/Contents/MacOS/cua-cursor-theme \ + > release/actual-packaged-cursor-entitlements.plist + python3 - <<'PY' + import plistlib + + with open("release/actual-packaged-cursor-entitlements.plist", "rb") as handle: + actual = plistlib.load(handle) + assert "com.apple.application-identifier" not in actual + assert "keychain-access-groups" not in actual + PY + codesign --verify --strict --verbose=2 release/CuaDriver.app # notarytool wants a zip (or .dmg / .pkg). Build one next to # the .app, submit, wait, then staple the bundle in place. diff --git a/libs/cua-driver/README.md b/libs/cua-driver/README.md index 00bda12925..710afcdfa7 100644 --- a/libs/cua-driver/README.md +++ b/libs/cua-driver/README.md @@ -20,6 +20,21 @@ language-native MCP facade and have no `/sdk`, `/mcp`, or `/native` public suffix. MCP remains implemented by the `cua-driver` executable as the runtime-neutral agent boundary. +## Computer History macOS preview + +Nightly macOS builds can provide an opt-in, encrypted history of actions +performed through Cua Driver. The preview stores a strict metadata allowlist, +stays local, and exposes permission-gated `history_status` and `history_query` +tools for read-only agent hydration. It never stores screenshots, typed text, +clipboard contents, raw arguments or results, accessibility trees, paths, +window titles, or URLs. + +See [Try the Computer History macOS +preview](docs/computer-history-preview.md) for installation, lifecycle, +inspection, deletion, and stable-channel return instructions. The [architecture +and staged plan](docs/computer-history-architecture.md) defines the format, +security boundary, release gates, and later NVIDIA OpenShell integration. + ## Permission modes `standard` is the promptless default for normal automation. `bounded` admits diff --git a/libs/cua-driver/docs/computer-history-agent-integration-rfc.md b/libs/cua-driver/docs/computer-history-agent-integration-rfc.md new file mode 100644 index 0000000000..bd3e9ce169 --- /dev/null +++ b/libs/cua-driver/docs/computer-history-agent-integration-rfc.md @@ -0,0 +1,665 @@ +--- +title: 'Cua Driver Computer History: Agent Integration Contract' +authors: + - Cua maintainers +created: 2026-08-14 +last_updated: 2026-08-15 +status: draft +target: Cua Driver desktop preview +--- + +# RFC: Cua Driver Computer History Agent Integration Contract + +## Summary + +This RFC defines how agent runtimes read Cua Driver Computer History. The first +preview exposes two permission-gated, read-only tools: + +- `history_status`, which maps to the `history.status` capability; and +- `history_query`, which maps to the `history.query` capability. + +Computer History is an opt-in, encrypted local record of Cua-mediated actions. +Agents can check its state and request a bounded metadata-only event slice. They +cannot enable capture, change retention, export encrypted chunks, delete data, +or obtain encryption keys. + +Agent integrations must discover the tools at runtime and tolerate their +absence, including on a supported operating system whose daemon did not admit +the preview. + +## Motivation + +An agent often starts a run without knowing what a prior run did. Existing +approaches either omit that context or ask the agent to inspect screenshots, +logs, and application state again. A durable history can reduce repeated work, +but desktop history may contain personal data even when it excludes screenshots +and typed text. + +The integration contract needs a narrow answer to four questions: + +1. How does a client detect that history is available? +2. Which permission authorizes each read? +3. Which fields may enter agent context? +4. How does a client continue when history is unavailable or incomplete? + +## Goals + +- Define a small read-only contract for agent runtimes. +- Keep status and event access under separate capabilities. +- Return bounded, typed data with an explicit model-context disclosure. +- Preserve the user's local encryption and retention boundaries. +- Let clients degrade safely when the feature, permission, or data is absent. +- Keep the tool and capability names usable by a later NVIDIA OpenShell adapter. + +## Non-goals + +- Give agents control over capture lifecycle, retention, quota, or deletion. +- Expose raw encrypted chunks, encryption keys, or filesystem paths. +- Record ambient desktop activity outside Cua-mediated actions. +- Return screenshots, typed text, clipboard contents, tool arguments, tool + results, accessibility trees, window titles, or URLs. +- Add model-generated summaries in the first preview. +- Require NVIDIA OpenShell for the first preview. +- Claim support for an unqualified operating-system version, desktop session, + or native credential-store configuration. +- Define product pricing, account eligibility, or release dates for other + platforms. + +## Community feedback themes + +Anonymous community feedback consistently favors local data ownership, local +control, and code and format auditability. Respondents also emphasized a hard +boundary against capturing typed text, individual keystrokes, or passwords. +There is interest in Windows support and a broader cross-platform roadmap, but +this RFC makes no delivery-date promises. A recurring use case is giving agents +useful prior-run context while keeping that context bounded, permission-gated, +and private to the user's machine. + +## Terminology + +**Computer History** +: The user-controlled encrypted local record defined by this RFC. + +**Admitted daemon** +: A Cua Driver daemon started with the experimental history capability. Tool + admission does not enable capture. + +**Enabled history** +: History for which the user completed the separate persistent opt-in flow. + +**History event** +: A CloudEvents 1.0 JSON envelope containing one allowlisted metadata payload. + +**Agent runtime** +: An MCP client, SDK host, coding agent, or agent framework that calls Cua + Driver tools. + +**Capability manifest** +: The immutable launch-time ceiling that narrows which tools a runtime may + call. Bounded permission mode requires one. + +## Availability and feature detection + +The preview registers the two history tools only when a supported desktop +daemon admits the experimental feature. Clients must inspect the runtime tool +list before calling either tool. + +| Observed state | Meaning | Client behavior | +| --- | --- | --- | +| Neither tool is advertised | The runtime does not admit this preview or the platform does not support it. | Continue without history. | +| `history_status` is advertised | The runtime admits the preview. | Request permission for `history.status` before reading status. | +| Status reports `enabled: false` | Capture is off, but earlier encrypted history may remain. | Query only if prior history is useful and `history.query` is authorized. | +| Status reports `paused: true` | New action capture is paused. Existing history remains queryable. | Treat the returned history as incomplete after the pause point. | +| Status reports dropped events or unhealthy storage | The record may contain gaps. | Preserve the health warning in agent reasoning and avoid claims of completeness. | + +Tool presence does not prove that the user granted the calling agent access. +The normal Cua Driver authorization path evaluates every invocation. + +## Proposed integration flow + +```mermaid +sequenceDiagram + participant A as Agent runtime + participant T as Cua tool registry + participant P as Cua permission system + participant H as Encrypted local history + + A->>T: Discover tools + alt History tools absent + T-->>A: Continue without history + else History tools present + A->>T: history_status({}) + T->>P: Authorize history.status + P-->>T: Allow or deny + T-->>A: Structured status or denial + opt Status permits a useful read + A->>T: history_query(bounded filters) + T->>P: Authorize history.query + P-->>T: Allow or deny + T->>H: Decrypt, validate, filter, and bound + H-->>T: Metadata-only events + T->>H: Append encrypted access record + T-->>A: Events plus context disclosure + end + end +``` + +The access record is appended when a successful query returns at least one +event. It is encrypted under the same history profile and is not included in +the response that caused it. + +## Agent consultation policy + +Tool discovery makes Computer History available to an agent, but it does not +by itself cause a model to call either tool. An agent host that supports +history-assisted continuation must add an explicit consultation policy through +a bundled skill, trusted system instruction, or deterministic host preflight. + +The policy applies when the user asks the agent to continue, resume, recall +recent Cua activity, explain what a prior Cua run did, or find where a prior +Cua-mediated workflow stopped. It does not apply to unrelated tasks merely +because history is available. + +For a matching request, the host must: + +1. discover the history tools before broader desktop inspection; +2. call `history_status` and preserve any disabled, paused, unhealthy, or + dropped-event state in its reasoning; +3. when useful and authorized, call `history_query` with a bounded recent + slice before enumerating the live desktop; +4. treat returned events as metadata-only evidence rather than a transcript; +5. keep omitted content, geometry, arguments, results, and user intent unknown; +6. use an identified application or capability as a lead, then verify current + state through the least intrusive appropriate source; and +7. continue without history after absence, denial, empty results, or a + recoverable history failure. + +A host may make more bounded queries when an initial slice contains a relevant +session or sequence boundary. It must not broaden the query merely to fill in +fields that the schema intentionally excludes. + +A new agent process repeats this flow. History is not continuously injected +into every model request, and enabling capture does not grant any agent read +access. Query results enter only the current authorized model context unless +the host separately defines and obtains consent for another memory boundary. + +The following integration levels keep product claims precise: + +| Integration level | Required behavior | Accurate claim | +| --- | --- | --- | +| Tool-capable | The runtime advertises the tools and schemas. | Agents can query Computer History. | +| History-aware | A bundled policy instructs the agent to consult history for matching requests, with deterministic tests of the policy and fallbacks. | The agent checks Computer History for recent-work and continuation requests. | +| Deterministic consultation | The trusted host performs or enforces the status and bounded-query preflight before the model begins broader discovery. | The agent automatically checks Computer History for matching requests. | + +Prompt wording alone can guide model behavior but cannot establish the +deterministic-consultation claim. A host making that claim must own the +preflight and prove it independently of model tool-selection variance. + +## Tool contract + +### `history_status` + +`history_status` returns operational metadata. It never returns history events. + +**Required capability:** `history.status` + +**Tool properties:** read-only, non-destructive, idempotent, closed-world + +**Input schema** + +```json +{ + "type": "object", + "properties": {}, + "additionalProperties": false +} +``` + +**Structured response fields** + +| Field | Type | Meaning | +| --- | --- | --- | +| `supported` | boolean | The current platform adapter supports this preview. | +| `admitted` | boolean | The daemon admits the experimental feature. | +| `enabled` | boolean | The user enabled capture. | +| `paused` | boolean | New action capture is paused. | +| `encrypted` | boolean | History payloads use the encrypted storage profile. Preview 0 always returns `true`. | +| `profile` | string | Storage profile identifier. | +| `retention_days` | integer | Query-visible retention period. Default: `7`. | +| `quota_bytes` | integer | Encrypted store quota. Default: `104857600`. | +| `bytes_used` | integer | Current encrypted bytes under the history root. | +| `dropped_events` | integer | Number of events dropped by the nonblocking capture path. | +| `health` | string | Fixed health category. | + +**Example response** + +```json +{ + "supported": true, + "admitted": true, + "enabled": true, + "paused": false, + "encrypted": true, + "profile": "cua-history-profile-v1/cbor-sequence+cose-encrypt0+cloudevents-json", + "retention_days": 7, + "quota_bytes": 104857600, + "bytes_used": 48291, + "dropped_events": 0, + "health": "ready" +} +``` + +Health categories are: + +```text +ready +disabled +paused +not_admitted +key_unavailable +key_locked +key_corrupt +key_destroy_failed +storage_unavailable +storage_corrupt +quota_reached +events_dropped +writer_stopped +``` + +### `history_query` + +`history_query` returns a bounded event slice. Query results may enter model +context. + +**Required capability:** `history.query` + +**Tool properties:** read-only, non-destructive, non-idempotent because a +successful non-empty read appends an encrypted access record, closed-world + +**Input fields** + +| Field | Type | Required | Bounds | Meaning | +| --- | --- | --- | --- | --- | +| `limit` | integer | No | `1..200` | Maximum matching events. Default: `50`. | +| `session_id` | string | No | `1..128` characters | Opaque session ID returned by history, or a caller-known session label resolved inside the history namespace. | +| `since_sequence` | integer | No | `>=1` | Inclusive lower sequence bound. | +| `until_sequence` | integer | No | `>=1` | Inclusive upper sequence bound. | + +Unknown fields are rejected. If both sequence bounds are present, +`since_sequence` must not exceed `until_sequence`. + +**Example request** + +```json +{ + "limit": 20, + "session_id": "33333333333333333333333333333333", + "since_sequence": 40 +} +``` + +**Structured response** + +```json +{ + "events": [ + { + "specversion": "1.0", + "id": "11111111111111111111111111111111", + "source": "urn:cua-driver:history:22222222222222222222222222222222", + "type": "cua-driver.history.action_completed.v0", + "subject": "action/44444444444444444444444444444444", + "time": "2026-08-14T12:00:00Z", + "datacontenttype": "application/json", + "dataschema": "urn:cua-driver:schema:history-event:v0", + "data": { + "session_id": "33333333333333333333333333333333", + "action_id": "44444444444444444444444444444444", + "sequence": 42, + "platform": "macos", + "process_model": "in_daemon", + "capability": "computer.pointer.click", + "caller_category": "cua_runtime", + "application": { + "bundle_id": "com.example.synthetic", + "display_name": "Example App" + }, + "payload": { + "kind": "action_completed", + "effect": "confirmed", + "route": "accessibility", + "delivery": "foreground", + "delivered_count": 1, + "evidence_kinds": ["accessibility_readback"] + } + } + } + ], + "metadata_only": true, + "model_context_disclosure": true +} +``` + +All sample identifiers and application values are synthetic. + +The current schema has no `unavailable_fields` member or fixed platform +limitation codes. The `application` object is optional; when present, it may +contain only the optional `bundle_id` and `display_name` fields shown above. +Clients must treat omitted application fields as unavailable context. Adding +explicit limitation metadata would require a future schema revision. + +### Ordering and bounded reads + +Events are ordered by `data.sequence` in ascending order. A query applies all +filters, keeps the newest `limit` matching events, and returns that slice in +ascending sequence order. + +The first preview has no opaque pagination token. A client can page toward +older records by setting `until_sequence` below the first sequence in its +current response. It can request newer records with `since_sequence` above the +last sequence it has processed. Sequence bounds are inclusive, so clients must +adjust the bound by one when they require non-overlapping pages. + +Clients must treat missing sequence numbers as valid gap evidence. Capture uses +a bounded nonblocking queue, so storage pressure or a concurrent serialized +query can drop events without failing the computer action that produced them. + +## Event contract + +Every returned event uses CloudEvents 1.0 JSON and the schema identifier +`urn:cua-driver:schema:history-event:v0`. + +| Event type | Payload kind | Meaning | +| --- | --- | --- | +| `cua-driver.history.control.v0` | `control` | User lifecycle operation such as enable, pause, or flush. | +| `cua-driver.history.action_started.v0` | `action_started` | A Cua-mediated state-changing action began. | +| `cua-driver.history.action_completed.v0` | `action_completed` | The validated action outcome. | +| `cua-driver.history.session_started.v0` | `session` | A Cua Driver lifecycle session began. | +| `cua-driver.history.session_ended.v0` | `session` | A Cua Driver lifecycle session ended. | +| `cua-driver.history.access.v0` | `access` | A local CLI or agent query returned events. | +| `cua-driver.history.health.v0` | `health` | A fixed writer-health or dropped-event marker. | + +The checked-in JSON Schema is +[`computer-history-event-v0.schema.json`](computer-history-event-v0.schema.json). +The encrypted file profile is defined by +[`computer-history-profile-v1.cddl`](computer-history-profile-v1.cddl). + +Clients must branch on both `dataschema` and `type`. A client that does not +support a returned schema must stop interpreting that event. It may still +report the schema identifier as unsupported. + +## Permission contract + +The two tools are separate private-observation operations. Both are classified +as operation-sensitive `R2` reads and use active authorization enforcement. +Permission for status does not imply permission to query events. + +In standard mode, each operation requires an explicit host authorization grant; +the ordinary promptless private-observation default does not apply to Computer +History. In bounded mode, the approved manifest must name both the tool and the +matching `resources.computer_history.operations` value. + +Tool discovery advertises the capability mapping: + +| Tool | Capability | +| --- | --- | +| `history_status` | `history.status` | +| `history_query` | `history.query` | + +In bounded mode, a trusted launcher must approve a capability manifest that +allows the exact tools. An illustrative manifest is: + +```yaml +version: 3 +expires_after: 1h +idle_timeout: 10m +resources: + computer_history: + operations: + - status + - query +allow: + tools: + - history_status + - history_query +``` + +An agent may propose this manifest, but the trusted launcher selects and +approves it. A manifest can narrow the runtime's authority. It cannot grant +authority denied by built-in policy, managed policy, user policy, or the active +permission mode. + +Clients must surface a denial and continue without history. They must not retry +with a broader tool, read the store directly, switch permission modes, or ask +the model to reconstruct denied history through another observation tool. + +A future NVIDIA OpenShell policy adapter may feed the same `history.status` and +`history.query` decisions into the native host authorization broker. It will +not receive direct access to native credential-store items, history files, or +vault keys. + +## Error contract + +Tool argument and history-store failures return a structured `code`. The Cua +authorization layer may deny the call before the tool runs; that denial uses +the existing authorization error envelope. + +| Code | Meaning | Client behavior | +| --- | --- | --- | +| `invalid_history_query` | Input does not match the closed schema. | Correct the request once. Do not retry unchanged input. | +| `invalid_history_query_range` | The lower sequence bound exceeds the upper bound. | Correct the bounds. | +| `history_preview_not_admitted` | Admission changed after tool discovery. | Refresh tool discovery and continue without history. | +| `history_key_unavailable` | The platform key cannot be loaded. | Report history as unavailable. | +| `history_key_locked` | The credential store is locked. | Let the user unlock it; do not prompt through another tool. | +| `history_key_corrupt` | The key reference or material is invalid. | Stop querying and direct the user to local recovery controls. | +| `history_storage_unavailable` | The encrypted store cannot be read. | Continue the agent task without history. | +| `history_storage_corrupt` | Framing, schema, sequence, or authentication validation failed. | Stop consuming results and direct the user to local recovery controls. | +| `history_quota_reached` | Capture reached its encrypted-byte quota. | Treat history after that point as incomplete. | +| `history_events_dropped` | The nonblocking writer dropped events. | Treat the affected interval as incomplete. | +| `history_writer_stopped` | The writer is unavailable. | Continue the agent task without assuming new events are recorded. | + +Clients must use the structured code. Human-readable text may change. + +## Security, privacy, and telemetry + +The first preview stores only allowlisted structured metadata: + +- event type and timestamp; +- opaque event, stream, session, and action identifiers; +- Cua capability name and fixed caller category; +- fixed-field platform application identifier and display name when available; +- fixed action outcome, route, delivery, and evidence categories; and +- fixed lifecycle, access, and health payloads. + +The following data is prohibited: + +- screenshots, video, audio, and accessibility trees; +- raw keystrokes, typed text, and clipboard contents; +- raw tool arguments and results; +- file paths, window titles, and URLs; and +- free-form diagnostic details or policy documents. + +### Answers to common privacy questions + +**Does Computer History observe everything a user does?** No. Preview 0 records +only actions mediated by Cua Driver after the user enables history. Manual +keyboard input, mouse input, and ambient application activity do not create +history events. + +**How does it know an action happened?** The record comes from Cua Driver's own +action lifecycle and fixed delivery and evidence categories. When an action +uses an Accessibility, UI Automation, AT-SPI, or native window-management +route, history may record that fixed route and outcome, but it never stores or +later scans an accessibility tree to infer ambient activity. + +**What happens when Cua types into a text field?** History may record that a +typing capability ran, which application received it, and whether delivery was +confirmed. It never records the characters, individual key events, raw tool +arguments, or clipboard contents. The same rule applies to passwords and other +sensitive text. + +**Where does the data live?** The encrypted records stay in the local user +account on the host. The namespace key stays in macOS Keychain, Windows +Credential Manager, or Linux Secret Service. Agents cannot request the key, +encrypted chunks, or a filesystem path through this contract. Users can +inspect bounded metadata through the local CLI and delete the history with an +explicit local command. + +**Can an integration audit the implementation and format?** Yes. Cua Driver is +open source, the event schema and CDDL storage profile are checked into the +repository, and this RFC defines the complete agent-visible field set. Direct +store access remains unsupported because it would bypass permission checks and +key custody. + +**Is the contract portable across desktop systems?** Yes. The CloudEvents +schema, COSE and CBOR Sequence profile, tool names, and capability names are +platform-neutral. macOS, Windows, and Linux use separate native credential and +application-identity adapters while returning the same agent-visible contract. + +History payloads stay local. Each CloudEvent is encrypted and authenticated +inside a COSE_Encrypt0 record before it reaches disk. Records use RFC 8742 CBOR +Sequence framing. The namespace root key is protected by the operating +system's native credential store, and each chunk uses a separate HKDF-derived +key. + +History tools are excluded from per-tool product telemetry and agent-session +aggregate telemetry. Product telemetry may contain a fixed CLI command counter +showing that a local history command ran. It never contains history events, +query filters, counts, identifiers, paths, or results. + +## Compatibility and versioning + +The first preview has these identifiers: + +| Contract | Identifier | +| --- | --- | +| Status tool | `history_status` | +| Query tool | `history_query` | +| Status capability | `history.status` | +| Query capability | `history.query` | +| Event schema | `urn:cua-driver:schema:history-event:v0` | +| Storage profile | `cua-history-profile-v1/cbor-sequence+cose-encrypt0+cloudevents-json` | + +The two tool and capability names are intended to remain stable. The event +schema is experimental. A field or semantic change that an existing consumer +cannot safely ignore requires a new `dataschema` identifier. A storage-format +change requires a new profile identifier. + +Clients must use runtime tool discovery and the advertised input schemas. They +must tolerate a history tool disappearing after a daemon restart, +configuration change, rollback, or move to an unsupported platform. + +Disabling capture preserves the encrypted store. Returning to a release that +does not understand this preview also preserves the store unless the user runs +an explicit history deletion or purge operation. + +## Alternatives considered + +### Direct encrypted-store access + +Rejected. It would make each agent responsible for filesystem coordination, +key custody, decryption, retention, schema validation, and authorization. It +would also bypass the native host policy boundary. + +### One umbrella `history` capability + +Rejected. Status and event retrieval expose different amounts of user data. +Separate capabilities let a host grant operational health without granting +event access. + +### Expose lifecycle mutation to agents + +Rejected for the first preview. Enabling, pausing, deleting, changing +retention, and exporting data are user controls. The agent surface remains +read-only. + +### Return model-generated summaries + +Deferred. A summary adds a model trust boundary and may require network egress. +The first preview returns deterministic structured events. + +## Implementation roadmap + +### Preview 0 + +- independent native verification on macOS, Windows, and Linux; +- explicit daemon admission and separate user opt-in; +- `history_status` and `history_query` through existing Cua permissions; +- seven-day retention and a 100 MiB encrypted quota; and +- event schema v0 with the Cua History Profile v1. + +### Integration requirements + +- provide generated tool schemas for MCP and SDK consumers; +- provide a bundled, product-neutral consultation policy for the main Cua + agent with explicit recent-work and continuation triggers; +- keep lifecycle and settings mutation outside the agent consultation policy; +- make the policy call `history_status` before one bounded `history_query` and + before broader desktop discovery for matching requests; +- require the policy to preserve unknown content, geometry, and intent rather + than reconstructing excluded fields; +- provide synthetic status, event, denial, corruption, and gap fixtures; +- provide one bounded capability-manifest example; +- add transport-parity tests for MCP and the native SDK; and +- document runtime discovery and supported-platform requirements. + +### Later stages + +- query verification and optional context scopes; +- an isolated no-network vault process; +- an NVIDIA OpenShell policy adapter; +- optional model brokers with separate consent; and +- additional native application-identity fidelity where a desktop exposes a + stable identifier without titles, paths, or other disallowed content. + +Each later stage requires its own privacy and compatibility review. + +## Test and acceptance plan + +The public integration contract is ready when all of the following pass: + +1. Tool discovery omits both tools when the daemon does not admit the preview. +2. Tool discovery advertises the exact capability mapping when admitted. +3. `history.status` permission does not authorize `history.query`. +4. Bounded mode refuses a manifest that omits either requested tool. +5. Status and query responses match their published schemas over MCP and SDK + transports. +6. Query defaults to 50, caps at 200, applies inclusive sequence bounds, and + returns the newest matching slice in ascending sequence order. +7. Unknown fields and reversed sequence bounds return the documented codes. +8. Synthetic fixtures prove that prohibited content never reaches a returned + event or raw encrypted file. +9. Key, storage, corruption, quota, drop, and writer failures return fixed + categories without failing the originating computer action. +10. A successful non-empty agent query appends an encrypted access record. +11. Disabled and paused stores remain queryable when admission and permission + remain valid. +12. Clients can continue their primary task after absence, denial, empty + results, or a recoverable history failure. +13. The main Cua agent's consultation policy selects `history_status` and then + a bounded `history_query` for a fresh continuation or recent-work request + before broader desktop discovery. +14. The same policy does not query history for an unrelated task solely + because the tools are present. +15. A hydrated agent can recover synthetic application, capability, effect, + route, and lifecycle metadata while preserving excluded content, geometry, + arguments, results, and user intent as unknown. +16. A representative fresh-agent run proves the history-aware behavior through + the public tool surface and makes no desktop mutation during consultation. + +## Feedback requested + +Reviewers should focus on the public integration boundary: + +- Is tool absence the right feature-detection mechanism, or should status stay + discoverable on unsupported and unadmitted runtimes? +- Are inclusive sequence bounds sufficient for pagination, or should the query + return an opaque cursor and `has_more` field? +- Should `session_id` accept only opaque IDs returned by history, or also a + caller-known public session label? +- Should an empty successful query append an access record? +- Does the structured error set support MCP, native SDKs, and embedded hosts + without transport-specific interpretation? +- Which parts of schema v0 need stronger portability guarantees before the + preview contract stabilizes? diff --git a/libs/cua-driver/docs/computer-history-architecture.md b/libs/cua-driver/docs/computer-history-architecture.md new file mode 100644 index 0000000000..9dd92d4bad --- /dev/null +++ b/libs/cua-driver/docs/computer-history-architecture.md @@ -0,0 +1,1142 @@ +# Computer History for Cua Driver + +**Last updated:** 2026-08-15 + +This document defines an opt-in computer-history capability for Cua Driver. It +is intentionally staged. The first stage records a narrow, encrypted history +of Cua-mediated actions on macOS, Windows, and Linux and exposes two +permission-gated read-only operations. Later stages may add richer local +context, stronger audit semantics, process isolation, and an NVIDIA OpenShell +policy adapter. + +## Summary + +Computer History gives users an encrypted local record of what Cua Driver did, when it did it, which application it targeted, and what outcome the driver could truthfully confirm. It is off by default and records only an explicit allowlist of structured metadata. + +The defining privacy boundary is permanent: + +- no screenshots; +- no raw keystrokes or typed text; +- no clipboard contents; +- no raw tool arguments or results; +- no accessibility trees; +- no file paths; +- no free-form diagnostic details. + +Preview 0 is action-history-first. It does not continuously observe the desktop. It records only Cua Driver session lifecycle, history controls, permission-gated history reads, and Cua-mediated computer actions. Ambient app/window events, window titles, URLs, model summarization, agent mutation/export, and unsupported operating systems are later stages with independent gates. + +## Motivation + +Cua Driver already has strong internal representations of session lifecycle and action outcomes, but users do not have a durable, inspectable answer to questions such as: + +- What actions did the driver perform during this session? +- Which applications did it act on? +- Which actions were confirmed, partial, refused, or unverifiable? +- When was history enabled, paused, resumed, or deleted? +- Did history drop events, reach its quota, or detect an incomplete/corrupt write? + +A useful history feature must answer those questions without turning the driver into a screen recorder, keylogger, or background telemetry collector. + +### Community feedback themes + +Anonymous community feedback favors local data ownership, local control, and +the ability to audit both code and storage formats. Respondents also emphasized +that typed text, individual keystrokes, and passwords must never be captured. +Preview 0 targets macOS, Windows, and Linux under one cross-platform contract. +Each platform remains gated on its native privacy and compatibility evidence; +this design makes no delivery-date promises. Another recurring theme is the +value of giving agents bounded prior-run context without weakening user privacy. + +## Decision + +1. Ship an experimental desktop Preview 0 behind both a daemon admission flag and explicit persistent user opt-in. +2. Reuse the canonical tool-dispatch and action-outcome seams. Do not build a second action runtime. +3. Encrypt every Preview 0 history record at rest with a namespace key protected by macOS Keychain, Windows Credential Manager, or Linux Secret Service and per-chunk derived keys. Store records as the Cua History Profile: a CBOR Sequence of per-record COSE_Encrypt0 objects containing CloudEvents JSON. There is no plaintext fallback. +4. Keep history event data local and separate from product telemetry. Existing fixed-enum CLI command counters may report that a history command ran, but never include history fields, identifiers, results, counts, paths, or content. +5. Never block or fail a computer action because history is slow or unavailable. +6. Keep the event, storage, and permission contracts platform-neutral and keep native adapters thin. +7. Expose exactly two read-only agent operations in Preview 0: `history_status` (`history.status`) and `history_query` (`history.query`). Route both through the existing Cua risk, permission, policy, and capability-manifest evaluator, and audit successful reads inside encrypted history. +8. Add mutation/export operations, optional sensitive-metadata scopes, process isolation, an NVIDIA OpenShell policy adapter, model summarization, and additional platforms only after independent privacy and compatibility review. + +## Goals + +- Give users an inspectable history of Cua-mediated computer actions. +- Preserve a strict metadata-only privacy boundary. +- Make consent, pause, resume, retention, quota, deletion, and access visible. +- Reuse Cua Driver's existing daemon, session, authorization, action-result, and platform contracts. +- Keep one schema across macOS, Windows, and Linux without changing Preview 0 records per platform. +- Define a storage profile that has interoperable implementations across macOS, Windows, and Linux. +- Keep storage, key custody, and authorization behind separate contracts so a future NVIDIA OpenShell policy adapter can govern callers without owning native host keys or files. +- Fail closed on privacy and fail open on action execution: unsafe data is never written, while history failures never break the requested computer action. + +## Non-goals + +- Recording the user's entire desktop activity in Preview 0. +- Capturing screenshots, video, audio, raw text, clipboard contents, or accessibility trees. +- Reusing trajectory recordings as history storage. +- Exposing agent mutation, deletion, raw encrypted chunks, keys, unrestricted queries, or model-generated summaries in Preview 0. +- Claiming physical secure erasure or complete rollback resistance. +- Claiming parity where an operating system or desktop cannot supply the same native evidence. +- Using history as product telemetry or uploading it to Cua services. + +## Terminology + +- **Computer History:** the user-controlled local feature defined here. +- **History event:** one allowlisted structured lifecycle, control, action, or health record. +- **History writer:** the non-blocking in-daemon Preview 0 component that persists events. +- **History store:** the namespace-aware encrypted local directory holding history chunks and non-sensitive format metadata. +- **Cua History Profile:** the versioned composition of CBOR Sequence framing, COSE_Encrypt0 record protection, and a CloudEvents JSON event envelope defined by this document. +- **Key provider:** the platform adapter that creates, loads, and destroys device-local history keys without exposing them to callers. +- **Vault:** the separate, least-privilege encrypted storage process introduced at Beta. Preview 0 is encrypted but remains daemon-owned. +- **Query broker:** the policy-enforcing boundary that mediates access to the hardened vault. +- **History capability:** one operation-specific permission, such as `history.query` or `history.pause`, evaluated independently from tool arguments. +- **Model broker:** a separate optional component that can call local or remote models without holding vault keys or reading encrypted chunks directly. +- **Raw content:** screenshots, video, audio, typed text, clipboard contents, accessibility trees, raw tool arguments/results, file paths, window titles, URLs, or free-form diagnostics. + +## Current state and reusable seams + +The implementation must extend current source-of-truth contracts: + +- [`cua-driver-core/src/tool.rs`](../rust/crates/cua-driver-core/src/tool.rs) is the canonical dispatch chokepoint. It joins the resolved tool name, sanitized arguments, timing, dispatch, and resulting action record. +- [`cua-driver-core/src/action_record.rs`](../rust/crates/cua-driver-core/src/action_record.rs) provides `ActionExecutionRecord::stable_projection()`, the validated source for fixed effect, route, delivery, evidence-kind, and escalation-kind fields. +- [`cua-driver-core/src/session.rs`](../rust/crates/cua-driver-core/src/session.rs) owns session lifecycle. Its current `SessionObserver` is a single `OnceLock` registration already used by telemetry; history must not try to register a competing observer. +- [`cua-driver-core/src/server.rs`](../rust/crates/cua-driver-core/src/server.rs) exposes a deliberately content-free `ToolCompletionObservation`. It is useful for fixed classifications but is not, by itself, a complete history event. +- [`cua-driver-core/src/recording.rs`](../rust/crates/cua-driver-core/src/recording.rs) demonstrates begin/finish hooks around dispatch and argument sanitization. History may reuse that hook shape but never its screenshot-bearing storage or consent model. +- The `platform-macos`, `platform-windows`, and `platform-linux` history adapters + provide native key custody and per-action application identity without adding + a continuous observer or widening the shared event schema. +- [`cua-driver/src/serve.rs`](../rust/crates/cua-driver/src/serve.rs), [`cua-driver-core/src/daemon.rs`](../rust/crates/cua-driver-core/src/daemon.rs), and [`cua-driver-core/src/socket_io.rs`](../rust/crates/cua-driver-core/src/socket_io.rs) own the daemon and client transport. +- [`cua-driver/src/cli.rs`](../rust/crates/cua-driver/src/cli.rs) preserves the + verified installed product, namespace, permission mode, grants, compatibility + mode, socket, and history admission across daemon relaunch on each platform. +- [`scripts/uninstall.sh`](../scripts/uninstall.sh) and + [`scripts/uninstall.ps1`](../scripts/uninstall.ps1) preserve history during a + normal uninstall and perform exact installed-helper cryptographic purge before + package removal when purge is explicit. +- [`cua-driver-core/src/authorization.rs`](../rust/crates/cua-driver-core/src/authorization.rs), [`cua-driver-core/src/policy.rs`](../rust/crates/cua-driver-core/src/policy.rs), and [`cua-driver-core/src/session_manifest.rs`](../rust/crates/cua-driver-core/src/session_manifest.rs) are authoritative for the Preview 0 read-only history operations and remain the stable seam for a later NVIDIA OpenShell adapter. +- [`cua-driver/src/bundle.rs`](../rust/crates/cua-driver/src/bundle.rs) is authoritative for release/local namespace separation and default state paths. + +Preview 0 adds a dedicated history hook beside recording's dispatch hooks. It does not widen product telemetry and does not depend on a new `AXObserver` stream. + +## Staged architecture + +```mermaid +flowchart LR + P0[Preview 0\nencrypted desktop action history\npermission-gated hydration] --> P1[Preview 1\nquery hardening and optional context] + P1 --> B[Beta\nNVIDIA OpenShell policy access\nand process isolation] + B --> X[Additional platforms\nand optional models] + + P0 -. gate .-> G0[Native credential encryption\nprivacy allowlist\nper-OS evidence] + P1 -. gate .-> G1[Verification\nquery and retention hardening] + B -. gate .-> G2[NVIDIA OpenShell adapter\nhost-broker authorization\nprocess isolation] + X -. gate .-> G3[Native platform proof\nper affected OS] +``` + +No later-stage guarantee may be used to describe an earlier stage. + +### Progressive architecture views + +These views explain the design at increasing levels of detail. The advanced +view describes the roadmap. Preview 0 does not include those components. + +#### Simple: the user mental model + +```mermaid +flowchart LR + U[You explicitly opt in] --> D[Cua Driver performs an action] + D --> V[Encrypted local history] + V --> H[You inspect it or authorize a query] + + G[Off by default\nfixed metadata only\nno screenshots or raw text] -. guardrails .-> D + G -. guardrails .-> V +``` + +#### Intermediate: the Preview 0 data path + +```mermaid +flowchart LR + A[Canonical tool dispatch] --> O[Validated action outcome] + O --> R[Fixed-field redactor] + M[Per-action platform app identity] --> R + R -->|nonblocking try_send| Q[Bounded queue] + Q --> W[Single history writer] + K[Native credential store\nnamespace root key] --> W + W --> S[CBOR Sequence\nCOSE-encrypted CloudEvents] + + U[User CLI] -->|enable pause inspect delete| D[Cua Driver daemon] + G[Agent] --> P[Existing Cua permission system] + P -->|history.status or history.query| D + D -->|serialized bounded read| W + S -->|metadata-only result| D + + T[Product telemetry] ---|no history data flow| S +``` + +#### Advanced: later trust boundaries and platform adapters + +```mermaid +flowchart TB + subgraph NOW[Preview 0 on macOS, Windows, and Linux] + TD[Tool dispatch] --> CR[Capture and fixed-field redaction] + CR --> IW[In-daemon writer] + MK[Native credential store] --> IW + IW --> ES[Encrypted event store] + EP[Existing permission system] --> HQ[Read-only history broker] + HQ --> ES + end + + subgraph BETA[Beta roadmap] + AG[Agent in a sandbox] --> OG[NVIDIA OpenShell policy adapter] + OG --> HB[Native host authorization broker] + HB --> VP[Isolated no-network vault process] + VP --> EI[Encrypted chunks and derived index] + HB -. explicit request .-> MB[Model broker without vault keys] + end + + subgraph CROSS[Preview 0 native key providers] + KP[Stable KeyProvider contract] + KC[macOS Keychain] + DP[Windows Credential Manager] + SS[Linux Secret Service] + KP --> KC + KP --> DP + KP --> SS + end + + ES -. same encrypted profile .-> VP + EP -. same capability contract .-> HB + VP -. platform adapter .-> KP + RAW[Still never persisted\nscreenshots raw text clipboard\narguments results AX trees paths titles URLs] -. privacy boundary .-> CR + RAW -. privacy boundary .-> VP +``` + +## Preview 0: experimental desktop action history + +### Scope + +Preview 0 records: + +- history enable, disable, pause, resume, flush, quota, recovery, and delete events; +- Cua Driver session start and end events; +- the start and completion of Cua-mediated, state-changing computer actions; +- fixed action outcome fields derived from the validated action record; +- per-action fixed-field application identity available during target resolution; +- writer health and dropped-event counts. + +Preview 0 does not record: + +- passive activity outside Cua-mediated actions; +- read-only snapshots or accessibility-tree reads; +- window titles or URLs; +- text-entry length, text classifications, key events, or clipboard events; +- model-generated summaries. + +### Preview 0 component flow + +```mermaid +flowchart LR + U[User CLI] -->|enable pause inspect delete| D[Cua Driver daemon] + G[Agent runtime] -->|history_status or history_query| P[Existing Cua permission system] + P -->|authorized read only| D + A[Canonical tool dispatch] --> H[History hook] + O[Validated action outcome] --> H + M[Per-action platform app context] --> H + H --> R[Fixed-field redactor] + R -->|try_send| Q[Bounded queue] + Q --> W[History writer] + W --> S[CBOR Sequence of\nCOSE-encrypted CloudEvents] + S --> U + S -->|bounded decrypted metadata| D + D --> G + + T[Product telemetry] ---|no history data flow| S +``` + +### Admission and consent + +Two independent gates are required: + +1. `cua-driver serve --experimental-history` admits the preview capability for that daemon generation. Without it, history mutation commands are unavailable. +2. `cua-driver history enable` records explicit user consent and persists `history_enabled: true` in the namespace-specific driver configuration. + +Admission alone never starts capture. Opt-in alone cannot bypass a daemon that was started without the experimental admission flag. + +For the standard installed product, the CLI owns the admission transition so +the flag is not lost when the daemon is relaunched. If `history enable` reaches +a healthy installed daemon that is not admitted, the CLI: + +1. records a namespace-specific, non-secret `history_preview_admitted` preference as the user's explicit request to enter the preview; +2. asks the current daemon to stop cleanly; +3. relaunches the same verified installed product through LaunchAgent, the + Windows scheduled task, the Linux systemd user unit, or an exact detached + installed binary, with `serve --experimental-history`; +4. verifies that the replacement daemon reports the expected namespace, + installed source, version, and admitted state, plus the macOS signing + identity where applicable; and +5. invokes the normal atomic enable operation only after that verification succeeds. + +The standard auto-launch path consults `history_preview_admitted` and supplies +the admission flag on later daemon starts. An absent, false, malformed, or +unsupported preference never supplies the flag. A manually staged binary that +is not the exact installed product is never allowed to perform lifecycle +control. Disabling capture leaves preview admission intact but sets +`history_enabled: false`, so a later enable does not require another relaunch. +Admission still cannot capture anything without the separate enabled state. +Purge removes both states. + +If stop, relaunch, or replacement-daemon verification fails, the CLI restores the previous admission preference, leaves `history_enabled: false`, and makes a best-effort attempt to restore the prior daemon mode. It reports a fixed lifecycle error rather than silently switching installations or treating admission as successful. + +Enablement is atomic with respect to capture state: the daemon creates or opens +the namespace-specific native credential, initializes the encrypted control +stream, completes an authenticated write/read self-test, and only then persists +`history_enabled: true`. A failure may leave an authenticated encrypted chunk +for diagnosis, but it never persists enabled state or writes plaintext history. + +`history enable` must print: + +- that the feature is experimental; +- the exact Preview 0 field allowlist; +- that Preview 0 files are encrypted with keys protected by the platform's native + credential store; +- that capture will refuse to start if the native credential store or encrypted + storage is unavailable; +- the retention period and disk quota; +- the commands to pause, disable, inspect, and delete history; +- whether the CLI completed an installed-daemon relaunch or a manual daemon restart is still required. + +### Standards profile + +The Cua History Profile composes these open specifications: + +- [RFC 8949](https://www.rfc-editor.org/rfc/rfc8949.html) for CBOR; +- [RFC 8610](https://www.rfc-editor.org/rfc/rfc8610.html) for the CDDL definition of profile items; +- [RFC 8742](https://www.rfc-editor.org/rfc/rfc8742.html) for appendable CBOR Sequences; +- [RFC 9052](https://www.rfc-editor.org/rfc/rfc9052.html) for COSE_Encrypt0 record protection; +- [RFC 9053](https://www.rfc-editor.org/rfc/rfc9053.html) algorithm 24 for ChaCha20/Poly1305; +- [RFC 5869](https://www.rfc-editor.org/rfc/rfc5869.html) for HKDF-SHA-256 chunk-key derivation; and +- [CloudEvents 1.0](https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md) and its [JSON event format](https://github.com/cloudevents/spec/blob/main/cloudevents/formats/json-format.md) for the logical event envelope. + +These specifications define the primitives. This document defines the Cua-specific profile that composes them, including key derivation, nonce construction, limits, recovery behavior, event types, and the privacy allowlist. The implementation must check in the exact CDDL for the file header and sequence items beside the event JSON Schema. A conforming reader does not need Cua-specific cryptographic or framing code, but it must implement this profile's validation rules. + +### Event envelope + +Every Preview 0 record is a CloudEvents 1.0 JSON event inside the encrypted COSE payload: + +```json +{ + "specversion": "1.0", + "id": "11111111111111111111111111111111", + "source": "urn:cua-driver:history:22222222222222222222222222222222", + "type": "cua-driver.history.action_completed.v0", + "subject": "action/44444444444444444444444444444444", + "time": "2026-08-14T12:00:00Z", + "datacontenttype": "application/json", + "dataschema": "urn:cua-driver:schema:history-event:v0", + "data": { + "session_id": "33333333333333333333333333333333", + "action_id": "44444444444444444444444444444444", + "sequence": 42, + "platform": "macos", + "process_model": "in_daemon", + "capability": "computer.pointer.click", + "caller_category": "cua_runtime", + "payload": { + "kind": "action_completed", + "effect": "confirmed", + "route": "accessibility", + "delivery": "foreground", + "delivered_count": 1, + "evidence_kinds": ["accessibility_readback"] + } + } +} +``` + +The sample values are synthetic. The `source` contains only a namespace-local opaque store identifier. Preview 0 does not persist transport ids, policy documents, raw authorization errors, or caller-provided labels. + +Session identifiers are deterministic only within one history namespace. The writer derives a dedicated session-id key from the namespace key with HKDF-SHA-256 and stores the first 128 bits of HMAC-SHA-256 over the effective session identifier. This prevents unkeyed offline guessing from the stored value and lets explicit and implicit runtime sessions be grouped without persisting their labels. Action, event, stream, and chunk identifiers use independent random inputs and are not derived from the session identifier. + +Readers must reject unknown `dataschema` identifiers and unknown fields they cannot safely interpret, and preserve ordering by `data.sequence` rather than wall clock alone. Additive fields require a new event schema identifier. The pair of `source` and `id` is unique within the store. CloudEvents fields and all `data` fields remain inside the encrypted payload; no application, session, action, caller, or timestamp value is exposed as a COSE header. + +### Preview 0 events + +| Event | Allowlisted payload | +|---|---| +| `cua-driver.history.control.v0` | one fixed operation: `enable`, `disable`, `pause`, `resume`, `flush`, or `delete` | +| `cua-driver.history.session_started.v0` / `session_ended.v0` | fixed phase and optional opaque session id | +| `cua-driver.history.action_started.v0` | opaque action/session ids, one fixed capability, optional bounded application identity | +| `cua-driver.history.action_completed.v0` | the same opaque ids/capability/application plus fixed effect, route, delivery, delivered count, evidence kinds, and escalation kind | +| `cua-driver.history.access.v0` | fixed caller operation (`agent_query` or `local_cli`) and returned-event count | +| `cua-driver.history.health.v0` | fixed health category and count; reserved for writer-emitted health summaries | + +Delete-all closes the writer and writes no plaintext or encrypted tombstone after destroying the namespace key. Its fixed outcome is returned only to the local CLI. + +The application identity is limited to: + +- bundle identifier, when available; +- bounded display name, when available. + +PID is diagnostic context, not a stable identity. Window IDs, titles, URLs, tab IDs, document names, profile names, and paths are excluded. + +### Normative privacy allowlist + +| Data | Preview 0 | Later possibility | +|---|---:|---| +| Raw tool name and operation arguments | prohibited | prohibited | +| Fixed capability and caller-category enums | stored | stored | +| Target coordinates, selectors, and element labels | prohibited | prohibited | +| Authorization decision IDs, policy text, or caller labels | prohibited | possible opaque fixed metadata after review | +| External account, credential, or telemetry identity | prohibited | prohibited | +| Fixed effect, route, delivery, evidence, refusal, and error classes | stored | stored | +| Wall time | stored | stored | +| Monotonic timings and durations | prohibited | possible fixed buckets after review | +| App bundle ID and process name | stored | stored under user scope controls | +| PID | prohibited | prohibited | +| Window ID | never stored in Preview 0 | possible as ephemeral/tokenized metadata after review | +| Window title | prohibited | separate opt-in after encryption | +| URL/domain | prohibited | separate opt-in after encryption | +| Raw tool arguments or results | prohibited | prohibited | +| Free-form action, evidence, escalation, or error details | prohibited | prohibited | +| Typed text, key events, or text length | prohibited | prohibited | +| Clipboard contents or metadata | prohibited | prohibited | +| Screenshots, video, or audio | prohibited | prohibited | +| Accessibility trees | prohibited | prohibited | +| File and profile paths | prohibited | prohibited | + +There is no debug mode that widens this table. Debugging may increase logs for fixed internal state categories, but it may never persist raw content. + +### Capture and dispatch integration + +The history hook is constructed at the canonical dispatch boundary beside the existing recording begin/finish flow: + +1. After tool resolution and authorization, derive only the stable capability and fixed caller category. +2. Inspect only an optional numeric PID for bounded application resolution, then discard argument values. History never serializes the raw argument object. +3. Derive fixed-field platform application identity from already-available + process/window context without adding a continuous observer or persisting a + title or executable path. +4. Allocate an `action_id` and enqueue `action_started` with non-blocking `try_send`. +5. Execute the tool normally. +6. Validate the resulting `ActionExecutionRecord` and transform its stable projection through a second history-specific fixed-field allowlist. Free-form `detail` fields are discarded. +7. Enqueue `action_completed` using the same `action_id`. + +The history capture hook is internal. Separately, Preview 0 registers `history_status` and `history_query` only on an admitted history-enabled host. Their distinct `history.status` and `history.query` capabilities are evaluated by the existing authorization and capability-manifest path before invocation. The hook does not use or replace the host-facing SDK activity observer, and it does not register a second value in the current single `SessionObserver` slot. + +### Backpressure and action isolation + +- The queue is bounded; the initial capacity is 512 events. +- Dispatch uses `try_send` and never waits for the writer. +- When the queue is full or unavailable, the current history event is dropped and an atomic counter is incremented. +- The status surface exposes the accumulated drop count. When the live queue next accepts a record after drops, the dispatch hook attempts to enqueue one fixed health record whose category is `events_dropped` before later action records; failure to enqueue that health record leaves the pending count for a later attempt. +- History initialization, serialization, rotation, sync, quota, and deletion errors never change the computer-action result. +- A panic in history code must be contained at the hook/writer boundary and must not unwind through dispatch. +- Repeated identical lifecycle/control events may be coalesced, but action records are never deduplicated. + +### Pause, disable, shutdown, and crash behavior + +- `pause` stops admission of new action-start records. +- An action admitted before the pause completes its matching action-completed record when possible. +- `resume` creates a new control record and admits subsequent actions. +- `disable` pauses capture, drains the bounded queue up to a short fixed deadline, flushes, closes the writer, and persists `history_enabled: false`. +- Daemon shutdown performs the same bounded drain. It must not hang daemon exit. +- At startup, the writer validates every prior CBOR Sequence and authenticated COSE item before creating a new chunk. Preview 0 fails closed on an incomplete or corrupt tail and never appends to that stream. Authenticated partial-tail recovery is a later compatible hardening step; the v1 framing preserves that option without changing valid records. +- A missing start/completion pair is valid evidence of a crash or dropped event and must not be silently synthesized. + +### Preview 0 storage + +The default root is derived from the product namespace and native user-state +location: + +```text +macOS: ~/Library/Application Support/{cua-driver|cua-driver-local}/computer-history/ +Windows: %LOCALAPPDATA%\{cua-driver|cua-driver-local}\computer-history\ +Linux: ${XDG_STATE_HOME:-~/.local/state}/{cua-driver|cua-driver-local}/computer-history/ +``` + +Preview 0 does not expose a file-root override. Production capture and purge +are confined to the platform-derived root for the exact product namespace. +Tests isolate storage by constructing `HistoryConfig` directly or by using an +isolated native user-state root. A new or empty root receives a Cua History +ownership marker. Preview builds created before this marker existed were never +publicly released and are not auto-adopted: any non-empty unmarked or malformed +root, symlinked root or managed entry, Windows reparse point, or unexpected +contents fails closed before purge destroys its key or deletes any file. File +creation also refuses existing final-component links. These checks prevent +accidental destructive redirection; a privileged same-user process racing +filesystem mutations remains outside the threat model defined below. + +```text +computer-history/ +├── .cua-history-root-v1 # fixed ownership marker; no user data +├── admission.json # non-secret preview admission preference +├── state.json # enabled and paused booleans only +├── writer.lock # empty OS-lock coordination file +└── chunks/ + ├── .cborseq + └── .cborseq +``` + +Chunk filenames use random store-local identifiers that are not the session or action identifiers inside an event. The only plaintext state files are the preview-admission boolean and enabled/paused booleans shown above. They contain no event timestamps, application identity, session/client identity, action fields, authorization identifiers, or queryable history. + +### Key-provider boundary + +`cua-driver-core` depends on a narrow `KeyProvider` contract: create or load a random device-local key, load one exact opaque key reference into zeroizing memory, and destroy that exact reference. Every operation takes an explicit release or local-development namespace. The contract never exposes a platform credential-store path, account name, raw operating-system error, export operation, or plaintext fallback. Its key epoch and per-chunk key reference fields allow later rotation without changing the v1 file framing. + +Preview 0 implements this contract with macOS Keychain, Windows Credential +Manager, and Linux Secret Service. Each provider creates a random 256-bit key, +reads it back before enabling capture, maps locked/corrupt/unavailable failures +to fixed categories, zeroizes key bytes, and verifies absence after deletion. +There is no file-key or environment-key fallback. + +Release builds use the macOS Data Protection Keychain and a signing-team-qualified access group tied to `com.trycua.driver`. Apple's [Data Protection Keychain guidance](https://developer.apple.com/documentation/technotes/tn3137-on-mac-keychains) and [provisioning-profile guidance](https://developer.apple.com/documentation/technotes/tn3125-inside-code-signing-provisioning-profiles) make the embedded Developer ID provisioning profile part of this boundary: it must authorize the exact restricted access-group entitlement carried by the packaged executable. `history enable` verifies those signed entitlements before admitting the installed preview. A build that cannot access its item returns a fixed key-unavailable, key-locked, or key-corrupt category, keeps capture disabled, and never creates a replacement key over an existing stream. Ad-hoc local-development builds cannot use that release access group, so their separate `cua-driver-local` namespace uses a non-synchronizing login-Keychain item. It remains encrypted and has no plaintext fallback, but it does not claim the release build's `ThisDeviceOnly` Data Protection class. + +### Preview 0 encryption format + +Encryption is a Preview 0 requirement: + +- Preview 0 uses one random 256-bit namespace key epoch stored as a + namespace-specific native credential. Every chunk receives a distinct + HKDF-derived key. The header already carries the key reference and epoch, so + later key rotation or per-session key policy is additive rather than a + framing migration. +- The installed Keychain item uses the Data Protection Keychain with + `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`, + `kSecAttrSynchronizable=false`, and a signing-team-qualified access group. + Installed packages verify those entitlements. The separate ad-hoc + local-development namespace uses a non-synchronizing login-Keychain item + because an ad-hoc signature has no installed-product access group. +- Windows uses the current user's Credential Manager through the native + credentials API. The exact product namespace and fixed history account name + select one credential; another Windows user or Cua release/local namespace + cannot enumerate it through the provider contract. +- Linux uses the current desktop user's Secret Service collection. An absent, + locked, denied, or malformed Secret Service fails closed. Cua Driver never + substitutes an environment variable, plaintext file, or process-local key. +- Release and local-development builds use different native credential service + namespaces and cannot unwrap each other's history. +- Decrypted key material lives only in zeroizing memory owned by the writer/query path; it is never logged, serialized, included in diagnostics, or returned over daemon protocols. +- Chunk keys are derived from the namespace key with HKDF-SHA-256. The random 128-bit chunk identifier is the salt. The info value contains `cua-driver/history-profile/v1/chunk-key`, the opaque stream identifier, and the key epoch. This separates keys across chunks, streams, epochs, and profile versions. +- Each `.cborseq` file is an RFC 8742 CBOR Sequence. Its first item is a deterministic, definite-length CBOR header containing only the profile version, COSE algorithm identifier, key epoch, opaque key reference, opaque stream and chunk identifiers, and bounded format metadata. The exact encoded header bytes become external authenticated data for every record in that file. +- Every later sequence item is a tagged COSE_Encrypt0 object whose ciphertext contains exactly one CloudEvents JSON event. The COSE protected header contains only the standard algorithm identifier. The unprotected header contains only the standard 96-bit IV parameter. The exact encoded file header is external authenticated data. Neither COSE header contains a history payload field. +- Preview 0 uses RFC 9053 algorithm 24, ChaCha20/Poly1305 with a 256-bit key, 128-bit authentication tag, and 96-bit nonce. +- The chunk-local record sequence starts at zero. The 96-bit nonce is `random_32_bit_chunk_prefix || uint64_be(record_sequence)`. The per-chunk derived key, random prefix, and strictly increasing record sequence make each `(key, nonce)` pair unique. Writers must rotate the chunk before sequence exhaustion. The encrypted CloudEvent's `data.sequence` is stream ordering and is distinct from this chunk-local nonce counter. +- A chunk is appendable only by the writer instance that created it. Every writer initialization, including daemon start and in-process reinitialization after a contained writer failure, seals prior chunks as read-only and creates a new random chunk identifier, derived chunk key, and nonce counter. This prevents reuse if a crash or contained failure loses the last previously used counter value before it becomes durable. +- Before reading the next stream sequence or creating a chunk, a writer takes an exclusive operating-system file lease on `writer.lock` and holds it for the writer lifetime. A competing daemon fails closed before writing. Exact duplicate `(source,id)` events from an older store are ignored during bounded reads; distinct duplicate sequence numbers remain corruption evidence. +- The COSE protected header, IV, and file header's external authenticated data bind the algorithm, profile version, key epoch, opaque key/stream/chunk references, nonce prefix, and record position to the ciphertext. +- COSE headers and the file header are authenticated but visible. They may contain only the generic format fields listed above. The CloudEvents envelope, event type, timestamp, application identity, session/action identifiers, authorization context, and payload remain encrypted. +- Total bytes read are bounded by the configured quota before decoding. Header, schema, nonce, sequence, and fixed-string validation then fail closed before any event is returned. +- Query snapshots are serialized with the writer. Producer hooks remain nonblocking and may drop events while the snapshot holds the writer handle lock; fixed health accounting reports those drops. +- On recovery, the reader accepts only the deterministic header followed by a contiguous sequence of authenticated COSE items. For the item at zero-based position `N`, it requires the IV suffix to equal `uint64_be(N)` and the prefix to equal the file header. A missing, duplicated, reordered, mismatched, incomplete, or unauthenticated item marks the chunk corrupt. Preview 0 does not auto-repair or append past that evidence. + +Control and action records use the same namespace root-key epoch and profile; each chunk receives a distinct HKDF-derived key. There is no environment variable, command-line option, debug mode, or recovery path that permits plaintext history. + +If native credential lookup, random-key generation, key derivation, nonce +construction, encryption, authentication, or permissions fail, capture remains +disabled and the CLI reports a fixed error category. Existing computer actions +continue normally without history. + +Preview 0 storage requirements: + +- the storage root and `chunks` directory use user-only `0700` permissions; +- files use user-only `0600` permissions; +- the writer appends complete COSE records and syncs periodically without performing encryption or sync on the dispatch thread; +- every writer instance starts a new chunk and never appends to a chunk created by an earlier instance; +- every writer generation creates a new chunk and treats all prior chunks as read-only; +- default retention is 7 days and query-visible expiry is exact by encrypted event timestamp; +- default total quota is 100 MiB; +- the writer seals live chunks on a fixed sub-hour cadence and prunes expired sealed chunks during long-lived maintenance; while the writer remains active this bounds physical ciphertext deletion slack to at most one hour beyond query-visible retention. When history is disabled or the daemon is offline, expired ciphertext may remain until the next enable or query checkpoint, but reads still enforce the exact event-time cutoff. On quota exhaustion, the writer stops accepting records, reports a fixed `quota_reached` health category, and never affects the originating computer action; +- no plaintext history index is permitted; +- `history show` scans bounded chunks in Preview 0; a derived index is deferred until query requirements justify it. + +Preview 0 supports delete-all only. It closes the writer, destroys the exact namespace key reference, and then removes the recognized `.cborseq` files. A deletion command reports success only after mandatory key destruction and file removal succeed. The CLI does not claim physical erasure from filesystem snapshots, backups, copy-on-write storage, SSD wear leveling, copied ciphertext, or already-decrypted process memory. + +### Preview 0 CLI + +Preview 0 exposes lifecycle and destructive controls only through local CLI +requests to a daemon-private method. The daemon authenticates the same-user +Unix-socket or named-pipe peer, resolves its executable from the kernel-reported +peer PID, and requires the exact helper inside the verified installed product. +macOS release admission additionally pins the Cua signing team and Apple +code-signing trust anchor. Caller-declared direct-CLI routing fields remain +defense in depth and cannot authenticate an arbitrary same-user process: + +```text +cua-driver history enable +cua-driver history disable +cua-driver history pause +cua-driver history resume +cua-driver history status [--json] +cua-driver history list [limit] [--json] +cua-driver history show [--json] +cua-driver history flush +cua-driver history delete --yes +``` + +Mutation commands follow the driver's protected-action rules. `delete` requires an explicit human-facing confirmation unless the caller supplies an existing trusted confirmation mechanism defined by the driver; it is never exposed as an agent tool in Preview 0. + +### Preview 0 agent hydration + +An agent runtime hydrates from history through the normal Cua Driver tool path: + +```mermaid +sequenceDiagram + participant A as Agent runtime + participant R as Cua tool registry + participant P as Existing permission system + participant H as Encrypted history + + A->>R: history_status() + R->>P: authorize history.status + P-->>R: allow or deny + R-->>A: content-free availability and health + A->>R: history_query(limit, sequence bounds, optional session id) + R->>P: authorize history.query + P-->>R: allow or deny + R->>H: bounded decrypt and schema validation + H-->>R: metadata-only events + R->>H: encrypted access-audit event + R-->>A: bounded events enter agent context +``` + +The agent first checks `history_status`. If history is available, it requests at most 200 metadata-only events with optional sequence and session bounds. A session bound may be the opaque id returned by an earlier query or a caller-known session label, which is transformed with the same namespace-local keyed derivation before comparison; the raw label is never written. The existing Cua permission mode, policy ceilings, and capability manifest decide whether each distinct tool may run. Standard mode requires an explicit operation-scoped history grant. In bounded mode the manifest must grant both the exact tool and the matching `resources.computer_history.operations` value. Query results can enter the model context, so the tool description and response explicitly disclose that fact. The agent cannot enable, pause, resume, delete, change retention, export raw chunks, or obtain keys. + +A future NVIDIA OpenShell policy adapter may constrain sandboxed callers and +feed the same stable `history.status` and `history.query` capability decision. +It will not replace the native credential store, encrypted store, or host-side +authorization check. + +`history status --json` includes only fixed operational fields: + +- supported, admitted, enabled, paused, and encrypted booleans; +- the fixed storage-profile identifier; +- retention days, quota bytes, and current encrypted bytes; +- dropped-event count; and +- one fixed writer-health category. + +### Uninstall, reinstall, and purge + +Normal uninstall preserves encrypted history files, the enabled and preview-admission preferences, and the exact namespace's native credential. The uninstaller must say that these data remain and name the explicit purge command. A later compatible reinstall can reopen the store; local-development and release installs still cannot access each other's namespace. + +`uninstall --purge` is a cryptographic deletion operation, not only file cleanup. Before removing the history directory, the uninstaller must use a packaged offline lifecycle helper to: + +1. stop the daemon and prove that no writer for the exact namespace remains active; +2. enumerate opaque key references only within that namespace's exact native credential service; +3. destroy and verify absence of the exact namespace root-key item, including an orphaned item no longer referenced by readable store metadata; +4. verify that every enumerated key can no longer be loaded; and +5. only after key absence is verified, remove recognized history files and then the enabled/admission lifecycle state. + +The Unix uninstaller rejects a process-wide root invocation. Every uninstaller +must run as the interactive login user so the helper resolves that user's home +or local-app-data directory and native credential store; the macOS script may +elevate only the exact protected app-bundle removal after cryptographic purge +succeeds. + +The purge path never performs broad credential-store deletion and never +touches the other release/local-development namespace. If enumeration, +destruction, or verification fails, it reports `history_purge_incomplete`, +preserves the history directory and lifecycle state needed to retry, and does +not claim that history was purged. Retained copied ciphertext is expected to +become unreadable after successful key destruction, subject to the +physical-erasure limits stated above. + +### Preview 0 launch gate + +Preview 0 may launch on a platform only when all of these requirements pass: + +1. The feature is off by default. Daemon admission and explicit persistent user + opt-in are separate requirements, and neither can substitute for the other. +2. Every history record is encrypted and authenticated with a namespace key in + the platform's native user credential store. Missing, locked, corrupt, or + denied credentials disable capture without a plaintext fallback. +3. Persisted-field allowlist and adversarial-redaction tests prove that typed + text, keystrokes, passwords, clipboard data, screenshots, paths, titles, + URLs, raw arguments/results, and free-form diagnostics never reach disk. +4. Retention, quota, queue saturation, writer failure, corrupt-record handling, + pause, resume, and restart behavior remain bounded and never change the + originating computer-action result. +5. Normal uninstall, compatible reinstall, upgrade, and rollback preserve the + encrypted store. Explicit deletion and purge destroy only the selected + namespace, verify key absence, and never report success while retained + ciphertext remains decryptable. +6. Local CLI and agent reads use the existing authenticated authorization path. + History content, query fields, identifiers, counts, and results remain absent + from product telemetry. +7. Schema and storage-profile compatibility tests cover upgrades, unsupported + newer formats, corruption, and rollback. An older binary must leave an + unknown store untouched; a newer binary must refuse unsafe mutation rather + than guess or perform an implicit plaintext migration. +8. The packaged application passes integrity, install, upgrade, rollback, + reinstall, and purge tests. Source-only tests do not replace package tests. +9. The platform's canonical desktop E2E harness passes with history disabled, + and a feature-enabled native smoke proves opt-in, encrypted capture, status, + restart hydration, pause, resume, disable, preservation, and purge. +10. The synchronous capture hook performs no disk, credential-store, network, + or blocking synchronization work and adds less than 1 ms p99 latency when + enabled, including the full-queue path. +11. Public documentation states supported platforms, opt-in behavior, stored + and excluded fields, local encryption, retention and quota, recovery and + purge limits, rollback behavior, and known operating-system or compositor + limitations. + +### Staged native qualification + +The shared schema, encrypted profile, writer, authorization contract, and +privacy tests qualify as one cross-platform core. Native support qualifies +separately: + +- macOS must pass its Keychain, signed-package identity, application-identity, + lifecycle, purge, and canonical desktop E2E checks; +- Windows must pass its Credential Manager, installed-control identity, + application-identity, lifecycle, purge, and canonical desktop E2E checks; and +- Linux must pass its Secret Service, installed-control identity, X11 or + compositor-specific application-identity, lifecycle, purge, and canonical + desktop E2E checks. + +A passing result on one platform does not qualify another. If a native +credential backend, desktop session, or compositor cannot satisfy the contract, +capture remains disabled or the limitation is documented without substituting +screenshots, OCR, raw text, or guessed identity fields. + +## Preview 1: audit, query, and optional-context hardening + +Preview 1 keeps the Preview 0 encrypted format and event schema additive while hardening verification, local queries, retention, key lifecycle, and optional metadata policy. + +### Key lifecycle hardening + +- Add explicit key inventory and health checks without printing key material. +- Define atomic key rotation that rewrites or rewraps one stream at a time and remains resumable after a crash. +- Distinguish a locked Keychain from missing, revoked, or corrupt keys in fixed error categories. +- Make backup/restore behavior explicit: encrypted files without their device-local Keychain keys are intentionally unrecoverable. +- Preserve the Preview 0 rule that loss or revocation fails closed and never falls back to plaintext. + +### Audit semantics + +Preview 1 adds cross-record and cross-chunk hash links over the exact encoded COSE items plus `history verify`. + +The guarantee is deliberately narrow: verification detects accidental corruption, missing records within the available chain, partial modification, and some truncation. It does not prove absence of a complete rollback by an attacker able to replace both local data and local anchors. Strong rollback resistance would require an independently protected or external monotonic checkpoint and is not part of this stage. + +### Optional sensitive metadata + +Window-title and URL/domain capture are separate opt-ins, not one broad content switch. Each scope has: + +- an independent enable/disable control; +- a user-visible preview of what will be stored; +- deny rules for applications, window-title patterns, and domains; +- a record of policy changes; +- encryption as a hard prerequisite; +- query-time access controls. + +Typed text, raw arguments/results, screenshots, clipboard contents, accessibility trees, and paths remain prohibited. + +## Beta: isolated vault and NVIDIA OpenShell-backed access + +Beta moves key ownership and storage into a separate least-privilege process +and adds an NVIDIA OpenShell policy adapter around the read-only capability +contract introduced in Preview 0. Cua Driver keeps a platform-neutral internal +capability contract so native policy and NVIDIA OpenShell reach the same +host-side authorization decision. + +```mermaid +flowchart LR + A[Platform and action adapters] --> N[Normalizer and redactor] + N -->|fixed schema| V[History Vault process] + V --> E[Encrypted chunks and derived index] + C[Native user CLI] --> P[Host policy and query broker] + G[Agent in a sandbox] --> O[NVIDIA OpenShell policy gateway] + O -->|operation-specific capability| P + S[MCP / SDK bridge] --> O + P -->|scoped query| V + V -->|bounded redacted result| P + P --> C + P --> O + P --> R[Rule-based summarizer] + P -. explicit model request .-> M[Model broker] + M -. optional network egress .-> L[Local or remote model] + + V --- K[No network I/O\nHolds vault key] + M --- Z[No vault key\nNo raw chunk access] +``` + +### Process boundary + +- The vault process performs no network I/O. +- It cannot execute Cua tools. +- Agent transports cannot connect to it directly. +- IPC follows the driver's existing authenticated local socket/framing conventions. +- The host policy/query broker authenticates the caller, maps one requested operation to one history capability, and evaluates the current Cua Driver authorization contract. It holds caller and policy context but not the vault key. +- The model broker holds neither vault keys nor direct raw-chunk access. + +An external sandbox policy may govern the caller, tool selection, network path, +and credential exposure without governing the native host process. The Cua +Driver daemon and vault therefore remain host-side security boundaries, and +the host broker still authenticates and authorizes every request before it +reaches history data. + +The integration must use a narrow authenticated bridge or relay. It must not grant the sandbox direct access to the history directory, Keychain items, vault socket, or a bearer credential that grants more than one scoped operation. + +### Query and authorization + +Preview 0 exposes the first two rows. Beta may add the later rows only after independent review: + +| Tool operation | Required capability | Notes | +|---|---|---| +| `history_status` | `history.status` | Preview 0: content-free operational state | +| `history_query` | `history.query` | Preview 0: bounded metadata-only event retrieval | +| `history.summarize` | `history.summarize` | Rule-based or explicitly selected model-backed summary | +| `history.pause` | `history.pause` | Elevated risk; stops admission of new action-start records and preserves caller attribution in the control event | +| `history.resume` | `history.resume` | Resumes capture after an explicit pause | +| `history.flush` | `history.flush` | Flushes the bounded writer | +| `history.retention.set` | `history.retention.set` | Agent-reachable calls may only extend the current retention within managed limits | +| `history.export` | `history.export` | Separate explicit scope; absent until a portable export profile ships | + +Destructive deletion remains protected by the driver's human-confirmation contract and is not exposed as an agent tool in this stage. + +Reducing retention can destroy history and follows the same human-confirmation contract as deletion. An agent-reachable `history.retention.set` call must request a value greater than or equal to the current value. `history.pause` is an elevated-risk operation because it can hide later actions; its audit record must retain caller category and any available opaque authorization decision and policy revision identifiers. + +The NVIDIA OpenShell integration must preserve a distinct tool name and +capability for each privilege boundary rather than depend on policy inspection +of arbitrary tool arguments. A generic `history.controls` or +`history(action: ...)` tool is prohibited because a tool-name allow rule could +grant every action hidden behind its arguments. + +Before exposure, each operation requires: + +- an explicit risk classification in `authorization.rs`; +- one stable capability and session-manifest scope key per operation; +- policy-listability rules; +- matching daemon, CLI, MCP, SDK, and generated-contract handling; +- an encrypted access audit record containing caller category, capability, optional opaque authorization decision and policy revision identifiers, query shape, time range, applied scope, and row count, but not returned content; +- advertisement in `tools/list` only when history is enabled and the operation is grantable. + +There is no raw SQL, raw-chunk, or unrestricted vault endpoint. + +### Query index and migrations + +The encrypted event chunks remain authoritative. Any index is derived, encrypted, versioned independently, and rebuildable. + +Migrations must be: + +- schema-version aware; +- crash resumable; +- reversible when no destructive transformation has occurred; +- backup-first when authoritative data changes; +- idempotent across daemon restarts; +- testable against fixtures from every previously shipped schema. + +## Model summarization stage + +The rule-based summarizer is the default and runs without network access. It produces fixed structured aggregates such as action counts, application transitions, outcome distributions, and bounded timelines. + +Model-backed summarization is optional and separate: + +- local models receive only the policy-gated, redacted query result needed for the request; +- remote models receive only an additional synthetic sketch produced locally, never raw events or raw chunks; +- each request shows the provider, destination category, included field classes, and time range before opt-in; +- approval is scoped and expires; +- prompts, template versions, provider identity, and result lineage are auditable without storing API keys; +- the vault process never opens HTTP, loopback, Unix-socket, or other model connections. + +## Platform adapters and parity limits + +The normalized event envelope is shared from Preview 0, but platform support is not claimed until the relevant adapter and native evidence exist. + +| Capability | macOS | Windows | Linux/X11 | Linux/Wayland | +|---|---|---|---|---| +| Cua-mediated action history | Preview 0 | Preview 0 | Preview 0 | Preview 0 | +| App identity | bundle ID | executable identity | `WM_CLASS`, then process identity | compositor `app_id`, then process identity | +| Ambient focus history | later | later | later | compositor-dependent | +| Window title opt-in | Preview 1 or later | later | later | partial/compositor-dependent | +| Browser URL/domain opt-in | later | later | later | later | + +The current event schema does not define `unavailable_fields` or platform +limitation codes. An adapter may omit the application object, or include only +its optional `bundle_id` and `display_name` fields when they are available. +Missing OS or compositor capabilities therefore produce less context. Adapters +never substitute screenshots, OCR, raw text, or guessed values. Explicit +limitation metadata would require a future schema revision. + +Each platform supplies a reviewed `KeyProvider` adapter for the same Cua History +Profile. Preview 0 uses macOS Keychain, Windows Credential Manager, and Linux +Secret Service. The exact backend must pass native locked, missing, corrupt, +namespace-isolation, and key-destruction tests before that platform qualifies +for preview support. If no conforming user-scoped provider is available, capture +remains disabled; there is no cross-platform plaintext or file-key fallback. + +The encrypted record format is cross-platform, but a live store is device-bound because its keys remain in the native credential store. Cross-device transfer is not implicit. A future portable export must decrypt through the host broker after explicit authorization and re-encrypt into a separately versioned recipient-based COSE envelope. Copying the history directory alone must never be described as a portable backup. + +Every affected platform requires focused contract tests and either native +verification or a documented OS/compositor limitation, following +[`test-harnesses-guide.md`](test-harnesses-guide.md). + +## Compatibility and migration + +### Day-0 migration rules + +The initial rollout must not silently import development prototypes or mutate +an unknown store. A pre-history binary leaves the history directory and native +credential namespace untouched. A history-aware binary opens only supported +profiles, performs explicit encrypted-to-encrypted migrations, and refuses +mutation when it encounters a newer schema. Upgrade, rollback, reinstall, and +purge tests must exercise these rules against packaged applications before a +platform qualifies. + +- Preview 0 uses Cua History Profile `v1` and event data schema `urn:cua-driver:schema:history-event:v0`; both are explicitly experimental. +- Additive fields may be introduced within v0 only when old readers safely ignore them. +- Removing, reinterpreting, or making an optional field required creates a new schema version. +- Changing CBOR sequence structure, COSE message type, algorithm, nonce construction, header meaning, key derivation, or event media type creates a new profile version. An algorithm or key rotation within the existing profile uses a new key epoch only when every reader can select it without ambiguity. +- No supported Preview 0 format is plaintext. Development-only plaintext prototypes are never imported automatically; the preview must identify and refuse them, and an explicit cleanup path must remove them before capture can start. +- Preview 1 preserves the Cua History Profile or performs an explicit encrypted-to-encrypted migration that verifies record counts and authentication before switching formats. +- The Beta vault reads supported earlier schemas or runs an explicit migration; it never silently drops unsupported records. +- Disabling the feature leaves data intact. Deletion is a separate explicit operation. +- Normal uninstall also leaves encrypted data, preferences, and namespace-scoped keys intact; only explicit purge performs key destruction and file cleanup. + +## Security, privacy, and telemetry + +### Trust boundaries + +- Preview 0 history is encrypted, daemon-owned local user data. The namespace root key is a user-scoped native credential; per-chunk keys and the keyed session-ID domain are derived from it, while decrypted events exist transiently in daemon memory during writes and user queries. +- Preview 1 hardens key lifecycle, verification, and optional metadata policy without weakening Preview 0 encryption. +- Beta isolates key/storage ownership from agent transports and network-capable + components. NVIDIA OpenShell may constrain sandboxed callers, while the native + host broker remains authoritative for history capabilities and vault access. +- A privileged same-user or root/admin attacker able to inspect process memory is outside the threat model. +- Full local-store rollback is not claimed to be detectable without an independent checkpoint. +- Encryption does not hide filesystem metadata. Directory and chunk counts, file sizes, modification times, and the configured rotation cadence can reveal coarse activity timing to a local observer who can inspect the history root. + +### Telemetry firewall + +History and product telemetry are separate systems: + +- no history event, field, identifier, result, count, path, title, URL, query, summary, or content is copied into telemetry; +- the existing CLI telemetry classifier may emit only a closed command name and closed history operation such as `enable`, `status`, or `delete`; +- telemetry enablement does not enable history; +- history enablement does not change telemetry payloads; +- telemetry identifiers are never written into history; +- history access cannot be inferred as permission to upload data; +- any future aggregate measurement requires a separate privacy review and explicit contract change. + +### Network policy + +- Preview 0 and Preview 1 history code performs no network I/O. +- The Beta vault performs no network I/O. +- The NVIDIA OpenShell bridge exposes only operation-specific, authenticated + requests and bounded redacted responses. It never forwards raw chunks or keys. +- Only the optional model broker may perform network I/O, after explicit scoped authorization. +- Local HTTP and local sockets still count as network/IPC egress for this policy. + +## Alternatives considered + +### Reuse trajectory recording storage + +Rejected. Trajectory recording is caller-directed and may contain screenshots, application state, and caller-selected output paths. Sharing storage or consent would blur the permanent no-screenshot boundary. + +### Use the SDK activity observer + +Rejected as the internal persistence seam. It is host-facing and per-runtime. The canonical dispatch hook has the authoritative tool and action outcome context. + +### Register history as the session observer + +Rejected for Preview 0 because the current registration is single-owner and already used by telemetry. A future multi-observer registry may be useful independently, but history does not require it to ship. + +### Start with continuous desktop accessibility observers + +Rejected for Preview 0. It expands TCC, lifecycle, privacy, deduplication, and cross-platform work. The preview starts with action-associated context already available during dispatch. + +### Start with model summarization + +Rejected. Useful deterministic history and user controls must exist before models receive any derived context. + +### Allow a plaintext metadata preview + +Rejected. Application identity, timestamps, and action history are personal data even without screenshots or text. Preview 0 must encrypt them at rest and fail closed if its native-credential-backed encrypted writer is unavailable. + +### Use a custom encrypted frame format + +Rejected. A custom frame codec would make every platform reimplement parsing, algorithm identifiers, and record protection. The Cua History Profile uses CBOR Sequence and COSE for those mechanics and limits custom code to the profile rules and event schema. + +### Store NVIDIA OpenShell policy documents with history or make them the storage format + +Rejected. Policy is an authorization input, not a portable event or encryption +format. Preview 0 stores only fixed capability and caller categories; policy +documents and decision diagnostics stay in their owning control plane. The +NVIDIA OpenShell adapter remains replaceable. + +### Use SQLite immediately + +Deferred. Bounded authenticated-record scanning is sufficient while the experimental schema is small. Beta may add an encrypted, rebuildable derived index after measured query requirements justify it. + +## Implementation plan + +### Increment 0A: schema and redaction boundary + +- Add CloudEvents-compatible platform-neutral history event types, stable operation-specific capability names, and a closed JSON serializer in `cua-driver-core`. +- Add persisted-field allowlist and adversarial privacy tests. +- Add a namespace-aware configuration/storage resolver and a platform-neutral + `KeyProvider` contract with macOS Keychain, Windows Credential Manager, and + Linux Secret Service implementations. +- Implement and check in Cua History Profile v1 CDDL and event JSON Schema: deterministic CBOR headers, fail-closed RFC 8742 sequence validation, tagged COSE_Encrypt0 records, HKDF chunk-key derivation, and RFC 9053 ChaCha20/Poly1305. +- Select maintained cryptographic and serialization crates, pin them through the repository lockfile, document their versions and rationale, and pass repository license and advisory checks without handwritten cryptographic primitives. +- Add standards-profile fixtures that can be decoded by an independent CBOR/COSE implementation. +- Add nonce-uniqueness across clean and writer restart; positional sequence/IV validation; wrong-key; authentication-failure; incomplete-final-item refusal; complete-item corruption refusal; and no-plaintext-fallback tests. +- No capture or public commands yet. + +### Increment 0B: non-blocking desktop dispatch hook + +- Add the begin/complete history hook beside the recording dispatch hook. +- Derive only fixed action projection fields and existing per-action application identity. +- Add the bounded encrypted writer queue, drop accounting, writer-generation rotation, fail-closed recovery, retention, quota, and exact-namespace key destruction. +- Add a checked-in hook-boundary benchmark and prove feature-off behavior is unchanged, feature-on work performs no synchronous disk, native-credential, network, or blocking sync operation, and enabled p99 added hook latency remains below 1 ms on each canonical desktop environment. + +### Increment 0C: installed lifecycle and CLI controls + +- Add persisted preview admission, verified installed-app relaunch, admission-aware auto-launch, and private CLI control methods. +- Add enable, disable, pause, resume, status, list, show, flush, and deletion. +- Add `history_status` and `history_query` as distinct read-only tools with `history.status` and `history.query` capabilities, existing-permission-system enforcement, bounded responses, and encrypted access auditing. +- Integrate normal-uninstall preservation and exact-namespace offline purge with + the packaged Unix and Windows uninstallers. +- Add focused native feature-on smoke coverage, including disk-content + inspection and native-credential-unavailable refusal. +- Document enable, disable, preservation, deletion, purge, and recovery behavior. +- Run platform-native lifecycle, package-integrity, upgrade, rollback-refusal, + and purge tests. + +### Increment 0D: Windows and Linux native support + +- Add thin Windows and Linux application-identity adapters without titles, + paths, typed text, screenshots, or raw arguments. +- Protect the namespace root key with Windows Credential Manager and Linux + Secret Service, preserving the same Cua History Profile and fail-closed + behavior. +- Authenticate local control callers by their exact installed executable and + preserve the managed Windows task or Linux systemd user service across + history relaunches. +- Exercise installed enable, recorded native action, encrypted query hydration, + disable-preservation, and exact-namespace purge through each native desktop + harness. +- Verify each platform's package, native credential lifecycle, limitations, and + rollback path independently. + +### Increment 1: audit, query, and optional-context hardening + +- Add key inventory, key epochs, algorithm rotation, encrypted-to-encrypted migration, and recovery UX. +- Add ciphertext-chain verification with honest rollback limits. +- Harden local queries, retention, and deny rules. +- Only then consider independently controlled title/domain opt-ins. + +### Increment 2: Beta vault and NVIDIA OpenShell access + +- Split the vault process. +- Add the host policy/query broker, stable capability evaluator, and encrypted access audit. +- Add an NVIDIA OpenShell adapter and authenticated bridge without granting sandbox filesystem or key access. +- Add rule-based summarization. +- Extend generated contracts only for reviewed new operations; keep the Preview 0 read-only tool and capability names stable. +- Verify allow and deny policies for each history tool name, including proof that one granted tool cannot invoke another operation through arguments. +- Prove that agent-reachable retention calls cannot reduce the current value and that an agent-granted pause remains attributable. +- Freeze the stable schema and migration contract. + +### Increment 3: additional platforms and optional models + +- Add and certify any additional desktop adapters independently. +- Add optional local/remote model broker with scoped approval and egress audit. +- Document every native limitation without claiming unsupported parity. + +## Test and acceptance plan + +### Unit and contract tests + +- serialization snapshots for every event type; +- CDDL and JSON Schema conformance tests for every checked-in fixture; +- CloudEvents required-attribute, unique `source`/`id`, type, subject, and data-schema validation tests; +- forbidden-field and adversarial-redaction tests; +- stable action-projection mapping tests; +- sequence, wall-clock, and monotonic-clock tests; +- RFC 8742 CBOR Sequence and RFC 9052 COSE interoperability fixtures decoded by an implementation outside the history codec; +- ChaCha20/Poly1305 record round-trip, nonce-uniqueness, reordered/missing/duplicated record refusal, sequence/IV mismatch, wrong-key, modified-ciphertext, modified-protected-header, and modified-external-AAD tests; +- native-credential namespace, stable release-identity upgrade, changed-identity refusal, locked/missing-key refusal, namespace-root-key destruction, and no-plaintext-fallback tests; +- exact-namespace native-credential enumeration, orphaned-key purge, cross-namespace purge refusal, and incomplete-purge retry tests; +- bounded-queue and drop-accounting tests; +- installed admission preference, launch-argument construction, verified-relaunch state transition, malformed-preference refusal, and enable-after-self-test ordering tests; +- hook-boundary benchmarks for accepted and full-queue paths, with checks that dispatch-thread code cannot reach disk, native credential stores, network, or blocking sync operations; +- chunk rotation, incomplete-final-item recovery, complete-item corruption refusal, retention, and quota tests; +- permission, pause-attribution, retention-reduction confirmation, and namespace-isolation tests; +- one-to-one operation/capability mapping tests that reject generic argument-selected privilege changes; +- unknown additive field and unsupported schema-version tests; +- telemetry firewall tests; +- feature-off no-op tests. + +### Preview 0 integration tests + +- daemon admission without opt-in records nothing; +- opt-in without admission cannot start capture; +- enable/action/status/show/pause/resume/disable flow; +- in-flight action completion across pause; +- writer failure does not change action result; +- raw-file scans cannot find fixture event content or serialized JSON payloads; +- visible CBOR and COSE headers contain only the profile's generic allowlist; +- an independent standards-based reader parses the sequence and COSE structure before Cua-specific event validation; +- delete-all closes the writer, destroys every exact-namespace native-credential reference, and removes original readable paths; +- restart restores explicit enabled/disabled state and refuses an incomplete or corrupt tail without appending; +- installed auto-launch restores preview admission without conflating it with enabled state and refuses a mismatched source, namespace, version, or signing identity during the enable relaunch; +- retention and quota cleanup remain bounded and never fall back to plaintext or unbounded growth; +- local and release installs cannot read, decrypt, or mutate each other's default stores; +- normal uninstall preserves history and permits a compatible reinstall to decrypt and append; +- purge stops writers, destroys all exact-namespace keys including orphans, leaves the other namespace untouched, and makes a copied ciphertext fixture undecryptable; +- an injected key-destruction failure leaves retryable history state and produces `history_purge_incomplete` rather than a success result. + +### Native verification + +- Run a focused native smoke with synthetic fixture applications and known + actions on each supported platform. +- Inspect both decrypted fields and raw encrypted files after the smoke. +- Verify installed lifecycle behavior, encrypted continuity across daemon + restart, forged-client refusal, uninstall preservation, and explicit purge. +- Measure the enabled hook and require less than 1 ms p99 added latency with no + synchronous disk, credential-store, network, or blocking synchronization work. +- Contract-test compositor-specific adapters and document any application + identity limitations. +- Record platform results separately. A passing result on one desktop does not + imply that another platform passed. + +## Unresolved questions + +The Preview 0 encryption construction is decided above. These later-stage questions remain: + +1. Should optional title and URL/domain scopes ship in Preview 1 or remain deferred until Beta policy/query isolation? +2. Does Beta need SQLite/FTS or is a smaller encrypted derived index sufficient for measured query volumes? +3. What independent checkpoint, if any, is worth adding for stronger rollback detection? +4. Should the process-isolated vault become mandatory on every platform or may constrained embedded hosts use a separately documented lower-assurance mode? +5. What minimum retention and quota controls should managed deployments be allowed to enforce without weakening user-visible privacy controls? +6. Which recipient and recovery-key modes should the separately consented portable COSE export profile support? +7. Which authenticated bridge should carry operation-specific NVIDIA OpenShell + requests to the native host broker on each platform? +8. What NVIDIA OpenShell policy-interface compatibility guarantees should Beta support? + +## Decision summary + +The architecture constraints are: + +- narrow the first stage on each supported desktop to Cua-mediated action + history; +- use real dispatch/action/session seams instead of hypothetical capture components; +- make the Preview 0 privacy schema an explicit allowlist; +- compose the disk format from CBOR Sequence, COSE_Encrypt0, and CloudEvents while defining the Cua-specific profile with checked-in CDDL and JSON Schema; +- define one cross-platform `KeyProvider` boundary with native macOS, Windows, + and Linux implementations, and fail closed on release-identity changes; +- make nonce uniqueness structural across crashes and in-process writer restarts, and reject missing, reordered, duplicated, or position-mismatched records; +- separate the networkless storage boundary from all model access; +- require native-credential-backed authenticated encryption in Preview 0 with + no plaintext fallback; +- treat retention reduction as deletion-equivalent and preserve a versioned key-reference boundary for exact-namespace orphan cleanup and future rotation; +- keep NVIDIA OpenShell outside the native host vault boundary and + expose one operation-specific capability per privilege boundary; +- state honest limits for key destruction, physical deletion, hash chains, and rollback detection; +- make installed desktop admission survive verified auto-launch without + conflating admission with capture enablement; +- distinguish normal uninstall preservation from exact-namespace cryptographic purge and move namespace-scoped key enumeration into the Preview 0 provider contract; +- verify native packages independently, including upgrade, rollback refusal, + purge, and applicable platform-integrity checks; +- document Preview 0 disclosures, review dependency licenses and advisories, + and enforce a measured sub-1-ms p99 synchronous-hook budget; +- ship only permission-gated, read-only agent hydration in Preview 0; defer + agent mutation/export, process isolation, models, ambient observers, and + additional platform adapters behind explicit gates; +- keep the proposal independent and grounded in Cua Driver's requirements and current repository contracts. diff --git a/libs/cua-driver/docs/computer-history-event-v0.schema.json b/libs/cua-driver/docs/computer-history-event-v0.schema.json new file mode 100644 index 0000000000..bcbba2566e --- /dev/null +++ b/libs/cua-driver/docs/computer-history-event-v0.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:cua-driver:schema:history-event:v0", + "title": "Cua Driver Computer History Event v0", + "type": "object", + "additionalProperties": false, + "required": ["specversion", "id", "source", "type", "subject", "time", "datacontenttype", "dataschema", "data"], + "properties": { + "specversion": { "const": "1.0" }, + "id": { "$ref": "#/$defs/opaqueId" }, + "source": { "type": "string", "pattern": "^urn:cua-driver:history:[0-9a-f]{32}$" }, + "type": { + "enum": [ + "cua-driver.history.control.v0", + "cua-driver.history.action_started.v0", + "cua-driver.history.action_completed.v0", + "cua-driver.history.session_started.v0", + "cua-driver.history.session_ended.v0", + "cua-driver.history.access.v0", + "cua-driver.history.health.v0" + ] + }, + "subject": { "type": "string", "minLength": 1, "maxLength": 160 }, + "time": { "type": "string", "format": "date-time" }, + "datacontenttype": { "const": "application/json" }, + "dataschema": { "const": "urn:cua-driver:schema:history-event:v0" }, + "data": { "$ref": "#/$defs/data" } + }, + "$defs": { + "opaqueId": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "application": { + "type": "object", + "additionalProperties": false, + "properties": { + "bundle_id": { "type": "string", "minLength": 1, "maxLength": 160 }, + "display_name": { "type": "string", "minLength": 1, "maxLength": 120 } + }, + "minProperties": 1 + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": ["sequence", "platform", "process_model", "caller_category", "payload"], + "properties": { + "session_id": { "$ref": "#/$defs/opaqueId" }, + "action_id": { "$ref": "#/$defs/opaqueId" }, + "sequence": { "type": "integer", "minimum": 1 }, + "platform": { "const": "macos" }, + "process_model": { "const": "in_daemon" }, + "capability": { "type": "string", "minLength": 1, "maxLength": 128 }, + "caller_category": { "const": "cua_runtime" }, + "application": { "$ref": "#/$defs/application" }, + "payload": { "$ref": "#/$defs/payload" } + } + }, + "payload": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "operation"], + "properties": { + "kind": { "const": "control" }, + "operation": { "enum": ["enable", "disable", "pause", "resume", "flush", "delete"] } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { "kind": { "const": "action_started" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "effect", "route", "evidence_kinds"], + "properties": { + "kind": { "const": "action_completed" }, + "effect": { "enum": ["confirmed", "partial", "unverifiable", "suspected_noop", "refused", "failed"] }, + "route": { "enum": ["accessibility", "synthetic_events", "global_input", "system_api", "dom", "trusted_input", "unknown"] }, + "delivery": { "enum": ["background", "foreground", "not_applicable", "unknown"] }, + "delivered_count": { "type": "integer", "minimum": 0, "maximum": 4294967295 }, + "evidence_kinds": { "type": "array", "maxItems": 16, "items": { "enum": ["accessibility_readback", "browser_readback", "value_readback", "window_change"] } }, + "escalation_kind": { "enum": ["activate_target", "retry_with_pixel_target", "retry_with_page_action", "refresh_page_state", "request_permission", "elevate_access", "expand_capture_scope", "prepare_session", "retry_with_foreground_delivery"] } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "phase"], + "properties": { + "kind": { "const": "session" }, + "phase": { "enum": ["started", "ended"] } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "operation", "returned_events"], + "properties": { + "kind": { "const": "access" }, + "operation": { "enum": ["agent_query", "local_cli"] }, + "returned_events": { "type": "integer", "minimum": 0, "maximum": 200 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "category", "count"], + "properties": { + "kind": { "const": "health" }, + "category": { "enum": ["ready", "disabled", "paused", "not_admitted", "key_unavailable", "key_locked", "key_corrupt", "key_destroy_failed", "storage_unavailable", "storage_corrupt", "quota_reached", "events_dropped", "writer_stopped"] }, + "count": { "type": "integer", "minimum": 0 } + } + } + ] + } + } +} diff --git a/libs/cua-driver/docs/computer-history-preview.md b/libs/cua-driver/docs/computer-history-preview.md new file mode 100644 index 0000000000..1c63105a20 --- /dev/null +++ b/libs/cua-driver/docs/computer-history-preview.md @@ -0,0 +1,205 @@ +# Try the Computer History macOS preview + +This guide shows you how to enable Cua Driver's encrypted Computer History, +inspect it, let an authorized agent read it, and remove it. It applies to +nightly macOS builds that include the experimental preview. + +Computer History is off by default. The preview records metadata for actions +performed through Cua Driver. It does not watch unrelated desktop activity. + +## Before you start + +- Use macOS. +- Install a Cua Driver nightly that includes Computer History. +- Grant Cua Driver its usual macOS permissions. + +## Install or switch to the nightly channel + +For a fresh installation, run: + +```bash +/bin/bash -c "$(curl -fsSL https://cua.ai/driver/install.sh)" -- --channel nightly +``` + +To switch an existing installation, run: + +```bash +cua-driver channel set nightly +cua-driver update --apply +``` + +Confirm the selected and installed channels: + +```bash +cua-driver channel status +``` + +## Enable Computer History + +Run: + +```bash +cua-driver history enable +``` + +The command admits the experimental feature, initializes its macOS Keychain +key, verifies an encrypted write and read, and then enables capture. If the +installed daemon must restart, Cua Driver preserves its existing permission +mode, capability manifest, approval flags, compatibility mode, and launch +grants. + +Check the result: + +```bash +cua-driver history status +``` + +The default retention period is 7 days and the default encrypted-store quota +is 100 MiB. Queries never return events older than 7 days. The writer seals a +live chunk at least hourly and prunes sealed chunks while it is running. If +history is disabled or the daemon is offline, expired ciphertext can remain on +disk until the next enable or query checkpoint, although it is never returned +after the query-visible cutoff. + +## Inspect history locally + +List recent events: + +```bash +cua-driver history list 50 +``` + +Show one event by sequence number: + +```bash +cua-driver history show 42 +``` + +Use `--json` with `status`, `list`, or `show` when another local program needs +structured output. + +## Let an agent hydrate from history + +Connect the agent through the normal Cua Driver tool path. The preview exposes +two read-only tools: + +- `history_status`, which requires `history.status`; +- `history_query`, which requires `history.query`. + +The existing Cua permission mode, policy ceiling, and capability manifest make +the decision for each call. A permitted query returns at most 200 metadata +events. The result can enter the agent's model context, and the encrypted store +records a fixed access-audit event. + +The agent cannot enable, pause, resume, disable, delete, export, or obtain the +encryption key through these tools. + +## Pause, resume, or disable capture + +Pause capture without clearing the enabled preference: + +```bash +cua-driver history pause +``` + +Resume it: + +```bash +cua-driver history resume +``` + +Disable capture and stop the writer: + +```bash +cua-driver history disable +``` + +These commands preserve existing encrypted history. + +## Delete Computer History + +Delete the encrypted store and destroy its exact namespace Keychain key: + +```bash +cua-driver history delete --yes +``` + +This is cryptographic deletion. Cua Driver does not claim physical erasure from +APFS snapshots, backups, copied ciphertext, SSD wear leveling, or memory that a +process already decrypted. + +To purge history while uninstalling, run the uninstaller as your login user +with `--purge`. Do not prefix the uninstaller with `sudo`: the script elevates +only protected app removal, while Keychain and history cleanup must remain in +the login user's context. + +## Return to the stable channel + +Disable capture before switching binaries: + +```bash +cua-driver history disable +cua-driver channel set stable +cua-driver update --apply +``` + +Switching channels does not delete encrypted history. A stable build that does +not understand the preview store must leave it untouched. + +## What the preview stores + +The encrypted event allowlist contains: + +- time and monotonic sequence; +- opaque session and action identifiers; +- a fixed Cua capability; +- an optional application bundle identifier and display name; +- fixed action outcome, delivery, route, evidence, and escalation categories; +- lifecycle, access-audit, and writer-health events. + +The preview never stores screenshots, video, audio, typed text, raw keystrokes, +clipboard contents, raw tool arguments or results, accessibility trees, file +paths, window titles, URLs, or free-form diagnostics. + +Files use the Cua History Profile: a CBOR Sequence of COSE_Encrypt0 records with +CloudEvents JSON inside each encrypted payload. ChaCha20-Poly1305 authenticates +each record. A macOS Keychain-protected namespace key and per-chunk HKDF keys +protect data at rest. There is no plaintext fallback and history performs no +network I/O. + +The filesystem can still reveal that a history directory exists, its total +size, and file modification times. The Keychain key is device-bound, so copying +only the encrypted files to another Mac does not provide recovery. + +## Troubleshooting + +**`history_preview_not_admitted`** + +The running local-development daemon was not started with the preview flag. +Restart that daemon with `cua-driver serve --experimental-history`, then run +`cua-driver history enable` again. Installed nightly builds handle the verified +relaunch automatically. + +**`history_key_locked` or `history_key_unavailable`** + +Unlock the macOS login session and Keychain, then retry. Capture stays disabled +and Cua Driver does not create plaintext files. + +**`history_quota_reached`** + +Delete history if you no longer need it. The preview does not allow unbounded +growth and does not block the computer action that encountered the full store. + +**`history_storage_corrupt`** + +The reader found an invalid, incomplete, reordered, or unauthenticated record. +It refuses the affected store instead of repairing or skipping evidence. If +you accept permanent data loss, `cua-driver history delete --yes` destroys the +namespace key and removes the corrupt encrypted store so capture can start +fresh. + +## Architecture and format + +See [Computer History for Cua Driver](computer-history-architecture.md) for the +staged architecture, security limits, storage profile, launch gates, and later +NVIDIA OpenShell integration plan. diff --git a/libs/cua-driver/docs/computer-history-profile-v1.cddl b/libs/cua-driver/docs/computer-history-profile-v1.cddl new file mode 100644 index 0000000000..b9a50634bd --- /dev/null +++ b/libs/cua-driver/docs/computer-history-profile-v1.cddl @@ -0,0 +1,24 @@ +; Cua History Profile v1 +; The file is an RFC 8742 CBOR Sequence whose first item is history-header and +; whose remaining items are tagged COSE_Encrypt0 records. The protected COSE +; header is exactly {1: 24}; the unprotected header is exactly {5: nonce}. +; For record position N, nonce is nonce-prefix || uint64_be(N), beginning at +; zero. The COSE external AAD is the exact encoded history-header byte string. + +history-header = [ + profile-version: 1, + algorithm: 24, ; ChaCha20/Poly1305 (RFC 9053) + key-epoch: uint .gt 0, + key-reference: tstr .size (1..128), + stream-id: tstr .size (1..128), + chunk-id: tstr .size 32, ; lower-case hexadecimal 128-bit identifier + nonce-prefix: bstr .size 4 +] + +history-record = #6.16([ + protected: bstr, + unprotected: { 5 => bstr .size 12 }, ; COSE IV header + ciphertext: bstr +]) + +history-file = history-header, * history-record diff --git a/libs/cua-driver/examples/agent-sdks/codex_agent.py b/libs/cua-driver/examples/agent-sdks/codex_agent.py index 7473e1c08f..cc03d057ae 100644 --- a/libs/cua-driver/examples/agent-sdks/codex_agent.py +++ b/libs/cua-driver/examples/agent-sdks/codex_agent.py @@ -54,7 +54,17 @@ async def main() -> None: 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. +unverified. When both history_status and history_query are advertised and the +task asks to continue, resume, or recall prior Cua work, call history_status +first. If history is healthy and access is admitted, make one bounded initial +history_query before broad application or window discovery. Use its +metadata only as a lead and verify current state; omitted content, geometry, +arguments, results, and user intent remain unknown. Continue without history +if either tool is absent, access is denied, results are empty, or history is +unhealthy. Do not query history for unrelated tasks merely because the tools +exist, and never mutate history lifecycle or settings. Make another bounded +query only for a relevant session or sequence boundary, never to reconstruct +excluded fields. """ ) print(result.final_response) diff --git a/libs/cua-driver/examples/agent-sdks/codex_agent.ts b/libs/cua-driver/examples/agent-sdks/codex_agent.ts index 6870a99a3b..8efc895996 100644 --- a/libs/cua-driver/examples/agent-sdks/codex_agent.ts +++ b/libs/cua-driver/examples/agent-sdks/codex_agent.ts @@ -26,7 +26,17 @@ 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.`; +action. When both history_status and history_query are advertised and the task +asks to continue, resume, or recall prior Cua work, call history_status first. +If history is healthy and access is admitted, make one bounded initial +history_query before broad application or window discovery. Use its metadata +only as a lead and verify current state; omitted content, geometry, arguments, +results, and user intent remain unknown. Continue without history if either +tool is absent, access is denied, results are empty, or history is unhealthy. +Do not query history for unrelated tasks merely because the tools exist, and +never mutate history lifecycle or settings. Make another bounded query only for +a relevant session or sequence boundary, never to reconstruct excluded fields. +Return a concise result and mention any step you could not verify.`; } async function main(): Promise { diff --git a/libs/cua-driver/rust/Cargo.lock b/libs/cua-driver/rust/Cargo.lock index d9485bdfab..b0b2f65f56 100644 --- a/libs/cua-driver/rust/Cargo.lock +++ b/libs/cua-driver/rust/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "ahash" version = "0.8.12" @@ -499,6 +509,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -690,8 +709,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", + "cipher", "cpufeatures 0.3.0", "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", ] [[package]] @@ -717,6 +751,44 @@ dependencies = [ "phf", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -795,6 +867,12 @@ dependencies = [ "windows-win", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -906,6 +984,16 @@ dependencies = [ "libc", ] +[[package]] +name = "coset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb98d5e9155e2cf7cd942c8b3033097d4563b6fb0a00b9caecb74669555c058" +dependencies = [ + "ciborium", + "ciborium-io", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -979,6 +1067,26 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "cua-driver" version = "0.20.0" @@ -1013,6 +1121,7 @@ dependencies = [ "ureq", "uuid", "windows 0.61.3", + "zeroize", ] [[package]] @@ -1040,8 +1149,15 @@ dependencies = [ "anyhow", "async-trait", "base64", + "chacha20poly1305", + "ciborium", + "coset", "cua-driver-contract", + "fs2", "futures-util", + "getrandom 0.4.2", + "hkdf", + "hmac", "image", "jsonschema", "libc", @@ -1053,12 +1169,14 @@ dependencies = [ "sha2", "tempfile", "thiserror 1.0.69", + "time", "tokio", "tokio-tungstenite", "tracing", "url", "uuid", "windows 0.58.0", + "zeroize", "zune-core", ] @@ -1166,8 +1284,9 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1546,6 +1665,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "funty" version = "2.0.0" @@ -1819,6 +1948,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -1835,6 +1982,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1996,6 +2152,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3019,6 +3184,7 @@ dependencies = [ "cursor-overlay", "foreign-types 0.5.0", "futures-util", + "getrandom 0.4.2", "image", "indexmap", "libc", @@ -3028,6 +3194,7 @@ dependencies = [ "objc2-quartz-core 0.2.2", "pip-preview", "screencapturekit", + "security-framework", "serde", "serde_json", "thiserror 1.0.69", @@ -3036,6 +3203,7 @@ dependencies = [ "tokio-tungstenite", "tracing", "uuid", + "zeroize", ] [[package]] @@ -3088,6 +3256,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash", +] + [[package]] name = "postcard" version = "1.1.3" @@ -4455,6 +4633,16 @@ dependencies = [ "weedle2", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -5648,6 +5836,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/libs/cua-driver/rust/Cargo.toml b/libs/cua-driver/rust/Cargo.toml index 1ef6e8a2f4..847b8d831e 100644 --- a/libs/cua-driver/rust/Cargo.toml +++ b/libs/cua-driver/rust/Cargo.toml @@ -42,3 +42,12 @@ uniffi = "=0.31.0" postcard = { version = "1", features = ["alloc"] } sha2 = "0.10" zstd = "0.13" +chacha20poly1305 = { version = "0.11", features = ["rand_core", "zeroize"] } +hkdf = "0.12" +zeroize = { version = "1", features = ["derive"] } +coset = "0.4.2" +ciborium = "0.2.2" +time = { version = "0.3", features = ["formatting", "parsing"] } +getrandom = "0.4" +fs2 = "0.4" +hmac = "0.12" diff --git a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md index 350df3a393..308d1040f4 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md +++ b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md @@ -1,6 +1,6 @@ --- name: cua-driver -description: Drive a native GUI app (macOS, Windows, Linux) via the 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. +description: Drive a native GUI app (macOS, Windows, Linux) via the 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, or to continue, resume, or recall recent Cua activity. version: 0.20.0 # x-release-please-version metadata: openclaw: @@ -36,6 +36,25 @@ a user asks to drive a native app, follow the loop in this skill rather than calling tools ad-hoc — the snapshot-before-action invariant is not optional and silently breaks if you skip it. +## Consult recent Cua activity only for continuation + +When both `history_status` and `history_query` are advertised and the user asks +to continue, resume, or recall prior Cua work, call `history_status` first. If +history is healthy and access is admitted, make one bounded initial +`history_query` before broad application or window discovery. Treat returned +metadata only as a lead and verify current state through the least intrusive +appropriate source. Content, geometry, arguments, results, and user intent +omitted from the metadata remain unknown. + +Make another bounded query only when the initial slice exposes a relevant +session or sequence boundary; never broaden a query to reconstruct excluded +fields. + +Continue without history when either tool is absent, access is denied, the +query is empty, or history is unhealthy. Do not query history for unrelated +tasks merely because the tools are advertised, and never mutate history +lifecycle or settings. + ## Platform-specific reading — read this first This file is the **cross-platform core**: snapshot invariant, CLI vs diff --git a/libs/cua-driver/rust/crates/cua-driver-core/Cargo.toml b/libs/cua-driver/rust/crates/cua-driver-core/Cargo.toml index 8171b241c8..8bb3e214a0 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/Cargo.toml +++ b/libs/cua-driver/rust/crates/cua-driver-core/Cargo.toml @@ -39,6 +39,15 @@ uuid = { workspace = true } base64 = { workspace = true } sha2 = "0.10" url = "2.5" +chacha20poly1305 = { workspace = true } +hkdf = { workspace = true } +zeroize = { workspace = true } +coset = { workspace = true } +ciborium = { workspace = true } +time = { workspace = true } +getrandom = { workspace = true } +fs2 = { workspace = true } +hmac = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/libs/cua-driver/rust/crates/cua-driver-core/examples/history_hook_bench.rs b/libs/cua-driver/rust/crates/cua-driver-core/examples/history_hook_bench.rs new file mode 100644 index 0000000000..cd71ead74e --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-core/examples/history_hook_bench.rs @@ -0,0 +1,143 @@ +//! Manual synchronous producer-boundary benchmark. +//! +//! Canonical release evidence is collected in the signed Lume candidate: +//! `cargo run -p cua-driver-core --release --example history_hook_bench`. +//! This deliberately has no always-on latency threshold. + +use cua_driver_core::history::{ + ApplicationIdentity, ApplicationIdentityProvider, HistoryConfig, HistoryError, HistoryKey, + HistoryManager, KeyProvider, DEFAULT_QUOTA_BYTES, DEFAULT_RETENTION_DAYS, +}; +use std::{ + collections::HashMap, + sync::{Arc, Condvar, Mutex}, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; + +#[derive(Default)] +struct Keys(Mutex>>); + +impl KeyProvider for Keys { + fn load_or_create(&self, namespace: &str) -> Result { + let reference = format!("{namespace}.history.v1"); + let mut keys = self.0.lock().unwrap(); + let bytes = keys.entry(reference.clone()).or_insert_with(|| vec![7; 32]); + Ok(HistoryKey { + reference, + epoch: 1, + bytes: Zeroizing::new(bytes.clone()), + }) + } + fn load(&self, _: &str, reference: &str) -> Result { + Ok(HistoryKey { + reference: reference.into(), + epoch: 1, + bytes: Zeroizing::new(vec![7; 32]), + }) + } + fn references(&self, namespace: &str) -> Result, HistoryError> { + Ok(self + .0 + .lock() + .unwrap() + .keys() + .filter(|key| key.starts_with(namespace)) + .cloned() + .collect()) + } + fn destroy(&self, _: &str, reference: &str) -> Result<(), HistoryError> { + self.0.lock().unwrap().remove(reference); + Ok(()) + } +} + +#[derive(Default)] +struct BlockingApps { + state: Mutex<(bool, bool)>, + changed: Condvar, +} +impl BlockingApps { + fn wait_entered(&self) { + let mut s = self.state.lock().unwrap(); + while !s.0 { + s = self.changed.wait(s).unwrap(); + } + } + fn release(&self) { + self.state.lock().unwrap().1 = true; + self.changed.notify_all(); + } +} +impl ApplicationIdentityProvider for BlockingApps { + fn resolve(&self, _: i64) -> Option { + let mut s = self.state.lock().unwrap(); + s.0 = true; + self.changed.notify_all(); + while !s.1 { + s = self.changed.wait(s).unwrap(); + } + None + } +} + +fn report(label: &str, mut samples: Vec) { + samples.sort_unstable(); + let at = |p: usize| samples[(samples.len() - 1) * p / 100].as_nanos(); + println!( + "{label}: p50={}ns p95={}ns p99={}ns n={}", + at(50), + at(95), + at(99), + samples.len() + ); +} + +fn main() { + let temp = tempfile::tempdir().unwrap(); + let apps = Arc::new(BlockingApps::default()); + let manager = HistoryManager::new( + HistoryConfig { + root: temp.path().into(), + namespace: "hook-bench".into(), + admitted: true, + platform: "macos".into(), + retention_days: DEFAULT_RETENTION_DAYS, + quota_bytes: DEFAULT_QUOTA_BYTES, + }, + Arc::new(Keys::default()), + Some(apps.clone()), + ); + manager.enable().unwrap(); + + for _ in 0..100 { + manager.begin_action("click", &serde_json::json!({}), None); + manager.flush().unwrap(); + } + let accepted = (0..2_000) + .map(|_| { + let now = Instant::now(); + manager.begin_action("click", &serde_json::json!({}), None); + let elapsed = now.elapsed(); + manager.flush().unwrap(); + elapsed + }) + .collect(); + report("accepted", accepted); + + manager.begin_action("click", &serde_json::json!({"pid": 1}), None); + apps.wait_entered(); + let before = manager.status().dropped_events; + while manager.status().dropped_events == before { + manager.begin_action("click", &serde_json::json!({}), None); + } + let full = (0..20_000) + .map(|_| { + let now = Instant::now(); + manager.begin_action("click", &serde_json::json!({}), None); + now.elapsed() + }) + .collect(); + report("full_queue", full); + apps.release(); +} diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs index fa8b72c041..b0a28962d0 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/authorization.rs @@ -255,6 +255,16 @@ const PRIVATE_OBSERVATION_SCOPE_KEYS: &[&str] = &[ "user_policy_sha256", ]; +const COMPUTER_HISTORY_OPERATIONS: &[&str] = &["history_status", "history_query"]; +const COMPUTER_HISTORY_SCOPE_KEYS: &[&str] = &[ + "daemon_generation", + "public_session", + "operation", + "permission_mode", + "managed_policy_sha256", + "user_policy_sha256", +]; + const DESKTOP_INPUT_OPERATIONS: &[&str] = &[ "click", "double_click", @@ -428,6 +438,23 @@ pub const ENFORCEMENT_ADAPTERS: &[EnforcementAdapterDescriptor] = &[ enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), profile_behavior: AdapterProfileBehavior::Routine, }, + EnforcementAdapterDescriptor { + id: "computer_history", + operations: COMPUTER_HISTORY_OPERATIONS, + state: RiskEnforcement::Active, + risk_class: RiskClass::R2, + resource_kind: "computer_history_metadata", + scope_keys: COMPUTER_HISTORY_SCOPE_KEYS, + grant_type: Some("protected_resource_grant"), + idle_ttl_seconds: Some(30 * 60), + absolute_ttl_seconds: Some(8 * 60 * 60), + authorization_requirement: "explicit_standard_grant_or_approved_capability_manifest", + revocation_triggers: SESSION_REVOCATION, + refusal_code: Some("authorization_required"), + authorization_source: "authorization_host_in_standard; approved_capability_manifest_in_bounded; trusted_unrestricted_mode", + enforcement_by_mode: AdapterEnforcement::uniform(RiskEnforcement::Active), + profile_behavior: AdapterProfileBehavior::GrantInStandard, + }, EnforcementAdapterDescriptor { id: "desktop_input", operations: DESKTOP_INPUT_OPERATIONS, @@ -729,6 +756,10 @@ pub fn enforcement_adapters_for_call( add("private_observation"); } + if matches!(tool, "history_status" | "history_query") { + add("computer_history"); + } + if DESKTOP_INPUT_OPERATIONS.contains(&tool) { add("desktop_input"); } @@ -889,7 +920,9 @@ pub fn advertised_risk_for(tool: &str) -> RiskAssessment { | "browser_navigate" | "browser_click" | "browser_type" - | "browser_pointer" => RiskClass::R2, + | "browser_pointer" + | "history_status" + | "history_query" => RiskClass::R2, // External/file side effects or generic compound action surfaces. "get_desktop_state" @@ -999,6 +1032,11 @@ pub fn classify_tool_call(tool: &str, args: &Value) -> RiskAssessment { enforcement: RiskEnforcement::Active, operation_sensitive: true, }, + "history_status" | "history_query" => RiskAssessment { + class: RiskClass::R2, + enforcement: RiskEnforcement::Active, + operation_sensitive: true, + }, "check_permissions" => RiskAssessment { class: if args.get("prompt").and_then(Value::as_bool).unwrap_or(false) { RiskClass::R2 @@ -1481,6 +1519,38 @@ mod tests { assert!(!risk.operation_sensitive); } + #[test] + fn history_reads_are_distinct_explicit_history_capabilities() { + for (tool, capability) in [ + ("history_status", "history.status"), + ("history_query", "history.query"), + ] { + let risk = classify_tool_call(tool, &serde_json::json!({})); + assert_eq!(risk.class, RiskClass::R2); + assert_eq!(risk.enforcement, RiskEnforcement::Active); + assert_eq!(crate::tool::default_capabilities_for(tool), &[capability]); + assert_eq!( + enforcement_adapters_for_call(tool, &serde_json::json!({})) + .into_iter() + .map(|adapter| adapter.id) + .collect::>(), + vec!["computer_history"] + ); + } + let adapter = ENFORCEMENT_ADAPTERS + .iter() + .find(|adapter| adapter.id == "computer_history") + .unwrap(); + assert_eq!( + adapter.profile_behavior.for_mode(PermissionMode::Standard), + ModeBehavior::RequireGrant + ); + assert_eq!( + adapter.profile_behavior.for_mode(PermissionMode::Bounded), + ModeBehavior::AllowWithoutGrant + ); + } + #[test] fn native_menu_invocation_matches_local_gui_action_risk() { let risk = advertised_risk_for("invoke_menu"); @@ -1597,6 +1667,7 @@ mod tests { "browser_prepare.isolated", "browser_prepare.existing_profile", "private_observation", + "computer_history", "desktop_input", "file_transfer_and_output", "browser_consequential_action", @@ -1622,6 +1693,7 @@ mod tests { "browser_prepare.isolated", "browser_prepare.existing_profile", "private_observation", + "computer_history", "desktop_input", "file_transfer_and_output", "browser_consequential_action", @@ -1897,6 +1969,7 @@ mod tests { "browser_prepare.isolated", "browser_prepare.existing_profile", "private_observation", + "computer_history", "desktop_input", "file_transfer_and_output", "browser_consequential_action", @@ -1922,6 +1995,7 @@ mod tests { "browser_prepare.isolated", "browser_prepare.existing_profile", "private_observation", + "computer_history", "desktop_input", "file_transfer_and_output", "browser_consequential_action", diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs new file mode 100644 index 0000000000..18634a5fb0 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/history.rs @@ -0,0 +1,3517 @@ +//! Encrypted, metadata-only Computer History for the macOS early preview. +//! +//! History is deliberately separate from trajectory recording. It accepts +//! only strongly typed, fixed-field events and performs encryption before any +//! event bytes reach disk. The action path uses `try_send`; storage failure or +//! backpressure can never fail the computer action that produced the event. + +use std::{ + fs::{self, File, OpenOptions}, + io::{Cursor, Read, Write}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc::{self, SyncSender, TrySendError}, + Arc, Mutex, + }, + thread, + time::{Duration, Instant, SystemTime}, +}; + +use chacha20poly1305::{ + aead::{Aead, KeyInit, Payload}, + ChaCha20Poly1305, Key, Nonce, +}; +use coset::{ + iana, AsCborValue, CoseEncrypt0, CoseEncrypt0Builder, HeaderBuilder, TaggedCborSerializable, +}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use zeroize::Zeroizing; + +use crate::action_record::{ + ActionEffect, ActionExecutionRecord, ActionRoute, ActualDelivery, EscalationKind, + ProjectedEvidenceKind, +}; +use crate::{ + protocol::ToolResult, + tool::{Tool, ToolDef}, +}; +use async_trait::async_trait; + +pub const HISTORY_PROFILE_VERSION: u8 = 1; +pub const HISTORY_SCHEMA_URN: &str = "urn:cua-driver:schema:history-event:v0"; +pub const DEFAULT_RETENTION_DAYS: u64 = 7; +pub const DEFAULT_QUOTA_BYTES: u64 = 100 * 1024 * 1024; +const WRITER_QUEUE_CAPACITY: usize = 512; +const MAX_QUERY_LIMIT: usize = 200; +const CHUNK_KEY_INFO: &[u8] = b"cua-driver/history-profile/v1/chunk-key"; +const SESSION_ID_KEY_INFO: &[u8] = b"cua-driver/history-profile/v1/session-id-key"; +const CHUNK_ROTATION_INTERVAL: Duration = Duration::from_secs(55 * 60); +const WRITER_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(30); +const ROOT_MARKER_NAME: &str = ".cua-history-root-v1"; +const ROOT_MARKER_CONTENT: &[u8] = b"cua-history-root-v1\n"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HistoryHealthCategory { + Ready, + Disabled, + Paused, + NotAdmitted, + KeyUnavailable, + KeyLocked, + KeyCorrupt, + KeyDestroyFailed, + StorageUnavailable, + StorageCorrupt, + QuotaReached, + EventsDropped, + WriterStopped, +} + +#[derive(Debug, thiserror::Error)] +#[error("{category:?}")] +pub struct HistoryError { + pub category: HistoryHealthCategory, +} + +impl HistoryError { + pub fn new(category: HistoryHealthCategory) -> Self { + Self { category } + } + + pub fn code(&self) -> &'static str { + match self.category { + HistoryHealthCategory::Ready => "ready", + HistoryHealthCategory::Disabled => "history_disabled", + HistoryHealthCategory::Paused => "history_paused", + HistoryHealthCategory::NotAdmitted => "history_preview_not_admitted", + HistoryHealthCategory::KeyUnavailable => "history_key_unavailable", + HistoryHealthCategory::KeyLocked => "history_key_locked", + HistoryHealthCategory::KeyCorrupt => "history_key_corrupt", + HistoryHealthCategory::KeyDestroyFailed => "history_key_destroy_failed", + HistoryHealthCategory::StorageUnavailable => "history_storage_unavailable", + HistoryHealthCategory::StorageCorrupt => "history_storage_corrupt", + HistoryHealthCategory::QuotaReached => "history_quota_reached", + HistoryHealthCategory::EventsDropped => "history_events_dropped", + HistoryHealthCategory::WriterStopped => "history_writer_stopped", + } + } +} + +/// Zeroizing key material returned only to the encrypted store. +pub struct HistoryKey { + pub reference: String, + pub epoch: u64, + pub bytes: Zeroizing>, +} + +pub trait KeyProvider: Send + Sync { + fn load_or_create(&self, namespace: &str) -> Result; + fn load(&self, namespace: &str, reference: &str) -> Result; + fn references(&self, namespace: &str) -> Result, HistoryError>; + fn destroy(&self, namespace: &str, reference: &str) -> Result<(), HistoryError>; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OfflinePurgeResult { + pub destroyed_keys: usize, + pub removed_files: usize, +} + +/// Cryptographically purge one exact history namespace while no writer owns it. +/// Keys are destroyed and re-enumerated before any retryable on-disk state is +/// removed, so a partial Keychain failure leaves the ciphertext available for +/// another purge attempt. +pub fn purge_offline( + root: &Path, + namespace: &str, + key_provider: &dyn KeyProvider, +) -> Result { + prepare_history_root(root)?; + let _writer_lease = WriterLease::acquire(root)?; + let references = key_provider.references(namespace)?; + for reference in &references { + key_provider.destroy(namespace, reference)?; + } + if !key_provider.references(namespace)?.is_empty() { + return Err(HistoryError::new(HistoryHealthCategory::KeyDestroyFailed)); + } + + let mut removed_files = 0; + for path in history_chunk_paths(root)? { + fs::remove_file(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + removed_files += 1; + } + for name in [ + "state.json", + "state.json.tmp", + "admission.json", + "admission.json.tmp", + ] { + let path = root.join(name); + if path.exists() { + fs::remove_file(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + removed_files += 1; + } + } + Ok(OfflinePurgeResult { + destroyed_keys: references.len(), + removed_files, + }) +} + +/// Establish that `root` is a dedicated Cua History directory before any +/// state write or destructive operation. A new or empty root is claimed by +/// writing an ownership marker. A non-empty unmarked root fails closed, as do +/// symlinked roots or managed entries. +pub fn prepare_history_root(root: &Path) -> Result<(), HistoryError> { + if !root.is_absolute() { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + reject_unsafe_root_components(root)?; + match fs::symlink_metadata(root) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => secure_create_dir(root)?, + Err(_) => { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + } + let metadata = fs::symlink_metadata(root) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + reject_unsafe_root_components(root)?; + + let marker = root.join(ROOT_MARKER_NAME); + match fs::symlink_metadata(&marker) { + Ok(metadata) => { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || fs::read(&marker).ok().as_deref() != Some(ROOT_MARKER_CONTENT) + { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let empty = fs::read_dir(root) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))? + .next() + .is_none(); + if !empty { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + let mut file = secure_create_new_file(&marker)?; + file.write_all(ROOT_MARKER_CONTENT) + .and_then(|_| file.sync_data()) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + } + Err(_) => { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + } + validate_history_root_layout(root) +} + +#[cfg(unix)] +fn reject_unsafe_root_components(root: &Path) -> Result<(), HistoryError> { + use std::os::unix::fs::MetadataExt; + for component in root.ancestors().filter(|path| !path.as_os_str().is_empty()) { + match fs::symlink_metadata(component) { + // Root-owned compatibility links such as macOS `/var` are outside + // the user's control. A user-owned link in the storage ancestry + // is not a stable destination for destructive history commands. + Ok(metadata) if metadata.file_type().is_symlink() && metadata.uid() != 0 => { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + } + } + Ok(()) +} + +#[cfg(windows)] +fn reject_unsafe_root_components(root: &Path) -> Result<(), HistoryError> { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + for component in root.ancestors().filter(|path| !path.as_os_str().is_empty()) { + match fs::symlink_metadata(component) { + Ok(metadata) + if metadata.file_type().is_symlink() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 => + { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + } + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn reject_unsafe_root_components(_root: &Path) -> Result<(), HistoryError> { + Ok(()) +} + +fn validate_history_root_layout(root: &Path) -> Result<(), HistoryError> { + let mut marker_seen = false; + for entry in fs::read_dir(root) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))? + { + let entry = + entry.map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + let name = entry.file_name(); + let name = name + .to_str() + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + let kind = entry + .file_type() + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + if is_reparse_point(&entry.path())? { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + match name { + ROOT_MARKER_NAME => { + marker_seen = true; + if !kind.is_file() + || fs::read(entry.path()).ok().as_deref() != Some(ROOT_MARKER_CONTENT) + { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + } + "admission.json" | "admission.json.tmp" | "state.json" | "state.json.tmp" + | "writer.lock" => { + if !kind.is_file() { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + } + "chunks" => validate_chunks_layout(&entry.path(), kind.is_dir())?, + _ => { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + } + } + if !marker_seen { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + Ok(()) +} + +fn validate_chunks_layout(path: &Path, is_directory: bool) -> Result<(), HistoryError> { + if !is_directory { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + for entry in fs::read_dir(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))? + { + let entry = + entry.map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + let kind = entry + .file_type() + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + if is_reparse_point(&entry.path())? + || !kind.is_file() + || entry.path().extension().and_then(|value| value.to_str()) != Some("cborseq") + { + return Err(HistoryError::new(HistoryHealthCategory::StorageUnavailable)); + } + } + Ok(()) +} + +#[cfg(windows)] +fn is_reparse_point(path: &Path) -> Result { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + fs::symlink_metadata(path) + .map(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) +} + +#[cfg(not(windows))] +fn is_reparse_point(_path: &Path) -> Result { + Ok(false) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApplicationIdentity { + #[serde(skip_serializing_if = "Option::is_none")] + pub bundle_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +pub trait ApplicationIdentityProvider: Send + Sync { + fn resolve(&self, pid: i64) -> Option; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum HistoryPayload { + Control { + operation: HistoryControlOperation, + }, + ActionStarted, + ActionCompleted { + effect: String, + route: String, + #[serde(skip_serializing_if = "Option::is_none")] + delivery: Option, + #[serde(skip_serializing_if = "Option::is_none")] + delivered_count: Option, + evidence_kinds: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + escalation_kind: Option, + }, + Session { + phase: String, + }, + Access { + operation: String, + returned_events: u32, + }, + Health { + category: HistoryHealthCategory, + count: u64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HistoryControlOperation { + Enable, + Disable, + Pause, + Resume, + Flush, + Delete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryAccessOperation { + AgentQuery, + LocalCli, +} + +impl HistoryAccessOperation { + fn as_str(self) -> &'static str { + match self { + Self::AgentQuery => "agent_query", + Self::LocalCli => "local_cli", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HistoryEventData { + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub action_id: Option, + pub sequence: u64, + pub platform: String, + pub process_model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub capability: Option, + pub caller_category: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub application: Option, + pub payload: HistoryPayload, +} + +/// CloudEvents 1.0 JSON envelope used as the plaintext inside COSE_Encrypt0. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HistoryEvent { + pub specversion: String, + pub id: String, + pub source: String, + #[serde(rename = "type")] + pub event_type: String, + pub subject: String, + pub time: String, + pub datacontenttype: String, + pub dataschema: String, + pub data: HistoryEventData, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PersistedState { + enabled: bool, + paused: bool, +} + +impl Default for PersistedState { + fn default() -> Self { + Self { + enabled: false, + paused: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct HistoryConfig { + pub root: PathBuf, + pub namespace: String, + pub admitted: bool, + pub platform: String, + pub retention_days: u64, + pub quota_bytes: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HistoryStatus { + pub supported: bool, + pub admitted: bool, + pub enabled: bool, + pub paused: bool, + pub encrypted: bool, + pub profile: &'static str, + pub retention_days: u64, + pub quota_bytes: u64, + pub bytes_used: u64, + pub dropped_events: u64, + pub health: HistoryHealthCategory, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HistoryQuery { + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub since_sequence: Option, + #[serde(default)] + pub until_sequence: Option, +} + +#[derive(Debug, Clone)] +pub struct PendingHistoryAction { + action_id: String, + session_id: Option, + capability: String, + application_pid: Option, +} + +enum WriterMessage { + Event { + event: HistoryEvent, + application_pid: Option, + }, + Flush(mpsc::Sender>), + ReadSnapshot { + retention_days: u64, + response: mpsc::Sender, HistoryError>>, + }, + Shutdown(mpsc::Sender<()>), +} + +struct WriterHandle { + tx: SyncSender, + join: Option>, +} + +impl WriterHandle { + fn shutdown(mut self) { + let (tx, rx) = mpsc::channel(); + let _ = self.tx.send(WriterMessage::Shutdown(tx)); + let _ = rx.recv_timeout(Duration::from_secs(5)); + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + +fn flush_sender(sender: &SyncSender) -> Result<(), HistoryError> { + let (tx, rx) = mpsc::channel(); + sender + .send(WriterMessage::Flush(tx)) + .map_err(|_| HistoryError::new(HistoryHealthCategory::WriterStopped))?; + rx.recv_timeout(Duration::from_secs(5)) + .map_err(|_| HistoryError::new(HistoryHealthCategory::WriterStopped))? +} + +pub struct HistoryManager { + config: HistoryConfig, + key_provider: Arc, + app_provider: Option>, + stream_id: String, + session_id_key: Mutex>>>, + enabled: AtomicBool, + paused: AtomicBool, + dropped_events: Arc, + pending_dropped_events: Arc, + next_sequence: AtomicU64, + health: Arc>, + writer: Mutex>, + control: Mutex<()>, +} + +impl HistoryManager { + pub fn new( + config: HistoryConfig, + key_provider: Arc, + app_provider: Option>, + ) -> Arc { + let persisted = read_state(&config.root).unwrap_or_default(); + let manager = Arc::new(Self { + stream_id: opaque_id(&format!("{}:{}", config.namespace, uuid::Uuid::new_v4())), + session_id_key: Mutex::new(None), + enabled: AtomicBool::new(persisted.enabled && config.admitted), + paused: AtomicBool::new(persisted.paused), + config, + key_provider, + app_provider, + dropped_events: Arc::new(AtomicU64::new(0)), + pending_dropped_events: Arc::new(AtomicU64::new(0)), + next_sequence: AtomicU64::new(1), + health: Arc::new(Mutex::new(if persisted.enabled { + HistoryHealthCategory::WriterStopped + } else { + HistoryHealthCategory::Disabled + })), + writer: Mutex::new(None), + control: Mutex::new(()), + }); + if manager.enabled.load(Ordering::Acquire) { + match manager.start_writer(None) { + Ok(()) => { + *manager.health.lock().unwrap() = if manager.paused.load(Ordering::Acquire) { + HistoryHealthCategory::Paused + } else { + HistoryHealthCategory::Ready + }; + } + Err(error) => { + *manager.health.lock().unwrap() = error.category; + manager.enabled.store(false, Ordering::Release); + } + } + } else if !manager.config.admitted && persisted.enabled { + *manager.health.lock().unwrap() = HistoryHealthCategory::NotAdmitted; + } + manager + } + + pub fn status(&self) -> HistoryStatus { + HistoryStatus { + supported: self.config.platform == "macos", + admitted: self.config.admitted, + enabled: self.enabled.load(Ordering::Acquire), + paused: self.paused.load(Ordering::Acquire), + encrypted: true, + profile: "cua-history-profile-v1/cbor-sequence+cose-encrypt0+cloudevents-json", + retention_days: self.config.retention_days, + quota_bytes: self.config.quota_bytes, + bytes_used: directory_bytes(&self.config.root), + dropped_events: self.dropped_events.load(Ordering::Relaxed), + health: *self.health.lock().unwrap(), + } + } + + pub fn enable(&self) -> Result { + let _control = self.control.lock().unwrap(); + if !self.config.admitted { + return Err(HistoryError::new(HistoryHealthCategory::NotAdmitted)); + } + if self.enabled.load(Ordering::Acquire) { + return Ok(self.status()); + } + self.paused.store(false, Ordering::Release); + let event = self.control_event(HistoryControlOperation::Enable); + self.start_writer(Some(event))?; + if let Err(error) = self.persist_state_values(true, false) { + self.stop_writer(); + *self.health.lock().unwrap() = error.category; + return Err(error); + } + self.enabled.store(true, Ordering::Release); + *self.health.lock().unwrap() = HistoryHealthCategory::Ready; + Ok(self.status()) + } + + pub fn disable(&self) -> Result { + let _control = self.control.lock().unwrap(); + if self.enabled.load(Ordering::Acquire) { + // Close the producer gate before persistence or writer shutdown. + self.enabled.store(false, Ordering::Release); + if let Err(error) = self.persist_state_values(false, false) { + self.enabled.store(true, Ordering::Release); + return Err(error); + } + // A poisoned or full writer must never trap the user in an + // enabled state. The durable disabled state and producer gate are + // authoritative; the terminal writer health already accounts for + // any lifecycle marker it cannot append. + let _ = self.send_and_flush(self.control_event(HistoryControlOperation::Disable)); + self.stop_writer(); + } else { + self.persist_state_values(false, false)?; + } + self.enabled.store(false, Ordering::Release); + self.paused.store(false, Ordering::Release); + *self.health.lock().unwrap() = HistoryHealthCategory::Disabled; + Ok(self.status()) + } + + pub fn pause(&self) -> Result { + let _control = self.control.lock().unwrap(); + self.require_enabled()?; + self.persist_state_values(true, true)?; + self.paused.store(true, Ordering::Release); + if let Err(error) = self.send_and_flush(self.control_event(HistoryControlOperation::Pause)) + { + self.paused.store(false, Ordering::Release); + let _ = self.persist_state_values(true, false); + return Err(error); + } + *self.health.lock().unwrap() = HistoryHealthCategory::Paused; + Ok(self.status()) + } + + pub fn resume(&self) -> Result { + let _control = self.control.lock().unwrap(); + self.require_enabled()?; + self.persist_state_values(true, false)?; + if let Err(error) = self.send_and_flush(self.control_event(HistoryControlOperation::Resume)) + { + let _ = self.persist_state_values(true, true); + return Err(error); + } + // Keep producers gated until the lossless resume marker is durable. + self.paused.store(false, Ordering::Release); + *self.health.lock().unwrap() = HistoryHealthCategory::Ready; + Ok(self.status()) + } + + pub fn flush(&self) -> Result { + let _control = self.control.lock().unwrap(); + self.require_enabled()?; + self.send_and_flush(self.control_event(HistoryControlOperation::Flush))?; + Ok(self.status()) + } + + pub fn delete_all(&self) -> Result { + let _control = self.control.lock().unwrap(); + if self.enabled.load(Ordering::Acquire) { + self.enabled.store(false, Ordering::Release); + self.paused.store(false, Ordering::Release); + // Deletion is the recovery path for an unhealthy store. A failed + // audit append cannot prevent writer shutdown, key destruction, + // and removal of the unreadable ciphertext. + let _ = self.send_and_flush(self.control_event(HistoryControlOperation::Delete)); + self.stop_writer(); + } + self.enabled.store(false, Ordering::Release); + self.paused.store(false, Ordering::Release); + self.persist_state_values(false, false)?; + let _writer_lease = WriterLease::acquire(&self.config.root)?; + // The key provider is the authority for namespace key enumeration. + // Do not parse untrusted chunk headers during an explicit delete: a + // torn or corrupt first CBOR item must not prevent the user from + // destroying the keys and removing the unreadable ciphertext. + let references = self.key_provider.references(&self.config.namespace)?; + for reference in references { + if let Err(error) = self + .key_provider + .destroy(&self.config.namespace, &reference) + { + *self.health.lock().unwrap() = error.category; + return Err(error); + } + } + if !self + .key_provider + .references(&self.config.namespace)? + .is_empty() + { + let error = HistoryError::new(HistoryHealthCategory::KeyDestroyFailed); + *self.health.lock().unwrap() = error.category; + return Err(error); + } + *self.session_id_key.lock().unwrap() = None; + if let Err(error) = remove_history_files(&self.config.root) { + *self.health.lock().unwrap() = error.category; + return Err(error); + } + self.next_sequence.store(1, Ordering::Release); + *self.health.lock().unwrap() = HistoryHealthCategory::Disabled; + Ok(self.status()) + } + + pub fn query( + &self, + query: HistoryQuery, + audit_operation: HistoryAccessOperation, + ) -> Result, HistoryError> { + let _control = self.control.lock().unwrap(); + self.require_admitted()?; + // The active writer performs the checkpoint and read as one queued + // operation, so it cannot append a partial item while scanning. Keep + // the handle lock while waiting; producers use try_lock and drop rather + // than blocking behind the query. + let writer_guard = self.writer.lock().unwrap(); + let writer_active = writer_guard.is_some(); + let (mut events, one_shot_lease) = if let Some(writer) = writer_guard.as_ref() { + ( + read_snapshot_sender(&writer.tx, self.config.retention_days)?, + None, + ) + } else { + let lease = WriterLease::acquire(&self.config.root)?; + prune_expired_chunks(&self.config.root, self.config.retention_days)?; + ( + HistoryStore::read_all( + &self.config.root, + &self.config.namespace, + self.key_provider.as_ref(), + self.config.quota_bytes, + )?, + Some(lease), + ) + }; + drop(writer_guard); + let next_sequence = events + .iter() + .map(|event| event.data.sequence) + .max() + .unwrap_or(0) + .saturating_add(1); + self.next_sequence + .fetch_max(next_sequence, Ordering::Relaxed); + let session = query + .session_id + .as_deref() + .map(|value| self.session_id(value)) + .transpose()?; + let retention_cutoff = OffsetDateTime::now_utc() + - time::Duration::seconds( + i64::try_from(self.config.retention_days.saturating_mul(24 * 60 * 60)) + .unwrap_or(i64::MAX), + ); + events.retain(|event| { + self.config.retention_days > 0 + && OffsetDateTime::parse(&event.time, &Rfc3339) + .is_ok_and(|event_time| event_time >= retention_cutoff) + && session.as_ref().is_none_or(|session| { + event.data.session_id.as_ref() == Some(session) + || event.data.session_id.as_deref() == query.session_id.as_deref() + }) + && query + .since_sequence + .is_none_or(|since| event.data.sequence >= since) + && query + .until_sequence + .is_none_or(|until| event.data.sequence <= until) + }); + let limit = query.limit.unwrap_or(50).clamp(1, MAX_QUERY_LIMIT); + if events.len() > limit { + events.drain(0..events.len() - limit); + } + if events.is_empty() { + return Ok(events); + } + let mut audit = self.event( + "cua-driver.history.access.v0", + "access", + None, + None, + None, + HistoryPayload::Access { + operation: audit_operation.as_str().to_owned(), + returned_events: events.len() as u32, + }, + ); + if writer_active { + self.send_and_flush(audit)?; + } else { + let mut store = HistoryStore::create_with_lease( + &self.config.root, + &self.config.namespace, + &self.stream_id, + self.config.quota_bytes, + self.key_provider.as_ref(), + one_shot_lease.expect("disabled query acquired a writer lease"), + )?; + audit.data.sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); + store.append(&audit)?; + store.flush()?; + } + Ok(events) + } + + pub fn begin_action( + &self, + tool_name: &str, + args: &Value, + implicit_session: Option<&str>, + ) -> Option { + if !self.is_capturing() || !crate::action_record::is_action_tool(tool_name) { + return None; + } + let action_id = opaque_id(&uuid::Uuid::new_v4().to_string()); + let session_id = args + .get("session") + .and_then(Value::as_str) + .or(implicit_session) + .filter(|value| !value.is_empty()) + .and_then(|value| self.session_id(value).ok()); + let capability = crate::tool::default_capabilities_for(tool_name) + .into_iter() + .next() + .unwrap_or_else(|| format!("tool.{tool_name}")); + let application_pid = args + .get("pid") + .and_then(Value::as_i64) + .filter(|pid| *pid > 0); + let pending = PendingHistoryAction { + action_id, + session_id, + capability, + application_pid, + }; + self.try_send_with_application_pid( + self.event( + "cua-driver.history.action_started.v0", + &format!("action/{}", pending.action_id), + pending.session_id.clone(), + Some(pending.action_id.clone()), + Some(pending.capability.clone()), + HistoryPayload::ActionStarted, + ), + pending.application_pid, + ); + Some(pending) + } + + pub fn finish_action( + &self, + pending: PendingHistoryAction, + record: Option<&ActionExecutionRecord>, + failed: bool, + ) { + // A pending action owns its admission decision. Pause suppresses new + // begins, but must not split an already-started action pair. Disable + // still closes the writer and therefore drops the completion safely. + if !self.enabled.load(Ordering::Acquire) { + return; + } + let payload = match record.and_then(|record| record.stable_projection().ok()) { + Some(projection) => HistoryPayload::ActionCompleted { + effect: effect_name(projection.effect).to_owned(), + route: route_name(projection.route).to_owned(), + delivery: projection + .delivery + .as_ref() + .map(|delivery| delivery_name(delivery.actual).to_owned()), + delivered_count: projection + .delivery + .and_then(|delivery| delivery.delivered_count), + evidence_kinds: projection + .evidence + .unwrap_or_default() + .into_iter() + .map(|evidence| evidence_name(evidence.kind).to_owned()) + .collect(), + escalation_kind: projection + .escalation + .map(|escalation| escalation_name(escalation.kind).to_owned()), + }, + None => HistoryPayload::ActionCompleted { + effect: if failed { "failed" } else { "unverifiable" }.to_owned(), + route: "unknown".to_owned(), + delivery: None, + delivered_count: None, + evidence_kinds: Vec::new(), + escalation_kind: None, + }, + }; + self.try_send_with_application_pid( + self.event( + "cua-driver.history.action_completed.v0", + &format!("action/{}", pending.action_id), + pending.session_id, + Some(pending.action_id), + Some(pending.capability), + payload, + ), + pending.application_pid, + ); + } + + pub fn session_event(&self, session: Option<&str>, started: bool) { + if !self.is_capturing() { + return; + } + let session_id = session + .filter(|value| !value.is_empty()) + .and_then(|value| self.session_id(value).ok()); + self.try_send(self.event( + if started { + "cua-driver.history.session_started.v0" + } else { + "cua-driver.history.session_ended.v0" + }, + "session", + session_id, + None, + None, + HistoryPayload::Session { + phase: if started { "started" } else { "ended" }.to_owned(), + }, + )); + } + + fn is_capturing(&self) -> bool { + self.config.admitted + && self.enabled.load(Ordering::Acquire) + && !self.paused.load(Ordering::Acquire) + } + + fn require_enabled(&self) -> Result<(), HistoryError> { + self.require_admitted()?; + if !self.enabled.load(Ordering::Acquire) { + return Err(HistoryError::new(HistoryHealthCategory::Disabled)); + } + Ok(()) + } + + fn require_admitted(&self) -> Result<(), HistoryError> { + if !self.config.admitted { + return Err(HistoryError::new(HistoryHealthCategory::NotAdmitted)); + } + Ok(()) + } + + fn start_writer(&self, first_event: Option) -> Result<(), HistoryError> { + let mut slot = self.writer.lock().unwrap(); + if slot.is_some() { + return Ok(()); + } + self.ensure_session_id_key()?; + let writer_lease = WriterLease::acquire(&self.config.root)?; + prune_expired_chunks(&self.config.root, self.config.retention_days)?; + let existing = HistoryStore::read_all( + &self.config.root, + &self.config.namespace, + self.key_provider.as_ref(), + self.config.quota_bytes, + )?; + let max_sequence = existing + .iter() + .map(|event| event.data.sequence) + .max() + .unwrap_or(0); + let mut store = HistoryStore::create_with_lease( + &self.config.root, + &self.config.namespace, + &self.stream_id, + self.config.quota_bytes, + self.key_provider.as_ref(), + writer_lease, + )?; + self.next_sequence + .store(max_sequence.saturating_add(1), Ordering::Release); + if let Some(mut event) = first_event { + event.data.sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); + validate_event(&event)?; + store.append(&event)?; + let verified = store.read_events()?; + if verified.last().map(|event| &event.id) != Some(&event.id) { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + } + let (tx, rx) = mpsc::sync_channel(WRITER_QUEUE_CAPACITY); + let thread_health = self.health.clone(); + let thread_dropped_events = self.dropped_events.clone(); + let thread_pending_dropped_events = self.pending_dropped_events.clone(); + let thread_app_provider = self.app_provider.clone(); + let thread_key_provider = self.key_provider.clone(); + let thread_namespace = self.config.namespace.clone(); + let quota_bytes = self.config.quota_bytes; + let retention_days = self.config.retention_days; + let join = thread::Builder::new() + .name("cua-history-writer".to_owned()) + .spawn(move || { + writer_loop( + &mut store, + rx, + thread_health, + thread_dropped_events, + thread_pending_dropped_events, + thread_app_provider, + thread_key_provider, + thread_namespace, + quota_bytes, + retention_days, + ) + }) + .map_err(|_| HistoryError::new(HistoryHealthCategory::WriterStopped))?; + *slot = Some(WriterHandle { + tx, + join: Some(join), + }); + Ok(()) + } + + fn stop_writer(&self) { + if let Some(writer) = self.writer.lock().unwrap().take() { + writer.shutdown(); + } + } + + fn send_lossless(&self, mut event: HistoryEvent) -> Result<(), HistoryError> { + validate_event(&event)?; + let writer = self.writer.lock().unwrap(); + let tx = writer + .as_ref() + .map(|writer| &writer.tx) + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::WriterStopped))?; + event.data.sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); + tx.send(WriterMessage::Event { + event, + application_pid: None, + }) + .map_err(|_| HistoryError::new(HistoryHealthCategory::WriterStopped)) + } + + fn flush_writer(&self) -> Result<(), HistoryError> { + let sender = self + .writer + .lock() + .unwrap() + .as_ref() + .map(|writer| writer.tx.clone()) + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::WriterStopped))?; + flush_sender(&sender) + } + + fn send_and_flush(&self, event: HistoryEvent) -> Result<(), HistoryError> { + self.send_lossless(event)?; + self.flush_writer() + } + + fn try_send(&self, event: HistoryEvent) { + self.try_send_with_application_pid(event, None); + } + + fn try_send_with_application_pid(&self, mut event: HistoryEvent, application_pid: Option) { + if validate_event(&event).is_err() { + self.record_drop(); + return; + } + let Ok(writer) = self.writer.try_lock() else { + self.record_drop(); + return; + }; + let tx = writer.as_ref().map(|writer| &writer.tx); + let Some(tx) = tx else { + self.record_drop(); + return; + }; + let pending = self.pending_dropped_events.swap(0, Ordering::AcqRel); + if pending > 0 { + let health = self.event( + "cua-driver.history.health.v0", + "writer", + None, + None, + None, + HistoryPayload::Health { + category: HistoryHealthCategory::EventsDropped, + count: pending, + }, + ); + let mut health = health; + health.data.sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); + if tx + .try_send(WriterMessage::Event { + event: health, + application_pid: None, + }) + .is_err() + { + self.pending_dropped_events + .fetch_add(pending, Ordering::Relaxed); + } + } + event.data.sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); + if matches!( + tx.try_send(WriterMessage::Event { + event, + application_pid, + }), + Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) + ) { + self.record_drop(); + } + } + + fn record_drop(&self) { + self.dropped_events.fetch_add(1, Ordering::Relaxed); + self.pending_dropped_events.fetch_add(1, Ordering::Relaxed); + } + + fn ensure_session_id_key(&self) -> Result<(), HistoryError> { + let mut cached = self.session_id_key.lock().unwrap(); + if cached.is_none() { + let namespace_key = self.key_provider.load_or_create(&self.config.namespace)?; + if namespace_key.bytes.len() != 32 { + return Err(HistoryError::new(HistoryHealthCategory::KeyCorrupt)); + } + let hkdf = Hkdf::::new(None, namespace_key.bytes.as_slice()); + let mut derived = Zeroizing::new(vec![0_u8; 32]); + hkdf.expand(SESSION_ID_KEY_INFO, &mut derived) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyCorrupt))?; + *cached = Some(derived); + } + Ok(()) + } + + fn session_id(&self, value: &str) -> Result { + self.ensure_session_id_key()?; + let key = self.session_id_key.lock().unwrap(); + let key = key + .as_ref() + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::KeyUnavailable))?; + let mut mac = as Mac>::new_from_slice(key.as_slice()) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyCorrupt))?; + mac.update(value.as_bytes()); + Ok(hex_128(&mac.finalize().into_bytes()[..16])) + } + + fn control_event(&self, operation: HistoryControlOperation) -> HistoryEvent { + self.event( + "cua-driver.history.control.v0", + "control", + None, + None, + None, + HistoryPayload::Control { operation }, + ) + } + + fn event( + &self, + event_type: &str, + subject: &str, + session_id: Option, + action_id: Option, + capability: Option, + payload: HistoryPayload, + ) -> HistoryEvent { + HistoryEvent { + specversion: "1.0".to_owned(), + id: opaque_id(&uuid::Uuid::new_v4().to_string()), + source: format!("urn:cua-driver:history:{}", self.stream_id), + event_type: event_type.to_owned(), + subject: subject.to_owned(), + time: OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()), + datacontenttype: "application/json".to_owned(), + dataschema: HISTORY_SCHEMA_URN.to_owned(), + data: HistoryEventData { + session_id, + action_id, + // Persistence paths replace this placeholder while serializing + // enqueue order. Assigning here would let concurrent callers + // reserve sequence numbers and enqueue them out of order. + sequence: 1, + platform: self.config.platform.clone(), + process_model: "in_daemon".to_owned(), + capability, + caller_category: "cua_runtime".to_owned(), + application: None, + payload, + }, + } + } + + fn persist_state_values(&self, enabled: bool, paused: bool) -> Result<(), HistoryError> { + write_state(&self.config.root, &PersistedState { enabled, paused }) + } +} + +pub struct HistoryStatusTool { + manager: Arc, +} + +static HISTORY_STATUS_DEF: std::sync::OnceLock = std::sync::OnceLock::new(); + +impl HistoryStatusTool { + pub fn new(manager: Arc) -> Self { + Self { manager } + } +} + +#[async_trait] +impl Tool for HistoryStatusTool { + fn def(&self) -> &ToolDef { + HISTORY_STATUS_DEF.get_or_init(|| ToolDef { + name: "history_status".to_owned(), + description: "For prompts to continue or revisit recent work, call this first, before broad desktop discovery. Reports whether encrypted local history is admitted, enabled, healthy, paused, or dropping events. If history is absent or access is denied, continue with normal discovery. Returns metadata-only status, never history entries, screen or page content, geometry, or inferred user intent, and does not change history lifecycle state.".to_owned(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + read_only: true, + destructive: false, + idempotent: true, + open_world: false, + }) + } + + async fn invoke(&self, _args: Value) -> ToolResult { + let status = serde_json::to_value(self.manager.status()) + .expect("HistoryStatus contains only infallibly serializable fields"); + ToolResult::text("Computer History status returned as structured metadata.") + .with_structured(status) + } +} + +pub struct HistoryQueryTool { + manager: Arc, +} + +static HISTORY_QUERY_DEF: std::sync::OnceLock = std::sync::OnceLock::new(); + +impl HistoryQueryTool { + pub fn new(manager: Arc) -> Self { + Self { manager } + } +} + +#[async_trait] +impl Tool for HistoryQueryTool { + fn def(&self) -> &ToolDef { + HISTORY_QUERY_DEF.get_or_init(|| ToolDef { + name: "history_query".to_owned(), + description: "After history_status reports ready for a prompt to continue or revisit recent work, make one bounded initial query before broad desktop discovery. Reads metadata-only encrypted local history; it omits screen and page content, geometry, and inferred user intent. Results may enter model context. Requires the existing permission and capability-manifest checks for this exact operation; if history is absent or access is denied, continue with normal discovery. This read does not enable, pause, resume, disable, or delete history.".to_owned(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_QUERY_LIMIT}, + "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "since_sequence": {"type": "integer", "minimum": 1}, + "until_sequence": {"type": "integer", "minimum": 1} + }, + "additionalProperties": false + }), + read_only: true, + destructive: false, + idempotent: false, + open_world: false, + }) + } + + async fn invoke(&self, mut args: Value) -> ToolResult { + // Authorization deliberately reattaches trusted runtime/session + // metadata before dispatch. It is not part of history_query's public + // closed schema, so remove only reserved adapter fields before the + // deny_unknown_fields deserialization below. + crate::tool_args::sanitize_reserved_args(&mut args); + let query: HistoryQuery = match serde_json::from_value(args) { + Ok(query) => query, + Err(_) => { + return ToolResult::error("history_query arguments are invalid") + .with_structured(serde_json::json!({"code": "invalid_history_query"})) + } + }; + if query + .since_sequence + .zip(query.until_sequence) + .is_some_and(|(since, until)| since > until) + { + return ToolResult::error("since_sequence must not exceed until_sequence") + .with_structured(serde_json::json!({"code": "invalid_history_query_range"})); + } + match self + .manager + .query(query, HistoryAccessOperation::AgentQuery) + { + Ok(events) => ToolResult::text(format!( + "Returned {} encrypted Computer History metadata event(s).", + events.len() + )) + .with_structured(serde_json::json!({ + "events": events, + "metadata_only": true, + "model_context_disclosure": true + })), + Err(error) => { + ToolResult::error(format!("Computer History query refused: {}", error.code())) + .with_structured(serde_json::json!({"code": error.code()})) + } + } + } +} + +impl Drop for HistoryManager { + fn drop(&mut self) { + if let Some(writer) = self.writer.get_mut().ok().and_then(Option::take) { + writer.shutdown(); + } + } +} + +fn writer_loop( + store: &mut HistoryStore, + rx: mpsc::Receiver, + health: Arc>, + dropped_events: Arc, + pending_dropped_events: Arc, + app_provider: Option>, + key_provider: Arc, + namespace: String, + quota_bytes: u64, + retention_days: u64, +) { + let mut terminal_error = None; + let mut last_maintenance = Instant::now(); + loop { + let message = match rx.recv_timeout(WRITER_MAINTENANCE_INTERVAL) { + Ok(message) => message, + Err(mpsc::RecvTimeoutError::Timeout) => { + if terminal_error.is_none() { + if let Err(error) = store.checkpoint_retention(retention_days) { + terminal_error = Some(error.category); + *health.lock().unwrap() = error.category; + } + last_maintenance = Instant::now(); + } + continue; + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }; + match message { + WriterMessage::Event { + mut event, + application_pid, + } => { + if terminal_error.is_none() { + let maintenance_due = retention_days == 0 + || store.created_at.elapsed() >= CHUNK_ROTATION_INTERVAL + || last_maintenance.elapsed() >= WRITER_MAINTENANCE_INTERVAL; + if maintenance_due { + if let Err(error) = store.checkpoint_retention(retention_days) { + dropped_events.fetch_add(1, Ordering::Relaxed); + pending_dropped_events.fetch_add(1, Ordering::Relaxed); + terminal_error = Some(error.category); + *health.lock().unwrap() = error.category; + continue; + } + } + if maintenance_due { + last_maintenance = Instant::now(); + } + if let Some(pid) = application_pid { + event.data.application = app_provider + .as_ref() + .and_then(|provider| provider.resolve(pid)) + .and_then(sanitize_application_identity); + } + if let Err(error) = store.append(&event) { + dropped_events.fetch_add(1, Ordering::Relaxed); + pending_dropped_events.fetch_add(1, Ordering::Relaxed); + terminal_error = Some(error.category); + *health.lock().unwrap() = error.category; + } + } else { + dropped_events.fetch_add(1, Ordering::Relaxed); + pending_dropped_events.fetch_add(1, Ordering::Relaxed); + } + } + WriterMessage::Flush(response) => { + let result = terminal_error + .map(|category| Err(HistoryError::new(category))) + .unwrap_or_else(|| store.flush()); + let _ = response.send(result); + } + WriterMessage::ReadSnapshot { + retention_days, + response, + } => { + let result = terminal_error + .map(|category| Err(HistoryError::new(category))) + .unwrap_or_else(|| { + store.checkpoint_retention(retention_days)?; + HistoryStore::read_all( + &store.root, + &namespace, + key_provider.as_ref(), + quota_bytes, + ) + }); + let _ = response.send(result); + } + WriterMessage::Shutdown(response) => { + let _ = store.flush(); + let _ = response.send(()); + break; + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NoncePrefix([u8; 4]); + +impl NoncePrefix { + fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } + + fn as_mut_bytes(&mut self) -> &mut [u8; 4] { + &mut self.0 + } +} + +impl Serialize for NoncePrefix { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for NoncePrefix { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct NoncePrefixVisitor; + + impl<'de> serde::de::Visitor<'de> for NoncePrefixVisitor { + type Value = NoncePrefix; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a four-byte nonce prefix") + } + + fn visit_bytes(self, value: &[u8]) -> Result + where + E: serde::de::Error, + { + let bytes: [u8; 4] = value + .try_into() + .map_err(|_| E::invalid_length(value.len(), &self))?; + Ok(NoncePrefix(bytes)) + } + + fn visit_byte_buf(self, value: Vec) -> Result + where + E: serde::de::Error, + { + self.visit_bytes(&value) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut bytes = [0_u8; 4]; + for (index, byte) in bytes.iter_mut().enumerate() { + *byte = sequence + .next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(index, &self))?; + } + if sequence.next_element::()?.is_some() { + return Err(serde::de::Error::invalid_length(5, &self)); + } + Ok(NoncePrefix(bytes)) + } + } + + deserializer.deserialize_bytes(NoncePrefixVisitor) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ChunkHeader(u8, i64, u64, String, String, String, NoncePrefix); + +struct WriterLease { + _file: File, +} + +impl WriterLease { + fn acquire(root: &Path) -> Result { + prepare_history_root(root)?; + let file = secure_open_lock_file(&root.join("writer.lock"))?; + fs2::FileExt::try_lock_exclusive(&file) + .map_err(|_| HistoryError::new(HistoryHealthCategory::WriterStopped))?; + Ok(Self { _file: file }) + } +} + +struct HistoryStore { + root: PathBuf, + path: PathBuf, + file: File, + header: ChunkHeader, + header_bytes: Vec, + key: Zeroizing>, + next_nonce_counter: u64, + quota_bytes: u64, + created_at: Instant, + _writer_lease: WriterLease, +} + +impl HistoryStore { + fn create_with_lease( + root: &Path, + namespace: &str, + stream_id: &str, + quota_bytes: u64, + key_provider: &dyn KeyProvider, + writer_lease: WriterLease, + ) -> Result { + secure_create_dir(root)?; + let chunks = chunks_dir(root); + secure_create_dir(&chunks)?; + let key = key_provider.load_or_create(namespace)?; + if key.bytes.len() != 32 { + return Err(HistoryError::new(HistoryHealthCategory::KeyCorrupt)); + } + let chunk_id = opaque_id(&uuid::Uuid::new_v4().to_string()); + let mut prefix = [0_u8; 4]; + getrandom::fill(&mut prefix) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyUnavailable))?; + let header = ChunkHeader( + HISTORY_PROFILE_VERSION, + iana::Algorithm::ChaCha20Poly1305 as i64, + key.epoch, + key.reference.clone(), + stream_id.to_owned(), + chunk_id.clone(), + NoncePrefix(prefix), + ); + let mut header_bytes = Vec::new(); + ciborium::ser::into_writer(&header, &mut header_bytes) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + if directory_bytes(root).saturating_add(header_bytes.len() as u64) > quota_bytes { + return Err(HistoryError::new(HistoryHealthCategory::QuotaReached)); + } + let path = chunks.join(format!("{chunk_id}.cborseq")); + let mut file = secure_create_new_file(&path)?; + file.write_all(&header_bytes) + .and_then(|_| file.sync_data()) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + Ok(Self { + root: root.to_path_buf(), + path, + file, + header, + header_bytes, + key: key.bytes, + next_nonce_counter: 0, + quota_bytes, + created_at: Instant::now(), + _writer_lease: writer_lease, + }) + } + + fn append(&mut self, event: &HistoryEvent) -> Result<(), HistoryError> { + validate_event(event)?; + if directory_bytes(&self.root) >= self.quota_bytes { + return Err(HistoryError::new(HistoryHealthCategory::QuotaReached)); + } + let plaintext = serde_json::to_vec(event) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + let chunk_key = derive_chunk_key(&self.key, &self.header)?; + let mut nonce = [0_u8; 12]; + nonce[..4].copy_from_slice(self.header.6.as_bytes()); + nonce[4..].copy_from_slice(&self.next_nonce_counter.to_be_bytes()); + self.next_nonce_counter = self + .next_nonce_counter + .checked_add(1) + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + let cipher_key = Key::try_from(chunk_key.as_slice()) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyCorrupt))?; + let cipher = ChaCha20Poly1305::new(&cipher_key); + let protected = HeaderBuilder::new() + .algorithm(iana::Algorithm::ChaCha20Poly1305) + .build(); + let unprotected = HeaderBuilder::new().iv(nonce.to_vec()).build(); + let encrypted = CoseEncrypt0Builder::new() + .protected(protected) + .unprotected(unprotected) + .try_create_ciphertext(&plaintext, &self.header_bytes, |plaintext, aad| { + let nonce = + Nonce::try_from(nonce.as_slice()).map_err(|_| chacha20poly1305::Error)?; + cipher.encrypt( + &nonce, + Payload { + msg: plaintext, + aad, + }, + ) + }) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))? + .build(); + let bytes = encrypted + .to_tagged_vec() + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + if directory_bytes(&self.root).saturating_add(bytes.len() as u64) > self.quota_bytes { + return Err(HistoryError::new(HistoryHealthCategory::QuotaReached)); + } + self.file + .write_all(&bytes) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) + } + + fn flush(&mut self) -> Result<(), HistoryError> { + self.file + .flush() + .and_then(|_| self.file.sync_data()) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) + } + + fn checkpoint_retention(&mut self, retention_days: u64) -> Result<(), HistoryError> { + self.flush()?; + let rotation_due = self.next_nonce_counter > 0 + && (retention_days == 0 || self.created_at.elapsed() >= CHUNK_ROTATION_INTERVAL); + if rotation_due { + let chunk_id = opaque_id(&uuid::Uuid::new_v4().to_string()); + getrandom::fill(self.header.6.as_mut_bytes()) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyUnavailable))?; + self.header.5 = chunk_id.clone(); + self.header_bytes.clear(); + ciborium::ser::into_writer(&self.header, &mut self.header_bytes) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + self.path = chunks_dir(&self.root).join(format!("{chunk_id}.cborseq")); + self.file = secure_create_new_file(&self.path)?; + self.file + .write_all(&self.header_bytes) + .and_then(|_| self.file.sync_data()) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + self.next_nonce_counter = 0; + self.created_at = Instant::now(); + } + prune_expired_chunks_except(&self.root, retention_days, Some(&self.path)) + } + + fn read_events(&mut self) -> Result, HistoryError> { + self.flush()?; + let bytes = read_bounded(&self.path, self.quota_bytes)?; + let (_, _, events, _) = + decode_chunk_bytes(&bytes, &self.header, &self.header_bytes, &self.key)?; + Ok(events) + } + + fn read_all( + root: &Path, + namespace: &str, + key_provider: &dyn KeyProvider, + quota_bytes: u64, + ) -> Result, HistoryError> { + if directory_bytes(root) > quota_bytes { + return Err(HistoryError::new(HistoryHealthCategory::QuotaReached)); + } + let mut paths = history_chunk_paths(root)?; + paths.sort(); + let mut events = Vec::new(); + for path in paths { + let bytes = read_bounded(&path, quota_bytes)?; + let (_, _, mut decoded, _) = decode_chunk(&bytes, namespace, key_provider)?; + events.append(&mut decoded); + } + events.sort_by(|left, right| { + left.data + .sequence + .cmp(&right.data.sequence) + .then_with(|| left.source.cmp(&right.source)) + .then_with(|| left.id.cmp(&right.id)) + }); + let mut seen = std::collections::HashSet::new(); + events.retain(|event| seen.insert((event.source.clone(), event.id.clone()))); + if events.iter().any(|event| event.data.sequence == 0) + || events + .windows(2) + .any(|pair| pair[0].data.sequence >= pair[1].data.sequence) + { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + Ok(events) + } +} + +fn decode_chunk( + bytes: &[u8], + namespace: &str, + key_provider: &dyn KeyProvider, +) -> Result<(ChunkHeader, Vec, Vec, u64), HistoryError> { + let mut cursor = Cursor::new(bytes); + let header: ChunkHeader = ciborium::de::from_reader(&mut cursor) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + validate_header(&header)?; + let header_end = cursor.position() as usize; + let header_bytes = bytes[..header_end].to_vec(); + let key = key_provider.load(namespace, &header.3)?; + let (_, _, events, counter) = decode_chunk_bytes(bytes, &header, &header_bytes, &key.bytes)?; + Ok((header, header_bytes, events, counter)) +} + +fn decode_chunk_bytes( + bytes: &[u8], + header: &ChunkHeader, + header_bytes: &[u8], + key: &[u8], +) -> Result<(ChunkHeader, Vec, Vec, u64), HistoryError> { + let mut cursor = Cursor::new(bytes); + let _: ChunkHeader = ciborium::de::from_reader(&mut cursor) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + let chunk_key = derive_chunk_key(key, header)?; + let cipher_key = Key::try_from(chunk_key.as_slice()) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyCorrupt))?; + let cipher = ChaCha20Poly1305::new(&cipher_key); + let mut events = Vec::new(); + let mut expected_counter = 0_u64; + while (cursor.position() as usize) < bytes.len() { + let value: coset::cbor::value::Value = ciborium::de::from_reader(&mut cursor) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + let coset::cbor::value::Value::Tag(tag, value) = value else { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + }; + if tag != ::TAG { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + let encrypted = CoseEncrypt0::from_cbor_value(*value) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + validate_cose_headers(&encrypted)?; + let nonce = encrypted.unprotected.iv.as_slice(); + if nonce.len() != 12 || nonce[..4] != *header.6.as_bytes() { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + let counter = u64::from_be_bytes( + nonce[4..] + .try_into() + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?, + ); + if counter != expected_counter { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + expected_counter = expected_counter + .checked_add(1) + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + let plaintext = encrypted.decrypt_ciphertext( + header_bytes, + || HistoryError::new(HistoryHealthCategory::StorageCorrupt), + |ciphertext, aad| { + let nonce = Nonce::try_from(nonce) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + cipher + .decrypt( + &nonce, + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt)) + }, + )?; + let event: HistoryEvent = serde_json::from_slice(&plaintext) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + validate_event(&event)?; + events.push(event); + } + Ok(( + header.clone(), + header_bytes.to_vec(), + events, + expected_counter, + )) +} + +fn validate_cose_headers(encrypted: &CoseEncrypt0) -> Result<(), HistoryError> { + let protected = &encrypted.protected.header; + let unprotected = &encrypted.unprotected; + let protected_exact = protected.alg + == Some(coset::Algorithm::Assigned( + iana::Algorithm::ChaCha20Poly1305, + )) + && protected.crit.is_empty() + && protected.content_type.is_none() + && protected.key_id.is_empty() + && protected.iv.is_empty() + && protected.partial_iv.is_empty() + && protected.counter_signatures.is_empty() + && protected.rest.is_empty(); + let unprotected_exact = unprotected.alg.is_none() + && unprotected.crit.is_empty() + && unprotected.content_type.is_none() + && unprotected.key_id.is_empty() + && unprotected.iv.len() == 12 + && unprotected.partial_iv.is_empty() + && unprotected.counter_signatures.is_empty() + && unprotected.rest.is_empty(); + if !protected_exact || !unprotected_exact { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + Ok(()) +} + +fn validate_header(header: &ChunkHeader) -> Result<(), HistoryError> { + if header.0 != HISTORY_PROFILE_VERSION + || header.1 != iana::Algorithm::ChaCha20Poly1305 as i64 + || header.2 == 0 + || header.3.is_empty() + || header.3.len() > 128 + || header.4.is_empty() + || header.4.len() > 128 + || header.5.is_empty() + || header.5.len() > 128 + { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + Ok(()) +} + +fn derive_chunk_key(key: &[u8], header: &ChunkHeader) -> Result>, HistoryError> { + if key.len() != 32 { + return Err(HistoryError::new(HistoryHealthCategory::KeyCorrupt)); + } + let salt = decode_hex_128(&header.5) + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + let hkdf = Hkdf::::new(Some(&salt), key); + let mut info = Vec::with_capacity(CHUNK_KEY_INFO.len() + header.4.len() + 8); + info.extend_from_slice(CHUNK_KEY_INFO); + info.extend_from_slice(header.4.as_bytes()); + info.extend_from_slice(&header.2.to_be_bytes()); + let mut output = Zeroizing::new(vec![0_u8; 32]); + hkdf.expand(&info, &mut output) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyCorrupt))?; + Ok(output) +} + +fn validate_event(event: &HistoryEvent) -> Result<(), HistoryError> { + let allowed_type = matches!( + event.event_type.as_str(), + "cua-driver.history.control.v0" + | "cua-driver.history.action_started.v0" + | "cua-driver.history.action_completed.v0" + | "cua-driver.history.session_started.v0" + | "cua-driver.history.session_ended.v0" + | "cua-driver.history.access.v0" + | "cua-driver.history.health.v0" + ); + if event.specversion != "1.0" + || event.datacontenttype != "application/json" + || event.dataschema != HISTORY_SCHEMA_URN + || event + .source + .strip_prefix("urn:cua-driver:history:") + .and_then(decode_hex_128) + .is_none() + || !allowed_type + || decode_hex_128(&event.id).is_none() + || event.subject.len() > 160 + || event.data.sequence == 0 + || event.data.platform != "macos" + || event.data.process_model != "in_daemon" + || event.data.caller_category != "cua_runtime" + || event + .data + .session_id + .as_deref() + .is_some_and(|value| decode_hex_128(value).is_none()) + || event + .data + .action_id + .as_deref() + .is_some_and(|value| decode_hex_128(value).is_none()) + || event + .data + .capability + .as_deref() + .is_some_and(|value| !is_bounded_scalar(value, 128)) + || event.data.application.as_ref().is_some_and(|application| { + application + .bundle_id + .as_deref() + .is_some_and(|value| !is_bounded_scalar(value, 160)) + || application + .display_name + .as_deref() + .is_some_and(|value| !is_bounded_scalar(value, 120)) + }) + || !valid_payload_for_event(&event.event_type, &event.data.payload) + { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + let value = serde_json::to_value(event) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageCorrupt))?; + reject_forbidden_fields(&value)?; + Ok(()) +} + +fn valid_payload_for_event(event_type: &str, payload: &HistoryPayload) -> bool { + match (event_type, payload) { + ("cua-driver.history.control.v0", HistoryPayload::Control { .. }) + | ("cua-driver.history.action_started.v0", HistoryPayload::ActionStarted) => true, + ( + "cua-driver.history.action_completed.v0", + HistoryPayload::ActionCompleted { + effect, + route, + delivery, + evidence_kinds, + escalation_kind, + .. + }, + ) => { + matches!( + effect.as_str(), + "confirmed" | "partial" | "unverifiable" | "suspected_noop" | "refused" | "failed" + ) && matches!( + route.as_str(), + "accessibility" + | "synthetic_events" + | "global_input" + | "system_api" + | "dom" + | "trusted_input" + | "unknown" + ) && delivery.as_deref().is_none_or(|value| { + matches!( + value, + "background" | "foreground" | "not_applicable" | "unknown" + ) + }) && evidence_kinds.len() <= 16 + && evidence_kinds.iter().all(|value| { + matches!( + value.as_str(), + "accessibility_readback" + | "browser_readback" + | "value_readback" + | "window_change" + ) + }) + && escalation_kind.as_deref().is_none_or(|value| { + matches!( + value, + "activate_target" + | "retry_with_pixel_target" + | "retry_with_page_action" + | "refresh_page_state" + | "request_permission" + | "elevate_access" + | "expand_capture_scope" + | "prepare_session" + | "retry_with_foreground_delivery" + ) + }) + } + ("cua-driver.history.session_started.v0", HistoryPayload::Session { phase }) => { + phase == "started" + } + ("cua-driver.history.session_ended.v0", HistoryPayload::Session { phase }) => { + phase == "ended" + } + ("cua-driver.history.access.v0", HistoryPayload::Access { operation, .. }) => { + matches!(operation.as_str(), "agent_query" | "local_cli") + } + ("cua-driver.history.health.v0", HistoryPayload::Health { .. }) => true, + _ => false, + } +} + +fn is_bounded_scalar(value: &str, max: usize) -> bool { + !value.is_empty() && value.len() <= max && !value.contains(['\n', '\r']) +} + +fn reject_forbidden_fields(value: &Value) -> Result<(), HistoryError> { + const FORBIDDEN: &[&str] = &[ + "args", + "arguments", + "result", + "content", + "text", + "clipboard", + "path", + "url", + "title", + "screenshot", + "image", + "tree", + "selector", + "detail", + "keystroke", + "password", + "credential", + "token", + ]; + match value { + Value::Object(map) => { + for (key, value) in map { + let key = key.to_ascii_lowercase(); + if FORBIDDEN + .iter() + .any(|forbidden| key == *forbidden || key.ends_with(&format!("_{forbidden}"))) + { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + reject_forbidden_fields(value)?; + } + } + Value::Array(values) => { + for value in values { + reject_forbidden_fields(value)?; + } + } + Value::String(value) if value.len() > 256 => { + return Err(HistoryError::new(HistoryHealthCategory::StorageCorrupt)); + } + _ => {} + } + Ok(()) +} + +fn sanitize_application_identity(mut identity: ApplicationIdentity) -> Option { + identity.bundle_id = identity + .bundle_id + .and_then(|value| bounded_string(value, 160)); + identity.display_name = identity + .display_name + .and_then(|value| bounded_string(value, 120)); + (identity.bundle_id.is_some() || identity.display_name.is_some()).then_some(identity) +} + +fn bounded_string(value: String, max: usize) -> Option { + let value = value.trim(); + (!value.is_empty() && value.len() <= max && !value.contains(['\n', '\r'])) + .then(|| value.to_owned()) +} + +fn opaque_id(value: &str) -> String { + let digest = Sha256::digest(value.as_bytes()); + hex_128(&digest[..16]) +} + +fn hex_128(value: &[u8]) -> String { + value.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn decode_hex_128(value: &str) -> Option<[u8; 16]> { + if value.len() != 32 || !value.is_ascii() { + return None; + } + let mut decoded = [0_u8; 16]; + for (index, byte) in decoded.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16).ok()?; + } + Some(decoded) +} + +fn effect_name(value: ActionEffect) -> &'static str { + match value { + ActionEffect::Confirmed => "confirmed", + ActionEffect::Partial => "partial", + ActionEffect::Unverifiable => "unverifiable", + ActionEffect::SuspectedNoop => "suspected_noop", + ActionEffect::Refused => "refused", + } +} + +fn route_name(value: ActionRoute) -> &'static str { + match value { + ActionRoute::Accessibility => "accessibility", + ActionRoute::SyntheticEvents => "synthetic_events", + ActionRoute::GlobalInput => "global_input", + ActionRoute::SystemApi => "system_api", + ActionRoute::Dom => "dom", + ActionRoute::TrustedInput => "trusted_input", + } +} + +fn delivery_name(value: ActualDelivery) -> &'static str { + match value { + ActualDelivery::Background => "background", + ActualDelivery::Foreground => "foreground", + ActualDelivery::NotApplicable => "not_applicable", + ActualDelivery::Unknown => "unknown", + } +} + +fn evidence_name(value: ProjectedEvidenceKind) -> &'static str { + match value { + ProjectedEvidenceKind::AccessibilityReadback => "accessibility_readback", + ProjectedEvidenceKind::BrowserReadback => "browser_readback", + ProjectedEvidenceKind::ValueReadback => "value_readback", + ProjectedEvidenceKind::WindowChange => "window_change", + } +} + +fn escalation_name(value: EscalationKind) -> &'static str { + match value { + EscalationKind::ActivateTarget => "activate_target", + EscalationKind::RetryWithPixelTarget => "retry_with_pixel_target", + EscalationKind::RetryWithPageAction => "retry_with_page_action", + EscalationKind::RefreshPageState => "refresh_page_state", + EscalationKind::RequestPermission => "request_permission", + EscalationKind::ElevateAccess => "elevate_access", + EscalationKind::ExpandCaptureScope => "expand_capture_scope", + EscalationKind::PrepareSession => "prepare_session", + EscalationKind::RetryWithForegroundDelivery => "retry_with_foreground_delivery", + } +} + +fn chunks_dir(root: &Path) -> PathBuf { + root.join("chunks") +} + +fn state_path(root: &Path) -> PathBuf { + root.join("state.json") +} + +fn read_state(root: &Path) -> Option { + let bytes = fs::read(state_path(root)).ok()?; + (bytes.len() <= 4096) + .then(|| serde_json::from_slice(&bytes).ok()) + .flatten() +} + +fn write_state(root: &Path, state: &PersistedState) -> Result<(), HistoryError> { + prepare_history_root(root)?; + let temporary = root.join("state.json.tmp"); + if temporary.exists() { + fs::remove_file(&temporary) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + } + let bytes = serde_json::to_vec(state) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + let mut file = secure_create_new_file(&temporary)?; + file.write_all(&bytes) + .and_then(|_| file.sync_data()) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + fs::rename(&temporary, state_path(root)) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) +} + +fn history_chunk_paths(root: &Path) -> Result, HistoryError> { + let directory = chunks_dir(root); + if !directory.exists() { + return Ok(Vec::new()); + } + let entries = fs::read_dir(directory) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + let mut paths = Vec::new(); + for entry in entries { + let entry = + entry.map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) == Some("cborseq") + && entry + .file_type() + .map(|kind| kind.is_file()) + .unwrap_or(false) + { + paths.push(path); + } + } + Ok(paths) +} + +fn remove_history_files(root: &Path) -> Result<(), HistoryError> { + for path in history_chunk_paths(root)? { + fs::remove_file(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + } + Ok(()) +} + +fn prune_expired_chunks(root: &Path, retention_days: u64) -> Result<(), HistoryError> { + prune_expired_chunks_except(root, retention_days, None) +} + +fn prune_expired_chunks_except( + root: &Path, + retention_days: u64, + except: Option<&Path>, +) -> Result<(), HistoryError> { + let retention = Duration::from_secs(retention_days.saturating_mul(24 * 60 * 60)); + let now = SystemTime::now(); + for path in history_chunk_paths(root)? { + if except.is_some_and(|except| except == path) { + continue; + } + let metadata = fs::metadata(&path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + let modified = metadata + .modified() + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + let expired = now + .duration_since(modified) + .map(|age| age >= retention) + .unwrap_or(false); + if expired { + fs::remove_file(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + } + } + Ok(()) +} + +fn read_snapshot_sender( + sender: &SyncSender, + retention_days: u64, +) -> Result, HistoryError> { + let (tx, rx) = mpsc::channel(); + sender + .send(WriterMessage::ReadSnapshot { + retention_days, + response: tx, + }) + .map_err(|_| HistoryError::new(HistoryHealthCategory::WriterStopped))?; + rx.recv_timeout(Duration::from_secs(5)) + .map_err(|_| HistoryError::new(HistoryHealthCategory::WriterStopped))? +} + +fn directory_bytes(root: &Path) -> u64 { + history_chunk_paths(root) + .unwrap_or_default() + .into_iter() + .filter_map(|path| fs::metadata(path).ok().map(|metadata| metadata.len())) + .sum() +} + +fn read_bounded(path: &Path, limit: u64) -> Result, HistoryError> { + let metadata = fs::metadata(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + if metadata.len() > limit.max(1) { + return Err(HistoryError::new(HistoryHealthCategory::QuotaReached)); + } + let mut file = File::open(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.read_to_end(&mut bytes) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + Ok(bytes) +} + +#[cfg(unix)] +fn secure_create_dir(path: &Path) -> Result<(), HistoryError> { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true).mode(0o700); + builder + .create(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable))?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) +} + +#[cfg(not(unix))] +fn secure_create_dir(path: &Path) -> Result<(), HistoryError> { + fs::create_dir_all(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) +} + +#[cfg(unix)] +fn secure_create_new_file(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) +} + +#[cfg(not(unix))] +fn secure_create_new_file(path: &Path) -> Result { + OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) +} + +#[cfg(unix)] +fn secure_open_lock_file(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) +} + +#[cfg(not(unix))] +fn secure_open_lock_file(path: &Path) -> Result { + OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(path) + .map_err(|_| HistoryError::new(HistoryHealthCategory::StorageUnavailable)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[derive(Default)] + struct MemoryKeyProvider { + keys: Mutex>>, + load_threads: Mutex>>, + } + + impl KeyProvider for MemoryKeyProvider { + fn load_or_create(&self, namespace: &str) -> Result { + let reference = format!("{namespace}.history.v1"); + let mut keys = self.keys.lock().unwrap(); + let bytes = keys.entry(reference.clone()).or_insert_with(|| { + let mut bytes = vec![0_u8; 32]; + getrandom::fill(&mut bytes).expect("test random key"); + bytes + }); + Ok(HistoryKey { + reference, + epoch: 1, + bytes: Zeroizing::new(bytes.clone()), + }) + } + + fn load(&self, _namespace: &str, reference: &str) -> Result { + self.load_threads + .lock() + .unwrap() + .push(thread::current().name().map(std::borrow::ToOwned::to_owned)); + let bytes = self + .keys + .lock() + .unwrap() + .get(reference) + .cloned() + .ok_or_else(|| HistoryError::new(HistoryHealthCategory::KeyUnavailable))?; + Ok(HistoryKey { + reference: reference.to_owned(), + epoch: 1, + bytes: Zeroizing::new(bytes), + }) + } + + fn destroy(&self, _namespace: &str, reference: &str) -> Result<(), HistoryError> { + self.keys.lock().unwrap().remove(reference); + Ok(()) + } + + fn references(&self, namespace: &str) -> Result, HistoryError> { + let prefix = format!("{namespace}.history."); + Ok(self + .keys + .lock() + .unwrap() + .keys() + .filter(|reference| reference.starts_with(&prefix)) + .cloned() + .collect()) + } + } + + fn config(root: &Path) -> HistoryConfig { + HistoryConfig { + root: root.to_path_buf(), + namespace: "test".to_owned(), + admitted: true, + platform: "macos".to_owned(), + retention_days: DEFAULT_RETENTION_DAYS, + quota_bytes: DEFAULT_QUOTA_BYTES, + } + } + + #[test] + fn history_tool_descriptions_define_bounded_history_first_consultation() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + let status = HistoryStatusTool::new(Arc::clone(&manager)); + let query = HistoryQueryTool::new(manager); + let status_description = &status.def().description; + let query_description = &query.def().description; + + for trigger in ["continue", "recent work"] { + assert!(status_description.contains(trigger)); + assert!(query_description.contains(trigger)); + } + assert!( + query_description.find("history_status") + < query_description.find("one bounded initial query") + ); + assert!( + query_description.find("one bounded initial query") + < query_description.find("broad desktop discovery") + ); + for boundary in [ + "metadata-only", + "screen and page content", + "geometry", + "inferred user intent", + "model context", + ] { + assert!(query_description.contains(boundary), "missing {boundary}"); + } + assert!(status_description.contains("never history entries")); + assert!(status_description.contains("does not change history lifecycle state")); + assert!(query_description.contains("does not enable, pause, resume, disable, or delete")); + for fallback in ["absent", "access is denied", "normal discovery"] { + assert!(status_description.contains(fallback)); + assert!(query_description.contains(fallback)); + } + } + + #[test] + fn history_consultation_keeps_closed_schemas_and_annotations() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + let status = HistoryStatusTool::new(Arc::clone(&manager)); + let query = HistoryQueryTool::new(manager); + + assert_eq!( + status.def().input_schema, + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + ); + assert_eq!( + query.def().input_schema, + serde_json::json!({ + "type": "object", + "properties": { + "limit": {"type": "integer", "minimum": 1, "maximum": MAX_QUERY_LIMIT}, + "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "since_sequence": {"type": "integer", "minimum": 1}, + "until_sequence": {"type": "integer", "minimum": 1} + }, + "additionalProperties": false + }) + ); + assert_eq!( + ( + status.def().read_only, + status.def().destructive, + status.def().idempotent, + status.def().open_world, + ), + (true, false, true, false) + ); + assert_eq!( + ( + query.def().read_only, + query.def().destructive, + query.def().idempotent, + query.def().open_world, + ), + (true, false, false, false) + ); + } + + #[tokio::test] + async fn history_query_ignores_trusted_mcp_session_metadata() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + manager.enable().unwrap(); + let result = HistoryQueryTool::new(manager) + .invoke(serde_json::json!({ + "limit": 1, + "_session_id": "trusted-runtime-session", + "_transport_session_id": "trusted-transport-session" + })) + .await; + + assert_ne!(result.is_error, Some(true)); + assert_eq!( + result + .structured_content + .as_ref() + .and_then(|value| value.get("metadata_only")), + Some(&Value::Bool(true)) + ); + } + + #[test] + fn encrypted_store_contains_no_event_plaintext_and_roundtrips() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys, None); + manager.enable().unwrap(); + manager.pause().unwrap(); + manager.resume().unwrap(); + manager.flush().unwrap(); + let events = manager + .query( + HistoryQuery { + limit: Some(20), + ..Default::default() + }, + HistoryAccessOperation::LocalCli, + ) + .unwrap(); + assert!(events.iter().any(|event| matches!( + event.data.payload, + HistoryPayload::Control { + operation: HistoryControlOperation::Enable + } + ))); + let bytes = fs::read(history_chunk_paths(temp.path()).unwrap().pop().unwrap()).unwrap(); + let rendered = String::from_utf8_lossy(&bytes); + assert!(!rendered.contains("action_started")); + assert!(!rendered.contains("test_query")); + } + + #[test] + fn implicit_sessions_use_keyed_ids_that_can_be_queried_as_opaque_ids() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + manager.enable().unwrap(); + assert!(manager + .begin_action( + "click", + &serde_json::json!({}), + Some("private-runtime-session"), + ) + .is_some()); + manager.flush().unwrap(); + let all = manager + .query( + HistoryQuery { + limit: Some(MAX_QUERY_LIMIT), + ..Default::default() + }, + HistoryAccessOperation::LocalCli, + ) + .unwrap(); + let opaque = all + .iter() + .find_map(|event| event.data.session_id.clone()) + .expect("captured action has a session id"); + assert_ne!(opaque, "private-runtime-session"); + assert!(decode_hex_128(&opaque).is_some()); + let filtered = manager + .query( + HistoryQuery { + limit: Some(MAX_QUERY_LIMIT), + session_id: Some(opaque.clone()), + ..Default::default() + }, + HistoryAccessOperation::LocalCli, + ) + .unwrap(); + assert!(!filtered.is_empty()); + assert!(filtered + .iter() + .all(|event| event.data.session_id.as_deref() == Some(opaque.as_str()))); + } + + #[test] + fn malformed_multibyte_hex_is_rejected_without_panicking() { + let malformed = format!("€{}", "a".repeat(29)); + assert_eq!(malformed.len(), 32); + assert_eq!(decode_hex_128(&malformed), None); + } + + #[test] + fn chunk_header_encodes_nonce_prefix_as_cbor_bytes() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + manager.enable().unwrap(); + manager.disable().unwrap(); + let path = history_chunk_paths(temp.path()).unwrap().pop().unwrap(); + let value: coset::cbor::value::Value = + ciborium::de::from_reader(File::open(path).unwrap()).unwrap(); + let coset::cbor::value::Value::Array(fields) = value else { + panic!("history header must be a CBOR array") + }; + assert!(matches!( + fields.get(6), + Some(coset::cbor::value::Value::Bytes(prefix)) if prefix.len() == 4 + )); + } + + fn legacy_header_bytes(header: &ChunkHeader) -> Vec { + use coset::cbor::value::{Integer, Value as CborValue}; + + let legacy = CborValue::Array(vec![ + CborValue::Integer(Integer::from(header.0)), + CborValue::Integer(Integer::from(header.1)), + CborValue::Integer(Integer::from(header.2)), + CborValue::Text(header.3.clone()), + CborValue::Text(header.4.clone()), + CborValue::Text(header.5.clone()), + CborValue::Array( + header + .6 + .as_bytes() + .iter() + .copied() + .map(|byte| CborValue::Integer(Integer::from(byte))) + .collect(), + ), + ]); + let mut bytes = Vec::new(); + ciborium::ser::into_writer(&legacy, &mut bytes).unwrap(); + bytes + } + + #[test] + fn chunk_header_reads_legacy_nonce_prefix_array_and_rewrites_canonical_bytes() { + use coset::cbor::value::Value as CborValue; + + let header = ChunkHeader( + HISTORY_PROFILE_VERSION, + iana::Algorithm::ChaCha20Poly1305 as i64, + 1, + "test.history.key".to_owned(), + "0123456789abcdef0123456789abcdef".to_owned(), + "fedcba9876543210fedcba9876543210".to_owned(), + NoncePrefix([0x12, 0x34, 0x56, 0x78]), + ); + let legacy_bytes = legacy_header_bytes(&header); + + let decoded: ChunkHeader = ciborium::de::from_reader(legacy_bytes.as_slice()).unwrap(); + assert_eq!(decoded.6.as_bytes(), &[0x12, 0x34, 0x56, 0x78]); + + let mut canonical_bytes = Vec::new(); + ciborium::ser::into_writer(&decoded, &mut canonical_bytes).unwrap(); + let canonical: CborValue = ciborium::de::from_reader(canonical_bytes.as_slice()).unwrap(); + let CborValue::Array(fields) = canonical else { + panic!("history header must remain a CBOR array") + }; + assert!(matches!( + fields.get(6), + Some(CborValue::Bytes(prefix)) if prefix == &[0x12, 0x34, 0x56, 0x78] + )); + } + + #[test] + fn legacy_nonce_prefix_array_remains_authenticated_as_original_aad() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys.clone(), None); + let key = keys.load_or_create("test").unwrap(); + let header = ChunkHeader( + HISTORY_PROFILE_VERSION, + iana::Algorithm::ChaCha20Poly1305 as i64, + key.epoch, + key.reference, + "0123456789abcdef0123456789abcdef".to_owned(), + "fedcba9876543210fedcba9876543210".to_owned(), + NoncePrefix([0x12, 0x34, 0x56, 0x78]), + ); + let header_bytes = legacy_header_bytes(&header); + let mut event = manager.control_event(HistoryControlOperation::Enable); + event.data.sequence = 1; + let plaintext = serde_json::to_vec(&event).unwrap(); + let chunk_key = derive_chunk_key(&key.bytes, &header).unwrap(); + let cipher_key = Key::try_from(chunk_key.as_slice()).unwrap(); + let cipher = ChaCha20Poly1305::new(&cipher_key); + let mut nonce = [0_u8; 12]; + nonce[..4].copy_from_slice(header.6.as_bytes()); + let protected = HeaderBuilder::new() + .algorithm(iana::Algorithm::ChaCha20Poly1305) + .build(); + let unprotected = HeaderBuilder::new().iv(nonce.to_vec()).build(); + let encrypted = CoseEncrypt0Builder::new() + .protected(protected) + .unprotected(unprotected) + .try_create_ciphertext(&plaintext, &header_bytes, |plaintext, aad| { + let nonce = + Nonce::try_from(nonce.as_slice()).map_err(|_| chacha20poly1305::Error)?; + cipher.encrypt( + &nonce, + Payload { + msg: plaintext, + aad, + }, + ) + }) + .unwrap() + .build(); + let mut chunk = header_bytes; + chunk.extend(encrypted.to_tagged_vec().unwrap()); + secure_create_dir(&chunks_dir(temp.path())).unwrap(); + fs::write( + chunks_dir(temp.path()).join(format!("{}.cborseq", header.5)), + chunk, + ) + .unwrap(); + + let events = + HistoryStore::read_all(temp.path(), "test", keys.as_ref(), DEFAULT_QUOTA_BYTES) + .unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].id, event.id); + assert_eq!(events[0].data.sequence, 1); + } + + #[test] + fn privacy_validator_rejects_forbidden_field_names() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + let mut event = manager.control_event(HistoryControlOperation::Enable); + let mut value = serde_json::to_value(&event).unwrap(); + value["data"]["clipboard"] = Value::String("secret".to_owned()); + assert!(serde_json::from_value::(value).is_err()); + event.data.caller_category = "wrong".to_owned(); + assert!(validate_event(&event).is_err()); + } + + #[test] + fn disabled_or_paused_capture_never_enqueues_actions() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + assert!(manager + .begin_action("click", &serde_json::json!({"session":"secret/path"}), None,) + .is_none()); + manager.enable().unwrap(); + manager.pause().unwrap(); + assert!(manager + .begin_action("click", &serde_json::json!({}), None) + .is_none()); + } + + #[test] + fn the_next_accepted_action_reports_pending_drops() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + manager.enable().unwrap(); + manager.record_drop(); + assert!(manager + .begin_action("click", &serde_json::json!({}), None) + .is_some()); + manager.flush().unwrap(); + let events = manager + .query( + HistoryQuery { + limit: Some(MAX_QUERY_LIMIT), + ..Default::default() + }, + HistoryAccessOperation::LocalCli, + ) + .unwrap(); + assert!(events.iter().any(|event| matches!( + event.data.payload, + HistoryPayload::Health { + category: HistoryHealthCategory::EventsDropped, + count: 1 + } + ))); + assert_eq!(manager.status().dropped_events, 1); + } + + #[test] + fn concurrent_capture_preserves_strict_stream_sequence_order() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + manager.enable().unwrap(); + let mut threads = Vec::new(); + for _ in 0..8 { + let manager = manager.clone(); + threads.push(thread::spawn(move || { + for _ in 0..32 { + assert!(manager + .begin_action("click", &serde_json::json!({}), None) + .is_some()); + } + })); + } + for thread in threads { + thread.join().unwrap(); + } + manager.flush().unwrap(); + let events = manager + .query( + HistoryQuery { + limit: Some(MAX_QUERY_LIMIT), + ..Default::default() + }, + HistoryAccessOperation::LocalCli, + ) + .unwrap(); + assert!(events + .windows(2) + .all(|pair| pair[0].data.sequence < pair[1].data.sequence)); + } + + #[test] + fn disabled_capture_remains_queryable() { + let temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + manager.enable().unwrap(); + manager.disable().unwrap(); + let events = manager + .query(HistoryQuery::default(), HistoryAccessOperation::LocalCli) + .unwrap(); + assert!(events.iter().any(|event| matches!( + event.data.payload, + HistoryPayload::Control { + operation: HistoryControlOperation::Disable + } + ))); + } + + #[test] + fn modified_ciphertext_and_wrong_key_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys.clone(), None); + manager.enable().unwrap(); + manager.disable().unwrap(); + + let path = history_chunk_paths(temp.path()).unwrap().pop().unwrap(); + let mut bytes = fs::read(&path).unwrap(); + *bytes.last_mut().unwrap() ^= 0x01; + fs::write(&path, bytes).unwrap(); + assert_eq!( + manager + .query(HistoryQuery::default(), HistoryAccessOperation::LocalCli) + .unwrap_err() + .category, + HistoryHealthCategory::StorageCorrupt + ); + + let wrong_key_temp = tempfile::tempdir().unwrap(); + let original_keys = Arc::new(MemoryKeyProvider::default()); + let original_manager = + HistoryManager::new(config(wrong_key_temp.path()), original_keys, None); + original_manager.enable().unwrap(); + original_manager.disable().unwrap(); + drop(original_manager); + let other_keys = Arc::new(MemoryKeyProvider::default()); + other_keys + .load_or_create("test") + .expect("create different key for same opaque reference"); + let other_manager = HistoryManager::new(config(wrong_key_temp.path()), other_keys, None); + assert_eq!( + other_manager + .query(HistoryQuery::default(), HistoryAccessOperation::LocalCli) + .unwrap_err() + .category, + HistoryHealthCategory::StorageCorrupt + ); + } + + fn mutate_first_cose(path: &Path, mutate: impl FnOnce(&mut CoseEncrypt0)) { + let bytes = fs::read(path).unwrap(); + let mut cursor = Cursor::new(bytes.as_slice()); + let _: ChunkHeader = ciborium::de::from_reader(&mut cursor).unwrap(); + let header_end = cursor.position() as usize; + let value: coset::cbor::value::Value = ciborium::de::from_reader(&mut cursor).unwrap(); + let first_end = cursor.position() as usize; + let coset::cbor::value::Value::Tag(_, inner) = value else { + panic!("expected tagged COSE item") + }; + let mut encrypted = CoseEncrypt0::from_cbor_value(*inner).unwrap(); + mutate(&mut encrypted); + let mut changed = bytes[..header_end].to_vec(); + changed.extend(encrypted.to_tagged_vec().unwrap()); + changed.extend_from_slice(&bytes[first_end..]); + fs::write(path, changed).unwrap(); + } + + #[test] + fn cose_profile_rejects_unknown_unprotected_and_mutated_protected_headers() { + for mutation in 0..3 { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys, None); + manager.enable().unwrap(); + manager.disable().unwrap(); + let path = history_chunk_paths(temp.path()).unwrap().pop().unwrap(); + mutate_first_cose(&path, |encrypted| match mutation { + 0 => encrypted.unprotected.rest.push(( + coset::Label::Text("unauthenticated-extension".to_owned()), + coset::cbor::value::Value::Bool(true), + )), + 1 => { + encrypted.protected.header.alg = + Some(coset::Algorithm::Assigned(iana::Algorithm::A128GCM)); + encrypted.protected.original_data = None; + } + _ => { + encrypted.protected.header.alg = None; + encrypted.protected.original_data = None; + } + }); + assert_eq!( + manager + .query(HistoryQuery::default(), HistoryAccessOperation::LocalCli) + .unwrap_err() + .category, + HistoryHealthCategory::StorageCorrupt + ); + } + } + + #[test] + fn quota_is_checked_before_a_chunk_header_reaches_disk() { + let temp = tempfile::tempdir().unwrap(); + let mut limited = config(temp.path()); + limited.quota_bytes = 1; + let manager = HistoryManager::new(limited, Arc::new(MemoryKeyProvider::default()), None); + assert_eq!( + manager.enable().unwrap_err().category, + HistoryHealthCategory::QuotaReached + ); + assert!(history_chunk_paths(temp.path()).unwrap().is_empty()); + assert!(!manager.status().enabled); + } + + #[test] + fn expired_chunks_are_pruned_before_a_new_writer_starts() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let mut immediate_retention = config(temp.path()); + immediate_retention.retention_days = 0; + let first = HistoryManager::new(immediate_retention.clone(), keys.clone(), None); + first.enable().unwrap(); + first.disable().unwrap(); + assert_eq!(history_chunk_paths(temp.path()).unwrap().len(), 1); + drop(first); + + let second = HistoryManager::new(immediate_retention, keys, None); + second.enable().unwrap(); + second.disable().unwrap(); + assert_eq!(history_chunk_paths(temp.path()).unwrap().len(), 1); + } + + #[test] + fn retention_is_enforced_for_disabled_and_long_lived_query_checkpoints() { + let disabled_temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let mut immediate = config(disabled_temp.path()); + immediate.retention_days = 0; + let disabled = HistoryManager::new(immediate, keys, None); + disabled.enable().unwrap(); + disabled.disable().unwrap(); + assert!(disabled + .query(HistoryQuery::default(), HistoryAccessOperation::LocalCli) + .unwrap() + .is_empty()); + assert!(history_chunk_paths(disabled_temp.path()) + .unwrap() + .is_empty()); + + let active_temp = tempfile::tempdir().unwrap(); + let mut immediate = config(active_temp.path()); + immediate.retention_days = 0; + let active = HistoryManager::new(immediate, Arc::new(MemoryKeyProvider::default()), None); + active.enable().unwrap(); + assert!(active + .query(HistoryQuery::default(), HistoryAccessOperation::LocalCli) + .unwrap() + .is_empty()); + assert!(active.status().enabled); + assert!(active + .begin_action("click", &serde_json::json!({}), None) + .is_some()); + active.flush().unwrap(); + } + + #[test] + fn query_hides_old_events_even_when_the_chunk_mtime_is_recent() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys.clone(), None); + let lease = WriterLease::acquire(temp.path()).unwrap(); + let mut store = HistoryStore::create_with_lease( + temp.path(), + "test", + "retention-test-stream", + DEFAULT_QUOTA_BYTES, + keys.as_ref(), + lease, + ) + .unwrap(); + let mut event = manager.control_event(HistoryControlOperation::Enable); + event.data.sequence = 1; + event.time = (OffsetDateTime::now_utc() - time::Duration::days(8)) + .format(&Rfc3339) + .unwrap(); + store.append(&event).unwrap(); + store.flush().unwrap(); + drop(store); + assert!(manager + .query(HistoryQuery::default(), HistoryAccessOperation::LocalCli) + .unwrap() + .is_empty()); + } + + #[test] + fn forced_chunk_age_rotates_and_prunes_a_sealed_chunk_without_sleeping() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys.clone(), None); + let lease = WriterLease::acquire(temp.path()).unwrap(); + let mut store = HistoryStore::create_with_lease( + temp.path(), + "test", + "rotation-test-stream", + DEFAULT_QUOTA_BYTES, + keys.as_ref(), + lease, + ) + .unwrap(); + let mut event = manager.control_event(HistoryControlOperation::Enable); + event.data.sequence = 1; + store.append(&event).unwrap(); + store.flush().unwrap(); + let sealed = store.path.clone(); + File::options() + .write(true) + .open(&sealed) + .unwrap() + .set_times( + fs::FileTimes::new() + .set_modified(SystemTime::UNIX_EPOCH) + .set_accessed(SystemTime::UNIX_EPOCH), + ) + .unwrap(); + store.created_at = Instant::now() - CHUNK_ROTATION_INTERVAL; + store.checkpoint_retention(DEFAULT_RETENTION_DAYS).unwrap(); + assert_ne!(store.path, sealed); + assert!(!sealed.exists()); + assert!(store.path.exists()); + } + + #[test] + fn each_writer_generation_creates_a_new_chunk_and_keeps_sequence_order() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let first = HistoryManager::new(config(temp.path()), keys.clone(), None); + first.enable().unwrap(); + first.disable().unwrap(); + drop(first); + + let second = HistoryManager::new(config(temp.path()), keys, None); + second.enable().unwrap(); + second.disable().unwrap(); + assert_eq!(history_chunk_paths(temp.path()).unwrap().len(), 2); + let events = second + .query( + HistoryQuery { + limit: Some(MAX_QUERY_LIMIT), + ..Default::default() + }, + HistoryAccessOperation::LocalCli, + ) + .unwrap(); + assert!(events + .windows(2) + .all(|pair| pair[0].data.sequence < pair[1].data.sequence)); + } + + #[test] + fn a_second_manager_cannot_write_until_the_first_releases_the_store() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let first = HistoryManager::new(config(temp.path()), keys.clone(), None); + first.enable().unwrap(); + + let second = HistoryManager::new(config(temp.path()), keys, None); + assert_eq!(second.status().health, HistoryHealthCategory::WriterStopped); + assert_eq!( + second.enable().unwrap_err().category, + HistoryHealthCategory::WriterStopped + ); + + first.disable().unwrap(); + second.enable().unwrap(); + second.disable().unwrap(); + let events = second + .query( + HistoryQuery { + limit: Some(MAX_QUERY_LIMIT), + ..Default::default() + }, + HistoryAccessOperation::LocalCli, + ) + .unwrap(); + assert!(events + .windows(2) + .all(|pair| pair[0].data.sequence < pair[1].data.sequence)); + } + + #[test] + fn active_query_runs_on_writer_and_producer_hook_does_not_block() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys.clone(), None); + manager.enable().unwrap(); + + keys.load_threads.lock().unwrap().clear(); + manager + .query(HistoryQuery::default(), HistoryAccessOperation::LocalCli) + .unwrap(); + assert!(keys + .load_threads + .lock() + .unwrap() + .iter() + .any(|name| name.as_deref() == Some("cua-history-writer"))); + + let writer_guard = manager.writer.lock().unwrap(); + let producer = manager.clone(); + let (done_tx, done_rx) = mpsc::channel(); + let join = thread::spawn(move || { + producer.session_event(Some("nonblocking-producer"), true); + done_tx.send(()).unwrap(); + }); + done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("producer hook blocked behind the writer handle lock"); + drop(writer_guard); + join.join().unwrap(); + assert!(manager.status().dropped_events > 0); + } + + #[test] + fn delete_destroys_an_orphaned_namespace_key_without_chunks() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + keys.load_or_create("test").unwrap(); + let manager = HistoryManager::new(config(temp.path()), keys.clone(), None); + manager.delete_all().unwrap(); + assert!(keys.references("test").unwrap().is_empty()); + } + + #[test] + fn delete_recovers_from_an_unreadable_chunk_header() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys.clone(), None); + manager.enable().unwrap(); + manager.disable().unwrap(); + let path = history_chunk_paths(temp.path()).unwrap().pop().unwrap(); + fs::write(path, [0xff]).unwrap(); + + manager.delete_all().unwrap(); + + assert!(keys.references("test").unwrap().is_empty()); + assert!(history_chunk_paths(temp.path()).unwrap().is_empty()); + } + + #[test] + fn terminal_writer_failure_cannot_block_disable_or_delete() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let mut limited = config(temp.path()); + limited.quota_bytes = 4096; + let manager = HistoryManager::new(limited, keys.clone(), None); + manager.enable().unwrap(); + let path = history_chunk_paths(temp.path()).unwrap().pop().unwrap(); + let mut file = fs::OpenOptions::new().append(true).open(path).unwrap(); + file.write_all(&vec![0_u8; 4096]).unwrap(); + file.flush().unwrap(); + manager.session_event(Some("quota-terminal"), true); + assert_eq!( + manager.flush().unwrap_err().category, + HistoryHealthCategory::QuotaReached + ); + + assert!(!manager.disable().unwrap().enabled); + manager.delete_all().unwrap(); + + assert!(keys.references("test").unwrap().is_empty()); + assert!(history_chunk_paths(temp.path()).unwrap().is_empty()); + } + + #[test] + fn offline_purge_is_exclusive_and_removes_state_only_after_keys() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys.clone(), None); + manager.enable().unwrap(); + assert_eq!( + purge_offline(temp.path(), "test", keys.as_ref()) + .unwrap_err() + .category, + HistoryHealthCategory::WriterStopped + ); + manager.disable().unwrap(); + drop(manager); + fs::write(temp.path().join("admission.json"), b"{}").unwrap(); + let result = purge_offline(temp.path(), "test", keys.as_ref()).unwrap(); + assert_eq!(result.destroyed_keys, 1); + assert!(keys.references("test").unwrap().is_empty()); + assert!(history_chunk_paths(temp.path()).unwrap().is_empty()); + assert!(!state_path(temp.path()).exists()); + assert!(!temp.path().join("admission.json").exists()); + } + + #[test] + fn purge_refuses_a_nonempty_unmarked_override_before_touching_keys_or_files() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("managed-override"); + fs::create_dir(&root).unwrap(); + let state = root.join("state.json"); + fs::write(&state, br#"{"history_enabled":true}"#).unwrap(); + let keys = MemoryKeyProvider::default(); + keys.load_or_create("test").unwrap(); + + assert_eq!( + purge_offline(&root, "test", &keys).unwrap_err().category, + HistoryHealthCategory::StorageUnavailable + ); + assert!(state.exists()); + assert_eq!(keys.references("test").unwrap().len(), 1); + assert!(!root.join(ROOT_MARKER_NAME).exists()); + } + + #[test] + fn purge_refuses_relative_roots_without_creating_or_deleting_any_file() { + let keys = MemoryKeyProvider::default(); + keys.load_or_create("test").unwrap(); + let root = Path::new("relative-computer-history"); + + assert_eq!( + purge_offline(root, "test", &keys).unwrap_err().category, + HistoryHealthCategory::StorageUnavailable + ); + assert_eq!(keys.references("test").unwrap().len(), 1); + assert!(!root.exists()); + } + + #[test] + fn nonempty_unmarked_computer_history_root_is_not_adopted_by_name() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("computer-history"); + fs::create_dir(&root).unwrap(); + fs::write(root.join("state.json"), b"{}").unwrap(); + + assert_eq!( + prepare_history_root(&root).unwrap_err().category, + HistoryHealthCategory::StorageUnavailable + ); + assert!(!root.join(ROOT_MARKER_NAME).exists()); + assert!(root.join("state.json").exists()); + } + + #[cfg(unix)] + #[test] + fn purge_refuses_symlinked_roots_and_managed_entries_before_key_destruction() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target"); + fs::create_dir(&target).unwrap(); + let linked_root = temp.path().join("linked-root"); + symlink(&target, &linked_root).unwrap(); + let keys = MemoryKeyProvider::default(); + keys.load_or_create("test").unwrap(); + assert_eq!( + purge_offline(&linked_root, "test", &keys) + .unwrap_err() + .category, + HistoryHealthCategory::StorageUnavailable + ); + assert_eq!(keys.references("test").unwrap().len(), 1); + + let root = temp.path().join("safe-root"); + prepare_history_root(&root).unwrap(); + let outside = temp.path().join("outside-chunks"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, root.join("chunks")).unwrap(); + assert_eq!( + purge_offline(&root, "test", &keys).unwrap_err().category, + HistoryHealthCategory::StorageUnavailable + ); + assert_eq!(keys.references("test").unwrap().len(), 1); + } + + #[cfg(unix)] + #[test] + fn state_writes_do_not_follow_a_managed_file_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("safe-root"); + prepare_history_root(&root).unwrap(); + let sentinel = temp.path().join("sentinel"); + fs::write(&sentinel, b"preserve-me").unwrap(); + symlink(&sentinel, root.join("state.json.tmp")).unwrap(); + let state = PersistedState { + enabled: true, + paused: false, + }; + + assert_eq!( + write_state(&root, &state).unwrap_err().category, + HistoryHealthCategory::StorageUnavailable + ); + assert_eq!(fs::read(&sentinel).unwrap(), b"preserve-me"); + } + + #[test] + fn delete_clears_the_cached_session_derivation_key_before_reenable() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let manager = HistoryManager::new(config(temp.path()), keys, None); + manager.enable().unwrap(); + let before = manager.session_id("same-session").unwrap(); + manager.delete_all().unwrap(); + manager.enable().unwrap(); + let after = manager.session_id("same-session").unwrap(); + assert_ne!(before, after); + manager.disable().unwrap(); + } + + #[test] + fn admission_opt_in_restart_and_pause_completion_are_independent() { + let temp = tempfile::tempdir().unwrap(); + let keys = Arc::new(MemoryKeyProvider::default()); + let denied = HistoryManager::new( + HistoryConfig { + admitted: false, + ..config(temp.path()) + }, + keys.clone(), + None, + ); + assert_eq!( + denied.enable().unwrap_err().category, + HistoryHealthCategory::NotAdmitted + ); + drop(denied); + + let admitted = HistoryManager::new(config(temp.path()), keys.clone(), None); + assert!(!admitted.status().enabled); + admitted.enable().unwrap(); + let pending = admitted + .begin_action("click", &serde_json::json!({}), None) + .unwrap(); + admitted.pause().unwrap(); + assert!(admitted + .begin_action("click", &serde_json::json!({}), None) + .is_none()); + admitted.finish_action(pending, None, false); + admitted.flush_writer().unwrap(); + drop(admitted); + + let restarted = HistoryManager::new(config(temp.path()), keys, None); + assert!(restarted.status().enabled); + assert!(restarted.status().paused); + let events = restarted + .query( + HistoryQuery { + limit: Some(MAX_QUERY_LIMIT), + ..Default::default() + }, + HistoryAccessOperation::LocalCli, + ) + .unwrap(); + assert!(events + .iter() + .any(|event| matches!(event.data.payload, HistoryPayload::ActionCompleted { .. }))); + restarted.disable().unwrap(); + } + + #[test] + fn lifecycle_state_write_failures_do_not_create_split_brain_enablement() { + let enable_temp = tempfile::tempdir().unwrap(); + fs::create_dir(enable_temp.path().join("state.json")).unwrap(); + let failed_enable = HistoryManager::new( + config(enable_temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + assert_eq!( + failed_enable.enable().unwrap_err().category, + HistoryHealthCategory::StorageUnavailable + ); + assert!(!failed_enable.status().enabled); + + let disable_temp = tempfile::tempdir().unwrap(); + let manager = HistoryManager::new( + config(disable_temp.path()), + Arc::new(MemoryKeyProvider::default()), + None, + ); + manager.enable().unwrap(); + fs::remove_file(disable_temp.path().join("state.json")).unwrap(); + fs::create_dir(disable_temp.path().join("state.json")).unwrap(); + assert_eq!( + manager.disable().unwrap_err().category, + HistoryHealthCategory::StorageUnavailable + ); + assert!(manager.status().enabled); + fs::remove_dir(disable_temp.path().join("state.json")).unwrap(); + manager.disable().unwrap(); + } +} diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/lib.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/lib.rs index 71a0cc7c6b..4638b9c264 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/lib.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/lib.rs @@ -64,6 +64,7 @@ pub mod element_token; pub mod expectation; pub mod ffmpeg_install; pub mod health_report; +pub mod history; pub mod image_utils; pub mod page; pub mod pip_hook; diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/protocol.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/protocol.rs index 9785974f9d..e572f48542 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/protocol.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/protocol.rs @@ -369,9 +369,11 @@ fn agent_instructions() -> String { format!( r#"cua-driver: cross-platform background computer-use automation. -Before starting UI work, classify the desired postcondition. For a non-GUI outcome, prefer a client-provided app API/SDK, headless/background interface, CLI, or filesystem operation and read the result back in that semantic domain. This server has no shell. +Before UI work, classify the desired outcome. For non-GUI outcomes, prefer a client-provided app API/SDK, headless/background interface, CLI, or filesystem operation and read the result back in that semantic domain. This server has no shell. -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 pixels, foreground delivery, and desktop fallback. Never advance on transport success alone. +On continuation/recent-work, when available, call `history_status`; if ready, make one bounded initial `history_query` before broad discovery; otherwise continue. + +For app/window outcomes, 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 pixels, foreground delivery, and desktop fallback. Never advance on transport success alone. Workflow per turn: 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. @@ -509,6 +511,26 @@ mod agent_instruction_tests { "initialize instructions must not require explicit session setup" ); } + + #[test] + fn initialize_instructions_conditionally_consult_history_before_discovery() { + let instructions = initialize_result()["instructions"] + .as_str() + .expect("initialize result should carry agent instructions") + .to_owned(); + + assert!(instructions.contains("continuation/recent-work")); + let status = instructions.find("call `history_status`").unwrap(); + let bounded_query = instructions + .find("one bounded initial `history_query`") + .unwrap(); + let discovery = instructions.find("broad discovery").unwrap(); + assert!(status < bounded_query); + assert!(bounded_query < discovery); + assert!(instructions.contains("if ready")); + assert!(instructions.contains("otherwise continue")); + assert!(instructions.split_whitespace().count() <= 200); + } } #[cfg(test)] diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/session_manifest.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/session_manifest.rs index 9956593665..4fa445fb19 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/session_manifest.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/session_manifest.rs @@ -50,6 +50,7 @@ pub struct SessionManifest { writable_roots: Vec, terminable_pids: HashSet, configuration_changes: Vec<(String, serde_json::Value)>, + computer_history_operations: HashSet, last_authorized_dispatch: Arc>, idle_expired: Arc, } @@ -180,6 +181,23 @@ impl SessionManifest { )) }; match adapter_id { + "computer_history" => { + if kind != "computer_history" { + return refused(); + } + let operation = resource + .get("operation") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "computer history did not attest an operation".to_owned())?; + self.computer_history_operations + .contains(operation) + .then_some(()) + .ok_or_else(|| { + format!( + "computer history operation '{operation}' is outside the capability manifest" + ) + }) + } "private_observation" => match kind { "window" => self.authorize_desktop_window(resource), "application" => self.authorize_desktop_application(resource), @@ -537,6 +555,8 @@ struct RawResources { processes: RawProcessResources, #[serde(default)] driver_configuration: RawDriverConfigurationResources, + #[serde(default)] + computer_history: RawComputerHistoryResources, } #[cfg(feature = "yaml")] @@ -640,6 +660,14 @@ struct RawDriverConfigurationResources { changes: Vec, } +#[cfg(feature = "yaml")] +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawComputerHistoryResources { + #[serde(default)] + operations: Vec, +} + #[cfg(feature = "yaml")] #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -745,6 +773,7 @@ pub fn load_manifest(path: &Path) -> Result { files, processes, driver_configuration, + computer_history, } = resources; let RawBrowserResources { existing_profiles: raw_existing_profiles, @@ -766,6 +795,9 @@ pub fn load_manifest(path: &Path) -> Result { let RawDriverConfigurationResources { changes: raw_configuration_changes, } = driver_configuration; + let RawComputerHistoryResources { + operations: raw_computer_history_operations, + } = computer_history; if !matches!(version, 1..=3) { return Err(format!( @@ -924,6 +956,23 @@ pub fn load_manifest(path: &Path) -> Result { } configuration_changes.push((key.to_owned(), change.value)); } + if version < 3 && !raw_computer_history_operations.is_empty() { + return Err( + "computer_history resources require capability manifest version 3".to_owned(), + ); + } + let mut computer_history_operations = HashSet::new(); + for operation in raw_computer_history_operations { + let operation = operation.trim(); + if !matches!(operation, "status" | "query") { + return Err(format!( + "unsupported computer_history operation '{operation}'; expected status or query" + )); + } + if !computer_history_operations.insert(operation.to_owned()) { + return Err(format!("computer_history operations repeats '{operation}'")); + } + } if !browser_origins.is_empty() { const ORIGIN_BYPASS_TOOLS: &[&str] = &[ "page", @@ -984,6 +1033,7 @@ pub fn load_manifest(path: &Path) -> Result { writable_roots, terminable_pids, configuration_changes, + computer_history_operations, last_authorized_dispatch: Arc::new(Mutex::new(Instant::now())), idle_expired: Arc::new(AtomicBool::new(false)), }) @@ -1813,6 +1863,41 @@ allow: .is_err()); } + #[cfg(feature = "yaml")] + #[test] + fn computer_history_resources_are_operation_scoped() { + let loaded = manifest( + r#" +version: 3 +resources: + computer_history: + operations: [status] +allow: + tools: [history_status, history_query] +"#, + ) + .unwrap(); + + loaded + .authorize_protected_resource( + "computer_history", + &serde_json::json!({"kind": "computer_history", "operation": "status"}), + ) + .unwrap(); + assert!(loaded + .authorize_protected_resource( + "computer_history", + &serde_json::json!({"kind": "computer_history", "operation": "query"}), + ) + .is_err()); + assert!(loaded + .authorize_protected_resource( + "computer_history", + &serde_json::json!({"kind": "display", "operation": "status"}), + ) + .is_err()); + } + #[cfg(feature = "yaml")] #[test] fn manifest_file_resources_reject_filesystem_roots() { diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs index 8c83a3ec2c..d7d916bc51 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs @@ -273,6 +273,7 @@ fn advertised_runtime_input_schema(tool_name: &str, schema: &Value) -> Value { /// `browser.input.click`, `browser.input.type`, `browser.input.files`, /// `browser.dialog` /// - `driver.update_check`, `driver.probe` +/// - `history.status`, `history.query` /// /// Tools with no entry get `[]` — that's fine, it just means /// downstream consumers fall back to matching by tool name for them. @@ -414,6 +415,10 @@ pub fn default_capabilities_for(tool_name: &str) -> Vec { "check_for_update" => &["driver.update_check"], "probe" => &["driver.probe"], + // ── encrypted local Computer History ───────────────────────── + "history_status" => &["history.status"], + "history_query" => &["history.query"], + // ── unsupported_platform stub & anything else ──────────────── _ => &[], }; @@ -574,6 +579,9 @@ pub struct ToolRegistry { order: Vec, /// Shared recording session — auto-records each non-read-only tool call. pub recording: Arc, + /// Optional encrypted Computer History runtime. It is installed only by a + /// trusted host that admitted the experimental preview. + history: Option>, replay_registry: ReplayRegistrySlot, session_end_hooks: Vec, session_revive_hooks: Vec, @@ -640,6 +648,7 @@ impl ToolRegistry { tools: HashMap::new(), order: Vec::new(), recording, + history: None, replay_registry: Arc::new(std::sync::Mutex::new(std::sync::Weak::new())), session_end_hooks: vec![session_end_hook], session_revive_hooks: Vec::new(), @@ -775,6 +784,20 @@ impl ToolRegistry { self.register(Box::new(crate::recording_tools::InstallFfmpegTool)); } + /// Install the encrypted history hook and its two permission-gated, + /// read-only agent tools. Lifecycle mutation remains daemon-private. + pub fn register_history_tools(&mut self, manager: Arc) { + self.history = Some(manager.clone()); + self.register(Box::new(crate::history::HistoryStatusTool::new( + manager.clone(), + ))); + self.register(Box::new(crate::history::HistoryQueryTool::new(manager))); + } + + pub fn history(&self) -> Option> { + self.history.clone() + } + /// Register the platform-independent lifecycle and compatibility tools /// (`start_session`, `get_session`, `list_sessions`, `end_session`, and /// the legacy capture-scope readers). Call alongside @@ -1156,12 +1179,14 @@ impl ToolRegistry { // 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) + let history_observation = has_adapter("computer_history"); + if (has_adapter("private_observation") || history_observation) + && (history_observation + || (!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 @@ -1171,6 +1196,11 @@ impl ToolRegistry { &public_args, context, runtime_session.as_deref(), + if history_observation { + "computer_history" + } else { + "private_observation" + }, ) .await { @@ -1406,6 +1436,9 @@ impl ToolRegistry { }; let start_ms = now_ms(); let cursor_event = crate::cursor_events::begin_tool(resolved_name, &args); + let pending_history = self.history.as_ref().and_then(|history| { + history.begin_action(resolved_name, &public_args, runtime_session.as_deref()) + }); // Reserve and capture the turn before dispatch so recorded evidence // shows the application immediately before the action changed it. @@ -1533,6 +1566,25 @@ impl ToolRegistry { // as a distinct call site. let name = resolved_name; + if let (Some(history), Some(pending)) = (self.history.as_ref(), pending_history) { + history.finish_action( + pending, + result.action_record.as_ref(), + result.is_error == Some(true), + ); + } + if result.is_error != Some(true) && matches!(name, "start_session" | "end_session") { + self.history.as_ref().map(|history| { + history.session_event( + public_args + .get("session") + .and_then(Value::as_str) + .or(runtime_session.as_deref()), + name == "start_session", + ) + }); + } + // Record non-read-only, non-recording tool calls. The recording- // control tools themselves are excluded so the recorded turn // stream stays the actual user-action sequence (not the meta @@ -1575,6 +1627,7 @@ impl ToolRegistry { args: &Value, context: &crate::session_authorization::EffectiveAuthorizationContext, lifecycle_session: Option<&str>, + adapter_id: &str, ) -> Result<(), crate::consent::ConsentError> { if context.mode() == crate::authorization::PermissionMode::Unrestricted && context.capability_manifest().is_none() @@ -1583,7 +1636,8 @@ impl ToolRegistry { } 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 adapter_id == "private_observation" + && context.mode() == crate::authorization::PermissionMode::Standard && context.capability_manifest().is_none() { // Standard observation is promptless, but browser observations @@ -1604,11 +1658,17 @@ impl ToolRegistry { // promptless operation into a pure-Wayland failure. return Ok(()); } - let browser_scope = tool - .protected_resource_scope("private_observation", args) - .await - .map_err(crate::consent::ConsentError::Provider)?; - let (mut resource, summary) = if let Some(resource) = browser_scope { + let history_resource = history_observation_resource(tool_name); + let browser_scope = if history_resource.is_none() { + tool.protected_resource_scope("private_observation", args) + .await + .map_err(crate::consent::ConsentError::Provider)? + } else { + None + }; + let (mut resource, summary) = if let Some((resource, summary)) = history_resource { + (resource, summary.to_owned()) + } else if let Some(resource) = browser_scope { let target_id = browser_target.unwrap_or("unknown"); ( resource, @@ -1714,7 +1774,7 @@ impl ToolRegistry { self.protected_resource_grants .authorize( context, - "private_observation", + adapter_id, crate::authorization::RiskClass::R2, lifecycle_session, resource, @@ -2229,6 +2289,46 @@ impl ToolRegistry { } } +fn history_observation_resource(tool_name: &str) -> Option<(Value, &'static str)> { + match tool_name { + "history_status" => Some(( + serde_json::json!({"kind": "computer_history", "operation": "status"}), + "Allow Cua to inspect Computer History status", + )), + "history_query" => Some(( + serde_json::json!({"kind": "computer_history", "operation": "query"}), + "Allow Cua to read encrypted Computer History metadata", + )), + _ => None, + } +} + +#[cfg(test)] +mod history_observation_tests { + use super::*; + + #[test] + fn history_grants_are_display_independent_and_operation_specific() { + let (status, _) = history_observation_resource("history_status").unwrap(); + let (query, _) = history_observation_resource("history_query").unwrap(); + assert_eq!( + status, + serde_json::json!({"kind":"computer_history","operation":"status"}) + ); + assert_eq!( + query, + serde_json::json!({"kind":"computer_history","operation":"query"}) + ); + assert_ne!(status, query); + for resource in [status, query] { + assert!(resource.get("width").is_none()); + assert!(resource.get("pid").is_none()); + assert!(resource.get("session").is_none()); + assert!(resource.get("since_sequence").is_none()); + } + } +} + fn required_path_arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, ToolResult> { args.get(key) .and_then(Value::as_str) @@ -3023,6 +3123,75 @@ mod runtime_isolation_tests { assert_ne!(result.is_error, Some(true)); } + #[tokio::test] + async fn standard_history_requires_an_explicit_protected_host_grant() { + let denied_hits = Arc::new(AtomicUsize::new(0)); + let denied_registry = observation_registry_for("history_query", None, denied_hits.clone()); + let denied = denied_registry + .invoke_with_context( + "history_query", + serde_json::json!({"session": "review"}), + standard_context(), + ) + .await; + assert_eq!(denied_hits.load(Ordering::SeqCst), 0); + assert_eq!( + denied + .structured_content + .as_ref() + .and_then(|value| value.pointer("/refusal/code")) + .and_then(serde_json::Value::as_str), + Some("authorization_required") + ); + + let provider = Arc::new(AcceptingProvider { + requests: AtomicUsize::new(0), + }); + let allowed_hits = Arc::new(AtomicUsize::new(0)); + let allowed_registry = observation_registry_for( + "history_query", + Some(provider.clone()), + allowed_hits.clone(), + ); + let allowed = allowed_registry + .invoke_with_context( + "history_query", + serde_json::json!({"session": "review"}), + standard_context(), + ) + .await; + assert_ne!(allowed.is_error, Some(true)); + assert_eq!(allowed_hits.load(Ordering::SeqCst), 1); + assert_eq!(provider.requests.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn bounded_history_requires_the_matching_operation_resource() { + for (operations, expected_allowed) in + [(["status"].as_slice(), false), (["query"].as_slice(), true)] + { + let hits = Arc::new(AtomicUsize::new(0)); + let registry = observation_registry_for("history_query", None, hits.clone()); + let operations = operations + .iter() + .map(|operation| format!(" - {operation}")) + .collect::>() + .join("\n"); + let context = bounded_context(&format!( + "version: 3\nexpires_after: 1h\nidle_timeout: 30m\nresources:\n computer_history:\n operations:\n{operations}\nallow:\n tools: [history_query]\n" + )); + let result = registry + .invoke_with_context( + "history_query", + serde_json::json!({"session": "review"}), + context, + ) + .await; + assert_eq!(result.is_error != Some(true), expected_allowed); + assert_eq!(hits.load(Ordering::SeqCst), usize::from(expected_allowed)); + } + } + #[tokio::test] async fn bounded_observation_uses_only_the_manifest_without_a_protected_host() { let hits = Arc::new(AtomicUsize::new(0)); @@ -4704,6 +4873,8 @@ mod capability_tests { "browser_set_input_files", "browser_download", "browser_pointer", + "history_status", + "history_query", ]; /// All capability tokens in the canonical vocabulary. Any token @@ -4790,6 +4961,9 @@ mod capability_tests { // driver self "driver.update_check", "driver.probe", + // encrypted local history + "history.status", + "history.query", ]; #[test] diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs index 8f40c8ba02..4f9ba965fa 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/abi.rs @@ -1301,6 +1301,15 @@ impl NativeAbiDriver { &self.runtime_scope_key } + pub(crate) fn history(&self) -> Option> { + unsafe { + self.raw_handle() + .cast::() + .as_ref() + .and_then(|handle| handle.runtime.history()) + } + } + pub(crate) fn is_available(&self) -> bool { let mut available = false; let mut error = CuaDriverBuffer::empty(); diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs index 9ac84e96e0..088436f8ce 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/lib.rs @@ -277,6 +277,20 @@ enum DriverBackend { Remote(Arc), } +impl CuaDriver { + /// Trusted Rust-host access to the daemon-owned local history controller. + /// This is intentionally absent from UniFFI and public agent protocols. + #[doc(hidden)] + pub fn local_history_manager(&self) -> Option> { + match &self.backend { + DriverBackend::Embedded(runtime) => runtime.history(), + DriverBackend::Daemon(_) + | DriverBackend::PrivateWorker(_) + | DriverBackend::Remote(_) => None, + } + } +} + struct DaemonBackend { socket_path: String, transport_session: String, diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs index f0e8b17aff..e9bed70901 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/runtime.rs @@ -235,6 +235,10 @@ impl DriverRuntime { self.is_running().then(|| self.registry.tools_list()) } + pub(crate) fn history(&self) -> Option> { + self.is_running().then(|| self.registry.history()).flatten() + } + pub(crate) async fn invoke(&self, name: &str, args: Value) -> Option { self.invoke_with_context(name, args, self.compatibility_context.clone()) .await diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs index 53b5866e0b..68e82c727e 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/mcp.rs @@ -30,9 +30,14 @@ pub struct McpDriver { next_id: u32, recording_dir: Option, recording_started: bool, + recording_started_at: Option, } static RECORDING_SEQUENCE: AtomicU64 = AtomicU64::new(1); +// ScreenCaptureKit can acknowledge capture before SCRecordingOutput has +// durably emitted its first sample. This total includes the 300 ms baseline +// settle and keeps immediate-refusal trajectories from finalizing empty. +const MIN_BEHAVIOR_RECORDING_DURATION: Duration = Duration::from_millis(750); impl McpDriver { /// Spawn the driver, start the stdout reader thread, and `initialize`. @@ -159,6 +164,7 @@ impl McpDriver { next_id: 2, recording_dir: None, recording_started: false, + recording_started_at: None, }; d.initialize(); d.prepare_e2e_recording(recording_label); @@ -235,7 +241,8 @@ impl McpDriver { "sequence": sequence, "behavior_video": { "status": "pending", - "baseline_settle_ms": 300 + "baseline_settle_ms": 300, + "minimum_duration_ms": MIN_BEHAVIOR_RECORDING_DURATION.as_millis() as u64 }, "hosted_runner_console": { "status": runner_console_status @@ -285,6 +292,7 @@ impl McpDriver { ); } self.recording_started = true; + self.recording_started_at = Some(Instant::now()); update_behavior_video_status(&output_dir, "started"); std::thread::sleep(Duration::from_millis(300)); mark_behavior_video_baseline_ready(&output_dir); @@ -304,6 +312,12 @@ impl McpDriver { let _ = std::fs::write(output_dir.join("recording-error.txt"), message); return; } + if let Some(started_at) = self.recording_started_at.take() { + let remaining = remaining_behavior_recording_time(started_at.elapsed()); + if !remaining.is_zero() { + std::thread::sleep(remaining); + } + } let response = self.call("stop_recording", serde_json::json!({})); let video_path = output_dir.join("recording.mp4"); let valid_video = !response.is_error() @@ -407,6 +421,10 @@ fn recording_label(name: &str) -> String { } } +fn remaining_behavior_recording_time(elapsed: Duration) -> Duration { + MIN_BEHAVIOR_RECORDING_DURATION.saturating_sub(elapsed) +} + impl Driver for McpDriver { fn call(&mut self, tool: &str, args: Value) -> ToolResponse { ToolResponse::from_mcp(self.call_raw(tool, args)) @@ -467,7 +485,8 @@ fn unix_ms() -> u64 { #[cfg(test)] mod tests { - use super::recording_label; + use super::{recording_label, remaining_behavior_recording_time}; + use std::time::Duration; #[test] fn recording_label_is_artifact_safe() { @@ -477,4 +496,14 @@ mod tests { ); assert_eq!(recording_label("///"), "unnamed-test"); } + + #[test] + fn short_behavior_recordings_receive_a_settle_interval() { + assert_eq!( + remaining_behavior_recording_time(Duration::from_millis(300)), + Duration::from_millis(450) + ); + assert!(remaining_behavior_recording_time(Duration::from_millis(750)).is_zero()); + assert!(remaining_behavior_recording_time(Duration::from_secs(2)).is_zero()); + } } diff --git a/libs/cua-driver/rust/crates/cua-driver/Cargo.toml b/libs/cua-driver/rust/crates/cua-driver/Cargo.toml index 1afae81e5f..3adaa86f10 100644 --- a/libs/cua-driver/rust/crates/cua-driver/Cargo.toml +++ b/libs/cua-driver/rust/crates/cua-driver/Cargo.toml @@ -23,6 +23,7 @@ pip-preview = { path = "../pip-preview" } async-trait = "0.1" base64 = { workspace = true } uuid = { workspace = true } +zeroize = { workspace = true } # Telemetry HTTP client. `ureq` over `reqwest` for the small dep footprint: # PostHog ingest is a single fire-and-forget POST with a 3s timeout. Uses # rustls (default) so Linux/Windows builds don't require system OpenSSL. diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index eb9693c25e..74e38926d3 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -72,6 +72,9 @@ pub enum Command { claude_code_compat: bool, /// Repeatable trusted launch grants. grants: Vec, + /// Admit the encrypted macOS Computer History early preview for this + /// daemon generation. Capture still requires separate persisted opt-in. + experimental_history: bool, }, Stop { socket: Option, @@ -95,6 +98,13 @@ pub enum Command { args: Vec, socket: Option, }, + History { + subcommand: String, + args: Vec, + socket: Option, + json: bool, + confirmed: bool, + }, DumpDocs { pretty: bool, doc_type: String, @@ -297,6 +307,7 @@ fn finite_command_name_from_args(args: &[String]) -> Option<&'static str> { Some("status") => Some("status"), Some("sessions") => Some("sessions"), Some("recording") => Some("recording"), + Some("history") => Some("history"), Some("dump-docs") => Some("dump_docs"), Some("update") => Some("update"), Some("check-update") => Some("check_update"), @@ -361,6 +372,18 @@ fn finite_operation_from_args(args: &[String]) -> &'static str { "render" => "render", _ => "other", }, + Some("history") => match subcommand.unwrap_or("status") { + "enable" => "enable", + "disable" => "disable", + "pause" => "pause", + "resume" => "resume", + "status" => "status", + "flush" => "flush", + "list" => "list", + "show" => "show", + "delete" => "delete", + _ => "other", + }, Some("permissions") => match subcommand.unwrap_or("status") { "status" => "status", "grant" => "grant", @@ -468,7 +491,7 @@ pub fn parse_command() -> Command { env!("CARGO_PKG_VERSION") ); println!("Usage: cua-driver [SUBCOMMAND] [OPTIONS]"); - 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!("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, history"); println!(); println!("permissions options (macOS):"); println!(" cua-driver permissions status Report Accessibility + Screen Recording status. Read-only (no prompt)."); @@ -610,6 +633,14 @@ pub fn parse_command() -> Command { println!(" --json Emit the probe report as JSON for scripting."); println!(); println!("experimental options (default: off):"); + println!( + " --experimental-history Admit encrypted local Computer History for this daemon (macOS only)." + ); + println!( + " Capture remains off until `cua-driver history enable`." + ); + println!(" cua-driver history enable Opt in and initialize encrypted local history."); + println!(" cua-driver history status|pause|resume|flush|list|show|disable|delete"); println!(" --experimental-pip Show a small always-on-top window with the latest"); println!( " post-action screenshot + a 1-line label. macOS only" @@ -734,6 +765,7 @@ pub fn parse_command() -> Command { no_permissions_gate: args.iter().any(|a| a == "--no-permissions-gate"), claude_code_compat, grants, + experimental_history: args.iter().any(|a| a == "--experimental-history"), }, Some("stop") => Command::Stop { socket }, Some("revoke") => { @@ -769,6 +801,17 @@ pub fn parse_command() -> Command { socket, } } + Some("history") => { + let subcommand = pos.next().unwrap_or("status").to_string(); + let rest: Vec = pos.map(str::to_owned).collect(); + Command::History { + subcommand, + args: rest, + socket, + json: args.iter().any(|arg| arg == "--json"), + confirmed: args.iter().any(|arg| arg == "--yes"), + } + } Some("dump-docs") => { let pretty = args.iter().any(|a| a == "--pretty" || a == "-p"); let doc_type = flag_value(&args, "--type").unwrap_or_else(|| "all".to_owned()); @@ -1143,6 +1186,22 @@ pub fn launch_daemon_and_wait( timeout_secs: u64, claude_code_compat: bool, grants: &[String], + experimental_history: bool, +) -> Result<(), LaunchDaemonError> { + let state = crate::history_runtime::DaemonLaunchState { + claude_code_compat, + grants: grants.to_vec(), + ..Default::default() + }; + launch_daemon_with_state_and_wait(socket_path, timeout_secs, &state, experimental_history) +} + +#[cfg(target_os = "macos")] +fn launch_daemon_with_state_and_wait( + socket_path: &str, + timeout_secs: u64, + state: &crate::history_runtime::DaemonLaunchState, + experimental_history: bool, ) -> Result<(), LaunchDaemonError> { use std::process::{Command as Cmd, Stdio}; use std::time::{Duration, Instant}; @@ -1154,14 +1213,10 @@ pub fn launch_daemon_and_wait( // user-supplied path that never comes up. Only added when the path // actually differs from the default, so the common case keeps the // shorter `open` argv (and matches Swift's invocation byte-for-byte). - let pass_socket = socket_path != crate::serve::default_socket_path(); let app_name = crate::bundle::app_name(); let app_path = crate::bundle::app_bundle_path(); - let mut open_args: Vec<&str> = vec!["-n", "-g", "-a", app_name, "--args", "serve"]; - if pass_socket { - open_args.push("--socket"); - open_args.push(socket_path); - } + let pass_socket = socket_path != crate::serve::default_socket_path(); + let open_args = daemon_launch_arguments(app_name, socket_path, state, experimental_history); // Thread the Claude-Code compat flag through to the daemon. Without this // the proxy-spawned daemon always called build_macos_registry() (compat // hardcoded false), so `cua-driver mcp --claude-code-computer-use-compat` @@ -1173,14 +1228,6 @@ pub fn launch_daemon_and_wait( // re-introduced the proxy path would not honour it. This makes the flag // travel end-to-end. Only honoured on a freshly-launched daemon — a // pre-existing daemon keeps whatever surface it launched with. - if claude_code_compat { - open_args.push("--claude-code-computer-use-compat"); - } - for grant in grants { - open_args.push("--grant"); - open_args.push(grant.as_str()); - } - let status = Cmd::new("/usr/bin/open") // `-n` forces a new instance: CuaDriver.app might already be // running from a previous MCP session, and without `-n`, `open @@ -1234,6 +1281,51 @@ pub fn launch_daemon_and_wait( }) } +#[cfg(target_os = "macos")] +fn daemon_launch_arguments( + app_name: &str, + socket_path: &str, + state: &crate::history_runtime::DaemonLaunchState, + experimental_history: bool, +) -> Vec { + let mut args = vec![ + "-n".to_owned(), + "-g".to_owned(), + "-a".to_owned(), + app_name.to_owned(), + "--args".to_owned(), + "serve".to_owned(), + ]; + if socket_path != crate::serve::default_socket_path() { + args.extend(["--socket".to_owned(), socket_path.to_owned()]); + } + if let Some(mode) = &state.permission_mode { + args.extend(["--permission-mode".to_owned(), mode.clone()]); + } + if state.dangerously_bypass_approvals { + args.push("--dangerously-bypass-approvals".to_owned()); + } + if let Some(manifest) = &state.capability_manifest { + args.extend(["--capability-manifest".to_owned(), manifest.clone()]); + } + if state.approve_capability_manifest { + args.push("--approve-capability-manifest".to_owned()); + } + if state.no_permissions_gate { + args.push("--no-permissions-gate".to_owned()); + } + if state.claude_code_compat { + args.push("--claude-code-computer-use-compat".to_owned()); + } + if experimental_history { + args.push("--experimental-history".to_owned()); + } + for grant in &state.grants { + args.extend(["--grant".to_owned(), grant.clone()]); + } + args +} + /// Run the MCP proxy path: ensure a daemon is up (spawning via /// `open` if needed), then `crate::proxy::run_proxy` against its /// socket. Builds its own tokio runtime — same shape as the other @@ -1314,8 +1406,13 @@ where and proxying MCP requests through it.", crate::bundle::cli_name() ); - if let Err(error) = launch_daemon_and_wait(&socket_path, 10, claude_code_compat, grants) - { + if let Err(error) = launch_daemon_and_wait( + &socket_path, + 10, + claude_code_compat, + grants, + crate::history_runtime::preview_admitted_preference(), + ) { if let Some(on_startup) = on_startup.take() { on_startup( if error.kind == LaunchDaemonErrorKind::Timeout { @@ -1435,6 +1532,7 @@ pub fn build_manifest() -> serde_json::Value { { "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." }, { "name": "--host-bundle-id", "type": "string", "description": "Advisory host bundle id label echoed in check_permissions output." } + ,{ "name": "--experimental-history", "type": "flag", "description": "Admit the encrypted macOS Computer History early preview for this daemon launch." } ] }, { "name": "stop", "description": "Stop a running daemon by sending it a shutdown request.", @@ -1482,6 +1580,14 @@ pub fn build_manifest() -> serde_json::Value { { "name": "subcommand", "type": "positional-string", "description": "One of: start, stop, status, render. Default: status." }, { "name": "--socket", "type": "string", "description": "Override the daemon socket path." } ] }, + { "name": "history", + "description": "Encrypted, metadata-only Computer History early-preview lifecycle and local inspection controls.", + "args": [ + { "name": "subcommand", "type": "positional-string", "description": "enable | disable | pause | resume | status | flush | list [limit] | show | delete --yes" }, + { "name": "--socket", "type": "string", "description": "Override the daemon socket path." }, + { "name": "--json", "type": "flag", "description": "Emit machine-readable output." }, + { "name": "--yes", "type": "flag", "description": "Confirm irreversible deletion of encrypted chunks and their Keychain key." } + ] }, { "name": "dump-docs", "description": "Dump every registered tool's docs as one document (markdown by default, JSON with --type json).", "args": [ @@ -2013,6 +2119,329 @@ pub fn run_call( } } +/// Operator-only lifecycle and inspection surface for encrypted Computer +/// History. Mutation is sent over a daemon-private method and is never +/// registered as an MCP tool. +pub fn run_history_cmd( + subcommand: &str, + args: &[String], + socket: Option<&str>, + json: bool, + confirmed: bool, +) { + let mut enabled_preview_for_this_command = false; + let mut prior_daemon_was_running = false; + let mut prior_daemon_state = crate::history_runtime::DaemonLaunchState::default(); + let prior_preview_admitted_preference = crate::history_runtime::preview_admitted_preference(); + let valid = matches!( + subcommand, + "enable" | "disable" | "pause" | "resume" | "status" | "flush" | "list" | "show" | "delete" + ); + if !valid { + eprintln!("Unknown history subcommand '{subcommand}'. Valid: enable, disable, pause, resume, status, flush, list, show , delete --yes"); + process::exit(64); + } + if subcommand == "delete" && !confirmed { + eprintln!("history delete destroys the encrypted files and their Keychain key. Re-run with --yes."); + process::exit(64); + } + #[cfg(not(target_os = "macos"))] + { + if subcommand == "status" { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "supported": false, + "admitted": false, + "enabled": false, + "paused": false, + "encrypted": true, + "health": "not_admitted" + })) + .unwrap() + ); + return; + } + eprintln!("Computer History early preview is available on macOS only."); + process::exit(1); + } + + let socket_path = socket + .map(str::to_owned) + .unwrap_or_else(crate::serve::default_socket_path); + + if subcommand == "enable" { + let admitted = history_daemon_status(&socket_path) + .and_then(|value| value.get("admitted").and_then(serde_json::Value::as_bool)) + == Some(true); + if !admitted { + if crate::bundle::is_local_installation() { + eprintln!( + "This local-development daemon is not admitted for Computer History. Restart it with:\n {} serve --experimental-history\nThen run `{} history enable` again.", + crate::bundle::cli_name(), + crate::bundle::cli_name(), + ); + process::exit(1); + } + #[cfg(target_os = "macos")] + if let Err(error) = crate::history_runtime::verify_installed_app_for_history() { + eprintln!("history enable: installed app verification failed: {error}"); + process::exit(1); + } + if crate::serve::is_daemon_listening(&socket_path) { + prior_daemon_was_running = true; + prior_daemon_state = match history_daemon_relaunch_state(&socket_path) { + Ok(state) => state, + Err(error) => { + eprintln!( + "history enable: cannot preserve the running daemon mode: {error}. Stop the daemon and retry." + ); + process::exit(1); + } + }; + } + if let Err(error) = crate::history_runtime::set_preview_admitted_preference(true) { + eprintln!("history enable: could not persist preview admission: {error}"); + process::exit(1); + } + enabled_preview_for_this_command = true; + #[cfg(target_os = "macos")] + if crate::serve::is_daemon_listening(&socket_path) { + if let Err(error) = stop_history_daemon(&socket_path) { + let _ = crate::history_runtime::set_preview_admitted_preference(false); + eprintln!( + "history enable: could not stop the existing daemon for preview admission: {error}" + ); + process::exit(1); + } + } + #[cfg(target_os = "macos")] + if let Err(error) = + launch_daemon_with_state_and_wait(&socket_path, 15, &prior_daemon_state, true) + { + rollback_history_preview( + &socket_path, + prior_daemon_was_running, + &prior_daemon_state, + prior_preview_admitted_preference, + ); + eprintln!("history enable: could not relaunch the installed daemon with preview admission: {error}"); + process::exit(1); + } + #[cfg(not(target_os = "macos"))] + { + eprintln!("Computer History early preview is available on macOS only."); + process::exit(1); + } + } + } + + if !crate::serve::is_daemon_listening(&socket_path) { + eprintln!( + "Cua Driver daemon is not running. Start it with: {} serve{}", + crate::bundle::cli_name(), + if crate::history_runtime::preview_admitted_preference() { + " --experimental-history" + } else { + "" + } + ); + process::exit(1); + } + if let Err(error) = ensure_compatible_daemon(&socket_path) { + #[cfg(target_os = "macos")] + if enabled_preview_for_this_command { + rollback_history_preview( + &socket_path, + prior_daemon_was_running, + &prior_daemon_state, + prior_preview_admitted_preference, + ); + } + eprintln!("Cua Driver daemon on {socket_path} is incompatible: {error}"); + process::exit(1); + } + + let mut request_args = serde_json::json!({"operation": subcommand}); + if subcommand == "list" { + if let Some(limit) = args.first().and_then(|value| value.parse::().ok()) { + request_args["limit"] = serde_json::json!(limit); + } + } + if subcommand == "show" { + let Some(sequence) = args.first().and_then(|value| value.parse::().ok()) else { + eprintln!( + "Usage: {} history show ", + crate::bundle::cli_name() + ); + process::exit(64); + }; + request_args["sequence"] = serde_json::json!(sequence); + } + let request = crate::serve::DaemonRequest { + method: "history_control".to_owned(), + name: None, + args: Some(request_args), + session_id: None, + observation_origin: Some(crate::serve::ToolObservationOrigin::Direct), + client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), + }; + match crate::serve::send_request(&socket_path, &request) { + Ok(response) if response.ok => { + let value = response.result.unwrap_or_else(|| serde_json::json!({})); + if json || matches!(subcommand, "list" | "show") { + println!("{}", serde_json::to_string_pretty(&value).unwrap()); + } else if subcommand == "enable" { + println!("Computer History preview enabled."); + println!("Stored fields: time, opaque session/action ids, fixed capability, app name/bundle id, and fixed action outcome metadata."); + println!("Never stored: screenshots, typed text, clipboard contents, raw arguments/results, accessibility trees, paths, titles, URLs, or free-form diagnostics."); + println!("Encryption: CBOR Sequence + COSE_Encrypt0 (ChaCha20-Poly1305), with the key protected by macOS Keychain."); + println!( + "Retention: {} days. Quota: {} MiB.", + value + .get("retention_days") + .and_then(serde_json::Value::as_u64) + .unwrap_or(7), + value + .get("quota_bytes") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) + / 1024 + / 1024 + ); + println!( + "Controls: history pause | resume | status | list | disable | delete --yes" + ); + } else { + println!("{}", serde_json::to_string_pretty(&value).unwrap()); + } + } + Ok(response) => { + #[cfg(target_os = "macos")] + if enabled_preview_for_this_command { + rollback_history_preview( + &socket_path, + prior_daemon_was_running, + &prior_daemon_state, + prior_preview_admitted_preference, + ); + } + eprintln!( + "history {subcommand}: {}", + response + .error + .unwrap_or_else(|| "operation failed".to_owned()) + ); + process::exit(response.exit_code.unwrap_or(1)); + } + Err(error) => { + #[cfg(target_os = "macos")] + if enabled_preview_for_this_command { + rollback_history_preview( + &socket_path, + prior_daemon_was_running, + &prior_daemon_state, + prior_preview_admitted_preference, + ); + } + eprintln!("history {subcommand}: {error}"); + process::exit(1); + } + } +} + +#[cfg(target_os = "macos")] +fn rollback_history_preview( + socket_path: &str, + prior_daemon_was_running: bool, + prior_daemon_state: &crate::history_runtime::DaemonLaunchState, + prior_preview_admitted_preference: bool, +) { + let _ = + crate::history_runtime::set_preview_admitted_preference(prior_preview_admitted_preference); + let _ = stop_history_daemon(socket_path); + if prior_daemon_was_running && !crate::serve::is_daemon_listening(socket_path) { + let _ = launch_daemon_with_state_and_wait(socket_path, 15, prior_daemon_state, false); + } +} + +#[cfg(target_os = "macos")] +fn stop_history_daemon(socket_path: &str) -> Result<(), String> { + if !crate::serve::is_daemon_listening(socket_path) { + return Ok(()); + } + let response = crate::serve::send_request( + socket_path, + &crate::serve::DaemonRequest { + method: "shutdown".to_owned(), + name: None, + args: None, + session_id: None, + observation_origin: None, + client_kind: None, + }, + ) + .map_err(|error| error.to_string())?; + if !response.ok { + return Err(response + .error + .unwrap_or_else(|| "daemon refused shutdown".to_owned())); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline { + if !crate::serve::is_daemon_listening(socket_path) { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err("daemon did not stop within 2 seconds".to_owned()) +} + +fn history_daemon_relaunch_state( + socket_path: &str, +) -> Result { + let response = crate::serve::send_request( + socket_path, + &crate::serve::DaemonRequest { + method: "history_relaunch_state".to_owned(), + name: None, + args: None, + session_id: None, + observation_origin: Some(crate::serve::ToolObservationOrigin::Direct), + client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), + }, + ) + .map_err(|error| error.to_string())?; + if !response.ok { + return Err(response + .error + .unwrap_or_else(|| "daemon refused relaunch-state inspection".to_owned())); + } + serde_json::from_value( + response + .result + .ok_or_else(|| "daemon omitted relaunch-state metadata".to_owned())?, + ) + .map_err(|error| format!("invalid relaunch-state metadata: {error}")) +} + +fn history_daemon_status(socket_path: &str) -> Option { + if !crate::serve::is_daemon_listening(socket_path) { + return None; + } + let request = crate::serve::DaemonRequest { + method: "history_control".to_owned(), + name: None, + args: Some(serde_json::json!({"operation": "status"})), + session_id: None, + observation_origin: Some(crate::serve::ToolObservationOrigin::Direct), + client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), + }; + crate::serve::send_request(socket_path, &request) + .ok()? + .result +} + /// `cua-driver recording ` — wrapper around /// `start_recording` / `stop_recording` / `get_recording_state` tools /// on the running daemon. @@ -2818,8 +3247,15 @@ fn run_permissions_grant() { "A dialog for {app_name} will appear — approve Accessibility \ and Screen Recording in System Settings, then this command continues." ); - // Permissions-grant launch never needs the compat screenshot surface. - if let Err(e) = launch_daemon_and_wait(&socket, 180, false, &[]) { + // Preserve explicit Computer History admission across the + // permission host's daemon launch/re-exec cycle. + if let Err(e) = launch_daemon_and_wait( + &socket, + 180, + false, + &[], + crate::history_runtime::preview_admitted_preference(), + ) { eprintln!("\nDidn't detect the {app_name} daemon: {e}"); eprintln!( "If you haven't yet, grant Accessibility + Screen Recording to {app_name} \ @@ -3959,6 +4395,56 @@ mod tests { values.iter().map(|value| (*value).to_owned()).collect() } + #[cfg(target_os = "macos")] + #[test] + fn history_relaunch_preserves_authorization_mode_and_every_grant() { + let grants = args(&["capability:a", "capability:b"]); + let state = crate::history_runtime::DaemonLaunchState { + permission_mode: Some("bounded".to_owned()), + dangerously_bypass_approvals: false, + capability_manifest: Some("/tmp/capabilities.yaml".to_owned()), + approve_capability_manifest: true, + no_permissions_gate: true, + claude_code_compat: true, + grants, + }; + let launch = daemon_launch_arguments("CuaDriver", "/tmp/history-test.sock", &state, true); + assert!(launch + .windows(2) + .any(|pair| pair == ["--permission-mode", "bounded"])); + assert!(launch + .windows(2) + .any(|pair| { pair == ["--capability-manifest", "/tmp/capabilities.yaml"] })); + assert!(launch.contains(&"--approve-capability-manifest".to_owned())); + assert!(launch.contains(&"--no-permissions-gate".to_owned())); + assert!(launch.contains(&"--claude-code-computer-use-compat".to_owned())); + assert!(launch.contains(&"--experimental-history".to_owned())); + assert!(launch + .windows(2) + .any(|pair| pair == ["--grant", "capability:a"])); + assert!(launch + .windows(2) + .any(|pair| pair == ["--grant", "capability:b"])); + assert!(launch + .windows(2) + .any(|pair| pair == ["--socket", "/tmp/history-test.sock"])); + } + + #[cfg(target_os = "macos")] + #[test] + fn history_relaunch_preserves_explicit_unrestricted_approval() { + let state = crate::history_runtime::DaemonLaunchState { + permission_mode: Some("unrestricted".to_owned()), + dangerously_bypass_approvals: true, + ..Default::default() + }; + let launch = daemon_launch_arguments("CuaDriver", "/tmp/history-test.sock", &state, true); + assert!(launch + .windows(2) + .any(|pair| { pair == ["--permission-mode", "unrestricted"] })); + assert!(launch.contains(&"--dangerously-bypass-approvals".to_owned())); + } + #[test] fn deprecated_session_policy_flag_remains_a_capability_manifest_alias() { let argv = args(&["serve", "--session-policy", "/tmp/legacy.yaml"]); @@ -4048,6 +4534,15 @@ mod tests { finite_operation_from_args(&args(&["channel", "set", "private-value"])), "set" ); + assert_eq!(finite_operation_from_args(&args(&["history"])), "status"); + assert_eq!( + finite_operation_from_args(&args(&["history", "show", "private-value"])), + "show" + ); + assert_eq!( + finite_operation_from_args(&args(&["history", "private-value"])), + "other" + ); assert_eq!( finite_operation_from_args(&args(&["doctor", "private-value"])), "not_applicable" @@ -4232,6 +4727,7 @@ mod tests { "status", "mcp-config", "manifest", + "history", ] { assert!(names.contains(&need), "missing subcommand '{need}'"); } diff --git a/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs b/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs new file mode 100644 index 0000000000..a5e6b46dd3 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs @@ -0,0 +1,493 @@ +//! Trusted standalone-daemon assembly for the macOS Computer History preview. + +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, OnceLock, + }, +}; + +#[cfg(target_os = "macos")] +use cua_driver_core::history::{ + HistoryConfig, HistoryManager, DEFAULT_QUOTA_BYTES, DEFAULT_RETENTION_DAYS, +}; +use cua_driver_core::tool::ToolRegistry; + +static HISTORY_ADMITTED: AtomicBool = AtomicBool::new(false); +static DAEMON_LAUNCH_STATE: OnceLock> = OnceLock::new(); +const RELEASE_TEAM_IDENTIFIER: &str = "YCK386LBJ7"; + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct DaemonLaunchState { + pub permission_mode: Option, + pub dangerously_bypass_approvals: bool, + pub capability_manifest: Option, + pub approve_capability_manifest: bool, + pub no_permissions_gate: bool, + pub claude_code_compat: bool, + pub grants: Vec, +} + +pub fn configure_admission(admitted: bool) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + if admitted { + verify_installed_app_for_history()?; + } + HISTORY_ADMITTED.store(admitted, Ordering::Release); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn configure_daemon_launch_state( + permission_mode: Option<&str>, + dangerously_bypass_approvals: bool, + capability_manifest: Option<&str>, + approve_capability_manifest: bool, + no_permissions_gate: bool, + claude_code_compat: bool, + grants: &[String], +) { + let state = DAEMON_LAUNCH_STATE.get_or_init(|| Mutex::new(DaemonLaunchState::default())); + *state.lock().unwrap() = DaemonLaunchState { + permission_mode: permission_mode.map(str::to_owned), + dangerously_bypass_approvals, + capability_manifest: capability_manifest.map(str::to_owned), + approve_capability_manifest, + no_permissions_gate, + claude_code_compat, + grants: grants.to_vec(), + }; +} + +pub fn daemon_launch_state() -> DaemonLaunchState { + DAEMON_LAUNCH_STATE + .get_or_init(|| Mutex::new(DaemonLaunchState::default())) + .lock() + .unwrap() + .clone() +} + +pub fn preview_admitted_preference() -> bool { + preview_admitted_preference_at(&admission_preference_path()) +} + +fn preview_admitted_preference_at(path: &Path) -> bool { + let Ok(metadata) = fs::symlink_metadata(path) else { + return false; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return false; + } + let Ok(bytes) = fs::read(path) else { + return false; + }; + if bytes.len() > 1024 { + return false; + } + serde_json::from_slice::(&bytes) + .ok() + .and_then(|value| { + value + .get("history_preview_admitted") + .and_then(|value| value.as_bool()) + }) + == Some(true) +} + +pub fn set_preview_admitted_preference(admitted: bool) -> anyhow::Result<()> { + set_preview_admitted_preference_at(&admission_preference_path(), admitted) +} + +fn set_preview_admitted_preference_at(path: &Path, admitted: bool) -> anyhow::Result<()> { + let root = path + .parent() + .ok_or_else(|| anyhow::anyhow!("admission preference has no parent"))?; + cua_driver_core::history::prepare_history_root(root)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?; + } + let temporary = root.join("admission.json.tmp"); + if temporary.exists() { + fs::remove_file(&temporary)?; + } + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = options.open(&temporary)?; + file.write_all( + serde_json::json!({"history_preview_admitted": admitted}) + .to_string() + .as_bytes(), + )?; + file.sync_data()?; + fs::rename(temporary, path)?; + Ok(()) +} + +#[cfg(target_os = "macos")] +pub fn run_offline_purge_if_requested() -> Option { + let args: Vec = std::env::args().skip(1).collect(); + if args.as_slice() != ["history", "purge-offline", "--yes"] { + return None; + } + let expected = + PathBuf::from(crate::bundle::app_bundle_path()).join("Contents/MacOS/cua-driver"); + let current = std::env::current_exe().ok(); + if current + .as_deref() + .is_none_or(|current| !is_exact_packaged_helper(current, &expected)) + || verify_installed_app_for_history().is_err() + { + eprintln!( + "history_purge_incomplete: purge requires the exact verified installed CuaDriver.app helper" + ); + return Some(1); + } + let provider = platform_macos::history::MacosKeychainKeyProvider::default(); + match cua_driver_core::history::purge_offline( + &history_root(), + crate::bundle::state_namespace(), + &provider, + ) { + Ok(result) => { + println!( + "history purge complete: destroyed {} key(s), removed {} file(s)", + result.destroyed_keys, result.removed_files + ); + Some(0) + } + Err(error) => { + eprintln!("history_purge_incomplete: {:?}", error.category); + Some(1) + } + } +} + +fn is_exact_packaged_helper(current: &Path, expected: &Path) -> bool { + fs::canonicalize(current).ok() == fs::canonicalize(expected).ok() + && fs::canonicalize(expected).is_ok() +} + +#[cfg(target_os = "macos")] +pub(crate) fn verify_history_cli_executable_path(path: &Path) -> anyhow::Result<()> { + let expected = PathBuf::from(crate::bundle::app_bundle_path()) + .join("Contents/MacOS") + .join(crate::bundle::cli_name()); + if !is_exact_packaged_helper(path, &expected) { + anyhow::bail!("history control peer is not the exact installed Cua Driver helper"); + } + verify_installed_app_for_history() +} + +#[cfg(target_os = "macos")] +pub fn verify_installed_app_for_history() -> anyhow::Result<()> { + use std::process::{Command, Stdio}; + + let path = crate::bundle::app_bundle_path(); + let metadata = fs::metadata(&path)?; + if !metadata.is_dir() { + anyhow::bail!("installed Cua Driver app path is not a directory"); + } + let verified = Command::new("/usr/bin/codesign") + .args(["--verify", "--deep", "--strict", &path]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !verified.success() { + anyhow::bail!("installed Cua Driver app failed strict code-signature verification"); + } + let expected_helper = PathBuf::from(&path) + .join("Contents/MacOS") + .join(crate::bundle::cli_name()); + let current_helper = std::env::current_exe().map_err(|error| { + anyhow::anyhow!("current Cua Driver executable is unavailable: {error}") + })?; + if !is_exact_packaged_helper(¤t_helper, &expected_helper) { + anyhow::bail!( + "history admission requires the exact executable inside the verified installed app" + ); + } + let helper = expected_helper.to_string_lossy(); + let helper_verified = Command::new("/usr/bin/codesign") + .args(["--verify", "--strict", helper.as_ref()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status()?; + if !helper_verified.success() { + anyhow::bail!("installed Cua Driver helper failed strict code-signature verification"); + } + let detail = Command::new("/usr/bin/codesign") + .args(["-d", "--verbose=4", helper.as_ref()]) + .output()?; + if !detail.status.success() { + anyhow::bail!("installed Cua Driver signing identity could not be inspected"); + } + let stderr = String::from_utf8_lossy(&detail.stderr); + let requirement = Command::new("/usr/bin/codesign") + .args(["-d", "-r-", helper.as_ref()]) + .output()?; + if !requirement.status.success() { + anyhow::bail!("installed Cua Driver designated requirement could not be inspected"); + } + // Unlike `codesign -d --verbose`, `codesign -d -r-` writes the + // designated requirement itself to stdout (and a short executable note to + // stderr). Read the semantic payload from stdout so a valid + // certificate-backed installation is not misclassified as ad-hoc. + let requirement = String::from_utf8_lossy(&requirement.stdout); + let team_identifier = stderr + .lines() + .find_map(|line| line.trim().strip_prefix("TeamIdentifier=")) + .filter(|value| !value.is_empty() && *value != "not set") + .ok_or_else(|| anyhow::anyhow!("installed Cua Driver signature has no team identifier"))?; + let entitlements = Command::new("/usr/bin/codesign") + .args(["-d", "--entitlements", "-", "--xml", helper.as_ref()]) + .output()?; + if !entitlements.status.success() { + anyhow::bail!("installed Cua Driver entitlements could not be inspected"); + } + let compact: String = String::from_utf8_lossy(&entitlements.stdout) + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); + validate_history_app_signature( + &stderr, + &requirement, + team_identifier, + &compact, + crate::bundle::bundle_id(), + crate::bundle::state_namespace() == "cua-driver", + ) +} + +fn validate_history_app_signature( + detail: &str, + requirement: &str, + team_identifier: &str, + compact_entitlements: &str, + bundle_id: &str, + require_release_entitlements: bool, +) -> anyhow::Result<()> { + let expected = format!("Identifier={bundle_id}"); + if !detail.lines().any(|line| line.trim() == expected) { + anyhow::bail!( + "installed Cua Driver signing identifier does not match the selected namespace" + ); + } + if !requirement.contains("certificate leaf") { + anyhow::bail!("installed Cua Driver signature is not certificate-backed"); + } + if team_identifier.is_empty() || team_identifier == "not set" { + anyhow::bail!("installed Cua Driver signature has no team identifier"); + } + if !require_release_entitlements { + return Ok(()); + } + if team_identifier != RELEASE_TEAM_IDENTIFIER { + anyhow::bail!("installed Cua Driver signature does not match the release signing team"); + } + if !requirement.contains("anchor apple generic") { + anyhow::bail!( + "installed Cua Driver signature is not anchored to Apple's code-signing trust chain" + ); + } + let application_identifier = format!("{team_identifier}.{bundle_id}"); + let application_entitlement = format!( + "com.apple.application-identifier{application_identifier}" + ); + let keychain_entitlement = format!( + "keychain-access-groups{application_identifier}" + ); + if !compact_entitlements.contains(&application_entitlement) + || !compact_entitlements.contains(&keychain_entitlement) + { + anyhow::bail!( + "installed Cua Driver lacks the device-protected Keychain entitlements required by Computer History" + ); + } + Ok(()) +} + +fn admission_preference_path() -> PathBuf { + history_root().join("admission.json") +} + +#[cfg(target_os = "macos")] +pub fn register_into(registry: &mut ToolRegistry) { + if !HISTORY_ADMITTED.load(Ordering::Acquire) { + return; + } + let manager = HistoryManager::new( + HistoryConfig { + root: history_root(), + namespace: crate::bundle::state_namespace().to_owned(), + admitted: true, + platform: "macos".to_owned(), + retention_days: DEFAULT_RETENTION_DAYS, + quota_bytes: DEFAULT_QUOTA_BYTES, + }, + platform_macos::history::MacosKeychainKeyProvider::shared(), + Some(platform_macos::history::application_identity_provider()), + ); + registry.register_history_tools(manager); +} + +#[cfg(not(target_os = "macos"))] +pub fn register_into(_registry: &mut ToolRegistry) {} + +pub fn register_host_tools(registry: &mut ToolRegistry) { + crate::check_update_tool::register_into(registry); + register_into(registry); +} + +pub fn history_root() -> PathBuf { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")); + home.join("Library") + .join("Application Support") + .join(crate::bundle::state_namespace()) + .join("computer-history") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_root_is_namespace_specific() { + let root = history_root(); + assert!(root.ends_with(format!( + "{}/computer-history", + crate::bundle::state_namespace() + ))); + } + + #[test] + fn admission_preference_roundtrips_and_rejects_malformed_state() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("computer-history/admission.json"); + assert!(!preview_admitted_preference_at(&path)); + set_preview_admitted_preference_at(&path, true).unwrap(); + assert!(preview_admitted_preference_at(&path)); + fs::write(&path, b"not-json").unwrap(); + assert!(!preview_admitted_preference_at(&path)); + } + + #[cfg(unix)] + #[test] + fn admission_preference_does_not_read_or_follow_symlinks() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("computer-history"); + cua_driver_core::history::prepare_history_root(&root).unwrap(); + let outside = temp.path().join("outside.json"); + fs::write(&outside, br#"{"history_preview_admitted":true}"#).unwrap(); + let path = root.join("admission.json"); + symlink(&outside, &path).unwrap(); + + assert!(!preview_admitted_preference_at(&path)); + assert!(set_preview_admitted_preference_at(&path, true).is_err()); + assert_eq!( + fs::read(&outside).unwrap(), + br#"{"history_preview_admitted":true}"# + ); + } + + #[test] + fn offline_purge_identity_guard_requires_the_exact_packaged_helper() { + let temp = tempfile::tempdir().unwrap(); + let expected = temp.path().join("CuaDriver.app/Contents/MacOS/cua-driver"); + fs::create_dir_all(expected.parent().unwrap()).unwrap(); + fs::write(&expected, b"synthetic").unwrap(); + assert!(is_exact_packaged_helper(&expected, &expected)); + let source = temp.path().join("target/debug/cua-driver"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"synthetic").unwrap(); + assert!(!is_exact_packaged_helper(&source, &expected)); + } + + #[test] + fn local_history_accepts_exact_certificate_identity_without_release_entitlements() { + validate_history_app_signature( + "Identifier=com.trycua.driver.local\nTeamIdentifier=TEAM123", + "designated => identifier \"com.trycua.driver.local\" and certificate leaf[subject.OU] = TEAM123", + "TEAM123", + "", + "com.trycua.driver.local", + false, + ) + .unwrap(); + } + + #[test] + fn history_admission_rejects_adhoc_or_wrong_bundle_identity() { + let adhoc = validate_history_app_signature( + "Identifier=com.trycua.driver.local\nTeamIdentifier=TEAM123", + "designated => cdhash H\"1234\"", + "TEAM123", + "", + "com.trycua.driver.local", + false, + ); + assert!(adhoc.is_err()); + let wrong_bundle = validate_history_app_signature( + "Identifier=com.trycua.driver\nTeamIdentifier=TEAM123", + "designated => identifier \"com.trycua.driver\" and certificate leaf[subject.OU] = TEAM123", + "TEAM123", + "", + "com.trycua.driver.local", + false, + ); + assert!(wrong_bundle.is_err()); + } + + #[test] + fn release_history_still_requires_device_protected_keychain_entitlements() { + let detail = + format!("Identifier=com.trycua.driver\nTeamIdentifier={RELEASE_TEAM_IDENTIFIER}"); + let requirement = format!( + "designated => anchor apple generic and identifier \"com.trycua.driver\" and certificate leaf[subject.OU] = {RELEASE_TEAM_IDENTIFIER}" + ); + assert!(validate_history_app_signature( + &detail, + &requirement, + RELEASE_TEAM_IDENTIFIER, + "", + "com.trycua.driver", + true, + ) + .is_err()); + let entitlements = format!( + "com.apple.application-identifier{RELEASE_TEAM_IDENTIFIER}.com.trycua.driverkeychain-access-groups{RELEASE_TEAM_IDENTIFIER}.com.trycua.driver" + ); + validate_history_app_signature( + &detail, + &requirement, + RELEASE_TEAM_IDENTIFIER, + &entitlements, + "com.trycua.driver", + true, + ) + .unwrap(); + + assert!(validate_history_app_signature( + "Identifier=com.trycua.driver\nTeamIdentifier=OTHERTEAM", + "designated => identifier \"com.trycua.driver\" and certificate leaf[subject.OU] = OTHERTEAM", + "OTHERTEAM", + "com.apple.application-identifierOTHERTEAM.com.trycua.driverkeychain-access-groupsOTHERTEAM.com.trycua.driver", + "com.trycua.driver", + true, + ) + .is_err()); + } +} diff --git a/libs/cua-driver/rust/crates/cua-driver/src/main.rs b/libs/cua-driver/rust/crates/cua-driver/src/main.rs index a28e69454c..ec12db4a00 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/main.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/main.rs @@ -22,6 +22,7 @@ mod bundle; mod check_update_tool; mod cli; mod doctor; +mod history_runtime; mod mcp_http; mod private_worker; mod proxy; @@ -291,7 +292,7 @@ fn build_driver( host_bundle_id: std::env::var(cua_driver_core::HOST_BUNDLE_ID_ENV).ok(), claude_code_compatibility: compatibility_mode, prepare_desktop_environment: true, - register_host_tools: Some(check_update_tool::register_into), + register_host_tools: Some(history_runtime::register_host_tools), authorization_host: None, activity_observer: None, }) @@ -325,7 +326,7 @@ fn inspect_tools_without_runtime() -> serde_json::Value { host_bundle_id: None, claude_code_compatibility: false, prepare_desktop_environment: false, - register_host_tools: Some(check_update_tool::register_into), + register_host_tools: Some(history_runtime::register_host_tools), authorization_host: None, activity_observer: None, }) @@ -431,6 +432,11 @@ mod mcp_runtime_selection_tests { #[cfg(target_os = "macos")] fn main() { + // The packaged uninstaller needs a truly offline, pre-telemetry purge + // path while this exact signed executable still exists on disk. + if let Some(code) = history_runtime::run_offline_purge_if_requested() { + std::process::exit(code); + } init_logging(); if let Some(code) = cli::run_permissions_host_request_if_requested() { std::process::exit(code); @@ -510,6 +516,7 @@ fn main() { no_permissions_gate, claude_code_compat, grants, + experimental_history, } => { if let Err(error) = configure_startup_permission_mode( permission_mode.as_deref(), @@ -522,6 +529,19 @@ fn main() { std::process::exit(64); } responsibility::reexec_disclaimed_if_needed(); + if let Err(error) = history_runtime::configure_admission(experimental_history) { + eprintln!("cua-driver: Computer History admission error: {error}"); + std::process::exit(1); + } + history_runtime::configure_daemon_launch_state( + permission_mode.as_deref(), + dangerously_bypass_approvals, + capability_manifest.as_deref(), + approve_capability_manifest, + no_permissions_gate, + claude_code_compat, + &grants, + ); let gate_opts = platform_macos::permissions::GateOpts::from_env_and_flag(no_permissions_gate); if let Some((progress, context)) = @@ -707,6 +727,15 @@ fn main() { } => { cli::run_recording_cmd(&subcommand, &args, socket.as_deref()); } + cli::Command::History { + subcommand, + args, + socket, + json, + confirmed, + } => { + cli::run_history_cmd(&subcommand, &args, socket.as_deref(), json, confirmed); + } cli::Command::DumpDocs { pretty, doc_type } => { let tools = inspect_tools_without_runtime(); cli::run_dump_docs_with_type(&tools, pretty, &doc_type); @@ -883,6 +912,7 @@ fn main() -> anyhow::Result<()> { no_permissions_gate, claude_code_compat, grants, + experimental_history: _, } => { configure_startup_permission_mode( permission_mode.as_deref(), @@ -955,6 +985,16 @@ fn main() -> anyhow::Result<()> { cli::run_recording_cmd(&subcommand, &args, socket.as_deref()); return Ok(()); } + cli::Command::History { + subcommand, + args, + socket, + json, + confirmed, + } => { + cli::run_history_cmd(&subcommand, &args, socket.as_deref(), json, confirmed); + return Ok(()); + } cli::Command::DumpDocs { pretty, doc_type } => { let tools = inspect_tools_without_runtime(); cli::run_dump_docs_with_type(&tools, pretty, &doc_type); diff --git a/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs b/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs index f835bd482c..99dea98150 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/sdk_adapter.rs @@ -106,6 +106,10 @@ impl SdkAdapter { self.tools_list.clone() } + pub fn history(&self) -> Option> { + self.driver.local_history_manager() + } + pub fn is_known_tool(&self, name: &str) -> bool { name == "type_text_chars" || self diff --git a/libs/cua-driver/rust/crates/cua-driver/src/serve.rs b/libs/cua-driver/rust/crates/cua-driver/src/serve.rs index 318cb0398b..fdb2913c28 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/serve.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/serve.rs @@ -200,6 +200,134 @@ fn is_session_lifecycle_tool(tool_name: &str) -> bool { matches!(tool_name, "start_session" | "end_session") } +fn history_control_response( + registry: &crate::sdk_adapter::SdkAdapter, + request: &DaemonRequest, + trusted_cli_connection: bool, +) -> DaemonResponse { + if !trusted_cli_connection + || request.client_kind != Some(cua_driver_core::daemon::DaemonClientKind::Cli) + || request.observation_origin != Some(ToolObservationOrigin::Direct) + { + return DaemonResponse::err("history_control_requires_local_cli", 77); + } + let operation = request + .args + .as_ref() + .and_then(|value| value.get("operation")) + .and_then(serde_json::Value::as_str) + .unwrap_or("status"); + let Some(history) = registry.history() else { + return if operation == "status" { + DaemonResponse::ok(serde_json::json!({ + "supported": cfg!(target_os = "macos"), + "admitted": false, + "enabled": false, + "paused": false, + "encrypted": true, + "health": "not_admitted" + })) + } else { + DaemonResponse::err("history_preview_not_admitted", 77) + }; + }; + let result: Result = match operation + { + "status" => serde_json::to_value(history.status()).map_err(|_| { + cua_driver_core::history::HistoryError::new( + cua_driver_core::history::HistoryHealthCategory::StorageCorrupt, + ) + }), + "enable" => history.enable().and_then(|status| { + serde_json::to_value(status).map_err(|_| { + cua_driver_core::history::HistoryError::new( + cua_driver_core::history::HistoryHealthCategory::StorageCorrupt, + ) + }) + }), + "disable" => history.disable().and_then(|status| { + serde_json::to_value(status).map_err(|_| { + cua_driver_core::history::HistoryError::new( + cua_driver_core::history::HistoryHealthCategory::StorageCorrupt, + ) + }) + }), + "pause" => history.pause().and_then(|status| { + serde_json::to_value(status).map_err(|_| { + cua_driver_core::history::HistoryError::new( + cua_driver_core::history::HistoryHealthCategory::StorageCorrupt, + ) + }) + }), + "resume" => history.resume().and_then(|status| { + serde_json::to_value(status).map_err(|_| { + cua_driver_core::history::HistoryError::new( + cua_driver_core::history::HistoryHealthCategory::StorageCorrupt, + ) + }) + }), + "flush" => history.flush().and_then(|status| { + serde_json::to_value(status).map_err(|_| { + cua_driver_core::history::HistoryError::new( + cua_driver_core::history::HistoryHealthCategory::StorageCorrupt, + ) + }) + }), + "delete" => history.delete_all().and_then(|status| { + serde_json::to_value(status).map_err(|_| { + cua_driver_core::history::HistoryError::new( + cua_driver_core::history::HistoryHealthCategory::StorageCorrupt, + ) + }) + }), + "list" | "show" => { + let args = request.args.as_ref().unwrap_or(&serde_json::Value::Null); + let sequence = (operation == "show") + .then(|| args.get("sequence").and_then(serde_json::Value::as_u64)) + .flatten(); + let limit = if operation == "show" { + Some(1) + } else { + args.get("limit") + .and_then(serde_json::Value::as_u64) + .map(|value| value as usize) + }; + history + .query( + cua_driver_core::history::HistoryQuery { + limit, + session_id: None, + since_sequence: sequence, + until_sequence: sequence, + }, + cua_driver_core::history::HistoryAccessOperation::LocalCli, + ) + .map(|events| serde_json::json!({"events": events, "metadata_only": true})) + } + _ => return DaemonResponse::err("unknown_history_operation", 64), + }; + match result { + Ok(value) => DaemonResponse::ok(value), + Err(error) => DaemonResponse::err(error.code(), 1), + } +} + +fn history_relaunch_state_response( + request: &DaemonRequest, + trusted_cli_connection: bool, +) -> DaemonResponse { + if !trusted_cli_connection + || request.client_kind != Some(cua_driver_core::daemon::DaemonClientKind::Cli) + || request.observation_origin != Some(ToolObservationOrigin::Direct) + { + return DaemonResponse::err("history_relaunch_state_requires_local_cli", 77); + } + DaemonResponse::ok( + serde_json::to_value(crate::history_runtime::daemon_launch_state()) + .expect("daemon launch state is serializable"), + ) +} + // ── Paths ───────────────────────────────────────────────────────────────────── /// Returns the platform default socket/pipe path. @@ -539,6 +667,35 @@ fn authenticate_embedded_host_connection(stream: &tokio::net::UnixStream) -> any Ok(()) } +#[cfg(target_os = "macos")] +fn authenticate_history_cli_connection(stream: &tokio::net::UnixStream) -> anyhow::Result<()> { + use std::os::unix::ffi::OsStringExt as _; + + let peer_pid = stream + .peer_cred() + .map_err(|error| anyhow::anyhow!("read history control peer credentials: {error}"))? + .pid() + .ok_or_else(|| anyhow::anyhow!("history control peer PID is unavailable"))?; + let mut buffer = vec![0_u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + let length = + unsafe { libc::proc_pidpath(peer_pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) }; + if length <= 0 { + anyhow::bail!("history control peer executable path is unavailable"); + } + let path_length = buffer[..length as usize] + .iter() + .position(|byte| *byte == 0) + .unwrap_or(length as usize); + buffer.truncate(path_length); + let path = std::path::PathBuf::from(std::ffi::OsString::from_vec(buffer)); + crate::history_runtime::verify_history_cli_executable_path(&path) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn authenticate_history_cli_connection(_stream: &tokio::net::UnixStream) -> anyhow::Result<()> { + anyhow::bail!("Computer History control is unavailable on this platform") +} + fn service_authorization_status(trusted_host_connection: bool) -> serde_json::Value { let mut status = cua_driver_core::authorization::status_json_with_provider(None); if trusted_host_connection { @@ -567,7 +724,10 @@ fn service_authorization_status(trusted_host_connection: bool) -> serde_json::Va #[cfg(all(test, unix))] mod peer_authentication_tests { - use super::{authenticate_unix_peer, authenticate_unix_uid}; + use super::{ + authenticate_unix_peer, authenticate_unix_uid, history_relaunch_state_response, + DaemonRequest, ToolObservationOrigin, + }; #[tokio::test] async fn same_user_unix_peer_is_accepted() { @@ -581,6 +741,25 @@ mod peer_authentication_tests { let error = authenticate_unix_uid(501, 502).unwrap_err(); assert!(error.to_string().contains("reject Unix peer uid 502")); } + + #[test] + fn forged_cli_metadata_does_not_authenticate_history_control() { + let request = DaemonRequest { + method: "history_relaunch_state".to_owned(), + name: None, + args: None, + session_id: None, + observation_origin: Some(ToolObservationOrigin::Direct), + client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), + }; + let response = history_relaunch_state_response(&request, false); + assert!(!response.ok); + assert_eq!( + response.error.as_deref(), + Some("history_relaunch_state_requires_local_cli") + ); + assert!(history_relaunch_state_response(&request, true).ok); + } } /// Run the daemon server. Binds `socket_path`, writes `pid_file_path`, @@ -653,6 +832,8 @@ pub async fn run_serve( } let trusted_host_connection = authenticate_embedded_host_connection(&stream).is_ok(); + let trusted_history_cli_connection = + authenticate_history_cli_connection(&stream).is_ok(); let reg = sdk.clone(); let shutdown_tx2 = shutdown_tx.clone(); let trusted_resume_registry = trusted_resume_registry.clone(); @@ -691,6 +872,15 @@ pub async fn run_serve( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; } + "history_relaunch_state" => { + let resp = history_relaunch_state_response( + &req, + trusted_history_cli_connection, + ); + let _ = writer.write_all( + (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() + ).await; + } "shutdown" => { let resp = DaemonResponse::ok(serde_json::json!({"shutdown": true})); let _ = writer.write_all( @@ -746,6 +936,16 @@ pub async fn run_serve( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; } + "history_control" => { + let resp = history_control_response( + ®, + &req, + trusted_history_cli_connection, + ); + let _ = writer.write_all( + (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() + ).await; + } "revoke_authorization" => { let args = req.args.as_ref().unwrap_or(&serde_json::Value::Null); let all = args.get("all").and_then(serde_json::Value::as_bool) @@ -1405,6 +1605,12 @@ pub async fn run_serve( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; } + "history_relaunch_state" => { + let resp = history_relaunch_state_response(&req, false); + let _ = writer.write_all( + (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() + ).await; + } "shutdown" => { let resp = DaemonResponse::ok(serde_json::json!({"shutdown": true})); let _ = writer.write_all( @@ -1443,6 +1649,12 @@ pub async fn run_serve( (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() ).await; } + "history_control" => { + let resp = history_control_response(®, &req, false); + let _ = writer.write_all( + (serde_json::to_string(&resp).unwrap() + "\n").as_bytes() + ).await; + } "revoke_authorization" => { let args = req.args.as_ref().unwrap_or(&serde_json::Value::Null); let all = args.get("all").and_then(serde_json::Value::as_bool) diff --git a/libs/cua-driver/rust/crates/cua-driver/src/skills.rs b/libs/cua-driver/rust/crates/cua-driver/src/skills.rs index 6c7c459903..8fd29e23c0 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/skills.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/skills.rs @@ -957,6 +957,70 @@ mod tests { } } + const HISTORY_CONSULTATION_POLICY: &[&str] = &[ + "continue, resume, or recall prior Cua work", + "call `history_status` first", + "one bounded initial", + "before broad application or window discovery", + "metadata only as a lead", + "verify current state", + "Content, geometry, arguments, results, and user intent", + "remain unknown", + "session or sequence boundary", + "never broaden a query to reconstruct excluded fields", + "either tool is absent", + "access is denied", + "query is empty", + "history is unhealthy", + "unrelated tasks merely because the tools are advertised", + "never mutate history", + "lifecycle or settings", + ]; + + fn assert_history_consultation_policy(skill: &str, source: &str) { + let normalized = skill.split_whitespace().collect::>().join(" "); + for required in HISTORY_CONSULTATION_POLICY { + assert!( + normalized.contains(required), + "{source} lost required history consultation guidance: {required}" + ); + } + } + + #[test] + fn bundled_skill_keeps_conditional_history_consultation_policy() { + 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"); + + assert!( + skill.lines().any(|line| { + line.starts_with("description:") + && line.contains("continue, resume, or recall recent Cua activity") + }), + "skill frontmatter must trigger for recent Cua activity continuation" + ); + assert_history_consultation_policy(&skill, "canonical skill"); + } + + #[test] + fn extracted_skill_pack_keeps_history_consultation_policy() { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let canonical = std::fs::read(crate_dir.join("../../Skills/cua-driver/SKILL.md")) + .expect("canonical skill must be readable"); + let bytes = build_tarball(&[( + "cua-driver-rs-v0.19.3-skills/SKILL.md", + canonical.as_slice(), + )]); + let dest = tempdir().unwrap(); + + extract_tar_gz(&bytes, dest.path(), false).unwrap(); + + let packaged = std::fs::read_to_string(dest.path().join("SKILL.md")) + .expect("extracted skill must be readable"); + assert_history_consultation_policy(&packaged, "extracted skill pack"); + } + #[test] fn bundled_skill_keeps_sessions_and_authorization_as_separate_concepts() { let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); diff --git a/libs/cua-driver/rust/crates/cua-driver/src/telemetry.rs b/libs/cua-driver/rust/crates/cua-driver/src/telemetry.rs index 16e17f50fd..2453fec210 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/telemetry.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/telemetry.rs @@ -544,6 +544,9 @@ impl AgentSessionState { escalation_reason: Option, outcome: &cua_driver_core::server::ToolCompletionObservation, ) { + if is_history_tool(&outcome.tool_name) { + return; + } self.transport_bits |= transport_bit(transport); self.used_window_modality |= matches!( capture_modality, @@ -994,6 +997,9 @@ pub(crate) fn capture_tool_completed( outcome: cua_driver_core::server::ToolCompletionObservation, transport: Transport, ) { + if is_history_tool(&outcome.tool_name) { + return; + } let is_first_value_candidate = outcome.computer_action && outcome.success && !outcome.refusal_code.is_refusal(); if !is_enabled() || !should_capture_tool_completion(Instant::now(), is_first_value_candidate) { @@ -1007,6 +1013,10 @@ pub(crate) fn capture_tool_completed( capture_bounded(event::MCP_TOOL_COMPLETED, properties, transport); } +fn is_history_tool(tool_name: &str) -> bool { + matches!(tool_name, "history_status" | "history_query") +} + #[derive(Debug)] struct ToolTelemetryRateLimit { window_started: Instant, @@ -1778,6 +1788,7 @@ pub(crate) fn capture_cli_completed( "stop" => "stop", "status" => "status", "recording" => "recording", + "history" => "history", "dump_docs" => "dump_docs", "update" => "update", "check_update" => "check_update", @@ -1932,6 +1943,7 @@ fn fixed_cli_command(command: &str) -> &'static str { "stop" => "stop", "status" => "status", "recording" => "recording", + "history" => "history", "dump_docs" => "dump_docs", "update" => "update", "check_update" => "check_update", @@ -1969,6 +1981,18 @@ fn fixed_cli_operation(command: &str, operation: &str) -> &'static str { "render" => "render", _ => "other", }, + "history" => match operation { + "enable" => "enable", + "disable" => "disable", + "pause" => "pause", + "resume" => "resume", + "status" => "status", + "flush" => "flush", + "list" => "list", + "show" => "show", + "delete" => "delete", + _ => "other", + }, "permissions" => match operation { "status" => "status", "grant" => "grant", @@ -3405,6 +3429,42 @@ mod tests { } } + #[test] + fn history_reads_do_not_change_tool_or_agent_session_telemetry() { + use cua_driver_core::server::{ + DurationBucket, OutputSizeBucket, OutputType, ToolCompletionObservation, ToolErrorClass, + }; + let mut state = AgentSessionState::new( + Transport::McpStdio, + cua_driver_core::session::SessionClientKind::PythonSdk, + cua_driver_core::CaptureScope::Auto, + ); + for tool_name in ["history_status", "history_query"] { + assert!(is_history_tool(tool_name)); + state.observe( + Transport::McpHttp, + false, + Some(cua_driver_core::session::CaptureModality::Desktop), + None, + &ToolCompletionObservation { + tool_name: tool_name.into(), + operation: cua_driver_core::server::ToolOperation::NotApplicable, + computer_action: false, + success: true, + error_class: ToolErrorClass::None, + refusal_code: cua_driver_core::server::ToolRefusalCode::None, + duration_bucket: DurationBucket::Under10Ms, + output_type: OutputType::Text, + output_size_bucket: OutputSizeBucket::Under1KiB, + }, + ); + } + assert_eq!(state.tool_count, 0); + assert_eq!(state.transport_bits, transport_bit(Transport::McpStdio)); + assert!(!state.used_desktop_modality); + assert!(!state.had_successful_tool); + } + #[test] fn failed_or_non_auto_escalation_is_not_counted() { use cua_driver_core::server::{ @@ -3738,6 +3798,9 @@ mod tests { fn cli_operations_and_client_kinds_are_revalidated_in_the_worker() { assert_eq!(fixed_cli_operation("recording", "start"), "start"); assert_eq!(fixed_cli_operation("recording", "/private/path"), "other"); + assert_eq!(fixed_cli_command("history"), "history"); + assert_eq!(fixed_cli_operation("history", "query-secret"), "other"); + assert_eq!(fixed_cli_operation("history", "show"), "show"); assert_eq!(fixed_cli_operation("doctor", "start"), "not_applicable"); assert_eq!( fixed_cli_client_kind("mcp_config", "claude_code"), diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_macos_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_macos_test.rs new file mode 100644 index 0000000000..2a74d291c1 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver/tests/computer_history_macos_test.rs @@ -0,0 +1,445 @@ +//! Packaged macOS lifecycle gate for the encrypted Computer History preview. +//! +//! The canonical Lume runner executes these two ignored tests around a real +//! daemon restart. The first test records one verified Cua-mediated action and +//! writes only opaque continuity evidence. The second proves that the fresh +//! daemon can hydrate from the encrypted store, then proves disable/preserve +//! and cryptographic deletion semantics. + +#![cfg(target_os = "macos")] + +use std::{ + fs, + io::{BufRead, BufReader, Write}, + os::unix::net::UnixStream, + path::{Path, PathBuf}, + process::Command, + thread::sleep, + time::{Duration, Instant}, +}; + +use cua_driver_testkit::{Driver, McpDriver}; +use serde_json::{json, Value}; + +const CHESS_BUNDLE: &str = "com.apple.Chess"; +const RAW_SESSION: &str = "centennial-lume-continuity"; +const HISTORY_KEYCHAIN_SERVICE: &str = "com.trycua.cua-driver-local.computer-history.v1"; +const HISTORY_KEYCHAIN_ACCOUNT: &str = "namespace-root-key-v1"; + +fn installed_driver() -> PathBuf { + std::env::var_os("CUA_E2E_INSTALLED_DRIVER_BIN") + .map(PathBuf::from) + .expect("CUA_E2E_INSTALLED_DRIVER_BIN must identify the packaged local driver") +} + +fn daemon_socket() -> String { + std::env::var("CUA_E2E_MACOS_DAEMON_SOCKET") + .expect("CUA_E2E_MACOS_DAEMON_SOCKET must identify the packaged daemon") +} + +fn marker_path() -> PathBuf { + std::env::var_os("CUA_E2E_HISTORY_MARKER") + .map(PathBuf::from) + .expect("CUA_E2E_HISTORY_MARKER must identify the run-owned continuity marker") +} + +fn history_root() -> PathBuf { + PathBuf::from(std::env::var_os("HOME").expect("HOME must be set")) + .join("Library/Application Support/cua-driver-local/computer-history") +} + +fn history_cli(subcommand: &str, extra: &[&str]) -> Value { + let mut command = Command::new(installed_driver()); + command.args(["history", subcommand]); + command.args(extra); + command.args(["--json", "--socket", &daemon_socket()]); + let output = command + .output() + .unwrap_or_else(|error| panic!("could not run history {subcommand}: {error}")); + assert!( + output.status.success(), + "history {subcommand} failed (status {:?}): stdout={} stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "history {subcommand} did not emit JSON: {error}; stdout={}", + String::from_utf8_lossy(&output.stdout) + ) + }) +} + +fn assert_forged_cli_control_is_rejected() { + let mut stream = UnixStream::connect(daemon_socket()) + .expect("connect test process directly to packaged daemon"); + let forged = json!({ + "method": "history_control", + "args": {"operation": "status"}, + "observation_origin": "direct", + "client_kind": "cli" + }); + writeln!(stream, "{forged}").expect("write forged CLI control request"); + let mut response = String::new(); + BufReader::new(stream) + .read_line(&mut response) + .expect("read forged CLI control response"); + let response: Value = + serde_json::from_str(&response).expect("forged CLI response must be JSON"); + assert_eq!(response["ok"], false, "forged CLI control was accepted"); + assert_eq!(response["exit_code"], 77); + assert_eq!(response["error"], "history_control_requires_local_cli"); +} + +fn assert_ready(status: &Value) { + assert_eq!(status["supported"], true, "history unsupported: {status}"); + assert_eq!(status["admitted"], true, "history not admitted: {status}"); + assert_eq!(status["enabled"], true, "history not enabled: {status}"); + assert_eq!( + status["paused"], false, + "history unexpectedly paused: {status}" + ); + assert_eq!( + status["encrypted"], true, + "history is not encrypted: {status}" + ); + assert_eq!(status["health"], "ready", "history is unhealthy: {status}"); + assert_eq!( + status["dropped_events"], 0, + "history dropped events before the action: {status}" + ); +} + +fn query(driver: &mut McpDriver, since_sequence: Option) -> Value { + let mut arguments = json!({"limit": 200}); + if let Some(sequence) = since_sequence { + arguments["since_sequence"] = json!(sequence); + } + let response = driver.call("history_query", arguments); + assert!( + !response.is_error(), + "history_query failed: {} / {}", + response.text(), + response.raw + ); + assert_eq!(response.structured()["metadata_only"], true); + assert_eq!(response.structured()["model_context_disclosure"], true); + response.structured().clone() +} + +fn max_sequence(query: &Value) -> u64 { + query["events"] + .as_array() + .into_iter() + .flatten() + .filter_map(|event| event["data"]["sequence"].as_u64()) + .max() + .unwrap_or(0) +} + +fn chess_window(driver: &mut McpDriver) -> (u64, u64, Value) { + let launch = driver.call("launch_app", json!({"bundle_id": CHESS_BUNDLE})); + assert!( + !launch.is_error(), + "could not launch Chess: {}", + launch.text() + ); + let pid = launch.structured()["pid"] + .as_u64() + .expect("Chess launch returned no pid"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let listed = driver.call("list_windows", json!({"pid": pid})); + assert!(!listed.is_error(), "list_windows failed: {}", listed.text()); + if let Some(window) = listed.structured()["windows"] + .as_array() + .and_then(|windows| { + windows.iter().find(|window| { + window["is_on_screen"].as_bool().unwrap_or(false) + && window["bounds"]["width"].as_f64().unwrap_or(0.0) > 100.0 + && window["bounds"]["height"].as_f64().unwrap_or(0.0) > 100.0 + }) + }) + { + return ( + pid, + window["window_id"] + .as_u64() + .expect("Chess window has no id"), + window["bounds"].clone(), + ); + } + assert!( + Instant::now() < deadline, + "Chess opened no usable on-screen window" + ); + sleep(Duration::from_millis(200)); + } +} + +fn assert_no_private_fields(value: &Value) { + const FORBIDDEN_KEYS: &[&str] = &[ + "screenshot", + "screenshot_png_b64", + "typed_text", + "clipboard", + "raw_arguments", + "raw_results", + "accessibility_tree", + "path", + "title", + "url", + "diagnostic", + ]; + match value { + Value::Object(object) => { + for (key, child) in object { + assert!( + !FORBIDDEN_KEYS.contains(&key.as_str()), + "history exposed forbidden field {key}" + ); + assert_no_private_fields(child); + } + } + Value::Array(items) => items.iter().for_each(assert_no_private_fields), + _ => {} + } +} + +fn ciphertext_paths(root: &Path) -> Vec { + let chunks = root.join("chunks"); + if !chunks.exists() { + return Vec::new(); + } + fs::read_dir(chunks) + .expect("read history chunk directory") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("cborseq")) + .collect() +} + +#[test] +#[ignore = "requires the signed, TCC-authorized packaged daemon in the canonical Lume runner"] +fn history_records_agent_action_before_restart() { + assert_forged_cli_control_is_rejected(); + + let deleted = history_cli("delete", &["--yes"]); + assert_eq!(deleted["enabled"], false, "initial purge failed: {deleted}"); + assert_eq!( + deleted["bytes_used"], 0, + "initial purge retained ciphertext" + ); + + let enabled = history_cli("enable", &[]); + assert_ready(&enabled); + + let mut driver = McpDriver::spawn_macos_daemon_proxy_named("macos-history-before-restart") + .expect("start installed macOS daemon proxy"); + let status = driver.call("history_status", json!({})); + assert!( + !status.is_error(), + "history_status failed: {}", + status.text() + ); + assert_ready(status.structured()); + + let baseline = query(&mut driver, None); + let since_sequence = max_sequence(&baseline).saturating_add(1).max(1); + + let started = driver.call("start_session", json!({"session": RAW_SESSION})); + assert!( + !started.is_error(), + "start_session failed: {}", + started.text() + ); + + let (pid, window_id, bounds) = chess_window(&mut driver); + let x = bounds["x"].as_f64().expect("Chess window x"); + let y = bounds["y"].as_f64().expect("Chess window y"); + let width = bounds["width"].as_f64().expect("Chess window width"); + let height = bounds["height"].as_f64().expect("Chess window height"); + let requested_x = x + 18.0; + let requested_y = y + 12.0; + let moved = driver.call( + "set_window_frame", + json!({ + "pid": pid, + "window_id": window_id, + "x": requested_x, + "y": requested_y, + "width": width, + "height": height, + "session": RAW_SESSION + }), + ); + assert!( + !moved.is_error(), + "set_window_frame failed: {} / {}", + moved.text(), + moved.raw + ); + assert_eq!(moved.action_effect(), Some("confirmed")); + assert_eq!(moved.action_route(), Some("accessibility")); + + let readback = driver.call("list_windows", json!({"pid": pid})); + let observed = readback.structured()["windows"] + .as_array() + .and_then(|windows| { + windows + .iter() + .find(|window| window["window_id"].as_u64() == Some(window_id)) + }) + .expect("moved Chess window disappeared"); + assert!((observed["bounds"]["x"].as_f64().unwrap() - requested_x).abs() <= 2.0); + assert!((observed["bounds"]["y"].as_f64().unwrap() - requested_y).abs() <= 2.0); + + let ended = driver.call("end_session", json!({"session": RAW_SESSION})); + assert!(!ended.is_error(), "end_session failed: {}", ended.text()); + let flushed = history_cli("flush", &[]); + assert_ready(&flushed); + + let hydrated = query(&mut driver, Some(since_sequence)); + assert_no_private_fields(&hydrated); + let events = hydrated["events"].as_array().expect("history events array"); + let completion = events + .iter() + .find(|event| { + event["data"]["capability"] == "window.frame.set" + && event["data"]["payload"]["kind"] == "action_completed" + && event["data"]["payload"]["effect"] == "confirmed" + && event["data"]["payload"]["route"] == "accessibility" + }) + .expect("history did not contain the confirmed window-frame action"); + let action_id = completion["data"]["action_id"] + .as_str() + .expect("history action has no opaque action id"); + let opaque_session_id = completion["data"]["session_id"] + .as_str() + .expect("history action has no opaque session id"); + assert_ne!( + opaque_session_id, RAW_SESSION, + "raw session id entered history" + ); + assert_eq!(completion["data"]["application"]["bundle_id"], CHESS_BUNDLE); + assert_eq!(completion["data"]["application"]["display_name"], "Chess"); + + let marker = json!({ + "schema": "cua-driver/history-continuity-evidence@v1", + "action_id": action_id, + "session_id": opaque_session_id, + "capability": "window.frame.set", + "last_sequence": max_sequence(&hydrated) + }); + fs::write( + marker_path(), + serde_json::to_vec_pretty(&marker).expect("serialize continuity marker"), + ) + .expect("write continuity marker"); + + let ciphertext = ciphertext_paths(&history_root()); + assert!( + !ciphertext.is_empty(), + "history produced no encrypted chunks" + ); + for path in ciphertext { + let bytes = fs::read(&path).expect("read encrypted history chunk"); + for forbidden in [ + RAW_SESSION, + "Chess", + CHESS_BUNDLE, + "window.frame.set", + "action_completed", + ] { + assert!( + !bytes + .windows(forbidden.len()) + .any(|window| window == forbidden.as_bytes()), + "plaintext marker {forbidden:?} appeared in {}", + path.display() + ); + } + } + + let final_status = driver.call("history_status", json!({})); + assert_ready(final_status.structured()); +} + +#[test] +#[ignore = "requires the daemon restart performed by the canonical Lume runner"] +fn history_reopens_after_restart_and_cryptographically_purges() { + let marker: Value = serde_json::from_slice( + &fs::read(marker_path()).expect("continuity marker from pre-restart test is missing"), + ) + .expect("continuity marker is invalid JSON"); + let action_id = marker["action_id"].as_str().expect("marker action id"); + + let mut driver = McpDriver::spawn_macos_daemon_proxy_named("macos-history-after-restart") + .expect("start restarted installed macOS daemon proxy"); + let status = driver.call("history_status", json!({})); + assert!( + !status.is_error(), + "history_status failed: {}", + status.text() + ); + assert_ready(status.structured()); + + let hydrated = query(&mut driver, None); + assert_no_private_fields(&hydrated); + assert!( + hydrated["events"] + .as_array() + .into_iter() + .flatten() + .any(|event| { + event["data"]["action_id"].as_str() == Some(action_id) + && event["data"]["payload"]["kind"] == "action_completed" + && event["data"]["capability"] == "window.frame.set" + }), + "restarted daemon could not hydrate the recorded action" + ); + + let disabled = history_cli("disable", &[]); + assert_eq!(disabled["enabled"], false, "disable did not stop capture"); + assert_eq!(disabled["health"], "disabled"); + assert!(disabled["bytes_used"].as_u64().unwrap_or(0) > 0); + + let preserved = history_cli("list", &["200"]); + assert!( + preserved["events"] + .as_array() + .into_iter() + .flatten() + .any(|event| event["data"]["action_id"].as_str() == Some(action_id)), + "disable unexpectedly removed stored history" + ); + + let deleted = history_cli("delete", &["--yes"]); + assert_eq!(deleted["enabled"], false); + assert_eq!(deleted["bytes_used"], 0, "delete retained encrypted chunks"); + assert!( + ciphertext_paths(&history_root()).is_empty(), + "delete retained encrypted history files" + ); + + let key_lookup = Command::new("/usr/bin/security") + .args([ + "find-generic-password", + "-s", + HISTORY_KEYCHAIN_SERVICE, + "-a", + HISTORY_KEYCHAIN_ACCOUNT, + ]) + .output() + .expect("could not inspect the history Keychain item"); + assert!( + !key_lookup.status.success(), + "history encryption key remained after delete" + ); + let key_error = String::from_utf8_lossy(&key_lookup.stderr); + assert!( + key_error.contains("could not be found") || key_error.contains("-25300"), + "unexpected Keychain lookup result after delete: {key_error}" + ); +} diff --git a/libs/cua-driver/rust/crates/platform-macos/Cargo.toml b/libs/cua-driver/rust/crates/platform-macos/Cargo.toml index ea9b14f2e7..52aff5d4fe 100644 --- a/libs/cua-driver/rust/crates/platform-macos/Cargo.toml +++ b/libs/cua-driver/rust/crates/platform-macos/Cargo.toml @@ -60,6 +60,9 @@ objc2-quartz-core = { version = "0.2", features = ["CALayer"] } # convenience that finalises an mp4 in-process, removing the per-binary # Screen Recording TCC prompt that subprocess capture would otherwise trip. screencapturekit = { version = "6", features = ["macos_15_0"] } +security-framework = { version = "3.7.0", default-features = false, features = ["OSX_10_15"] } +zeroize = { workspace = true } +getrandom = { workspace = true } # tiny-skia for cursor rendering tiny-skia = { version = "0.11", default-features = false, features = ["std"] } diff --git a/libs/cua-driver/rust/crates/platform-macos/src/history.rs b/libs/cua-driver/rust/crates/platform-macos/src/history.rs new file mode 100644 index 0000000000..acf630b0c0 --- /dev/null +++ b/libs/cua-driver/rust/crates/platform-macos/src/history.rs @@ -0,0 +1,229 @@ +//! macOS adapters for encrypted Computer History. + +use cua_driver_core::history::{ + ApplicationIdentity, ApplicationIdentityProvider, HistoryError, HistoryHealthCategory, + HistoryKey, KeyProvider, +}; +use security_framework::{ + access_control::{ProtectionMode, SecAccessControl}, + passwords::{ + delete_generic_password_options, generic_password, set_generic_password_options, + PasswordOptions, + }, +}; +use std::sync::Arc; +use zeroize::Zeroizing; + +const KEY_ACCOUNT: &str = "namespace-root-key-v1"; + +#[derive(Default)] +pub struct MacosKeychainKeyProvider; + +impl MacosKeychainKeyProvider { + pub fn shared() -> Arc { + Arc::new(Self) + } + + fn service(namespace: &str) -> Result { + let valid = !namespace.is_empty() + && namespace.len() <= 64 + && namespace + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')); + if !valid { + return Err(HistoryError::new(HistoryHealthCategory::KeyUnavailable)); + } + Ok(format!("com.trycua.{namespace}.computer-history.v1")) + } + + fn uses_data_protection(namespace: &str) -> bool { + namespace == "cua-driver" + } + + fn lookup_options(service: &str, data_protection: bool) -> PasswordOptions { + let mut options = PasswordOptions::new_generic_password(service, KEY_ACCOUNT); + options.set_access_synchronized(Some(false)); + if data_protection { + options.use_protected_keychain(); + } + options + } + + fn creation_options( + service: &str, + data_protection: bool, + ) -> Result { + let mut options = Self::lookup_options(service, data_protection); + if data_protection { + let access = SecAccessControl::create_with_protection( + Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly), + 0, + ) + .map_err(map_keychain_error)?; + options.set_access_control(access); + } + options.set_label("Cua Driver Computer History encryption key"); + options.set_description("Device-local key for encrypted Cua Driver Computer History"); + Ok(options) + } +} + +impl KeyProvider for MacosKeychainKeyProvider { + fn load_or_create(&self, namespace: &str) -> Result { + let service = Self::service(namespace)?; + let data_protection = Self::uses_data_protection(namespace); + match generic_password(Self::lookup_options(&service, data_protection)) { + Ok(bytes) => key(service, bytes), + Err(error) if error.code() == -25300 => { + let mut bytes = Zeroizing::new(vec![0_u8; 32]); + getrandom::fill(&mut bytes) + .map_err(|_| HistoryError::new(HistoryHealthCategory::KeyUnavailable))?; + set_generic_password_options( + &bytes, + Self::creation_options(&service, data_protection)?, + ) + .map_err(map_keychain_error)?; + // Read back through the same device-only item before any + // encrypted file is created. This detects locked/inaccessible + // Keychain state and prevents a plaintext or replacement-key + // fallback. + let verified = Zeroizing::new( + generic_password(Self::lookup_options(&service, data_protection)) + .map_err(map_keychain_error)?, + ); + if verified.as_slice() != bytes.as_slice() { + return Err(HistoryError::new(HistoryHealthCategory::KeyCorrupt)); + } + zeroizing_key(service, verified) + } + Err(error) => Err(map_keychain_error(error)), + } + } + + fn load(&self, namespace: &str, reference: &str) -> Result { + let service = Self::service(namespace)?; + if reference != service { + return Err(HistoryError::new(HistoryHealthCategory::KeyUnavailable)); + } + let bytes = generic_password(Self::lookup_options( + &service, + Self::uses_data_protection(namespace), + )) + .map_err(map_keychain_error)?; + key(service, bytes) + } + + fn references(&self, namespace: &str) -> Result, HistoryError> { + let service = Self::service(namespace)?; + match generic_password(Self::lookup_options( + &service, + Self::uses_data_protection(namespace), + )) { + Ok(bytes) => { + let _bytes = Zeroizing::new(bytes); + Ok(vec![service]) + } + Err(error) if error.code() == -25300 => Ok(Vec::new()), + Err(error) => Err(map_keychain_error(error)), + } + } + + fn destroy(&self, namespace: &str, reference: &str) -> Result<(), HistoryError> { + let service = Self::service(namespace)?; + if reference != service { + return Err(HistoryError::new(HistoryHealthCategory::KeyUnavailable)); + } + let data_protection = Self::uses_data_protection(namespace); + match delete_generic_password_options(Self::lookup_options(&service, data_protection)) { + Ok(()) => {} + Err(error) if error.code() == -25300 => {} + Err(error) => return Err(map_keychain_error(error)), + } + match generic_password(Self::lookup_options(&service, data_protection)) { + Err(error) if error.code() == -25300 => Ok(()), + Ok(bytes) => { + let _bytes = Zeroizing::new(bytes); + Err(HistoryError::new(HistoryHealthCategory::KeyDestroyFailed)) + } + Err(error) => Err(map_keychain_error(error)), + } + } +} + +fn key(reference: String, bytes: Vec) -> Result { + zeroizing_key(reference, Zeroizing::new(bytes)) +} + +fn zeroizing_key(reference: String, bytes: Zeroizing>) -> Result { + if bytes.len() != 32 { + return Err(HistoryError::new(HistoryHealthCategory::KeyCorrupt)); + } + Ok(HistoryKey { + reference, + epoch: 1, + bytes, + }) +} + +fn map_keychain_error(error: security_framework::base::Error) -> HistoryError { + let category = match error.code() { + -25308 | -25293 => HistoryHealthCategory::KeyLocked, + -25300 => HistoryHealthCategory::KeyUnavailable, + _ => HistoryHealthCategory::KeyUnavailable, + }; + HistoryError::new(category) +} + +#[derive(Default)] +pub struct MacosApplicationIdentityProvider; + +impl ApplicationIdentityProvider for MacosApplicationIdentityProvider { + fn resolve(&self, pid: i64) -> Option { + let pid = i32::try_from(pid).ok()?; + crate::apps::list_running_apps() + .into_iter() + .find(|app| app.pid == pid) + .map(|app| ApplicationIdentity { + bundle_id: app.bundle_id, + display_name: Some(app.name), + }) + } +} + +pub fn application_identity_provider() -> Arc { + Arc::new(MacosApplicationIdentityProvider) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keychain_namespace_is_strict_and_separated() { + assert_ne!( + MacosKeychainKeyProvider::service("cua-driver").unwrap(), + MacosKeychainKeyProvider::service("cua-driver-local").unwrap() + ); + assert!(MacosKeychainKeyProvider::service("../escape").is_err()); + assert!(MacosKeychainKeyProvider::uses_data_protection("cua-driver")); + assert!(!MacosKeychainKeyProvider::uses_data_protection( + "cua-driver-local" + )); + } + + #[test] + #[ignore = "mutates one uniquely named login-Keychain item; run in macOS release qualification"] + fn local_keychain_key_lifecycle_is_destroyable() { + let namespace = format!("cua-driver-local-test-{}", uuid::Uuid::new_v4().simple()); + let provider = MacosKeychainKeyProvider; + let created = provider.load_or_create(&namespace).unwrap(); + assert_eq!(created.bytes.len(), 32); + let loaded = provider.load(&namespace, &created.reference).unwrap(); + assert_eq!(created.bytes.as_slice(), loaded.bytes.as_slice()); + provider.destroy(&namespace, &created.reference).unwrap(); + match provider.load(&namespace, &created.reference) { + Err(error) => assert_eq!(error.category, HistoryHealthCategory::KeyUnavailable), + Ok(_) => panic!("destroyed Keychain reference unexpectedly remained loadable"), + } + } +} diff --git a/libs/cua-driver/rust/crates/platform-macos/src/lib.rs b/libs/cua-driver/rust/crates/platform-macos/src/lib.rs index a75c1dc96f..c1a426c2a7 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/lib.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/lib.rs @@ -23,6 +23,8 @@ pub mod focus_guard; #[cfg(target_os = "macos")] pub mod focus_steal; #[cfg(target_os = "macos")] +pub mod history; +#[cfg(target_os = "macos")] pub mod input; #[cfg(target_os = "macos")] pub mod permissions; diff --git a/libs/cua-driver/scripts/README.md b/libs/cua-driver/scripts/README.md index ea2037c54d..a682099747 100644 --- a/libs/cua-driver/scripts/README.md +++ b/libs/cua-driver/scripts/README.md @@ -43,6 +43,16 @@ security unlock-keychain "$SIGNING_KEYCHAIN" export CUA_DRIVER_LOCAL_SIGNING_KEYCHAIN="$SIGNING_KEYCHAIN" ``` +To use an existing certificate without allowing the installer to select a +different identity from that keychain, also provide its exact SHA-1 fingerprint: + +```bash +export CUA_DRIVER_LOCAL_SIGNING_IDENTITY="<40-hex-character SHA-1>" +``` + +The installer fails closed when that exact usable code-signing identity is not +present in `CUA_DRIVER_LOCAL_SIGNING_KEYCHAIN`. + The first install creates `CuaDriver Local Signing (cua-driver-rs)` in that keychain. If `codesign` cannot use its private key non-interactively, unlock the keychain, trust the certificate in Keychain Access, and authorize Apple diff --git a/libs/cua-driver/scripts/_local-signing.sh b/libs/cua-driver/scripts/_local-signing.sh index 1b836d7eab..0ad1a31527 100644 --- a/libs/cua-driver/scripts/_local-signing.sh +++ b/libs/cua-driver/scripts/_local-signing.sh @@ -39,6 +39,18 @@ ensure_local_signing_identity() { kc="$(local_signing_keychain)" [ -f "$kc" ] || { printf -- '-'; return; } local identity + if [ -n "${CUA_DRIVER_LOCAL_SIGNING_IDENTITY:-}" ]; then + case "$CUA_DRIVER_LOCAL_SIGNING_IDENTITY" in + *[!0-9A-Fa-f]*|'') printf -- '-'; return ;; + esac + [ "${#CUA_DRIVER_LOCAL_SIGNING_IDENTITY}" -eq 40 ] \ + || { printf -- '-'; return; } + identity="$(security find-identity -p codesigning "$kc" 2>/dev/null \ + | awk -v wanted="$CUA_DRIVER_LOCAL_SIGNING_IDENTITY" \ + '{ for (field = 1; field <= NF; field++) if (toupper($field) == toupper(wanted)) { print $field; exit } }')" + [ -n "$identity" ] && printf '%s' "$identity" || printf -- '-' + return + fi identity="$(security find-identity -p codesigning "$kc" 2>/dev/null \ | awk -v cn="$CUA_LOCAL_SIGN_CN" 'index($0, "\"" cn "\"") { print $2; exit }')" if [ -n "$identity" ]; then diff --git a/libs/cua-driver/scripts/tests/test_install_local.py b/libs/cua-driver/scripts/tests/test_install_local.py index 2ab5f3303c..9733d03fec 100644 --- a/libs/cua-driver/scripts/tests/test_install_local.py +++ b/libs/cua-driver/scripts/tests/test_install_local.py @@ -37,6 +37,75 @@ def _write_executable(path: Path, body: str) -> None: path.chmod(0o755) +def test_explicit_local_signing_identity_is_selected_exactly(tmp_path: Path) -> None: + keychain = tmp_path / "signing.keychain-db" + keychain.touch() + fake_bin = tmp_path / "fake-bin" + _write_executable(fake_bin / "codesign", "exit 0\n") + wanted = "F2D26B5AFAAB910B340FBD8F480F88DF748D9D48" + other = "A" * 40 + _write_executable( + fake_bin / "security", + f"printf '%s\\n' ' 1) {wanted} \"Developer ID Application: Example\"' " + f"' 2) {other} \"Developer ID Application: Renewal\"'\n", + ) + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:/usr/bin:/bin", + "CUA_DRIVER_LOCAL_SIGNING_KEYCHAIN": str(keychain), + "CUA_DRIVER_LOCAL_SIGNING_IDENTITY": wanted.lower(), + } + ) + result = subprocess.run( + [ + "/bin/bash", + "-c", + f'OS=Darwin; . "{LOCAL_SIGNING}"; ensure_local_signing_identity', + ], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == wanted + + +def test_explicit_local_signing_identity_never_falls_back(tmp_path: Path) -> None: + keychain = tmp_path / "signing.keychain-db" + keychain.touch() + fake_bin = tmp_path / "fake-bin" + _write_executable(fake_bin / "codesign", "exit 0\n") + _write_executable( + fake_bin / "security", + f"printf '%s\\n' ' 1) {'A' * 40} \"Developer ID Application: Other\"'\n", + ) + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:/usr/bin:/bin", + "CUA_DRIVER_LOCAL_SIGNING_KEYCHAIN": str(keychain), + "CUA_DRIVER_LOCAL_SIGNING_IDENTITY": "B" * 40, + } + ) + result = subprocess.run( + [ + "/bin/bash", + "-c", + f'OS=Darwin; . "{LOCAL_SIGNING}"; ensure_local_signing_identity', + ], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "-" + + @pytest.mark.parametrize("relative_target", [False, True], ids=["absolute", "relative"]) def test_installer_stages_binary_from_custom_cargo_target( tmp_path: Path, relative_target: bool diff --git a/libs/cua-driver/scripts/tests/test_macos_lume_runner.py b/libs/cua-driver/scripts/tests/test_macos_lume_runner.py index f8a1934085..25e192f6a1 100644 --- a/libs/cua-driver/scripts/tests/test_macos_lume_runner.py +++ b/libs/cua-driver/scripts/tests/test_macos_lume_runner.py @@ -20,12 +20,9 @@ REPO_ROOT = Path(__file__).resolve().parents[4] RUN_ALL = REPO_ROOT / "libs/cua-driver/tests/runners/macos-lume/run-all.sh" RUN_RUST_E2E = REPO_ROOT / "scripts/ci/macos/run-rust-e2e.sh" -ELECTRON_BUILD = ( - REPO_ROOT / "libs/cua-driver/tests/fixtures/apps/cross-platform/electron/build.sh" -) +ELECTRON_BUILD = REPO_ROOT / "libs/cua-driver/tests/fixtures/apps/cross-platform/electron/build.sh" ELECTRON_LOCK = ( - REPO_ROOT - / "libs/cua-driver/tests/fixtures/apps/cross-platform/electron/package-lock.json" + REPO_ROOT / "libs/cua-driver/tests/fixtures/apps/cross-platform/electron/package-lock.json" ) TAURI_BUILD = REPO_ROOT / "libs/cua-driver/tests/fixtures/apps/cross-platform/tauri/build.sh" @@ -337,6 +334,7 @@ def test_fixture_builds_use_fresh_run_owned_state() -> None: 'printf "only=%s\\n" "$RETRY_ONLY"\n' 'printf "standalone=%s\\n" "$RUN_STANDALONE_BROWSER"\n' 'printf "nobuild=%s\\n" "$NO_BUILD"\n' + 'printf "lane=%s\\n" "$RETRY_INTERNAL_LANE"\n' ) @@ -380,6 +378,55 @@ def test_retry_attempts_default_to_one() -> None: assert fields["attempts"] == "1" +def test_swiftui_retry_is_routed_to_the_native_lane() -> None: + fields = _parse( + [ + "--retry-cell", + "macos-swiftui-left-click-ax-background", + "--retry-only", + ] + ) + assert fields["status"] == "0" + assert fields["harness"] == "swiftui" + assert fields["lane"] == "native" + + +@pytest.mark.parametrize( + "args", + [ + [ + "--retry-cell", + "macos-swiftui-left-click-ax-background", + "--retry-harness", + "electron", + ], + [ + "--retry-cell", + "macos-electron-left-click-ax-background", + "--retry-harness", + "swiftui", + ], + ], +) +def test_swiftui_retry_cell_and_harness_must_agree(args: list[str]) -> None: + assert _parse(args)["status"] == "2" + + +def test_native_swiftui_selector_matches_exactly_one_owned_cell() -> None: + completed = _run( + RUN_RUST_E2E, + 'CUA_E2E_HARNESS_FILTER="swiftui"\n' + 'CUA_E2E_CELL_FILTER="macos-swiftui-left-click-ax-background"\n' + 'if native_swiftui_test_selected "macos-swiftui-left-click-ax-background"; then ' + 'echo "selected=yes"; fi\n' + 'if native_swiftui_test_selected "macos-swiftui-set-value-ax-background"; then ' + 'echo "wrong=yes"; fi\n', + ) + assert completed.returncode == 0, completed.stderr + assert "selected=yes" in completed.stdout + assert "wrong=yes" not in completed.stdout + + @pytest.mark.parametrize( "args", [ @@ -440,6 +487,89 @@ def test_full_matrix_clears_inherited_retry_filters(tmp_path: Path) -> None: assert output.read_text(encoding="utf-8") == "cell=\nharness=\nlane=\n" +def test_unrestricted_daemon_admits_the_history_preview(tmp_path: Path) -> None: + fake_open = tmp_path / "bin/open" + output = tmp_path / "open-args.txt" + _write_executable(fake_open, 'printf "%s\\n" "$@" > "$CUA_TEST_OPEN_ARGS"\n') + completed = _run( + RUN_ALL, + 'LOCAL_APP="/Applications/Test.app"\nstart_unrestricted_daemon\n', + env={ + "PATH": f"{fake_open.parent}:{os.environ['PATH']}", + "CUA_TEST_OPEN_ARGS": str(output), + }, + ) + assert completed.returncode == 0, completed.stderr + assert output.read_text(encoding="utf-8").splitlines() == [ + "-n", + "-g", + "/Applications/Test.app", + "--args", + "serve", + "--permission-mode", + "unrestricted", + "--dangerously-bypass-approvals", + "--experimental-history", + ] + + +def test_history_hook_p99_parser_is_exact(tmp_path: Path) -> None: + report = tmp_path / "history-hook-benchmark.txt" + report.write_text( + "accepted: p50=10ns p95=20ns p99=30ns n=2000\n" + "full_queue: p50=4ns p95=5ns p99=6ns n=20000\n", + encoding="utf-8", + ) + completed = _run( + RUN_ALL, + 'printf "accepted=%s\\n" "$(history_hook_p99_ns accepted "$REPORT")"\n' + 'printf "full=%s\\n" "$(history_hook_p99_ns full_queue "$REPORT")"\n', + env={"REPORT": str(report)}, + ) + assert completed.returncode == 0, completed.stderr + assert _fields(completed.stdout) == {"accepted": "30", "full": "6"} + + +def test_runner_requires_an_exact_identity_hash_when_configured() -> None: + text = RUN_ALL.read_text(encoding="utf-8") + assert '[[ ! "${CUA_E2E_SIGNING_IDENTITY}" =~ ^[0-9A-Fa-f]{40}$ ]]' in text + assert 'export CUA_DRIVER_LOCAL_SIGNING_IDENTITY="${CUA_E2E_SIGNING_IDENTITY}"' in text + + +def test_required_keychains_unlock_login_without_retaining_password( + tmp_path: Path, +) -> None: + signing_keychain = tmp_path / "signing.keychain-db" + login_keychain = tmp_path / "login.keychain-db" + signing_keychain.touch() + login_keychain.touch() + fake_security = tmp_path / "bin/security" + log = tmp_path / "security.log" + _write_executable( + fake_security, + """printf '%s\\n' "$*" >> "$CUA_TEST_SECURITY_LOG" +""", + ) + completed = _run( + RUN_ALL, + "unlock_required_keychains\n" + 'printf "password_present=%s\\n" "${CUA_E2E_SIGNING_KEYCHAIN_PASSWORD+x}"\n', + env={ + "PATH": f"{fake_security.parent}:{os.environ['PATH']}", + "CUA_E2E_SIGNING_KEYCHAIN": str(signing_keychain), + "CUA_E2E_LOGIN_KEYCHAIN": str(login_keychain), + "CUA_E2E_SIGNING_KEYCHAIN_PASSWORD": "fixture-password", + "CUA_TEST_SECURITY_LOG": str(log), + }, + ) + assert completed.returncode == 0, completed.stderr + assert "password_present=" in completed.stdout + assert log.read_text(encoding="utf-8").splitlines() == [ + f"unlock-keychain -p fixture-password {signing_keychain}", + f"unlock-keychain -p fixture-password {login_keychain}", + ] + + # -------------------------------------------------------------------------- # Retry bookkeeping # -------------------------------------------------------------------------- @@ -474,8 +604,8 @@ def _write_failures(path: Path, **overrides: object) -> None: CHECK_ELIGIBILITY = ( - "RETRY_CELL=\"$RETRY_CELL_UNDER_TEST\"\n" - "RETRY_HARNESS=\"$RETRY_HARNESS_UNDER_TEST\"\n" + 'RETRY_CELL="$RETRY_CELL_UNDER_TEST"\n' + 'RETRY_HARNESS="$RETRY_HARNESS_UNDER_TEST"\n' "status=0\n" 'retry_selection_is_eligible "$RESULTS" "$FAILURES" || status=$?\n' 'printf "status=%s\\n" "$status"\n' @@ -989,9 +1119,7 @@ def test_watchdog_does_not_restart_after_one_transient_probe_failure(tmp_path: P ) completed = _run( RUN_ALL, - INSTALLED_BIN_PRELUDE - + 'ARTIFACT_DIR="$TEST_ARTIFACT_DIR"\n' - "watchdog_check_once\n", + INSTALLED_BIN_PRELUDE + 'ARTIFACT_DIR="$TEST_ARTIFACT_DIR"\nwatchdog_check_once\n', env=env, ) assert completed.returncode == 0, completed.stderr @@ -1021,8 +1149,7 @@ def test_sigterm_restores_standard_daemon_and_preserves_signal_status(tmp_path: calls, env = _daemon_fakes(tmp_path, initial_mode="unrestricted") completed = _run( RUN_ALL, - INSTALLED_BIN_PRELUDE - + "RESTORE_STANDARD_DAEMON=1\n" + INSTALLED_BIN_PRELUDE + "RESTORE_STANDARD_DAEMON=1\n" "install_daemon_restore_traps\n" "kill -TERM $$\n", env=env, @@ -1071,8 +1198,7 @@ def test_a_failed_restoration_fails_an_otherwise_green_run(tmp_path: Path) -> No env["CUA_TEST_LOAD_MODE"] = "unrestricted" completed = _run( RUN_ALL, - INSTALLED_BIN_PRELUDE - + "RESTORE_STANDARD_DAEMON=1\ninstall_daemon_restore_traps\nexit 0\n", + INSTALLED_BIN_PRELUDE + "RESTORE_STANDARD_DAEMON=1\ninstall_daemon_restore_traps\nexit 0\n", env=env, ) assert completed.returncode == 1 @@ -1083,8 +1209,7 @@ def test_the_trap_leaves_an_unclaimed_daemon_alone(tmp_path: Path) -> None: calls, env = _daemon_fakes(tmp_path) completed = _run( RUN_ALL, - INSTALLED_BIN_PRELUDE - + "RESTORE_STANDARD_DAEMON=0\ninstall_daemon_restore_traps\nexit 4\n", + INSTALLED_BIN_PRELUDE + "RESTORE_STANDARD_DAEMON=0\ninstall_daemon_restore_traps\nexit 4\n", env=env, ) assert completed.returncode == 4 diff --git a/libs/cua-driver/scripts/tests/uninstall-history-purge-test.sh b/libs/cua-driver/scripts/tests/uninstall-history-purge-test.sh new file mode 100644 index 0000000000..eb960dc4b2 --- /dev/null +++ b/libs/cua-driver/scripts/tests/uninstall-history-purge-test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +UNINSTALL="$SCRIPT_DIR/../uninstall.sh" +FIXTURE="$(mktemp -d)" +APP="$FIXTURE/CuaDriver.app" +HELPER="$APP/Contents/MacOS/cua-driver" +STATE="$FIXTURE/computer-history" +LOG="$FIXTURE/helper.log" +mkdir -p "$(dirname "$HELPER")" "$STATE" +touch "$STATE/state.json" + +cat > "$HELPER" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$UNINSTALL_FIXTURE_LOG" +if [[ "$*" == "history purge-offline --yes" && "${UNINSTALL_FIXTURE_FAIL:-0}" == "1" ]]; then + exit 1 +fi +SH +chmod +x "$HELPER" +cat > "$FIXTURE/codesign" <<'SH' +#!/usr/bin/env bash +exit 0 +SH +chmod +x "$FIXTURE/codesign" + +CUA_DRIVER_UNINSTALL_TEST_SOURCE_ONLY=1 source "$UNINSTALL" +export UNINSTALL_FIXTURE_LOG="$LOG" +if reject_root_invocation 0 2> "$FIXTURE/root-error.log"; then + echo "expected root invocation refusal" >&2 + exit 1 +fi +grep -Fq 'do not run the Cua Driver uninstaller with sudo' "$FIXTURE/root-error.log" +reject_root_invocation 501 +purge_macos_history "$APP" "$HELPER" 1 "$FIXTURE/codesign" +[[ "$(sed -n '1p' "$LOG")" == "stop" ]] +[[ "$(sed -n '2p' "$LOG")" == "history purge-offline --yes" ]] + +export UNINSTALL_FIXTURE_FAIL=1 +if purge_macos_history "$APP" "$HELPER" 1 "$FIXTURE/codesign" 2> "$FIXTURE/error.log"; then + echo "expected synthetic purge failure" >&2 + exit 1 +fi +grep -Fq history_purge_incomplete "$FIXTURE/error.log" +[[ -d "$APP" ]] +[[ -f "$STATE/state.json" ]] + +purge_line="$(grep -n 'purge_macos_history \\' "$UNINSTALL" | tail -1 | cut -d: -f1)" +remove_line="$(grep -n 'rm -rf "\$APP_BUNDLE"' "$UNINSTALL" | head -1 | cut -d: -f1)" +[[ "$purge_line" -lt "$remove_line" ]] +grep -Fq 'preserved encrypted Computer History' "$UNINSTALL" + +echo "uninstall history purge fixture: ok" diff --git a/libs/cua-driver/scripts/uninstall.sh b/libs/cua-driver/scripts/uninstall.sh index 4eee7ad170..63013854aa 100755 --- a/libs/cua-driver/scripts/uninstall.sh +++ b/libs/cua-driver/scripts/uninstall.sh @@ -109,6 +109,41 @@ fi # ---------------------------------------------------------------------- log() { printf '==> %s\n' "$*"; } +purge_macos_history() { + local app_bundle="$1" + local helper="$2" + local rust_install_present="$3" + local codesign_tool="$4" + if [[ "$rust_install_present" != "1" || ! -x "$helper" ]] \ + || ! "$codesign_tool" --verify --deep --strict "$app_bundle" >/dev/null 2>&1; then + printf 'history_purge_incomplete: installed signed Cua Driver helper unavailable; preserved history state for retry\n' >&2 + return 1 + fi + "$helper" stop >/dev/null 2>&1 || true + if ! "$helper" history purge-offline --yes; then + printf 'history_purge_incomplete: exact-namespace key destruction was not verified; preserved history state and app for retry\n' >&2 + return 1 + fi +} + +reject_root_invocation() { + local effective_uid="$1" + if [[ "$effective_uid" == "0" ]]; then + printf 'error: do not run the Cua Driver uninstaller with sudo; run it as the login user so Computer History is purged from the correct home directory and Keychain. The script elevates only protected app removal when needed.\n' >&2 + return 77 + fi +} + +# Narrow source-only seam for the synthetic uninstall fixture. Production +# execution never sets this variable and continues through the full script. +if [[ "${CUA_DRIVER_UNINSTALL_TEST_SOURCE_ONLY:-0}" == "1" ]]; then + return 0 +fi + +if ! reject_root_invocation "$(id -u)"; then + exit 77 +fi + # TCC revocation is on by default so uninstall leaves the next macOS install # in a clean promptable state. The bundle id com.trycua.driver is shared with # the retired Swift driver, so `--keep-tcc` remains available for users who @@ -309,6 +344,21 @@ if [[ "$USE_RUST_BACKEND" == "1" ]]; then fi fi + # Cryptographic history purge must run while the exact packaged, signed + # executable still exists. The helper uses the production KeyProvider and + # its own bundle-derived namespace, then takes the exclusive writer lease; + # failure leaves the app and all retryable history state in place. + if [[ "$OS" == "Darwin" && "$PURGE_DATA" == "1" ]]; then + HISTORY_PURGE_HELPER="$APP_BUNDLE/Contents/MacOS/cua-driver" + if ! purge_macos_history \ + "$APP_BUNDLE" "$HISTORY_PURGE_HELPER" "$RUST_INSTALL_PRESENT" /usr/bin/codesign; then + exit 1 + fi + log "cryptographically purged release Computer History key and local history state" + elif [[ "$OS" == "Darwin" ]]; then + log "preserved encrypted Computer History if present; reinstall to reopen it or run uninstall.sh --purge to destroy it" + fi + # --- Revoke TCC grants BEFORE removing the app --- # tccutil resolves com.trycua.driver through LaunchServices, so the reset # only works while /Applications/CuaDriver.app is still installed. Running diff --git a/libs/cua-driver/tests/runners/macos-lume/README.md b/libs/cua-driver/tests/runners/macos-lume/README.md index 747a0ee90f..afb3c44b29 100644 --- a/libs/cua-driver/tests/runners/macos-lume/README.md +++ b/libs/cua-driver/tests/runners/macos-lume/README.md @@ -312,8 +312,11 @@ libs/cua-driver/scripts/sync-vm-worktree.sh push "lume@${VM_IP}" '~/cua' Open Terminal in the VM display and run the single guest entrypoint. Do not run it over SSH: GUI fixtures must inherit the logged-in console session. The -runner asks once for the dedicated keychain password after each worker boot; -do not put that password in the repository or VM image. +runner asks for the dedicated keychain password and then the console user's +login Keychain password after each worker boot. The explicit second unlock is +required because a fresh public image can leave the login Keychain locked even +after automatic GUI login. Use the same local VM credential for both prompts, +and do not put it in the repository, image, logs, or artifacts. ```bash cd ~/cua @@ -351,9 +354,10 @@ complete this setup there, stop it, and use that new seed for workers; never update the original seed in place. The entrypoint refuses the wrong OS, user session, SIP state, dirty or -unidentified source, missing dependencies, ad-hoc signature, stale installed -daemon, unusable TCC grants, or missing Terminal/CuaDriver Automation grants. -It reinstalls the exact source commit and then runs the canonical macOS matrix. +unidentified source, missing dependencies, ad-hoc signature, unavailable login +Keychain, stale installed daemon, unusable TCC grants, or missing +Terminal/CuaDriver Automation grants. It reinstalls the exact source commit and +then runs the canonical macOS matrix. ## Build isolation across reruns @@ -404,15 +408,17 @@ to 1. `--retry-harness` is optional and must match the failing row's harness. After a failing full matrix the runner retries only when all of these hold: - exactly one typed cell did not pass, and it is the `--retry-cell` selection; -- the only failing lane is `shared-app-matrix`; +- the only failing lane is `shared-app-matrix` or the one SwiftUI test that + owns the selected cell; - the environment preflight, typed report validation, and trajectory-video checks all passed; - the failure record contains exactly one failure signal. Otherwise it prints why the retry was refused and exits with the matrix's -failure. Only shared web-action cells are retryable: they are the cells a -single-cell filter can select without leaving another lane with no cells to run. -Reproduce a native, capture, or embedded-browser failure with a full rerun. +failure. Shared web-action cells and the five typed SwiftUI cells are retryable. +The runner routes a `macos-swiftui-*` selection to the native lane and invokes +only the test that owns that exact cell. Reproduce other native, capture, or +embedded-browser failures with a full rerun. To rerun just that cell later in the same booted worker after the first run already restored standard mode, use `--retry-only`. It reinstalls the exact @@ -423,8 +429,8 @@ the selection, so the retry cannot silently execute against the standard daemon: cd ~/cua libs/cua-driver/tests/runners/macos-lume/run-all.sh \ --retry-only \ - --retry-cell macos-electron-drag-px-foreground \ - --retry-harness electron \ + --retry-cell macos-swiftui-left-click-ax-background \ + --retry-harness swiftui \ --retry-attempts 3 ``` diff --git a/libs/cua-driver/tests/runners/macos-lume/run-all.sh b/libs/cua-driver/tests/runners/macos-lume/run-all.sh index 59ca74130d..5155bed344 100755 --- a/libs/cua-driver/tests/runners/macos-lume/run-all.sh +++ b/libs/cua-driver/tests/runners/macos-lume/run-all.sh @@ -11,7 +11,8 @@ ARTIFACT_DIR="${REPO_ROOT}/artifacts/cua-driver/macos" ARTIFACT_HISTORY_ROOT="${REPO_ROOT}/artifacts/cua-driver/macos-history" SOURCE_MARKER="${CUA_E2E_SOURCE_MARKER:-${REPO_ROOT}/.cua-e2e-source-sha}" SIGNING_KEYCHAIN="${CUA_E2E_SIGNING_KEYCHAIN:-${HOME}/Library/Keychains/cua-driver-signing.keychain-db}" -SIGNING_CN="CuaDriver Local Signing (cua-driver-rs)" +LOGIN_KEYCHAIN="${CUA_E2E_LOGIN_KEYCHAIN:-${HOME}/Library/Keychains/login.keychain-db}" +SIGNING_CN="${CUA_E2E_SIGNING_CN:-CuaDriver Local Signing (cua-driver-rs)}" LOCAL_APP="/Applications/CuaDriverLocal.app" # install-local.sh intentionally uses a separate namespace from release installs. INSTALLED_BIN="${HOME}/.local/bin/cua-driver-local" @@ -20,8 +21,8 @@ CUA_E2E_MACOS_DAEMON_SOCKET="${CUA_E2E_MACOS_DAEMON_SOCKET:-${HOME}/Library/Cach # A run-owned Cargo namespace keeps a certification build off the seed image's # and any other commit's target state without deleting a shared cache. CARGO_TARGET_ROOT="${CUA_E2E_CARGO_TARGET_ROOT:-${HOME}/Library/Caches/cua-driver-e2e/cargo-target}" -# Only the shared web-action lanes honor cell and harness filters, so a targeted -# retry always reruns that internal lane. +# Shared web-action retries use the shared lane. The supported native SwiftUI +# cells are routed to the native lane after argument validation. RETRY_INTERNAL_LANE=shared RETRY_ATTEMPTS_LIMIT=3 # How long to wait for a daemon mode transition, in one-second polls. @@ -30,7 +31,14 @@ DAEMON_MODE_WAIT_ATTEMPTS="${CUA_E2E_DAEMON_WAIT_ATTEMPTS:-10}" # single-cell rerun can reproduce. The embedded-browser lane is excluded on # purpose: filtering to one of its cells leaves the shared web-action lane with # no selected cells, which that lane reports as a failure of its own. -RETRYABLE_LANES=(shared-app-matrix) +RETRYABLE_LANES=( + shared-app-matrix + swiftui-harness_swiftui_smoke + swiftui-harness_swiftui_counter_background + swiftui-harness_swiftui_set_value_background + swiftui-harness_swiftui_popover_foreground + swiftui-harness_swiftui_verify_state +) usage() { cat <<'EOF' @@ -140,6 +148,20 @@ validate_arguments() { echo "--retry-only cannot be combined with --standalone-browser" >&2 return 2 fi + + if [[ "${RETRY_CELL}" == macos-swiftui-* ]]; then + if [[ -n "${RETRY_HARNESS}" && "${RETRY_HARNESS}" != swiftui ]]; then + echo "${RETRY_CELL} belongs to the swiftui harness, not ${RETRY_HARNESS}" >&2 + return 2 + fi + RETRY_HARNESS=swiftui + RETRY_INTERNAL_LANE=native + elif [[ "${RETRY_HARNESS}" == swiftui ]]; then + echo "the swiftui harness requires a macos-swiftui-* retry cell" >&2 + return 2 + else + RETRY_INTERNAL_LANE=shared + fi } # Capture a command's full output before matching it. Piping a producer into @@ -165,6 +187,30 @@ output_contains() { [[ "${CAPTURED_OUTPUT}" == *"${needle}"* ]] } +unlock_required_keychains() { + local keychain_password="${CUA_E2E_SIGNING_KEYCHAIN_PASSWORD:-}" + unset CUA_E2E_SIGNING_KEYCHAIN_PASSWORD + + echo "[SIGNING] Unlocking the golden image's dedicated signing keychain" + if [[ -n "${keychain_password}" ]]; then + security unlock-keychain -p "${keychain_password}" "${SIGNING_KEYCHAIN}" + else + security unlock-keychain "${SIGNING_KEYCHAIN}" + fi + + if [[ ! -f "${LOGIN_KEYCHAIN}" ]]; then + echo "Missing console user's login Keychain: ${LOGIN_KEYCHAIN}" >&2 + return 2 + fi + echo "[HISTORY] Unlocking the login Keychain for encrypted computer history" + if [[ -n "${keychain_password}" ]]; then + security unlock-keychain -p "${keychain_password}" "${LOGIN_KEYCHAIN}" + else + security unlock-keychain "${LOGIN_KEYCHAIN}" + fi + keychain_password="" +} + json_string_array() { local item local result='' @@ -243,6 +289,7 @@ start_unrestricted_daemon() { serve \ --permission-mode unrestricted \ --dangerously-bypass-approvals \ + --experimental-history \ >/dev/null 2>&1 } @@ -459,6 +506,81 @@ run_full_matrix() { return "${status}" } +history_hook_p99_ns() { + local label="$1" + local report="$2" + sed -nE "s/^${label}: .*p99=([0-9]+)ns .*$/\\1/p" "${report}" +} + +run_history_hook_benchmark() { + local report="${ARTIFACT_DIR}/history-hook-benchmark.txt" + local accepted_p99 full_queue_p99 + echo "[HISTORY] Measuring the synchronous producer hook" + ( + cd "${RUST_ROOT}" + cargo run -p cua-driver-core --release --example history_hook_bench + ) 2>&1 | tee "${report}" + accepted_p99="$(history_hook_p99_ns accepted "${report}")" + full_queue_p99="$(history_hook_p99_ns full_queue "${report}")" + if [[ ! "${accepted_p99}" =~ ^[0-9]+$ ]] \ + || [[ ! "${full_queue_p99}" =~ ^[0-9]+$ ]]; then + echo "Computer History hook benchmark did not emit parseable p99 results" >&2 + return 1 + fi + if ((accepted_p99 >= 1000000 || full_queue_p99 >= 1000000)); then + echo "Computer History hook p99 exceeded the 1 ms preview gate" >&2 + return 1 + fi + jq -n \ + --arg schema 'cua-driver/history-hook-benchmark@v1' \ + --argjson threshold_ns 1000000 \ + --argjson accepted_p99_ns "${accepted_p99}" \ + --argjson full_queue_p99_ns "${full_queue_p99}" \ + '{schema: $schema, threshold_ns: $threshold_ns, + accepted_p99_ns: $accepted_p99_ns, + full_queue_p99_ns: $full_queue_p99_ns, + status: "pass"}' \ + > "${ARTIFACT_DIR}/history-hook-benchmark.json" +} + +restart_unrestricted_daemon() { + stop_unrestricted_watchdog + stop_worker_daemon + start_unrestricted_daemon + if ! wait_for_permission_mode unrestricted "${DAEMON_MODE_WAIT_ATTEMPTS}"; then + printf '%s\n' "${CAPTURED_OUTPUT}" >&2 + echo "The macOS history gate could not restart its admitted daemon" >&2 + return 1 + fi + start_unrestricted_watchdog +} + +run_computer_history_gate() { + local marker="${ARTIFACT_DIR}/history-continuity-marker.json" + local test_binary="computer_history_macos_test" + export CUA_TEST_DRIVER_BIN="${CARGO_TARGET_DIR}/release/cua-driver" + export CUA_E2E_HISTORY_MARKER="${marker}" + + echo "[HISTORY] Recording one packaged action before daemon restart" + ( + cd "${RUST_ROOT}" + cargo test -p cua-driver --release --test "${test_binary}" \ + history_records_agent_action_before_restart -- \ + --ignored --exact --nocapture --test-threads=1 + ) 2>&1 | tee "${ARTIFACT_DIR}/history-before-restart.log" + + echo "[HISTORY] Restarting the packaged daemon and reopening encrypted state" + restart_unrestricted_daemon + ( + cd "${RUST_ROOT}" + cargo test -p cua-driver --release --test "${test_binary}" \ + history_reopens_after_restart_and_cryptographically_purges -- \ + --ignored --exact --nocapture --test-threads=1 + ) 2>&1 | tee "${ARTIFACT_DIR}/history-after-restart.log" + + run_history_hook_benchmark +} + RETRY_BUILD_DONE=0 run_retry_matrix() { local status=0 @@ -651,14 +773,16 @@ if [[ ! -f "${SIGNING_KEYCHAIN}" ]]; then echo "Create the private seed according to tests/runners/macos-lume/README.md" >&2 exit 2 fi -echo "[SIGNING] Unlocking the golden image's dedicated signing keychain" -if [[ -n "${CUA_E2E_SIGNING_KEYCHAIN_PASSWORD:-}" ]]; then - security unlock-keychain -p "${CUA_E2E_SIGNING_KEYCHAIN_PASSWORD}" "${SIGNING_KEYCHAIN}" - unset CUA_E2E_SIGNING_KEYCHAIN_PASSWORD -else - security unlock-keychain "${SIGNING_KEYCHAIN}" -fi -if ! output_contains "\"${SIGNING_CN}\"" \ +unlock_required_keychains +if [[ -n "${CUA_E2E_SIGNING_IDENTITY:-}" ]]; then + if [[ ! "${CUA_E2E_SIGNING_IDENTITY}" =~ ^[0-9A-Fa-f]{40}$ ]] \ + || ! output_contains "${CUA_E2E_SIGNING_IDENTITY}" \ + security find-identity -v -p codesigning "${SIGNING_KEYCHAIN}"; then + echo "The dedicated keychain does not contain the requested exact signing identity" >&2 + exit 2 + fi + export CUA_DRIVER_LOCAL_SIGNING_IDENTITY="${CUA_E2E_SIGNING_IDENTITY}" +elif ! output_contains "\"${SIGNING_CN}\"" \ security find-identity -v -p codesigning "${SIGNING_KEYCHAIN}"; then echo "The dedicated keychain has no valid ${SIGNING_CN} identity" >&2 exit 2 @@ -782,6 +906,8 @@ if [[ "${RETRY_ONLY}" == 1 ]]; then exit 0 fi +run_computer_history_gate + echo "[E2E] Running the canonical macOS matrix" MATRIX_STATUS=0 run_full_matrix || MATRIX_STATUS=$? diff --git a/scripts/ci/macos/run-rust-e2e.sh b/scripts/ci/macos/run-rust-e2e.sh index 7a36eb1fba..d631746aed 100755 --- a/scripts/ci/macos/run-rust-e2e.sh +++ b/scripts/ci/macos/run-rust-e2e.sh @@ -133,6 +133,29 @@ run_test() { fi } +filter_contains_exact() { + local filter="$1" + local expected="$2" + local value + [[ -z "${filter}" ]] && return 0 + while IFS= read -r value; do + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + [[ "${value}" == "${expected}" ]] && return 0 + done < <(tr ',' '\n' <<< "${filter}") + return 1 +} + +native_retry_filter_active() { + [[ -n "${CUA_E2E_CELL_FILTER:-}" || -n "${CUA_E2E_HARNESS_FILTER:-}" ]] +} + +native_swiftui_test_selected() { + local cell="$1" + filter_contains_exact "${CUA_E2E_HARNESS_FILTER:-}" swiftui \ + && filter_contains_exact "${CUA_E2E_CELL_FILTER:-}" "${cell}" +} + if [[ "${CUA_E2E_RUNNER_LIB_ONLY:-0}" == 1 ]]; then # Sourced by the focused runner tests, which exercise the helpers above # without a live macOS desktop. @@ -313,10 +336,12 @@ if [[ "${SUITE}" == shared || "${SUITE}" == all ]]; then --nocapture --test-threads=1 fi if [[ "${SUITE}" == native || "${SUITE}" == all ]]; then - run_test agent-cursor-showcase cargo test -p cua-driver \ - --test agent_cursor_showcase_test -- \ - --ignored --nocapture --test-threads=1 - for appkit_test in \ + NATIVE_FILTER_MATCHES=0 + if ! native_retry_filter_active; then + run_test agent-cursor-showcase cargo test -p cua-driver \ + --test agent_cursor_showcase_test -- \ + --ignored --nocapture --test-threads=1 + for appkit_test in \ harness_appkit_smoke \ harness_appkit_query_projects_structured_elements \ harness_appkit_stale_element_token_fails_closed \ @@ -334,24 +359,38 @@ if [[ "${SUITE}" == native || "${SUITE}" == all ]]; then harness_appkit_double_click_px_foreground \ harness_appkit_double_click_px_background \ harness_appkit_slider_drag_px_foreground \ - harness_appkit_slider_drag_px_background; do - run_test "appkit-${appkit_test}" cargo test -p cua-driver --test harness_appkit_test -- \ - --ignored --exact "${appkit_test}" --nocapture --test-threads=1 - done - for swiftui_test in \ - harness_swiftui_smoke \ - harness_swiftui_counter_background \ - harness_swiftui_set_value_background \ - harness_swiftui_popover_foreground \ - harness_swiftui_verify_state; do - run_test "swiftui-${swiftui_test}" cargo test -p cua-driver --test harness_swiftui_test -- \ - --ignored --exact "${swiftui_test}" --nocapture --test-threads=1 - done - run_test installed-app-launch cargo test -p cua-driver --test installed_app_launch_macos_test -- \ - --ignored --nocapture --test-threads=1 - run_test installed-app-textedit cargo test -p cua-driver --test installed_app_textedit_macos_test -- \ - --ignored --exact background_type_on_native_cocoa_is_ax_verified \ - --nocapture --test-threads=1 + harness_appkit_slider_drag_px_background; do + run_test "appkit-${appkit_test}" cargo test -p cua-driver --test harness_appkit_test -- \ + --ignored --exact "${appkit_test}" --nocapture --test-threads=1 + done + fi + + while IFS='|' read -r swiftui_cell swiftui_test; do + if native_swiftui_test_selected "${swiftui_cell}"; then + NATIVE_FILTER_MATCHES=$((NATIVE_FILTER_MATCHES + 1)) + run_test "swiftui-${swiftui_test}" cargo test -p cua-driver --test harness_swiftui_test -- \ + --ignored --exact "${swiftui_test}" --nocapture --test-threads=1 + fi + done <<'EOF' +macos-swiftui-ax-tree-ax-not-applicable|harness_swiftui_smoke +macos-swiftui-left-click-ax-background|harness_swiftui_counter_background +macos-swiftui-set-value-ax-background|harness_swiftui_set_value_background +macos-swiftui-popover-open-ax-foreground|harness_swiftui_popover_foreground +macos-swiftui-verify-state-ax-not-applicable|harness_swiftui_verify_state +EOF + + if native_retry_filter_active; then + if ((NATIVE_FILTER_MATCHES == 0)); then + echo "No native test owns the requested harness/cell filter" >&2 + note_lane_failure native-filter-selection + fi + else + run_test installed-app-launch cargo test -p cua-driver --test installed_app_launch_macos_test -- \ + --ignored --nocapture --test-threads=1 + run_test installed-app-textedit cargo test -p cua-driver --test installed_app_textedit_macos_test -- \ + --ignored --exact background_type_on_native_cocoa_is_ax_verified \ + --nocapture --test-threads=1 + fi fi if [[ "${SUITE}" == capture || "${SUITE}" == all ]]; then run_test capture-contract cargo test -p cua-driver --test capture_contract_test -- \