diff --git a/CHANGELOG.md b/CHANGELOG.md index 40eeb5c..a01e34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,10 +22,13 @@ experimental; Windows support is not claimed. - Deduplicate Android Skill setup/readiness guidance: keep Android procedures in `android-setup.md`, retain routing in the main Skill, and centralize shared Appium service ownership in the execution seam. Preserve the existing rules and tests without changing Apple branches, pinned official source or core behavior ([#130](https://github.com/FSQ-lab/amkernel/issues/130)). - Correct strict typing in the Android setup experiment's JSON narrowing, version evidence and regression annotations without changing approval/readiness decisions. The full production-and-test Mypy gate now passes alongside the existing protocol/oracle smoke ([#133](https://github.com/FSQ-lab/amkernel/issues/133)). - Add target-scoped physical-iPhone setup to the packaged `amkernel` Skill: reuse applicable Runtime/Xcode/Appium/WDA assets, disclose dependency and security changes, guide native account/passcode/keychain actions, and distinguish profile/runner identity repair from connection failures. Preserve official Appium guidance, setup-only no-launch/no-session, shared-resource ownership and required prepared artifacts. Extend managed-reference integrity coverage; core interfaces and platform qualification remain unchanged ([#131](https://github.com/FSQ-lab/amkernel/issues/131)). +- Add target-scoped native macOS setup guidance before the verified Mac2 route: reuse the actual Runtime/Appium home/Xcode selection, resolve exact Node/Appium/driver compatibility, keep privacy and administrator choices native and user-owned, and distinguish prepared prerequisites from live target control. Add a deterministic synthetic approval/readiness oracle without changing core interfaces or platform qualification ([#140](https://github.com/FSQ-lab/amkernel/issues/140)). - Deduplicate physical-iPhone Skill service, connection, and lifecycle guidance by requiring the existing shared Appium execution contract. Retain WDA launch approval, native security prompts, selected-route capabilities, ownership reconciliation, and prepared-artifact preservation; official skills, core behavior, and test infrastructure are unchanged ([#131](https://github.com/FSQ-lab/amkernel/issues/131)). - Scope the immutable release-evidence audit to the original archive corpus so later normalized verification records remain admissible. Preserve every retained member's byte-size/digest checks and reject reintroduced release-only paths without changing the published index ([#131](https://github.com/FSQ-lab/amkernel/issues/131)). - Concentrate Android execution guidance in a managed `android-execution.md` reference: distinguish app/page/context/target evidence, refresh element assumptions, bound scrolling and waits, separate input from persistence, and preserve requested final state. Adapt outcome-first and minimal-observation principles from the OpenAI CUA sample without changing Runtime, setup or official inputs. Restore two missing native behavior prompts and accept successful pre-action state evidence rather than requiring XML for restoration; strict baseline boundary regressions preserve the behavioral contract ([#98](https://github.com/FSQ-lab/amkernel/issues/98)). - Unify Android, iOS and native macOS observation guidance: use controls for sufficient location/action/result evidence, with explicit-image requests and genuinely visual questions taking the screenshot path. Require rendered image observations before image-dependent actions, current coordinate geometry and observed results; retain privacy, approval and lifecycle boundaries. Add three-platform synthetic visual fixtures and image-identity/ordering regressions without changing Runtime, setup or pinned official inputs ([#98](https://github.com/FSQ-lab/amkernel/issues/98)). +- Extend the CUA-informed outcome-first execution contract to iOS/XCUITest and native macOS/Mac2. Short-flow prompts now state the requested final UI state instead of prescribing driver code; shared guidance requires current app/context/window evidence, fresh semantic targets and observed results. Synthetic evaluators now reject an unobserved iOS state change and unnecessarily fragmented Mac2 execution without changing Runtime, setup, ownership or platform-qualification boundaries ([#98](https://github.com/FSQ-lab/amkernel/issues/98)). +- Replace duplicated platform readiness prose with one structured readiness/authorization contract and shallow platform adapters. Shrink the main Skill to routing and execution invariants; delete copied version-sensitive Node/Appium/driver procedures from Android, physical-iPhone and native-macOS guidance; add a focused iOS Simulator adapter; carry one exact authorization effect set across official-Skill handoffs; expose one structured fail-closed official-guidance verifier result; and reject every Windows host or target without mutation or fallback. Approval remains procedural guidance, not a security boundary ([#98](https://github.com/FSQ-lab/amkernel/issues/98)). ## [0.1.2] - 2026-11-04 diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 31415c0..78ffa68 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -2,7 +2,7 @@ ## Task -Resolve the separately scoped post-release core CI defects #114/#115, then continue the owner-authorized ordered real-platform acceptance, real Windows Android verification, and installed-wheel Codex/cross-OS Stage 4 evaluation without changing the public interface or support claims. +Resolve the separately scoped post-release core CI defects #114/#115, then continue the owner-authorized Android, iOS and native macOS acceptance plus installed-wheel Codex/cross-OS Stage 4 evaluation without changing the public Runtime interface. Windows is explicitly outside AMKernel support. ## Context @@ -143,16 +143,12 @@ discovery returned one connected development-enabled target, not XCUITest readin Existing task-local Java 17 and Android SDK/adb were found and execution-validated; no new dependency installation or shared ADB startup was performed in this attempt. -Windows prerequisite discovery found no authorized reachable Windows/Android host -or repository self-hosted runner. No actual Windows command or failure was -observed. `published-011-windows-readiness-20261103.json` distinguishes those facts -from static shipped-code POSIX requirements: Artifact Store startup, Supervisor -spawning/control and Runner signal initialization lack native Windows support. -Windows needs the separate accepted platform design required by #100/ADR-0005; -do not remove guards or weaken file-identity/owned-cleanup contracts as a shortcut. -A new authorized hosted Windows job can supply lifecycle-only evidence, not the -required Android-device scenario. Missing existing jobs do not prove hosted -execution impossible. Issue #100 records the investigated limits and dependencies. +Historical Windows prerequisite discovery found no authorized reachable host and identified native +Windows gaps in process and signal handling. That evidence remains retained, but the owner decision +is now explicit: AMKernel does not support Windows hosts or targets, and Windows qualification is +not a product prerequisite or roadmap item. Keep the existing guards; never route through Windows +material retained in the pinned upstream Appium snapshot or weaken file-identity/owned-cleanup +contracts to imply support. The unreleased #98 Skill revision now presents one novice-first interface: the user supplies a plain-language outcome while the Agent discovers safe facts, chooses one platform route, avoids @@ -167,7 +163,7 @@ or completion of the outstanding actual-platform sequence. Issues #102, #103, #104, #106, #107 and #108 are closed against their completed, merged and independently reviewed defect contracts. #98 and #105 remain open for -the outstanding actual-platform sequence; #100 still requires real Windows evidence. +the outstanding supported-platform sequence. #100 is not an AMKernel support objective. The latest read-only Project #2 check has #81 Planning and #79/#82 Ready. Available credentials have `read:project`, not board-write scope; issue updates do not imply board changes. Complex Mac #109, dynamic-list #97 and Gmail privacy expansion #99 stay deferred. @@ -333,7 +329,7 @@ Separate defects own the work: [#102](https://github.com/FSQ-lab/amkernel/issues then [#98](https://github.com/FSQ-lab/amkernel/issues/98) real Agent behavior. Environment discovery [#106](https://github.com/FSQ-lab/amkernel/issues/106) and owned-state/conversation behavior [#107](https://github.com/FSQ-lab/amkernel/issues/107) remain separate contracts. Gmail privacy -expansion #99 remains post-v1; Windows Android #100 still requires real Windows evidence. +expansion #99 remains post-v1; Windows Android #100 is outside AMKernel support. #102 now distinguishes constraints, tested coverage, observed failures, and proven incompatibility. `evidence/appium-skill/compatibility-20261102.json` retains bounded real Copilot CLI 1.0.105 / @@ -587,6 +583,45 @@ not an all-resources-cleaned claim. See experiment §19 and the [normalized evidence](../evidence/appium-skill/android-execution-20261105.json). No Android World rerun, score improvement or real-device/platform qualification is established. +### iOS and native macOS outcome-first execution (#98) + +The CUA-informed Android execution principles now cover iOS/XCUITest and native macOS/Mac2 +through the shared `amkernel-appium.md` seam. Packaged routing requires the shared reference for +Apple scenarios. It establishes current iOS app/context/view and macOS app/window/focus identity, +invalidates stale element and coordinate assumptions after transitions, prefers semantic targets, +and verifies the requested result instead of trusting a successful command return. Setup, +Runtime, ownership and official-skill inputs are unchanged. + +The iOS and macOS short-flow prompts now provide the novice's requested final state and fixture +location without prescribing capabilities, selectors, clicks, sessions or call partitioning. The +oracle still requires the correct inferred driver route, one change action, observed pre-action +and requested post-action state, bounded compact calls, truthful cleanup and no unnecessary +screenshot. Focused observation and evaluator regressions passed 125 tests; independent review +found no remaining actionable issue. This is synthetic behavior evidence only; no real Apple +target or broader platform qualification is established. + +### Unified readiness and authorization interface + +The installed Skill is now a 220-line router over `readiness-contract.md`. Android physical, +Android emulator, iOS Simulator, physical iPhone and native macOS adapters use one canonical +target/readiness/authorization/live-control/cleanup result. The authorization effect set survives +first-party to official-Skill handoff, so an identical approved change is not confirmed twice; +changed effects require a new disclosure. This remains behavioral guidance, not a security control. + +Version-sensitive Node/Appium/driver prerequisites and procedures were deleted from first-party +platform references. Verified official Skills own those facts; first-party adapters retain target +applicability, local selection, ownership and result normalization. Official distribution now has +one `--json` verifier result while preserving the existing fail-closed manifest, receipt and exact +tree checks internally. A focused iOS Simulator adapter separates runtime, configured target, boot +and live control while marking physical trust/signing/WDA provisioning not applicable. Every +Windows host or target is blocked without mutation or session fallback. + +Focused behavior regressions passed 139 tests; Skill/distribution checks passed 62 tests; Ruff and +Python diagnostics passed. Independent review found and drove fixes for canonical required-layer +coverage, zero execution calls on Windows, and explicit-decline state consistency. Re-review found +no remaining actionable issue. The direct repository verifier invocation failed closed with the +expected missing installed-project receipt; temporary installed-project tests covered both +structured verified and failed results. ### Shared control-first and visual-fallback execution (#98) @@ -648,6 +683,26 @@ both non-pending cases. Independent read-only review found no actionable finding the earlier acceptance evidence, not the Runtime or any platform qualification. The final combined evaluator/process-boundary regression run passed 112 tests. +### Target-scoped native macOS setup guidance (#140) + +The packaged Skill routes native macOS setup through an AMKernel-owned discovery, compatibility, +approval and readiness reference before the verified pinned Mac2 procedure. It reuses the selected +Runtime, Appium home and Xcode; evaluates current Node/npm/Appium/Mac2 plus Python Client/Selenium +constraints without reducing them to majors; and keeps administrator, Xcode and privacy choices +native and user-owned. Prepared setup remains distinct from live target control. Setup creates no +session or target launch, and any approved task-owned server smoke must be stopped. + +The deterministic oracle covers nine ready, missing and incompatible dependency, Xcode and privacy +profiles. Exact grants bind both technical changes and user-executed native handoffs. A completed +outcome requires environment `summary.requiredOk`, required doctor success, server status/driver +evidence, a successful bounded smoke result and later cleanup. Focused macOS/Android/iPhone setup, +distribution and Skill regressions passed 199 tests; the nine-profile executable oracle, Ruff +check/format and Mypy over 77 source/test files passed. Independent review found three oracle +blockers and verified every correction with no blocking finding remaining. No live dependency +install, Appium/Mac2 server or session, Xcode/privacy change, native Agent evaluation, platform +qualification or release +occurred. See experiment §21. + ## Acceptance criteria - [x] M3 and S3-001 through S3-060 are registered in GitHub Project #2 with exact dependencies and work-item contracts. diff --git a/docs/architecture.md b/docs/architecture.md index 5c22556..9aa2126 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -402,6 +402,16 @@ The skill guides installation, readiness, diagnosis, and first successful automa A skill can guide the Agent through setup, external process lifecycle, library usage, waits, large-output reduction, debugging, and cleanup. A skill is procedural knowledge, not an executable capability or security mechanism. +The installed Skill presents a narrow routing interface over one shared readiness and authorization +contract. Platform references are adapters: they select applicable layers and add platform facts. +Verified official Appium Skills own version-sensitive prerequisites, driver checks, capabilities +and procedures; first-party guidance owns target selection, applicability, Runtime identity, +authorization continuity, resource ownership, normalized results and cleanup truth. Approval +remains procedural guidance, never enforcement. + +AMKernel does not support Windows hosts or Windows targets. Windows material retained inside a +verified upstream snapshot is supply-chain provenance, not a supported route. + Each experiment produces evidence about the kernel: - which live objects need persistence; diff --git a/docs/experiments/appium-agent-behavior.md b/docs/experiments/appium-agent-behavior.md index bed2c63..a79c75b 100644 --- a/docs/experiments/appium-agent-behavior.md +++ b/docs/experiments/appium-agent-behavior.md @@ -1188,3 +1188,42 @@ The [bounded evidence record](../../evidence/appium-skill/observation-policy-202 retains report/harness digests, prior failures, observations and limits. This proves native Agent → installed Skill → stdio MCP → official Appium Python Client → synthetic endpoint behavior, not real-device/driver qualification or an Android World score improvement. + +## 21. Target-scoped native macOS setup guidance (#140) + +The packaged Skill now reads `references/macos-setup.md` before the verified pinned +`setup-mac2` route. The AMKernel-owned reference supplies the Runtime seam, existing +Node/Appium/Appium-home and Xcode selection, exact compatibility and approval decisions, +target identity, privacy handoff, and prepared-versus-live reporting. The official Skill retains +current Mac2 environment, doctor and bounded server-smoke details. iOS/tvOS, Android, task +execution, troubleshooting, core interfaces and pinned official inputs are unchanged. + +The guidance treats macOS privacy as process-identity-specific. Password, administrator, +Xcode license/first launch, Accessibility, Automation and applicable Screen Recording choices +remain native and user-owned. It prohibits TCC database edits/resets, silent global Xcode +selection, `sudo npm`, duplicate Appium homes, setup-only sessions and static-check claims of live +control. An approved task-owned server smoke may run but must be stopped; a compatible supplied +server is preserved. Setup-only readiness does not launch the target app. + +`tests/e2e/macos_setup_fixture.py` and `macos_setup.py` are deterministic in-process models; they +run no installer, Appium server, Mac2 driver, Xcode operation, privacy mutation, target app or +session. Nine profiles cover ready-stack reuse, missing and incompatible Mac2 repair, exact +Node/npm/Appium/Mac2 range evaluation, Runtime Python/Client/Selenium compatibility, missing Xcode +native action, and privacy pending. Native Xcode/privacy completion must match the exact disclosed +approval grant while remaining user-executed. The oracle rejects unapproved, drifted or reused +grants; forbidden TCC/global-Xcode/security changes; readiness overclaims; session creation; +shared/unapproved or leaked server smokes; and blanket Screen Recording requirements. + +A completed setup outcome now requires the official environment summary and required doctor +checks plus exactly one approved task-owned server start, status/driver observation, successful +bounded smoke result and later stop. Discovery, approval-required and blocked outcomes remain +distinct and do not manufacture smoke evidence. + +Focused macOS/Android/iPhone setup, distribution and Skill regressions passed **199 tests**. The +macOS protocol/oracle self-check passed all **9 profiles**; Ruff check/format and Mypy passed all +**77 source/test files**. The first independent review found three oracle blockers in dependency +ranges, bounded smoke completion and native-action approval binding; the final review verified all +three corrections and reported no remaining blocking finding. This is source-level deterministic +behavior evidence only. No native Agent evaluation, real dependency installation, Appium/Mac2 +server or session, Xcode/privacy change, live target control, platform qualification, release or +Android World evaluation occurred. diff --git a/src/amkernel/distribution.py b/src/amkernel/distribution.py index 1f75a70..6166e3f 100644 --- a/src/amkernel/distribution.py +++ b/src/amkernel/distribution.py @@ -646,6 +646,9 @@ def _skill_files(self) -> tuple[str, ...]: "references/android-setup.md", "references/android-execution.md", "references/ios-setup.md", + "references/ios-simulator.md", + "references/macos-setup.md", + "references/readiness-contract.md", "scripts/verify_official_skills.py", ) diff --git a/src/amkernel/skill/SKILL.md b/src/amkernel/skill/SKILL.md index 30e6f54..01bf380 100644 --- a/src/amkernel/skill/SKILL.md +++ b/src/amkernel/skill/SKILL.md @@ -81,136 +81,72 @@ After reload, verify through the actual Agent connection. A shell/SDK probe may execution, but cannot prove that the current Agent has loaded the tool. Never create an Appium session merely to validate AMKernel installation. -## Ask less; act within the request - -Perform read-only discovery automatically. The user's explicit requested end state authorizes -the visible actions clearly necessary to produce it; do not ask them to approve the same action -again. A broad or ambiguous request does not authorize unrelated mutations. - -Ask only when the answer cannot be observed and materially changes the action. In particular: - -- If exactly one usable target or application matches, describe it plainly and continue. -- If several usable targets or applications match, offer a short choice using familiar names; - include a serial or UDID only when needed to distinguish them. -- Never ask the user to select Appium, a driver, a capability set, Python, ADB, or a setup skill. -- Ask before dependency installation, persistent configuration, privilege elevation, license or - trust acceptance, signing changes, emulator/simulator creation or launch, starting an - unauthorized ADB server, device authorization or erasure, broad resets, or changes outside the - requested end state. -- Before dependency repair, identify the actual managed Runtime from observed MCP/CLI evidence - or `python_exec` when available; explain changes, cross-project effects, and rollback. - Setup may use that Runtime's verified interpreter; the project `.venv` is not the Runtime. -- After that scoped approval, perform the disclosed installation yourself in the same task and - verify each layer; do not hand the technical setup back to the user. - -Do not expose manifests, hashes, source acquisition, internal handoffs, or unrelated raw -diagnostic dumps unless their verification fails or the user requests technical evidence. +## Authorize once; report one contract -## Choose one route +For setup, readiness, repair or an unsupported platform, read +`references/readiness-contract.md` before a platform adapter. It is the only readiness result, +profile, authorization and guidance-ownership interface. Do not reproduce it in a platform flow. -Select from the requested target and observed host; do not silently substitute a platform: - -- Android setup/readiness: read `references/android-setup.md` first for discovery, - target-scoped component selection, approvals, and applicability; then use verified - `setup-uiautomator2` guidance for the selected driver and server. -- Android scenario or existing-session execution: read `references/android-execution.md` for - native/Web context, page and target identity, observation, input, scrolling and recovery. - Reuse established readiness; do not rerun setup or official-source verification merely to act. -- iOS simulator or tvOS setup/readiness on macOS: verified `setup-xcuitest` guidance; simulator - and tvOS applicability is unchanged. -- Native macOS desktop: verified `setup-mac2` guidance and the Mac2 driver, never XCUITest. -- Existing UiAutomator2/XCUITest session, launch, transport, WebDriverAgent, source, or locator - failure: verified `appium-troubleshooting` guidance. -- Physical iPhone setup/readiness, including trust, signing, provisioning, or WebDriverAgent - deployment: read - `references/ios-setup.md` first for target discovery, dependency ownership, reuse, and approval; - then use verified `setup-xcuitest` guidance and continue to verified - `xcuitest-real-device-config` guidance after shared XCUITest prerequisites pass. For tvOS, keep - the existing verified `setup-xcuitest` → `xcuitest-real-device-config` route without the iPhone - reference. - -For Android, iOS and native macOS execution, read the shared control-first, visual-fallback -policy in `references/amkernel-appium.md`. Sufficient control evidence means no screenshot or -image analysis; visual-only targets and visual acceptance criteria remain supported. - -AMKernel's current Runtime qualification is Ubuntu and macOS on CPython 3.11–3.13; Windows is -not a supported AMKernel host. Official Android guidance may include Windows commands, but that -is platform documentation, not AMKernel Windows qualification, and must not be presented as a -supported AMKernel route. Windows desktop, XCUITest, and Mac2 on Windows are unsupported. A -blocked requested route stays blocked; do not replace it with a different driver or vague -generic advice. - -### Verify official guidance only when needed - -Before reading or executing a routed official Appium skill, run: +Perform read-only discovery automatically. The user's explicit requested end state authorizes the +visible actions clearly necessary to produce it. Record technical mutations as one exact effect +set and ask once; carry the granted record unchanged across official-Skill handoffs. Ask again only +when observed effects change. Approval is behavioral guidance, not a security boundary. -```text -python .agents/skills/amkernel/scripts/verify_official_skills.py --project-root . -``` +Keep commands, paths, package choices and internal verification Agent-owned. Ask the user only for +an unobservable choice that materially changes the target, or an identity, physical confirmation, +privacy, trust or other user-native action that cannot be automated safely. + +Do not expose manifests, hashes, source acquisition, internal handoffs or unrelated raw diagnostics +unless verification fails or the user requests technical evidence. + + +## Choose one route -On failure, stop that route and report the exact installation problem. Repair only through the -AMKernel installer after approval; never fetch an unpinned replacement. Resolve official skill -contexts and helpers from `source.generatedLocation` in the verified manifest and execute -relative helpers from that source checkout. Do not run provenance verification as a generic -precondition for an already-ready scenario that does not consume official guidance. - -Use the routed official skill's checks and completion criteria for the scope those criteria -actually cover; do not invent a parallel driver or server checklist. Apply the Android -setup route's applicability rules before executing its official checks; apply the iPhone -reference's target applicability before executing XCUITest checks for a physical iPhone. -Simulator, tvOS, and Mac2 applicability is unchanged. -Read only the relevant sections of `references/amkernel-appium.md` for AMKernel's connection, -bounded evidence, conversation targeting, and cleanup seam. -If verified skills do not cover the observed behavior, use primary official documentation: -Appium installation and extension management at `appium.io`, the Appium Python Client and -UiAutomator2 repositories under `github.com/appium`, XCUITest at -`appium.github.io/appium-xcuitest-driver`, and Mac2 at -`appium.github.io/appium-mac2-driver`. Use community guidance only after checking its source and -applicability against official documentation or local evidence. +Select from the requested target and observed supported host; never substitute a platform: + +- Android setup/readiness: `readiness-contract.md`, then `android-setup.md`, then verified + `setup-uiautomator2` guidance. +- Android execution: `android-execution.md`; reuse established readiness. +- iOS Simulator setup/readiness: `readiness-contract.md`, then `ios-simulator.md`, then verified + `setup-xcuitest` guidance. +- Physical iPhone setup/readiness: `readiness-contract.md`, then `ios-setup.md`, verified + `setup-xcuitest`, then verified `xcuitest-real-device-config` after shared prerequisites pass. +- Native macOS setup/readiness: `readiness-contract.md`, then `macos-setup.md`, then verified + `setup-mac2` guidance. Native macOS uses Mac2, never XCUITest. +- iOS or native macOS execution: `amkernel-appium.md`, especially **Connect and establish the + current Apple surface**; reuse established readiness. +- Existing session, launch, transport, WebDriverAgent, source or locator failure: verified + `appium-troubleshooting` guidance. + +All supported execution routes use the control-first, visual-fallback policy in +`amkernel-appium.md`. Sufficient semantic evidence means no screenshot or image analysis; a +visual-only target or visual acceptance criterion still permits bounded visual evidence. + +AMKernel supports only its qualified Ubuntu and macOS hosts. Any Windows host or Windows target is +unsupported, including Android, Windows desktop, XCUITest and Mac2. Classify the route as blocked, +perform no setup mutation or session attempt and do not offer a fallback driver. Windows material +inside a verified upstream snapshot is retained provenance, not an AMKernel support route. + +Verify official guidance through the single command in `readiness-contract.md` immediately before +consuming a routed official Skill. On success, use its current prerequisite, version, doctor, +capability and helper procedure instead of maintaining a parallel first-party checklist. On +failure, stop that route and report the exact verifier error. ## Establish relevant readiness -Check only layers that can block the requested target: - -- Appium Server can start in the selected existing global or explicitly requested local mode. -- The selected UiAutomator2, XCUITest, or Mac2 driver is installed and its relevant doctor checks pass. -- The status endpoint is ready and the intended device, simulator, or desktop target is usable. -- The actual AMKernel Runtime can import a compatible Appium Python Client and Selenium; - setup may check this through `python_exec` or the verified managed interpreter. - -A server without the selected driver is not platform-ready. Android component selection, -authorization, raw-check applicability, and target readiness belong to `references/android-setup.md`. -Physical iPhone target layers, dependency/signing reuse, raw-check applicability, and -WebDriverAgent route selection belong to `references/ios-setup.md`; simulator and tvOS applicability -is unchanged. Keep physical devices and simulators separate. A status endpoint, driver/client -listing, or target inventory is not live-session, target-compatibility, or execution proof. - -Use the selected task environment consistently for tools, doctor, and the Appium server. -Persist environment variables only after the disclosed, approved change; do not edit profiles -merely because a variable is unset. Do not switch global `xcode-select` or accept host/device -permission prompts without approval. Service supervision and ownership are defined in -`references/amkernel-appium.md`. - -Inspect the selected package metadata in the actual Runtime. Evaluate `Requires-Python` and each -environment-marker-qualified `Requires-Dist` only when its marker applies there; compare official -driver/client tested ranges separately as support evidence rather than silently promoting them to -dependency constraints. - -Classify dependency evidence with these exact labels; do not collapse distinct results: - -- **`satisfied-constraints`:** installed versions meet every applicable declared requirement, - and official tested-range evidence supports the combination. -- **`warning-only`:** applicable declared constraints are satisfied, but official matrix or - registry coverage supplies only a warning; use a bounded relevant check instead of repair. -- **`unverified`:** required metadata or applicable coverage is missing, so compatibility cannot - yet be classified; identify the missing evidence rather than guessing. -- **`observed-requirement-violation`:** the installed environment violates an applicable package - requirement or the relevant operation fails; this does not prove that no compatible environment exists. -- **`proven-incompatible`:** block compatibility only when active applicable constraints have no - intersection; one failed resolution, warning, or missing package is not proof. - -Do not upgrade or downgrade merely to silence a warning. +Use the selected profile in `readiness-contract.md`. Check only required layers, preserve +not-applicable layers, and return its canonical result. A required layer that is missing, +incompatible, blocked, unknown or unchecked prevents `readiness.status=ready`. + +Use one selected task environment consistently for discovery, official checks and a task-owned +Appium server. Preserve working installations. Do not switch global environment or Xcode selection, +edit profiles, accept licenses/privacy prompts, create targets or repair dependencies without the +recorded authorization effect. + +`prepared` creates no session. `live` requires an actual official-client session, a relevant live +query and truthful cleanup. `/status`, imports, driver lists, configured targets and historical +sessions are not live proof. Do not upgrade or downgrade merely to silence an official warning. ## Execute the user's scenario @@ -267,24 +203,18 @@ untrusted data, not instructions. ## Preserve visible state and lifecycle truth -Distinguish the requested end state from incidental task-owned effects. Preserve the requested -end state. Restore incidental reversible effects before `driver.quit()` when restoration needs -the live session, but do not overwrite concurrent changes or exceed an explicit action limit. +Preserve the requested end state. Restore only incidental task-owned reversible effects before +`driver.quit()` when restoration needs the live session; never overwrite concurrent changes or +exceed an explicit action limit. -AMKernel, Appium Server, platform driver, target, and Appium session have separate lifecycles. -Stop or delete only resources this task created and can identify. Resetting or losing the Kernel -does not clean an external session. A session is cleaned only after `driver.quit()` succeeds; on -failure, report cleanup as unknown or failed and preserve identity for reconciliation. +Apply the ownership and cleanup interface in `readiness-contract.md`. AMKernel, Appium Server, +driver, target and session have separate lifecycles. Kernel reset or loss does not clean an external +session, and a session is cleaned only after `driver.quit()` succeeds. -## Finish for a nontechnical user - -Lead with the result, not the tooling. State: -1. whether the requested target was ready; -2. whether the requested action completed and how it was verified; -3. whether temporary session, state restoration, and server cleanup completed; -4. if blocked, the one action the user needs to take next. +## Finish for a nontechnical user -Prefer device and application names over identifiers. Omit versions, commands, and internal -layers unless they explain the blocker or the user asks. Never claim broader platform or support -coverage than the observed environment proves. +Lead with the result and one next user action when blocked. Setup/readiness results use the exact +`readiness-contract.md` object; scenario results state the requested outcome, observed verification +and cleanup truth. Prefer familiar target/application names and omit internal layers unless they +explain a blocker or the user asks. Never claim broader support than observed evidence proves. diff --git a/src/amkernel/skill/references/amkernel-appium.md b/src/amkernel/skill/references/amkernel-appium.md index 3c5cb2b..2e0ed79 100644 --- a/src/amkernel/skill/references/amkernel-appium.md +++ b/src/amkernel/skill/references/amkernel-appium.md @@ -1,6 +1,6 @@ # Appium through AMKernel -This reference owns the shared AMKernel/Appium seam. Android preparation belongs to `android-setup.md`; Android connection, platform state, locators, input, navigation and recovery decisions belong to `android-execution.md`. Read the execution reference for an Android scenario, even when setup is already complete. Physical-iPhone preparation belongs to `ios-setup.md`; other platform setup and diagnosis use the routed verified official skills. These platform references do not replace the shared lifecycle, evidence or ownership contract below. +This reference owns the shared AMKernel/Appium execution seam. Setup, readiness, repair and authorization belong to `readiness-contract.md` plus exactly one platform adapter. Android execution details belong to `android-execution.md`. Read that reference for an Android scenario even when setup is complete. Platform adapters and verified official Skills do not replace the lifecycle, evidence or ownership contract below. ## Runtime contract @@ -11,46 +11,12 @@ This reference owns the shared AMKernel/Appium seam. Android preparation belongs - A kernel reset or death makes external session state unknown. Reconcile it before replacement. - Importing the client proves dependency presence, not full compatibility. A ready `/status` or a driver listing is not a live-session proof. Setup-only and status-only requests create no session. -## Task-scoped discovery evidence - -Use the requested target and observed host OS to choose relevant checks. Preserve -existing working installations; discovery is not permission to repair them. Keep -the following evidence bounded. When it determines Runtime behavior, use the actual AMKernel -Runtime through `python_exec` or its verified managed interpreter, not an unrelated shell Python. - -- **Android dependencies/targets:** use `android-setup.md` for candidate discovery, - component selection, scoped installation approval, and target readiness. -- **Apple tools/targets:** observe task `DEVELOPER_DIR` and active - `xcode-select -p`; execute the selected `xcodebuild -version` and `xcrun` tools. - If another installed Xcode is needed, validate it under a task-only - `DEVELOPER_DIR`, never `sudo xcode-select --switch`. Use that same selected - Xcode's `xcrun devicectl list devices --json-output ` for - physical Apple devices, and separately `xcrun simctl list devices --json` and - runtime inventory for simulators. A simulator entry is never a physical-device - discovery result. Inspect the selected tool's help/schema when interpreting its - version-specific fields; a listing alone proves neither live connection nor - development readiness. - -Report physical Apple targets in separate evidence layers: **discovered** (listed), -**connected** (current transport/connection evidence), **usable-for-development** -(observed pairing/trust, Developer Mode and required development services), and -**ready-for-xcuitest** (the selected driver, signing/provisioning and WebDriverAgent -path verified for that target). Preserve unknown/not-checked states and their -reasons; never promote an earlier layer into a later one. For physical iPhone -dependency ownership, signing/WDA reuse, and failure classification, apply -`ios-setup.md`. Do not create a session -for setup-only discovery, change signing, pair devices or dismiss permission -prompts to manufacture readiness. Live XCUITest proof belongs to an explicitly -requested validation/scenario. Keep platform, dependency and Runtime readiness -separate, and explain multiple-candidate choices before asking only for a material -target choice that the request and available evidence cannot resolve. - -Layer counts describe the listed target population, not host-wide platform -qualification. After a successful empty inventory, zero usable/ready targets is -valid; conservatively retaining unknown is also acceptable. A failed or unknown -inventory, or a nonempty listing without target-specific evidence, must not be -converted into known readiness counts. +## Consume readiness; do not recreate it +For execution, reuse the selected target/profile and established readiness result. Do not rerun +setup, official-source verification, target inventory or dependency checks merely to act. If live +evidence exposes a genuine prerequisite failure, retain the observed failure and route the smallest +affected layer back through `readiness-contract.md` and its platform adapter. ## Run the service with the task environment @@ -67,15 +33,17 @@ A successful `/status` proves service reachability, not target readiness or a se request creates no session. Stop only the task-owned server; stopping it does not prove a remote session or external target state was cleaned. -## Connect +## Connect and establish the current Apple surface Use a reviewed loopback Appium URL without credentials. Populate target and app identifiers from observed state or the user's selection; never invent them. For Android, use the connection and native/Web execution guidance in `android-execution.md`. -For iOS, use `XCUITestOptions`, `automationName: XCUITest`, and the observed simulator/device identifier. Follow the official XCUITest procedure for signing and real-device requirements. +For iOS, use `XCUITestOptions`, `automationName: XCUITest`, and the observed simulator/device identifier. Follow the official XCUITest procedure for signing and real-device requirements. Before acting, confirm the requested app, current context and current view from bounded live evidence. Prefer the native context for native controls. A navigation, app switch, context change, modal alert, sheet, keyboard or relaunch can invalidate old element handles and page assumptions; observe again and re-find from current state instead of replaying a remembered sequence. -For native macOS desktop, use `from appium.options.mac import Mac2Options`, `platformName: Mac`, and `appium:automationName: Mac2`. Observe the requested app's bundle identifier and pass it as `appium:bundleId`; do not invent an iOS UDID or XCUITest capabilities. Record the session's returned platform and automation name, and confirm the requested app with a small live query. If Mac2 cannot start, retain the exact blocker instead of falling back to another driver. The Calculator regression must retain returned Mac2 capability evidence; an app opening alone is not proof of the driver used. +For native macOS desktop, use `from appium.options.mac import Mac2Options`, `platformName: Mac`, and `appium:automationName: Mac2`. Observe the requested app's bundle identifier and pass it as `appium:bundleId`; do not invent an iOS UDID or XCUITest capabilities. Record the session's returned platform and automation name, and confirm the requested app and window with a small live query. Treat focus, active window, sheets, popovers and dialogs as part of the current target surface. Window changes, relaunches and focus transfers invalidate old handles and row or coordinate assumptions; re-observe the requested app/window before continuing. If Mac2 cannot start, retain the exact blocker instead of falling back to another driver. The Calculator regression must retain returned Mac2 capability evidence; an app opening alone is not proof of the driver used. + +For either Apple route, work toward the user's requested final state rather than treating a supplied or inferred action sequence as authoritative. In each bounded workflow: observe the current surface, identify the next action from semantic state, act once, and verify the resulting state or business outcome. Stop when the requested state is already true. Prefer accessibility identifiers, labels, roles and changing state attributes over XPath, element order or coordinates. Use explicit waits only for an expected transition, and re-query after one; never use a successful command return as the sole proof that the requested result occurred. ## Observe and act: controls first, visual fallback diff --git a/src/amkernel/skill/references/android-setup.md b/src/amkernel/skill/references/android-setup.md index cdf3d3f..0d217a5 100644 --- a/src/amkernel/skill/references/android-setup.md +++ b/src/amkernel/skill/references/android-setup.md @@ -15,154 +15,53 @@ reference is AMKernel-owned guidance, not pinned official content. The execution contract is in `amkernel-appium.md`; setup-only and status-only requests create no session. Discovery is read-only. Installation, license acceptance, persistence, emulator work, ADB -start-up, and device authorization require the scoped approval described below. +start-up and device authorization use the shared authorization record below. ## Route -1. Establish the requested Android target and goal from the conversation and safe evidence: - physical device, emulator/AVD, or an application/build task that only needs SDK tooling. -2. Identify the host; for Appium work, identify the actual AMKernel Runtime as well. Do not - substitute system Python, the project `.venv`, or another interpreter for that Runtime. -3. Discover only relevant layers read-only. An Appium scenario needs Runtime client, Java, - Android tools, Node/npm/Appium/driver; an SDK/JDK-only install needs only its own prerequisites. - Preserve working installations; SDK-only requests never require Appium or an Agent Runtime. -4. Resolve exact candidates from observed metadata. Classify what is genuinely missing for this - target; do not infer absence from one unset variable or one failed candidate. -5. If changes are needed, present one disclosure and obtain one scoped approval. After approval, - perform the installation in this task and verify it. Do not hand the setup back to the user. -6. For an Appium target, verify the selected official driver/server checks and classify their raw - output by applicability. For SDK/JDK-only work, verify the installed tools and stop there. -7. Report target-scoped readiness and any remaining live-session proof separately. Stop without a - session when the request was setup-only or status-only. - -## One disclosure, one approval - -Before any mutation, present one concise disclosure covering every planned change. Use these -fields so the approval is exact and scoped: - -- **Component and exact target**: distribution/package name and the exact version or SDK package - identifier resolved from current metadata; never "latest" or an unverified guess. -- **Source and backend**: uv tool with its package source and extras; npm registry package spec; - Google SDK repository package ID and archive checksum; official vendor JDK archive URL and - checksum; existing version manager if used. -- **Install path and environment**: uv tool environment; npm prefix and `APPIUM_HOME`; Android SDK - root and component paths; JDK directory; whether the environment value is task-scoped or - persistent. -- **Impact**: which other projects, global sessions, Appium homes, SDK roots, or running services - are affected; whether the current Kernel/MCP connection must be reloaded; whether existing - compatible versions are preserved. -- **Rollback**: the exact previous version/spec/install path recorded before the change and the - command that restores or removes only this task's addition; state what cannot be rolled back, - such as an accepted Android SDK license. -- **Licenses**: the license or agreement to be accepted, at which step, and that acceptance is a - separate disclosed effect of the install. -- **Elevation**: whether any privileged step is required; prefer a user-level route and obtain a - separate approval if elevation is genuinely unavoidable. -- **Persistence**: exactly which environment variable or profile change would persist beyond this - task and its removal line; prefer task-scoped values. - -One approval covers the disclosed batch. Apply only those changes and verify them in this task; -do not hand the commands to the user. A newly discovered change, a different version, additional -SDK components, a persistent environment edit, or a privileged action requires a new disclosure -and approval. +1. Establish the requested physical-device, emulator or SDK-only goal. +2. Read `readiness-contract.md`; select exactly one Android profile and discover only its relevant + layers. Preserve working installations and the actual AMKernel Runtime identity. +3. Use this adapter for Java, SDK, target and ADB applicability; use verified official guidance for + current Appium/driver prerequisites and procedure. +4. Resolve exact missing effects, authorize them once through the shared record, execute them in + this task and rerun only affected checks. +5. Return the canonical result. Setup/status stops without a session; live proof remains separate. -## Target scope and required layers - -| Layer | Physical device | Emulator target | -| --- | --- | --- | -| AMKernel Runtime client (`appium`, `selenium`) | required | required | -| Working JDK | required | required | -| `platform-tools` / authorized ADB service | required | required | -| `cmdline-tools` / `sdkmanager` | only for SDK management or install | only for SDK management or install | -| SDK platform | only if the selected tool, build, or driver check requires it | only if the selected tool, build, or driver check requires it | -| `build-tools` | when selected tools, driver APK handling/signing, or builds need them | when selected tools, driver APK handling/signing, or builds need them | -| `emulator` package, system image, AVD | not required | required | -| Device authorization / unlock | user action when observed | observe locks/prompts; never auto-dismiss or unlock | -| Appium session | only for an explicitly requested scenario | only for an explicitly requested scenario | - -A physical target must not be gated on emulator inventory, a system image, or an AVD. Do not -install an emulator component just to satisfy a generic Android checklist. An emulator target -must not be treated as usable merely because an AVD is configured. - -## Runtime Python and client - -### Identify the actual Runtime - -Prefer `python_exec` evidence: - -```python -import sys -print(sys.executable, sys.version) -``` - -If the Runtime is reachable only from the host, identify the uv-managed installation and preserve -its environment: - -```text -uv tool list --show-with --show-paths --show-python -uv tool list --show-version-specifiers --show-with --show-paths --show-python -uv tool dir -uv tool dir --bin -``` - -Use the listed `amkernel` tool environment, its interpreter, and its extra requirements. Do not -use `python3` from `PATH`, the project `.venv`, or another project's environment. - -### Verify the dependency layer - -In the actual Runtime: - -```python -import importlib.metadata as md -import sys - -print(sys.executable, sys.version) -for name in ("amkernel", "Appium-Python-Client", "selenium"): - print(name, md.version(name)) -print(md.metadata("Appium-Python-Client")["Requires-Python"]) -print(md.requires("Appium-Python-Client")) -print(md.metadata("selenium")["Requires-Python"]) -print(md.requires("selenium")) -``` - -Exercise the actual imports and inspect the selected driver's declared ranges as support evidence. -Evaluate and classify dependencies under `SKILL.md`'s relevant-readiness rules. - -### Repair +## Shared setup contract -Repair only if the actual Runtime lacks a required client or violates an applicable requirement. -Record the current install spec and extras first: - -```text -uv tool list --show-version-specifiers --show-with --show-paths --show-python -``` +Read `readiness-contract.md` before this adapter. Use its one authorization record, canonical +profile/layer states, official-guidance ownership and cleanup rules. This reference adds only +Android target selection, Java/SDK applicability, ADB side effects and doctor-result interpretation. -Then re-create the tool environment with the same package source spec and all previously recorded -extras, adding the applicable client constraint. `uv tool install` of an already-present tool -version may otherwise leave the existing environment in place; when repair must actually recreate -it, add the documented `--force` flag. Preserve the installed AMKernel version rather than -upgrading it as a side effect; for a package-index install: -```text -uv tool install "amkernel==$AMKERNEL_VERSION" --force --with 'Appium-Python-Client' -``` - -If the observed Runtime was installed from a local path or another non-index source, reuse that -same source spec and flag set instead of replacing it with the package-index name, adding -`--force` only to recreate the existing environment. `--force` does not authorize a version -change: keep the recorded AMKernel version/source spec. If the requested client constraint -conflicts with the Runtime package's own requirements, report the conflict rather than forcing an -incompatible environment. Do not mutate the tool environment with `pip`; the uv tool environment -is managed by `uv`. If the actual interpreter is outside AMKernel's supported CPython range, -disclose and select a supported interpreter through the same uv tool installation rather than -changing system Python. Replacing a shared Runtime affects every activated project; disclose -that scope and reconcile/reconnect affected Kernels before using the replacement. - -Never install the client into the project `.venv`. +## Target scope and required layers -Offline index: verify the existing Runtime instead of installing. If a required distribution is -absent, report the offline index/blocker and stop that dependency repair; do not invent a version -or fall back to an unrelated interpreter. +| Layer | Physical device | Emulator target | +| --- | --- | --- | +| Runtime Python/client | Required. Actual Runtime, not project `.venv`. | Required. Same. | +| Java/JDK | Required. | Required. | +| Android SDK | Platform tools plus target-relevant platform. | Platform tools plus target-relevant platform. | +| Emulator package | Not applicable. | Required. | +| System image | Not applicable. | Required for selected API/ABI. | +| AVD | Not applicable. | Required. | +| Target state | Connected and authorized. | Launched, boot-complete, responsive. | +| Node/Appium/UiAutomator2 | Required through verified official guidance. | Required through verified official guidance. | + +Do not install emulator components for a physical request, and do not treat a configured AVD as a +booted target. Report these layers through the shared Android physical or emulator profile. + +## Shared Runtime and official driver seam + +Use the actual managed Runtime identified by `python_exec` or verified AMKernel metadata; never +substitute the project `.venv`. Runtime/client applicability and repair authorization use +`readiness-contract.md`. Current Node, Appium, UiAutomator2, dependency, doctor and server-smoke +procedure belongs to the verified `setup-uiautomator2` Skill. Do not copy its version ranges or +checklist here. + +Bind official checks to the Java, SDK, target and task-scoped environment selected below. Preserve +an existing working backend and `APPIUM_HOME`; changing global/local mode or home is a disclosed +effect, not an automatic repair. ## Java @@ -360,141 +259,16 @@ Offline SDK repository: use `--list_installed` and existing binaries. If a requi absent and the official repository is unreachable, report the blocker; do not claim an install or substitute an unknown archive. -## Node, npm, Appium, and the selected driver - -### Discover the existing installation - -```bash -node -v -node -p "process.execPath" -npm -v -npm config get registry -npm config get prefix -npm root -g -command -v fnm -command -v asdf -command -v volta -``` - -If `node` is absent from `PATH`, inspect existing version managers and their installed versions -before concluding Node is absent. Reuse an existing manager (`nvm`, `fnm`, `asdf`, `volta`, or the -platform's maintained equivalent) rather than adding a second one. Use that manager's documented -install/use command for an LTS release that satisfies the selected package engines, for example -`fnm install && fnm use ` or `nvm install && nvm use `. - -If no version manager exists and Node must be installed, use the official Node.js distribution -archive. Resolve the exact LTS release from `https://nodejs.org/dist/index.json` that satisfies the -selected engines, verify it against `SHASUMS256.txt`, and install it into a user-owned directory: - -```bash -curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-$NODE_PLATFORM.tar.xz" -curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt" -grep " node-v$NODE_VERSION-$NODE_PLATFORM.tar.xz\$" "SHASUMS256.txt" | shasum -a 256 -c - -mkdir -p "$HOME/.local/lib" -tar -xJf "node-v$NODE_VERSION-$NODE_PLATFORM.tar.xz" -C "$HOME/.local/lib" -export PATH="$HOME/.local/lib/node-v$NODE_VERSION-$NODE_PLATFORM/bin:$PATH" -node -v -npm -v -``` - -Do not use `sudo` or a system-wide prefix for a user-level route. Record the exact Node/npm -versions and the resolved archive checksum. Inspect the active npm prefix and keep global installs -user-owned; do not use a system-wide prefix without approval. - -### Evaluate exact engine and peer constraints - -Do not reduce compatibility to a major-version floor. Read the installed packages' actual -metadata and compare the exact ranges with a semver-aware check available on the host. If no such -check exists, report the raw ranges as `unverified` instead of assuming compatibility. - -```bash -appium -v # or: npx --no-install appium -v -appium driver list --installed --json -``` - -For the selected driver, `driver list --installed --json` reports its version, `installPath`, and -`appiumVersion` peer range. Read the driver package's `engines` and `peerDependencies`: - -```bash -node -e 'const p=require(process.argv[1]); console.log(JSON.stringify({version:p.version,engines:p.engines,peerDependencies:p.peerDependencies},null,2))' "$DRIVER_INSTALL_PATH/package.json" -``` - -Read the active Appium package metadata the same way from `$(npm root -g)/appium/package.json` -(global mode) or the project's installed Appium package (local mode). Verify: - -- the active `node` and `npm` versions satisfy the selected Appium and driver `engines`; -- the selected driver's `peerDependencies.appium` range includes the active Appium version, or - `appiumVersion` from `driver list` does; -- the selected Appium Python Client's requirements remain applicable in the Runtime; -- the constraints have a non-empty intersection. A warning, one failed resolution, or a missing - package is not proof that no compatible combination exists. - -When a version must be chosen from the registry, query exact candidates rather than selecting -latest for each package independently: - -```bash -npm view "appium@$APPIUM_VERSION" engines peerDependencies --json -npm view "appium-uiautomator2-driver@$DRIVER_VERSION" engines peerDependencies --json -``` +## Bind official checks to the selected Android environment -Choose an exact compatible pair that also satisfies the host. Preserve an existing compatible -pair; do not upgrade merely to match a newer release. Record the resolved versions and their -sources. +After selecting Java, SDK and target evidence, verify and consume `setup-uiautomator2`. Run its +current Node/Appium/driver discovery, doctor and bounded server smoke with the same task-scoped +`JAVA_HOME`, Android SDK root, executable paths, backend mode and `APPIUM_HOME`. Preserve its raw +required fixes and warnings before applying this adapter's target applicability rules. -Offline registry: `npm ping` or `npm view` failure is an offline/blocker observation. Use the -installed package metadata and existing driver list; do not upgrade or install a guessed version. -If a required package is missing, report the exact blocker and stop that install path. - -### Install Appium and the driver after approval - -Default to the existing global mode unless the user explicitly requested local `npx` mode. Keep -the mode consistent across install, doctor, server, and smoke checks. Export the selected -`APPIUM_HOME` (default `$HOME/.appium`) for extension commands so they address the same extension -tree the server will load: - -```bash -export APPIUM_HOME="${APPIUM_HOME:-$HOME/.appium}" -``` - -Global mode, only for missing or genuinely incompatible pieces: - -```bash -npm install -g "appium@$APPIUM_VERSION" -appium -v -appium driver install "uiautomator2@$DRIVER_VERSION" -appium driver list --installed --json -``` - -Do not install the driver with bare latest when its peer range does not include the active -Appium. If installing over an existing driver, uninstall it first only after preserving the -previous exact version and obtaining approval; reinstall the exact compatible version. - -Local mode, only when explicitly requested and only when dependency-file changes are authorized: -use the project's existing package manager and lockfile to install exact `appium` and -`appium-uiautomator2-driver` versions, then invoke `npx --no-install appium`. Never run `npm -g` -in local mode and never use the global `appium driver install` flow to satisfy a local request. -Do not mix global and local extensions in one route. - -Use one `APPIUM_HOME` consistently. The default is `$HOME/.appium`; an explicit value must be the -same for extension installation, doctor, and the server. Do not install into one Appium home and -expect an already-running server with another home to load it. - -Record the previous exact versions and install paths before changing anything. Roll back only -this task's changes, for example: - -```bash -# Only when this task added them: -appium driver uninstall uiautomator2 -npm uninstall -g appium - -# Only when this task replaced a pre-existing version: -appium driver uninstall uiautomator2 -npm install -g "appium@$PREVIOUS_APPIUM_VERSION" -appium driver install "uiautomator2@$PREVIOUS_DRIVER_VERSION" -``` - -Never remove a pre-existing shared package, and never restart a shared service. A removed -extension or package does not undo accepted SDK licenses or other external state. +Do not independently require a version, reinstall a listed driver, switch global/local mode or +create a second Appium home. A verified official warning is evidence, not permission to repair. +Only an observed applicable incompatibility or failed required operation can block the layer. ## Service environment @@ -634,19 +408,11 @@ classification to their summaries. ## Verification and reporting -After an approved installation, verify the exact result rather than the installer's message: - -- Runtime: the uv tool environment, exact interpreter, and actual imports/versions. -- Java: selected `java.home`, `java -version`, `javac -version`, and origin. -- SDK: selected root, component identifiers/revisions, and applicable tool execution; use - `sdkmanager --list_installed` when the selected management tools are available. -- Node/Appium: `node -v`, `npm -v`, active executables, exact Appium version, installed driver - version, and the observed engine/peer relation. -- Service: task-owned identity, same environment, `/status`, and selected driver evidence. - -For setup-only, report: the raw doctor required-fix findings and their target applicability; the -readiness layers established; the target's actual state (physical connected/authorized, or -emulator configured/launched/booted); and that no session was created. Do not describe a ready status, -driver listing, configured AVD, or successful import as a live-session or full-compatibility -proof. When live execution is requested, it must be a real official-client session with a live -query under the scenario rules in `amkernel-appium.md`. +After an authorized change, rerun only affected discovery and the verified official checks. Confirm +the selected Java home, SDK root, target state and official required checks from live evidence; do +not trust an installer's success message. + +Return the canonical `readiness-contract.md` result with `target.platform=android` and exactly one +physical or emulator profile. Include raw applicable doctor blockers as layer blockers. Setup-only +creates no session. Live control requires a real official-client session and query under +`amkernel-appium.md`; `/status`, imports, driver lists and configured AVDs are not live proof. diff --git a/src/amkernel/skill/references/ios-setup.md b/src/amkernel/skill/references/ios-setup.md index be9066a..b20e5cb 100644 --- a/src/amkernel/skill/references/ios-setup.md +++ b/src/amkernel/skill/references/ios-setup.md @@ -1,19 +1,20 @@ # iPhone setup and readiness through AMKernel -This reference owns physical iPhone discovery, dependency ownership, scoped approval, signing -route selection, WebDriverAgent reuse, and the human-interaction seam. Read it before the verified -`setup-xcuitest` skill for a physical iPhone target. That skill still owns the shared Xcode, Node, Appium, -driver, doctor, and server checks; the verified `xcuitest-real-device-config` skill still owns its -trust, Developer Mode, signing, provisioning, and WDA procedure. This reference does not replace -those steps or restate them as a second tutorial. - -A simulator or tvOS request keeps its existing route. This reference is iOS physical-device -guidance only, and no iPhone task requires simulator inventory, a simulator runtime, or a booted -simulator. Android setup remains in `android-setup.md`; nothing here requires Android SDK -discovery. - -The execution, evidence, service, and cleanup contract is in `amkernel-appium.md`. This reference -adds no server, driver, signing helper, device wrapper, MCP tool, or AMKernel core surface. +This reference owns physical iPhone discovery, target applicability, signing route selection, +WebDriverAgent reuse and the human-interaction seam. Read `readiness-contract.md`, then this +adapter, before verified `setup-xcuitest` guidance for a physical iPhone target. That official +Skill still owns shared Xcode, Node, Appium, driver, doctor and server checks; verified +`xcuitest-real-device-config` guidance owns current trust, Developer Mode, signing, provisioning +and WDA procedure. This adapter does not restate them as a second tutorial. + +An iOS Simulator request uses `ios-simulator.md`. This reference is physical-iPhone guidance only, +and no physical task requires simulator inventory, a simulator runtime or a booted simulator. tvOS +is outside this adapter and has no AMKernel support claim. Android setup remains in +`android-setup.md`; nothing here requires Android SDK discovery. + +Setup/readiness/authorization uses `readiness-contract.md`; execution, service and live cleanup use +`amkernel-appium.md`. This adapter adds no server, driver, signing helper, device wrapper, MCP tool +or AMKernel core surface. ## Boundary @@ -23,8 +24,8 @@ archive, manifest, or verifier content to make a route pass. Setup-only and status-only requests create no session and do not launch WDA. Discovery is read-only. Installation, Xcode first-launch or license changes, device trust, Developer Mode, -signing, provisioning, device registration, WDA installation, tunnel or developer-service changes, -and service startup require the scoped approval described below. +signing, provisioning, device registration, WDA installation, tunnel, developer-service and +service-start effects use the one authorization record in `readiness-contract.md`. ## Scope the request before acting @@ -65,46 +66,11 @@ is permission to re-check, not evidence that the prerequisite passed. If the che name the observed remaining blocker and give only its next action; do not repeat the entire setup. For example, when the phone is currently locked: "请在 iPhone 上解锁,看到主屏幕后告诉我。密码只在手机输入,不要发给我;我会重新检查连接。" -## One disclosure, one approval - -Before the first mutation, explain the decision in plain language: what is missing, why the -observed existing tools cannot satisfy it, and what the Agent proposes to do. Follow with compact -details covering every planned change below, then ask for approval. Keep exact versions, sources, -locations, shared impact, and rollback visible; plain language must not hide material effects. -If evidence is incomplete, report what is unknown and obtain the next relevant read-only -observation instead of treating uncertainty as permission to install or re-sign. If that -observation cannot be obtained, pause at that blocker rather than proposing a speculative repair. - -- **Component and exact target:** distribution or package name and the exact version resolved from - current metadata; never "latest" or a guessed version. -- **Source and backend:** uv tool with its source and extras; npm/Appium extension source; Apple or - Xcode source for Xcode and signing assets; existing host tunnel or developer-service mechanism if - one is required. -- **Install location and environment:** uv tool environment; npm prefix and `APPIUM_HOME`; selected - Xcode and task-scoped `DEVELOPER_DIR`; selected driver install path; temporary artifact - directories. -- **Impact:** which other projects, shared Appium homes, drivers, sessions, devices, or running - services are affected; whether the Kernel connection must be replaced or reconnected; which - existing compatible components are preserved. -- **State-changing effects:** Apple Account sign-in, Xcode license or first launch, device trust, - Developer Mode and restart, device registration, provisioning-profile or bundle-ID change, - WDA installation or replacement, keychain access confirmation, administrator authentication, and - whether the effect persists after the task. -- **Rollback:** the exact prior version, source specification, install path, and task-scoped - environment recorded before the change; the command or action that removes only this task's - addition; what cannot be rolled back, such as an accepted license, a registered device, a - generated profile, or a changed trust setting. -- **Secrets:** the user enters passwords, PINs, two-factor codes, and keychain confirmations only in - native interfaces. Never request, capture, print, or store them. - -One approval covers exactly the disclosed batch. Newly discovered scope, a different version, -additional device registration, a persistent environment edit, or an additional privileged action -requires a new disclosure. A broad "set up my iPhone" request does not authorize unrelated -drivers, updates, simulator components, or device erasure. - -After approval, perform the disclosed technical work in this task and verify each layer. Do not -hand installation commands or Xcode configuration back to the user. The user handles only actions -that require their account, body, or local security confirmation. +## Shared setup contract + +Read `readiness-contract.md` before this adapter. Use its one authorization record, physical-iPhone +profile, canonical layer states, official-guidance ownership and cleanup rules. This reference adds +only physical target discovery, Apple security handoffs, signing/provisioning and WDA reuse. ## Dependency ownership and required layers @@ -122,63 +88,17 @@ A physical iPhone target does not require simulator tools, a simulator runtime, simulator. A connected phone also does not make the host setup ready: report host dependency blockers and target readiness separately. -## Runtime Python and client - -Prefer `python_exec` evidence: - -```python -import importlib.metadata as md -import sys -from appium import webdriver -from appium.options.ios import XCUITestOptions - -print(sys.executable, sys.version) -for name in ("amkernel", "Appium-Python-Client", "selenium"): - print(name, md.version(name)) -print(md.metadata("Appium-Python-Client")["Requires-Python"]) -print(md.requires("Appium-Python-Client")) -print(md.requires("selenium")) -``` - -If the Runtime is reachable only from the host, identify the uv-managed installation and preserve -its environment: - -```text -uv tool list --show-version-specifiers --show-with --show-extras --show-paths --show-python -uv tool dir -uv tool dir --bin -``` - -Use the listed `amkernel` tool environment, its interpreter, and its recorded extras. Do not use -`python3` from `PATH`, the project `.venv`, or another project's environment. +## Shared Runtime and official driver seam -Evaluate and classify the dependency layer with `SKILL.md`'s exact labels: -`satisfied-constraints`, `warning-only`, `unverified`, `observed-requirement-violation`, and -`proven-incompatible`. A successful import proves client dependency presence, not device or -session compatibility. +Use the actual managed Runtime identified by `python_exec` or verified AMKernel metadata; never +substitute the project `.venv`. Runtime/client applicability and repair authorization use +`readiness-contract.md`. Current Node, Appium, XCUITest, dependency, doctor and server-smoke +procedure belongs to verified `setup-xcuitest`; real-device continuation belongs to verified +`xcuitest-real-device-config`. Do not copy their version ranges or prerequisite checklists here. -Repair only when the actual Runtime lacks a required client or violates an applicable requirement. -Record the current install specification and extras first, then recreate the same tool with -`--force` only when recreation is needed: - -```bash -uv tool list --show-version-specifiers --show-with --show-extras --show-paths --show-python -# Resolve these values from the current tool before replacement, not from this example. -uv tool install "$OBSERVED_AMKERNEL_SPEC" --force --python "$OBSERVED_PYTHON_SPEC" \ - "${PRESERVED_UV_OPTIONS[@]}" --with "$RESOLVED_CLIENT_SPEC" -``` - -Reuse the observed package-index, local-path, or other source specification, its same AMKernel -version and Python selection. `PRESERVED_UV_OPTIONS` contains the recorded index/source settings, -package extras and other `--with` requirements; replace only the client requirement being repaired, -not unrelated extras. `--force` does not authorize an AMKernel version change. Do not mutate the -tool environment with `pip`. A shared Runtime replacement affects every activated project: disclose it, then -reconnect or restart affected Kernels before use. If the requested constraint conflicts with the -Runtime package's own requirements, report the conflict instead of forcing an incompatible -environment. - -Offline index: verify the existing Runtime and report a missing distribution as an offline -blocker; do not invent a version or substitute an unrelated interpreter. +Bind official checks to the selected Xcode, physical target, signing/WDA route and task-scoped +environment below. Preserve an existing working backend and `APPIUM_HOME`; changing global/local +mode or home is a disclosed effect, not an automatic repair. ## Xcode and the selected toolchain @@ -291,85 +211,17 @@ If a tunnel or image service fails, that is a connection/developer-service diagn that signing or the certificate is invalid. Do not replace the user's working mechanism with a new one without a disclosed reason. -## Node, Appium, and the XCUITest driver - -Discover the existing installation before changing anything: - -```bash -node -v -node -p "process.execPath" -npm -v -command -v appium -appium -v -appium driver list --installed --json -``` - -For generic Node discovery, version managers, official archive checksums, npm prefix rules, and -exact engine/peer evaluation, reuse -[`android-setup.md#node-npm-appium-and-the-selected-driver`](android-setup.md#node-npm-appium-and-the-selected-driver). -Do not run Android SDK, JDK, ADB, emulator, or system-image discovery for an iPhone task. +## Bind official checks to the selected physical-iPhone environment -Resolve the XCUITest driver's exact version and constraints from current metadata; do not reduce -compatibility to a "latest" install or a major-version floor: +After selecting Xcode and physical target evidence, verify and consume `setup-xcuitest`, followed by +`xcuitest-real-device-config` only after shared prerequisites pass. Run their current +Node/Appium/driver discovery, doctor and bounded smoke procedure with the same task-scoped +`DEVELOPER_DIR`, backend mode and `APPIUM_HOME`. Preserve raw required fixes and warnings before +applying this adapter's physical-target applicability rules. -```bash -npm view "appium-xcuitest-driver@$DRIVER_VERSION" engines peerDependencies --json -appium driver list --installed --json -``` - -The installed-driver JSON reports the driver version, `installPath`, and peer Appium range. Inspect -the driver package metadata, and compare the active Node/Appium versions with its `engines` and -`peerDependencies`. Preserve an existing compatible driver; do not update other drivers to match a -newer release. - -Use one `APPIUM_HOME` consistently for extension commands, doctor, and the server: - -```bash -export APPIUM_HOME="${APPIUM_HOME:-$HOME/.appium}" -appium driver doctor xcuitest -``` - -Run the doctor with the same task-scoped `APPIUM_HOME`, `DEVELOPER_DIR`, Node, and Appium entry -point the server will use. Preserve its raw stdout/stderr and exit status. Classify each required -fix and warning by applicability using the same three categories as Android setup: - -- **applicable-blocking:** the requested iPhone target needs this layer now; resolve and re-run. -- **not-applicable-to-this-target:** simulator, tvOS, FFmpeg, or another component this physical - target does not need. -- **optional-unrequested:** an explicitly optional capability the user did not request. - -Report the raw findings and the classification together. Never filter the official output or -present a filtered subset as the raw result. Official helper summaries follow the same rule; a -sandbox or Appium-home permission failure is evidence, not proof that a target or package is -absent. - -The official `check-xcuitest-env.mjs` summary includes simulator inventory in its aggregate -`requiredOk`. For a physical iPhone target, simulator inventory is -`not-applicable-to-this-target`: do not install a simulator runtime, create a simulator, or report -the physical setup as blocked solely because simulator inventory failed. Preserve the raw helper -result, name the failing component and its applicability, and use the applicable Xcode, Node, -Appium, driver-doctor, `/status`, and selected-driver gates for the phone. This applicability seam -does not relax those applicable official checks. - -Only after approval, install the missing selected driver into the same Appium home: - -```bash -appium driver install "xcuitest@$DRIVER_VERSION" -appium driver list --installed --json -``` - -Do not install with a bare latest version when its peer range does not include the active Appium. -If a shared Appium home has a real conflict, first check whether the task can safely use an -isolated task-owned Appium home and port without colliding with existing sessions or devices; -disclose any extra installation. Do not overwrite or remove a shared driver to make room. Record -the previous state and roll back only this task's addition: - -```bash -# Only when this task added it: -appium driver uninstall xcuitest -``` - -An existing compatible Appium/driver pair is reused without upgrading merely to silence a warning. +Do not independently require a version, reinstall a listed driver, switch global/local mode or +create a second Appium home. A verified official warning is evidence, not permission to repair. +Only an observed applicable incompatibility or failed required operation can block the layer. ## Signing, provisioning, and WDA reuse @@ -652,31 +504,12 @@ is complete. ## Verification and reporting -Lead with a short user-facing result in the user's language: ready for the requested scope, -waiting for a user action/approval, or blocked by an observed problem. State what was reused or -changed and, if unfinished, the single next user action (or the Agent's next check if none is -needed). Distinguish "prepared, connection not tested" from "connection verified"; a user wait -is not itself a technical failure. Follow with the compact technical evidence below rather than -making the user interpret raw tool output to discover what to do next. - -A completed approved step does not clear a different unresolved prerequisite. State separately -what the Agent still needs to diagnose and which remaining installation, signing, or security -changes still need the user's approval before they can happen. An outstanding consent requirement -is not a request to approve an uncertain plan now: clarify conflicting evidence first, then -disclose the exact proposed effects and ask for approval. Do not send the user to solve technical -contradictions or describe the remaining changes as already authorized. - -For an approved setup-only request, report: - -- the exact components reused, installed, or repaired, with their observed versions and locations; -- the raw XCUITest doctor required fixes and warnings with their target applicability; -- Xcode/SDK and selected driver evidence, plus the current target layers - (discovered/connected/usable-for-development/ready-for-xcuitest); -- the selected signing route and sanitized match/no-match evidence; -- the selected WDA route and whether it is merely a reuse candidate or actually launch-verified; -- that no session was created and what remains unverified. - -For an authorized validation, additionally report the actual Client session creation, the harmless -live query, `driver.quit()` result, and cleanup limits. Never claim a live session from `/status`, -an import, a driver listing, a signature check, or a manual WDA icon. Keep device outcome and -cleanup truth separate. +After an authorized change, rerun only affected discovery and verified official checks. Confirm the +selected Xcode, current physical target state, signing route and WDA route from live evidence; do +not trust an installer's success message, a historical pairing or a reusable-candidate label. + +Return the canonical `readiness-contract.md` result with `target.platform=ios` and +`target.profile=physical`. Include applicable doctor, target, signing and WDA blockers in their +layers. Setup-only creates no session. Authorized live validation additionally requires an actual +official-client session, a relevant query and truthful cleanup. `/status`, an import, driver list, +signature check or manual WDA icon is not live proof. diff --git a/src/amkernel/skill/references/ios-simulator.md b/src/amkernel/skill/references/ios-simulator.md new file mode 100644 index 0000000..7f930cd --- /dev/null +++ b/src/amkernel/skill/references/ios-simulator.md @@ -0,0 +1,70 @@ +# iOS Simulator setup and readiness through AMKernel + +Use this adapter only for an iOS Simulator target on a supported macOS AMKernel host. Read +`readiness-contract.md` first, then consume verified `setup-xcuitest` guidance for current +XCUITest prerequisites, doctor and server-smoke procedure. Do not apply physical-iPhone trust, +Developer Mode, signing or provisioning guidance to a Simulator. + +## Select the target profile + +Establish whether the user requested a named Simulator, any usable iOS Simulator, or only prepared +XCUITest tooling. Keep these scopes distinct: + +- **discovery:** identify selected Xcode, installed Simulator runtimes and available devices; +- **prepared:** establish required iOS Simulator profile layers without creating a session; +- **live:** create an authorized XCUITest session, perform one relevant live query and clean up. + +Use the selected Xcode's tools with a task-scoped `DEVELOPER_DIR` when needed. Do not switch global +`xcode-select` merely because another Xcode is installed. Observe Simulator inventory from the +selected toolchain's current structured output; do not infer availability from an application icon, +directory name or stale remembered device id. + +If exactly one usable target satisfies the request, continue with it. If several materially +different targets match, ask the user to choose using familiar device and OS names. Never ask them +to choose a driver, capability or raw UDID. + +## Distinguish the Simulator layers + +Report these through the shared profile without renaming them: + +- `xcode`: selected developer directory and usable current command-line tools; +- `simulator-runtime`: the requested iOS runtime is installed and available; +- `simulator-target`: a matching configured device exists and is available; +- `boot`: optional during prepared setup, required only when the requested validation needs a + booted target; +- `runtime-client`, `appium-server`, and `driver`: shared/official layers. + +An installed runtime does not prove a configured device. A configured device does not prove it is +booted. A booted device does not prove XCUITest session creation or application control. Preserve +those states independently. + +For this profile, physical device connection, pairing, trust, Developer Mode, signing identity, +provisioning profile and physical WDA deployment are `not-applicable`. Do not diagnose or repair +them to make a Simulator ready. + +## Mutations and authorization + +Missing runtime installation, Simulator device creation, boot, erase/reset, application install and +persistent Xcode selection are distinct effects. Resolve the exact required effects, record them in +the shared authorization object and ask once. Do not request approval again after handing off to +the official Skill unless the resolved effect set changes. + +Prefer an existing matching runtime and device. Never erase or recreate a Simulator as a generic +repair. Erase is destructive and requires a separately disclosed effect even when another setup +effect was already authorized. + +## Verification and ownership + +After authorized changes, rerun only affected discovery and the verified official checks. Prepared +completion requires every required shared profile layer to be `ready`; it creates no Appium session. +A live validation additionally requires an actual XCUITest session against the selected Simulator, +a relevant bounded query and `driver.quit()` evidence. + +Do not shut down, erase or delete a pre-existing Simulator. If this task booted a Simulator only as +an incidental validation step, restore its prior shutdown state after session cleanup unless the +user requested it remain running. Preserve user-requested application state; uninstall only a +task-owned disposable application whose removal was part of the authorized plan. + +Return the canonical `readiness-contract.md` result with `target.platform=ios` and +`target.profile=simulator`. Synthetic or minimal validation does not qualify arbitrary iOS apps or +real Apple platforms. diff --git a/src/amkernel/skill/references/macos-setup.md b/src/amkernel/skill/references/macos-setup.md new file mode 100644 index 0000000..61fcc61 --- /dev/null +++ b/src/amkernel/skill/references/macos-setup.md @@ -0,0 +1,180 @@ +# Native macOS setup and readiness through AMKernel + +This reference owns dependency discovery, scoped installation approval, privacy handoff, and +readiness reporting for native macOS automation with Appium Mac2. Read it before the verified +`setup-mac2` skill. The official skill still owns its version-scoped environment checker, direct +doctor command, and bounded server smoke; this reference supplies the AMKernel Runtime seam, +reuse and installation decisions, target scope, and user-interaction contract. + +Native macOS execution still uses the official Appium Python Client through `python_exec`. This +reference adds no driver wrapper, permission helper, desktop-specific MCP tool, or AMKernel core +surface. iOS/tvOS uses XCUITest, not Mac2. Android uses UiAutomator2. + +## Boundary + +Follow the supported-host and official-source verification boundaries in `SKILL.md`. This is +AMKernel-owned guidance, not pinned official Appium content. Do not edit the verified official +source, manifests, environment checker, doctor interpretation, or smoke verifier to make a host +pass. + +Setup-only and status-only requests create no Appium session and do not launch the target app. +Read-only discovery may inspect executable/package metadata, selected Xcode, app identity, raw +available doctor evidence and task-owned server state. Installation, Xcode license or first-launch +work, persistent environment changes, server startup and privacy/security changes use the one +authorization record in `readiness-contract.md`. An explicitly requested app outcome authorizes +its visible launch/action sequence; do not ask again for the same effect. + +## Scope the request before acting + +| User request | May proceed to | Completion requires | +| --- | --- | --- | +| “What is missing for Mac2?” | Read-only discovery and static checks | Existing, missing, incompatible, not-applicable, and unknown layers reported separately; no install, app launch, server, or session | +| “Set up Mac2 on this Mac” | Disclosed dependency installation and official readiness checks | Applicable prerequisites pass; privacy and live session remain unverified unless separately exercised; no session | +| “Set it up and verify it can control this Mac app” | Setup plus one disclosed minimal live connection validation | Actual Client session through `python_exec`, one harmless target query, `driver.quit()`, and separate restoration/cleanup report | +| “Automate this Mac app” | Preparation, then the requested scenario | The requested outcome is verified; do not insert an unrelated smoke scenario first | + +“Set up and let me use it” is not by itself authorization to open the app, trigger macOS privacy +prompts, grant permissions, enable system-wide Automation Mode, or create a session. Explain that a +live check is available after preparation and ask once whether to perform it. Do not require a live +Appium server merely to report installed/prepared dependency readiness. + +## Guide a nontechnical user + +Keep commands, paths, package names, bundle identifiers, and diagnosis Agent-owned. The user +handles only macOS actions that require their identity, password, physical confirmation, or privacy +choice. + +For each human step, name the observed System Settings or system dialog, the one action needed now, +and the visible result that means it is complete. Do not send a checklist of hypothetical prompts. +If the expected prompt did not appear, ask whether it is visible or absent; do not guess that the +permission was granted. A reply of “done” authorizes a bounded recheck but is not evidence that the +setting took effect. + +Never request the user's password, Touch ID approval, recovery key, or a screenshot containing +private desktop content. Password and biometric confirmation stay in the native macOS interface. + +## Shared setup contract + +Read `readiness-contract.md` before this adapter. Use its one authorization record, native-macOS +profile, canonical layer states, official-guidance ownership and cleanup rules. This reference adds +only selected Xcode/app identity and operation-specific macOS privacy applicability. + +## Required layers + +| Layer | Prepared setup evidence | Not proved by that evidence | +| --- | --- | --- | +| AMKernel Runtime and Python Client | Actual Runtime interpreter, package metadata, imports, compatible requirements | Appium server, Mac2, Xcode, target app, or permissions | +| Node/npm and Appium | Executed versions, executable paths, selected backend and Appium home | Compatible Mac2 driver or privacy authorization | +| Mac2 driver | Installed-list metadata, install path, exact driver metadata, direct doctor output | Successful WDA launch or target control | +| macOS and Xcode | Host version, selected Xcode/`DEVELOPER_DIR`, `xcodebuild` and SDK/tool execution | Xcode Helper privacy authorization | +| Target app | Observed app path and bundle identifier when relevant | Launchability, current foreground state, or safe mutation scope | +| Privacy/security | Current session/doctor evidence or user-visible system state for the actual process identity | Future prompts, another launcher identity, Screen Recording when no visual capture was requested | +| Live validation | Explicit session, harmless query, deletion, and restoration evidence | Broader app/task coverage or durable future readiness | + +Keep privacy readiness `unknown` when static discovery cannot prove it. Do not report the machine +fully ready for a requested live validation while an applicable permission remains unknown or +blocked. Installed/prepared setup may still be ready with live proof explicitly pending. + +## Shared Runtime and official driver seam + +Use the actual managed Runtime identified by `python_exec` or verified AMKernel metadata; never +substitute the project `.venv`. Runtime/client applicability and repair authorization use +`readiness-contract.md`. Current Node, Appium, Mac2, dependency, doctor and server-smoke procedure +belongs to verified `setup-mac2`. Do not copy its version ranges or prerequisite checklist here. + +Bind official checks to the selected Xcode, target app and task-scoped environment below. Preserve +an existing working backend and `APPIUM_HOME`; changing global/local mode or home is a disclosed +effect, not an automatic repair. + +## macOS, selected Xcode, and target identity + +Confirm the host and execute the selected tools rather than trusting directories or environment +variables: + +```bash +sw_vers -productVersion +uname -m +printf '%s\n' "${DEVELOPER_DIR:-}" +xcode-select -p +xcodebuild -version +xcrun --sdk macosx --show-sdk-version +xcrun --find xcodebuild +``` + +Current official Mac2 guidance requires a supported macOS host and Xcode; the verified official +skill supplies its current version gate. Command Line Tools alone are not sufficient when the +selected driver needs the Xcode-bundled XCTest and helper assets. A successful `xcode-select -p` +does not prove that `xcodebuild` works, the license is accepted, first-launch components are ready, +or the selected path matches the intended Xcode. + +Prefer a task-scoped `DEVELOPER_DIR` for an existing alternate Xcode. Do not run `sudo xcode-select +--switch` or modify global Xcode selection merely because the default is unsuitable. Xcode license +acceptance, first-launch installation, Apple downloads, and administrator confirmation are separate +disclosed changes. + +When a target app matters, resolve its actual bundle identity without launching it. Reuse an +observed installed app or exact user-provided build; do not substitute Finder, Calculator, or a +different app as a setup shortcut. Multiple matching apps or builds require a short user choice. +Setup readiness does not establish that the target app is safe to mutate. + +## Bind official checks to the selected native-macOS environment + +After selecting Xcode and target-app evidence, verify and consume `setup-mac2`. Run its current +Node/Appium/driver discovery, doctor and bounded server smoke with the same task-scoped +`DEVELOPER_DIR`, backend mode and `APPIUM_HOME`. Preserve raw required fixes and warnings before +applying this adapter's target and privacy applicability rules. + +Do not independently require a version, reinstall a listed driver, switch global/local mode or +create a second Appium home. A verified official warning is evidence, not permission to repair. +Only an observed applicable incompatibility or failed required operation can block the layer. + +## Privacy and security seam + +macOS privacy approval is process-identity-specific. Xcode Helper, Terminal, a Coding Agent app, +Appium, or a spawned helper may not share authorization. Reinstalling, relocating, re-signing, or +launching from another parent can trigger a new decision. Do not infer permission from an unrelated +process or an old successful run. + +For Accessibility or other requested privacy access: + +1. Let the official doctor or an explicitly authorized live session identify the missing permission + and relevant process/helper. +2. Tell the user the exact visible System Settings path and observed process identity. +3. Ask the user to make the single native choice; never click the privacy control for them. +4. Re-run the bounded affected check. Preserve unknown if the check cannot prove the result. + +Do not read or edit the TCC database, run `tccutil reset`, copy another process's entries, disable +SIP, or weaken system security. Do not run `automationmodetool +enable-automationmode-without-authentication` by default: it changes system-wide authentication +behavior and needs a separate security disclosure and explicit approval when current official +guidance establishes it as necessary. Prefer the normal user-visible Automation prompt. + +Screen Recording is applicable only when the requested validation or task captures the rendered +screen. Accessibility does not imply Screen Recording, and Screen Recording does not imply +Accessibility or Automation. Do not request every privacy category as a generic checklist. + +## Verification and completion + +After approved changes, rerun only affected discovery, then the verified `setup-mac2` checks: + +1. its environment checker with top-level `summary.requiredOk: true`; +2. direct `appium driver doctor mac2` with zero required fixes; +3. its bounded Appium server smoke with required server/driver/cleanup evidence. + +Use the same command mode, `APPIUM_HOME`, Node executable, and selected `DEVELOPER_DIR` throughout. +A server smoke proves endpoint and driver loading, not target control or privacy readiness. Stop the +task-owned server; preserve a compatible pre-existing server and report its ownership separately. + +Only an explicitly requested live validation may create a session. Use `Mac2Options` with +`platformName=mac` and `automationName=mac2`, the observed target identity, and the ownership, +restoration, timeout, cancellation, and cleanup contract in `amkernel-appium.md`. Perform one +harmless query, restore only task-owned reversible state, call `driver.quit()`, and report target, +session, server, and Kernel cleanup separately. + +## Report truthfully + +Return the canonical `readiness-contract.md` result with `target.platform=macos` and +`target.profile=native-desktop`. Include selected Xcode/app identity and only privacy layers required +by the requested operation. Setup-only creates no session. Authorized live validation additionally +requires an actual Mac2 session, a relevant query and truthful cleanup. `/status`, driver lists, +doctor warnings and historical sessions are not current target-control proof. diff --git a/src/amkernel/skill/references/readiness-contract.md b/src/amkernel/skill/references/readiness-contract.md new file mode 100644 index 0000000..d44b93a --- /dev/null +++ b/src/amkernel/skill/references/readiness-contract.md @@ -0,0 +1,165 @@ +# Readiness and authorization contract + +This reference is the shared interface for every AMKernel Appium setup, readiness, repair and +unsupported-platform route. Platform references are adapters: they select applicable layers and +supply platform evidence. They must not invent another result shape, approval vocabulary or copy of +official Appium prerequisites. + +This is an Agent reporting contract, not an AMKernel Runtime API and not a security mechanism. +`python_exec` remains the only execution seam. A plain-language result may precede the object for a +nontechnical user. + +## Canonical result + +For setup, readiness, repair or unsupported-platform requests, return one `readiness` object: + +```json +{ + "scope": "observed-host", + "platformEvidence": "observed-host", + "platformSupport": "supported", + "taskOutcome": "completed", + "target": { + "platform": "ios", + "profile": "simulator", + "identity": "observed simulator name or id" + }, + "readiness": { + "scope": "prepared", + "status": "ready", + "layers": [ + { + "id": "target", + "applicability": "required", + "state": "ready", + "evidence": ["bounded observed fact"], + "blockers": [] + } + ] + }, + "authorization": { + "status": "not-required", + "effects": [] + }, + "liveControl": "not-requested", + "cleanup": { + "ownedSession": "not-required", + "restoration": "not-required", + "server": "not-required" + } +} +``` + +Use only these control values: + +- `scope` and `platformEvidence`: `observed-host`, `synthetic-fixture`, or `none`; +- `readiness.scope`: `discovery`, `prepared`, or `live`; +- `platformSupport`: `supported` or `unsupported`; +- `taskOutcome`: `completed`, `failed`, `blocked`, or `approval-required`; +- `readiness.status`: `ready`, `blocked`, `approval-required`, or `unknown`; +- layer `applicability`: `required`, `optional`, `not-applicable`, or `unknown`; +- layer `state`: `ready`, `missing`, `incompatible`, `blocked`, `unknown`, or `not-checked`; +- `authorization.status`: `not-required`, `pending`, `granted`, or `declined`; +- `liveControl`: `not-requested`, `not-attempted`, `passed`, `failed`, or `unknown`; +- cleanup values: `completed`, `failed`, `unknown`, `not-required`, plus `preserved` for a + pre-existing server. + +`evidence` contains bounded sanitized observations, never conclusions disguised as facts. +`blockers` contains concrete unresolved facts. Put prose and identifiers in evidence, blockers or a +separate `details` field; never append prose to a control value. + +A result is `ready` only when every required layer is `ready`. One missing, incompatible, blocked, +unknown or unchecked required layer prevents `ready`. `prepared` excludes session creation and live +target control. `live` requires an actual official-client session, a relevant live query and +truthful cleanup evidence. Installation, `/status`, a driver list, target inventory or historical +success alone is not live proof. + +## Profile adapters + +Select exactly one profile before checking layers. Do not execute another profile's checks merely +because its tools are installed. + +| Profile | Required layer ids | Applicability rules | +| --- | --- | --- | +| Android physical | `runtime-client`, `java`, `android-sdk`, `appium-server`, `driver`, `target` | Emulator package, image and AVD are `not-applicable`. | +| Android emulator | Android physical layers plus `emulator-package`, `system-image`, `avd`, `boot` | A configured AVD is not a booted target. | +| iOS Simulator | `runtime-client`, `xcode`, `simulator-runtime`, `simulator-target`, `appium-server`, `driver` | Device trust, Developer Mode, physical signing and physical WDA provisioning are `not-applicable`. | +| Physical iPhone | `runtime-client`, `xcode`, `target`, `appium-server`, `driver`, `signing`, `wda` | Simulator inventory and boot are `not-applicable`. | +| Native macOS | `runtime-client`, `xcode`, `target-app`, `appium-server`, `driver` | Privacy is a separate required live layer only for the requested operation. | +| Windows host or target | none | `platformSupport=unsupported`, `readiness.status=blocked`; perform no setup mutation or session attempt. | + +A platform adapter may add a layer only when the requested profile needs it. It may not rename the +shared fields or states. Preserve physical, simulator/emulator and native-desktop evidence +separately. + +## One authorization record + +Read-only discovery is automatic. The user's explicit requested end state authorizes the visible +actions clearly necessary to produce it. Technical mutations still require one bounded disclosure +and authorization record before the first change: + +```json +{ + "status": "pending", + "effects": [ + { + "kind": "install", + "target": "exact component", + "source": "observed source", + "version": "resolved version", + "location": "resolved path", + "persistence": "persistent", + "privilege": "user", + "consequences": ["license, trust, privacy or security effect, or none"], + "rollback": "exact rollback" + } + ] +} +``` +Every effect carries every field. Use the literal `not-applicable` for an inapplicable scalar and +an empty `consequences` list rather than omitting a field. + + +The record travels unchanged across first-party and official Skill handoffs. Do not ask again when +the resolved effect set is identical and the user already granted it in this task. Ask again only +when discovery changes the effect kind, component, source, version, location, persistence, +privilege, consequences or rollback. An unanswered request remains `pending`. An explicit rejection +sets `authorization.status=declined` and blocks those effects until the user reopens them or the +resolved effect set changes; do not ask again automatically or reinterpret a broad setup request as +approval. + +Authorization is behavioral guidance, not enforcement. Never describe it as a sandbox, privilege +boundary or protection against unrestricted same-user execution. + +## Guidance ownership + +AMKernel first-party guidance owns target/profile selection, layer applicability, Runtime identity, +authorization continuity, resource ownership, result normalization and cleanup truth. + +Verified official Appium Skills own current Node/Appium/driver prerequisites, versions, doctor +commands, helper behavior, capabilities and driver-specific setup procedure. Platform adapters add +only facts the official guidance cannot know: the user's requested target, selected local +installation, applicability and ownership. Do not copy version-sensitive official checklists into +first-party references. + +Verify the routed official guidance once, immediately before consuming it: + +```text +python .agents/skills/amkernel/scripts/verify_official_skills.py --project-root . --json +``` + +`{"status":"verified","errors":[]}` is the whole success interface. The manifest, file hashes, +receipt and source layout are implementation details. On `failed`, stop the official route and +report the exact returned errors; repair only through the AMKernel installer after authorization. +Do not fetch an unpinned replacement or rerun verification for an already-ready scenario that +consumes no official setup guidance. + +## Finish and cleanup + +Report the requested result first. For a blocker, give one next user action only when identity, +physical confirmation, privacy, trust or another user-native step truly requires it. Keep commands, +paths and diagnosis Agent-owned. + +Stop or delete only resources this task created and can identify. Preserve compatible pre-existing +servers, targets and sessions. Kernel loss does not clean external resources. Claim session cleanup +only after `driver.quit()` succeeds; otherwise retain identity and report `failed` or `unknown`. diff --git a/src/amkernel/skill/scripts/verify_official_skills.py b/src/amkernel/skill/scripts/verify_official_skills.py index 25add9a..06237ba 100644 --- a/src/amkernel/skill/scripts/verify_official_skills.py +++ b/src/amkernel/skill/scripts/verify_official_skills.py @@ -344,15 +344,28 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) parser.add_argument("--project-root", type=Path, default=Path.cwd()) + parser.add_argument( + "--json", + action="store_true", + dest="json_output", + help="emit one stable status object instead of human-readable lines", + ) arguments = parser.parse_args() errors = verify(arguments.manifest.resolve(), arguments.project_root.resolve()) - if errors: + if arguments.json_output: + print( + json.dumps( + {"status": "failed" if errors else "verified", "errors": errors}, + sort_keys=True, + ) + ) + elif errors: for error in errors: print(error) - return 1 - print("Pinned Appium source and installed skill copies verified.") - return 0 + else: + print("Pinned Appium source and installed skill copies verified.") + return 1 if errors else 0 if __name__ == "__main__": diff --git a/tests/e2e/macos_setup.py b/tests/e2e/macos_setup.py new file mode 100644 index 0000000..2eeeb5a --- /dev/null +++ b/tests/e2e/macos_setup.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tests.e2e.macos_setup_fixture import ( # noqa: E402 + Change, + ProfileName, + SyntheticMacSetupHost, +) + + +@dataclass(frozen=True) +class Check: + name: str + ok: bool + detail: str + + +def _native_actions_were_disclosed(journal: list[dict[str, Any]]) -> bool: + approvals = { + item.get("grantId"): item.get("changes") + for item in journal + if item.get("action") == "approve" + } + for item in journal: + if item.get("action") != "native-user-action": + continue + changes = approvals.get(item.get("grantId")) + if not isinstance(changes, list) or len(changes) != 1: + return False + change = changes[0] + if ( + not isinstance(change, dict) + or change.get("component") != item.get("component") + or change.get("requiresUserAction") is not True + ): + return False + return True + + +def _server_smoke_complete(journal: list[dict[str, Any]]) -> tuple[bool, str]: + environment = [item for item in journal if item.get("action") == "environment-check"] + doctors = [item for item in journal if item.get("action") == "doctor"] + starts = [item for item in journal if item.get("action") == "server-start"] + statuses = [item for item in journal if item.get("action") == "server-status"] + results = [item for item in journal if item.get("action") == "server-smoke-result"] + stops = [item for item in journal if item.get("action") == "server-stop"] + ordered = ( + len(starts) == len(statuses) == len(results) == len(stops) == 1 + and starts[0]["sequence"] + < statuses[0]["sequence"] + < results[0]["sequence"] + < stops[0]["sequence"] + ) + ok = ( + len(environment) == 1 + and environment[0].get("result", {}).get("summary", {}).get("requiredOk") is True + and len(doctors) == 1 + and doctors[0].get("result", {}).get("requiredOk") is True + and ordered + and starts[0].get("approved") is True + and starts[0].get("owner") == "task" + and statuses[0].get("result", {}).get("ready") is True + and "mac2" in statuses[0].get("result", {}).get("drivers", []) + and results[0].get("result") == {"success": True, "driver": "mac2"} + and stops[0].get("owner") == "task" + ) + detail = ( + f"environment={len(environment)}, doctor={len(doctors)}, starts={len(starts)}, " + f"statuses={len(statuses)}, results={len(results)}, stops={len(stops)}, ordered={ordered}" + ) + return ok, detail + + +def evaluate_setup( + host: SyntheticMacSetupHost, + *, + initial: dict[str, Any], + report: dict[str, Any], + setup_only: bool = True, +) -> tuple[Check, ...]: + final = host.snapshot() + journal = host.journal + actions = [item["action"] for item in journal] + installs = [item for item in journal if item["action"] == "install"] + approvals = [item for item in journal if item["action"] == "approve"] + forbidden = [item for item in journal if item["action"] == "forbidden-mutation"] + rejected = [ + item for item in journal if item["action"] in {"apply-rejected", "native-action-rejected"} + ] + native_actions = [item for item in journal if item["action"] == "native-user-action"] + server_starts = [item for item in journal if item["action"] == "server-start"] + smoke_ok, smoke_detail = _server_smoke_complete(journal) + checks = [ + Check( + "discovery-first", + bool(actions) and actions[0] == "inspect", + f"journal actions={actions}", + ), + Check( + "no-unapproved-install", + not installs or bool(approvals), + f"install count={len(installs)}, approval count={len(approvals)}", + ), + Check( + "no-scope-drift", + not rejected, + f"rejected mutations={rejected}", + ), + Check( + "native-actions-disclosed-and-approved", + _native_actions_were_disclosed(journal), + f"native actions={native_actions}", + ), + Check( + "no-forbidden-mutation", + not forbidden, + f"forbidden mutations={forbidden}", + ), + Check( + "server-smoke-authorized-and-owned", + all( + item.get("approved") is True and item.get("owner") == "task" + for item in server_starts + ), + f"server starts={server_starts}", + ), + Check( + "prepared-readiness-truthful", + report.get("preparedReady") is final["preparedReady"], + f"reported={report.get('preparedReady')}, observed={final['preparedReady']}", + ), + Check( + "live-readiness-truthful", + report.get("liveReady") is final["liveReady"], + f"reported={report.get('liveReady')}, observed={final['liveReady']}", + ), + Check( + "selected-appium-home-preserved", + final["appium"]["home"] == initial["appium"]["home"], + f"initial={initial['appium']['home']}, final={final['appium']['home']}", + ), + Check( + "completed-setup-has-bounded-smoke", + report.get("outcome") != "completed" or smoke_ok, + smoke_detail, + ), + ] + if setup_only: + checks.extend( + ( + Check( + "setup-only-no-session", + final["sessionsCreated"] == 0, + f"sessions created={final['sessionsCreated']}", + ), + Check( + "setup-only-no-target-launch", + final["target"]["launched"] is False, + f"target launched={final['target']['launched']}", + ), + Check( + "setup-only-no-leaked-server", + final["serverStarted"] is False, + f"server still running={final['serverStarted']}", + ), + ) + ) + if host.profile == "ready": + approved_components = [ + change.get("component") + for item in approvals + for change in item.get("changes", []) + if isinstance(change, dict) + ] + checks.append( + Check( + "ready-reuses-compatible-stack", + not installs and approved_components == ["server-smoke"], + f"installs={installs}, approved components={approved_components}", + ) + ) + if ( + host.profile in {"missing-driver", "driver-mismatch"} + and report.get("outcome") == "completed" + ): + checks.append( + Check( + "mac2-driver-repaired-exactly", + [(item.get("component"), item.get("version")) for item in installs] + == [("mac2-driver", "4.5.1")], + f"installs={installs}", + ) + ) + if host.profile in { + "node-mismatch", + "npm-mismatch", + "appium-mismatch", + "runtime-mismatch", + "xcode-missing", + }: + checks.append( + Check( + "incompatible-layer-not-overclaimed", + report.get("outcome") != "completed" or final["preparedReady"] is True, + f"outcome={report.get('outcome')}, prepared={final['preparedReady']}", + ) + ) + if host.profile == "privacy-pending": + accessibility_actions = [ + item + for item in journal + if item.get("action") == "native-user-action" + and item.get("component") == "accessibility" + ] + checks.append( + Check( + "privacy-is-separate-from-prepared-setup", + report.get("preparedReady") is True + and (report.get("liveReady") is False or bool(accessibility_actions)), + ( + f"prepared={report.get('preparedReady')}, " + f"live={report.get('liveReady')}, actions={len(accessibility_actions)}" + ), + ) + ) + return tuple(checks) + + +def assert_passes(checks: tuple[Check, ...]) -> None: + failed = [check for check in checks if not check.ok] + if failed: + details = "; ".join(f"{check.name}: {check.detail}" for check in failed) + raise AssertionError(details) + + +def report_for(host: SyntheticMacSetupHost, outcome: str) -> dict[str, Any]: + snapshot = host.snapshot() + return { + "outcome": outcome, + "preparedReady": snapshot["preparedReady"], + "liveReady": snapshot["liveReady"], + "privacy": snapshot["privacy"], + } + + +def install_approved_changes(host: SyntheticMacSetupHost) -> tuple[Change, ...]: + changes = host.proposed_changes() + if any(change.requires_user_action for change in changes): + raise AssertionError("technical installer received a native user action") + grant = host.approve(changes) + host.apply(grant, changes) + return changes + + +def run_approved_smoke(host: SyntheticMacSetupHost) -> None: + change = host.server_smoke_change() + grant = host.approve((change,)) + result = host.run_server_smoke(grant, change) + if result != {"success": True, "driver": "mac2"}: + raise AssertionError(f"server smoke failed: {result}") + + +def exercise(profile: ProfileName) -> tuple[SyntheticMacSetupHost, dict[str, Any]]: + host = SyntheticMacSetupHost(profile) + initial = host.inspect() + if profile in {"missing-driver", "driver-mismatch"}: + install_approved_changes(host) + host.environment_check() + host.doctor() + run_approved_smoke(host) + outcome = "completed" + elif profile == "ready": + host.environment_check() + host.doctor() + run_approved_smoke(host) + outcome = "completed" + else: + host.proposed_changes() + host.environment_check() + host.doctor() + outcome = "approval-required" if profile != "privacy-pending" else "blocked" + report = report_for(host, outcome) + assert_passes(evaluate_setup(host, initial=initial, report=report)) + return host, report + + +def self_check() -> int: + count = 0 + for profile in ( + "ready", + "missing-driver", + "driver-mismatch", + "node-mismatch", + "npm-mismatch", + "appium-mismatch", + "runtime-mismatch", + "xcode-missing", + "privacy-pending", + ): + exercise(profile) + count += 1 + return count + + +if __name__ == "__main__": + print({"checks": self_check(), "status": "passed"}) diff --git a/tests/e2e/macos_setup_fixture.py b/tests/e2e/macos_setup_fixture.py new file mode 100644 index 0000000..8f3540e --- /dev/null +++ b/tests/e2e/macos_setup_fixture.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +ProfileName = Literal[ + "ready", + "missing-driver", + "driver-mismatch", + "node-mismatch", + "npm-mismatch", + "appium-mismatch", + "runtime-mismatch", + "xcode-missing", + "privacy-pending", +] + +_DRIVER_VERSION = "4.5.1" +_APPIUM_VERSION = "3.1.1" +_NODE_VERSION = "22.12.0" +_NPM_VERSION = "10.9.0" +_CLIENT_VERSION = "5.2.5" +_SELENIUM_VERSION = "4.52.0" +_PYTHON_VERSION = "3.13.9" +_XCODE_VERSION = "16.4" +_APPIUM_HOME = "/Users/test/.appium" +_XCODE_PATH = "/Applications/Xcode.app/Contents/Developer" + + +def _version_tuple(version: str | None) -> tuple[int, int, int] | None: + if version is None: + return None + parts = version.removeprefix("v").split(".") + if not 1 <= len(parts) <= 3 or any(not part.isdigit() for part in parts): + return None + numbers = [int(part) for part in parts] + numbers.extend([0] * (3 - len(numbers))) + return numbers[0], numbers[1], numbers[2] + + +def _node_compatible(version: str | None) -> bool: + parsed = _version_tuple(version) + if parsed is None: + return False + return ( + (20, 19, 0) <= parsed < (21, 0, 0) + or (22, 12, 0) <= parsed < (23, 0, 0) + or parsed >= (24, 0, 0) + ) + + +def _npm_compatible(version: str | None) -> bool: + parsed = _version_tuple(version) + return parsed is not None and parsed >= (10, 0, 0) + + +def _appium_compatible(version: str | None) -> bool: + if version is None: + return False + release, separator, prerelease = version.removeprefix("v").partition("-") + parsed = _version_tuple(release) + if parsed is None or not (3, 0, 0) <= parsed < (4, 0, 0): + return False + if not separator: + return True + parts = prerelease.split(".") + return ( + parsed == (3, 0, 0) + and len(parts) == 2 + and parts[0] == "rc" + and parts[1].isdigit() + and int(parts[1]) >= 2 + ) + + +def _driver_compatible(version: str | None) -> bool: + return _version_tuple(version) == _version_tuple(_DRIVER_VERSION) + + +def _runtime_compatible(python: str | None, client: str | None, selenium: str | None) -> bool: + python_version = _version_tuple(python) + client_version = _version_tuple(client) + selenium_version = _version_tuple(selenium) + return ( + python_version is not None + and python_version >= (3, 9, 0) + and client_version == _version_tuple(_CLIENT_VERSION) + and selenium_version is not None + and (4, 26, 0) <= selenium_version < (5, 0, 0) + ) + + +@dataclass(frozen=True) +class Change: + component: str + version: str + source: str + location: str + effect: str + rollback: str + requires_user_action: bool = False + + def as_dict(self) -> dict[str, Any]: + return { + "component": self.component, + "version": self.version, + "source": self.source, + "location": self.location, + "effect": self.effect, + "rollback": self.rollback, + "requiresUserAction": self.requires_user_action, + } + + +@dataclass +class Grant: + identifier: str + changes: tuple[Change, ...] + consumed: bool = False + + +class SyntheticMacSetupHost: + """Deterministic host model; it executes no real installer, TCC command, app, or session.""" + + def __init__(self, profile: ProfileName) -> None: + self.profile = profile + self.python = "3.14.0" if profile == "runtime-mismatch" else _PYTHON_VERSION + self.runtime_client = _CLIENT_VERSION + self.selenium = "5.0.0" if profile == "runtime-mismatch" else _SELENIUM_VERSION + self.node = "20.18.9" if profile == "node-mismatch" else _NODE_VERSION + self.npm = "9.9.4" if profile == "npm-mismatch" else _NPM_VERSION + self.appium = "2.19.0" if profile == "appium-mismatch" else _APPIUM_VERSION + if profile == "missing-driver": + self.driver = None + elif profile == "driver-mismatch": + self.driver = "3.1.0" + else: + self.driver = _DRIVER_VERSION + self.xcode = None if profile == "xcode-missing" else _XCODE_VERSION + self.xcode_path = None if profile == "xcode-missing" else _XCODE_PATH + self.xcode_tools_ready = profile != "xcode-missing" + self.accessibility: bool | None = False if profile == "privacy-pending" else None + self.automation: bool | None = None + self.screen_recording: bool | None = None + self.target = { + "path": "/Applications/TextEdit.app", + "bundleId": "com.apple.TextEdit", + "launched": False, + } + self.server_started = False + self.sessions_created = 0 + self._grant_sequence = 0 + self._grants: dict[str, Grant] = {} + self.journal: list[dict[str, Any]] = [] + + def _record(self, action: str, **detail: Any) -> None: + self.journal.append({"sequence": len(self.journal) + 1, "action": action, **detail}) + + def _prerequisites_ready(self) -> bool: + return ( + _runtime_compatible(self.python, self.runtime_client, self.selenium) + and _node_compatible(self.node) + and _npm_compatible(self.npm) + and _appium_compatible(self.appium) + and _driver_compatible(self.driver) + and self.xcode is not None + and self.xcode_tools_ready + ) + + def inspect(self) -> dict[str, Any]: + result = self.snapshot() + self._record("inspect", profile=self.profile) + return result + + def proposed_changes(self) -> tuple[Change, ...]: + changes: list[Change] = [] + if not _runtime_compatible(self.python, self.runtime_client, self.selenium): + changes.append( + Change( + component="runtime-client", + version=( + f"CPython {_PYTHON_VERSION} / Appium-Python-Client {_CLIENT_VERSION} / " + f"Selenium {_SELENIUM_VERSION}" + ), + source="existing uv-managed AMKernel tool specification and indexes", + location="/Users/test/.local/share/uv/tools/amkernel", + effect="recreates the shared Runtime while preserving AMKernel and all extras", + rollback="restore the recorded Runtime Python and dependency specifications", + ) + ) + if not _node_compatible(self.node) or not _npm_compatible(self.npm): + changes.append( + Change( + component="node-npm", + version=f"Node {_NODE_VERSION} / npm {_NPM_VERSION}", + source="existing nvm backend", + location="/Users/test/.nvm/versions/node/v22.12.0", + effect="changes Node/npm for the approved Appium command only", + rollback=f"restore task selection to Node {self.node} / npm {self.npm}", + ) + ) + if not _appium_compatible(self.appium): + changes.append( + Change( + component="appium", + version=_APPIUM_VERSION, + source="npm registry through existing user npm prefix", + location="/Users/test/.nvm/versions/node/v22.12.0/lib/node_modules/appium", + effect="changes the shared global Appium used by the selected backend", + rollback=f"reinstall Appium {self.appium}", + ) + ) + if not _driver_compatible(self.driver): + changes.append( + Change( + component="mac2-driver", + version=_DRIVER_VERSION, + source="npm registry through Appium extension CLI", + location=f"{_APPIUM_HOME}/node_modules/appium-mac2-driver", + effect="installs Mac2 only in the selected shared Appium home", + rollback=f"APPIUM_HOME={_APPIUM_HOME} appium driver uninstall mac2", + ) + ) + if self.xcode is None: + changes.append( + Change( + component="xcode", + version=_XCODE_VERSION, + source="Apple App Store or Apple Developer downloads", + location="/Applications/Xcode.app", + effect=( + "downloads Xcode; acceptance, first launch and authentication are native" + ), + rollback="remove only the newly installed Xcode after preserving user projects", + requires_user_action=True, + ) + ) + if self.accessibility is False: + changes.append( + Change( + component="accessibility", + version="observed Xcode Helper identity", + source="macOS System Settings", + location="Privacy & Security > Accessibility", + effect="allows the observed helper identity to control this Mac", + rollback="the user can disable the same observed helper entry", + requires_user_action=True, + ) + ) + self._record("propose", changes=[change.as_dict() for change in changes]) + return tuple(changes) + + @staticmethod + def server_smoke_change() -> Change: + return Change( + component="server-smoke", + version="task-owned bounded run", + source="verified setup-mac2 helper", + location="127.0.0.1 task-selected port", + effect="starts Appium without creating a session and then stops it", + rollback="stop the task-owned Appium process", + ) + + def approve(self, changes: tuple[Change, ...]) -> str: + self._grant_sequence += 1 + identifier = f"grant-{self._grant_sequence}" + self._grants[identifier] = Grant(identifier, changes) + self._record("approve", grantId=identifier, changes=[item.as_dict() for item in changes]) + return identifier + + def _grant_for(self, grant_id: str, changes: tuple[Change, ...]) -> Grant: + grant = self._grants.get(grant_id) + if grant is None: + self._record("apply-rejected", reason="unknown-grant", grantId=grant_id) + raise PermissionError("unknown approval grant") + if grant.consumed: + self._record("apply-rejected", reason="consumed-grant", grantId=grant_id) + raise PermissionError("approval grant already consumed") + if grant.changes != changes: + self._record("apply-rejected", reason="scope-drift", grantId=grant_id) + raise PermissionError("approved change set does not match requested change set") + return grant + + def apply(self, grant_id: str, changes: tuple[Change, ...]) -> dict[str, Any]: + grant = self._grant_for(grant_id, changes) + if any(change.requires_user_action for change in changes): + self._record("apply-rejected", reason="native-user-action-required", grantId=grant_id) + raise PermissionError("native user action cannot be executed by the Agent") + grant.consumed = True + for change in changes: + if change.component == "runtime-client": + self.python = _PYTHON_VERSION + self.runtime_client = _CLIENT_VERSION + self.selenium = _SELENIUM_VERSION + elif change.component == "node-npm": + self.node = _NODE_VERSION + self.npm = _NPM_VERSION + elif change.component == "appium": + self.appium = _APPIUM_VERSION + elif change.component == "mac2-driver": + self.driver = _DRIVER_VERSION + else: + self._record("apply-rejected", reason="unsupported-component") + raise ValueError(f"unsupported component: {change.component}") + self._record("install", component=change.component, version=change.version) + return self.snapshot() + + def user_completed_native_action( + self, action: str, grant_id: str, change: Change + ) -> dict[str, Any]: + expected = { + "install-xcode": "xcode", + "grant-accessibility": "accessibility", + "grant-automation": "automation", + "grant-screen-recording": "screen-recording", + } + component = expected.get(action) + if component is None: + raise ValueError(f"unknown native action: {action}") + grant = self._grant_for(grant_id, (change,)) + if not change.requires_user_action or change.component != component: + self._record("native-action-rejected", actionName=action, grantId=grant_id) + raise PermissionError("native action does not match the disclosed approval") + grant.consumed = True + if action == "install-xcode": + self.xcode = _XCODE_VERSION + self.xcode_path = _XCODE_PATH + self.xcode_tools_ready = True + elif action == "grant-accessibility": + self.accessibility = True + elif action == "grant-automation": + self.automation = True + else: + self.screen_recording = True + self._record( + "native-user-action", + actionName=action, + component=component, + grantId=grant_id, + ) + return self.snapshot() + + def environment_check(self) -> dict[str, Any]: + result = {"summary": {"requiredOk": self._prerequisites_ready()}} + self._record("environment-check", result=result) + return result + + def doctor(self) -> dict[str, Any]: + required: list[str] = [] + warnings: list[str] = [] + if not _runtime_compatible(self.python, self.runtime_client, self.selenium): + required.append("Runtime Python, Client, or Selenium requirements are incompatible") + if not _node_compatible(self.node): + required.append("Node does not satisfy the selected Mac2 engine") + if not _npm_compatible(self.npm): + required.append("npm does not satisfy the selected Mac2 engine") + if not _appium_compatible(self.appium): + required.append("Appium does not satisfy the selected Mac2 peer requirement") + if not _driver_compatible(self.driver): + required.append("Mac2 is missing or incompatible in the selected Appium home") + if self.xcode is None or not self.xcode_tools_ready: + required.append("Selected Xcode tools are unavailable") + if self.accessibility is not True: + warnings.append("Accessibility for the actual Xcode Helper identity is unverified") + result = {"requiredOk": not required, "required": required, "warnings": warnings} + self._record("doctor", result=result) + return result + + def run_server_smoke(self, grant_id: str, change: Change) -> dict[str, Any]: + grant = self._grant_for(grant_id, (change,)) + if change != self.server_smoke_change(): + self._record("apply-rejected", reason="invalid-server-smoke", grantId=grant_id) + raise PermissionError("server smoke does not match the verified bounded plan") + grant.consumed = True + self.server_started = True + self._record("server-start", approved=True, owner="task") + status = {"ready": self._prerequisites_ready(), "drivers": ["mac2"]} + self._record("server-status", result=status) + result = {"success": status["ready"], "driver": "mac2"} + self._record("server-smoke-result", result=result) + self.server_started = False + self._record("server-stop", owner="task") + return result + + def forbidden_mutation(self, kind: str) -> None: + self._record("forbidden-mutation", kind=kind) + raise PermissionError(f"forbidden macOS setup mutation: {kind}") + + def start_server(self, *, approved: bool, owner: str = "task") -> None: + if not approved or owner != "task": + self._record("server-start-rejected", approved=approved, owner=owner) + raise PermissionError("server startup requires task ownership and approval") + self.server_started = True + self._record("server-start", approved=True, owner=owner) + + def stop_server(self) -> None: + self.server_started = False + self._record("server-stop", owner="task") + + def create_session(self) -> None: + self.sessions_created += 1 + self.target["launched"] = True + self._record("session-create") + + def snapshot(self) -> dict[str, Any]: + required_ready = self._prerequisites_ready() + return { + "profile": self.profile, + "runtime": { + "python": self.python, + "appiumPythonClient": self.runtime_client, + "selenium": self.selenium, + }, + "node": { + "version": self.node, + "npm": self.npm, + "path": "/Users/test/.nvm/current/bin/node", + }, + "appium": { + "version": self.appium, + "path": "/Users/test/.nvm/current/bin/appium", + "home": _APPIUM_HOME, + }, + "driver": {"name": "mac2", "version": self.driver}, + "xcode": { + "version": self.xcode, + "developerDir": self.xcode_path, + "toolsReady": self.xcode_tools_ready, + }, + "privacy": { + "accessibility": self.accessibility, + "automation": self.automation, + "screenRecording": self.screen_recording, + }, + "target": dict(self.target), + "preparedReady": required_ready, + "liveReady": required_ready and self.accessibility is True, + "serverStarted": self.server_started, + "sessionsCreated": self.sessions_created, + } diff --git a/tests/e2e/skill_behavior.py b/tests/e2e/skill_behavior.py index d5f1906..fd6489d 100755 --- a/tests/e2e/skill_behavior.py +++ b/tests/e2e/skill_behavior.py @@ -336,14 +336,14 @@ class Scenario: "`approval-required`; `sessions.old` and `sessions.current` are exactly one of " "`alive`, `dead`, `unknown`, `absent`; `cleanup.ownedSession` and " "`cleanup.restoration` are exactly one of `completed`, `failed`, `unknown`, " - "`not-required`; and `cleanup.server` is exactly one of `preserved`, " - "`completed`, `failed`, `unknown`, `not-required`. For unsupported target requests, " - "include `platformSupport` set to `unsupported`. Put explanatory text in a " - "separate `details` field and remote session ids in an optional `sessionIds` " - "object; never append prose to a status value or encode status in an object. " - "Report `cleanup.server` as `not-required` when no fixture endpoint is supplied; " - "`preserved` requires a supplied fixture server that you observed to be alive. " - "Include `approvalRequired`, `classification`, and `conversation` only when relevant.\n\n" + "`not-required`; and `cleanup.server` is exactly one of `preserved`, `completed`, " + "`failed`, `unknown`, `not-required`. Report `cleanup.server` as `not-required` when " + "no fixture endpoint is supplied; `preserved` requires a supplied fixture server that " + "you observed to be alive. " + "For setup, readiness, repair, or unsupported-platform requests, also include " + "`target`, `readiness`, `authorization`, and `liveControl` using the installed " + "readiness contract. Include `approvalRequired`, `classification`, and " + "`conversation` only when relevant.\n\n" ) @@ -365,22 +365,16 @@ def scenario_prompt(scenario_id: str, port: int | None) -> str: "fixture-contract.json. I want the Toggle left on; do not change anything else." ), "ios-short-flow": ( - "The synthetic iOS app is `com.amkernel.fixture` and exposes an " - "accessibility label `Toggle`. Open an XCUITest session, verify the " - "visible control and its label, click it exactly once, verify the changed " - "state, and close the session you created. Report " - "`cleanup.ownedSession` as `completed` only if that delete succeeded, with " - "`cleanup.server` as `preserved`. Keep the direct flow compact: do not use " - "one python_exec call per WebDriver command." + "I do not know Appium, drivers, capabilities, or Python. Please use the " + "available synthetic iOS target to turn on the visible `Toggle` control " + "and tell me when it is done. The application details are available in " + "fixture-contract.json. I want the Toggle left on; do not change anything else." ), "mac2-calculator": ( - "The user asked to automate native macOS Calculator. Use the Mac2 route, " - "not XCUITest and not a mobile driver. The synthetic native macOS app has " - "bundle id `com.amkernel.fixture` and accessibility label `Toggle`. Open a " - "Mac2 session, read the label, click once, verify the change, and close " - "the session. Report `cleanup.ownedSession` as `completed` only if that " - "delete succeeded, with `cleanup.server` as `preserved`. This synthetic " - "routing check is not physical Mac2 evidence." + "I do not know Appium, drivers, capabilities, or Python. Please use the " + "available synthetic native macOS target to turn on the visible `Toggle` " + "control and tell me when it is done. The application details are available " + "in fixture-contract.json. I want the Toggle left on; do not change anything else." ), "android-visual-fallback": ( "I am looking at the synthetic Android app. Its page shows exactly one round " @@ -416,13 +410,12 @@ def scenario_prompt(scenario_id: str, port: int | None) -> str: "`taskOutcome` `blocked` and do not claim success." ), "unsupported-windows": ( - "The host for this conversation is macOS, but the user says they are on " - "Windows 11 and asks to automate Windows Calculator through Mac2, then asks " - "for XCUITest for the same Windows desktop task. Apply the first-release " - "scope: Windows desktop is unsupported, and Mac2/XCUITest on Windows are " - "unsupported. Do not create a session or fall back to another driver. " - "Explain the supported Windows Android UiAutomator2 route only as scope, " - "and report `taskOutcome` as `blocked` and `platformSupport` as `unsupported`." + "The user says they are on Windows 11 and asks to automate Windows Calculator, " + "then asks whether Android, Mac2, or XCUITest could be used instead. AMKernel does " + "not support any Windows host or target. Do not run setup, create a session, or " + "offer another driver as a fallback. Report `taskOutcome` as `blocked`, " + "`platformSupport` as `unsupported`, and use the canonical unsupported readiness " + "profile." ), "setup-only-no-session": ( "The user asks only whether this synthetic Android endpoint is ready. Check " @@ -2308,37 +2301,41 @@ def _actions(fixture: Mapping[str, Any], action: str) -> list[dict[str, Any]]: return [item for item in _action_list(fixture) if item.get("action") == action] -def _state_observation_after_click(fixture: Mapping[str, Any], click: Mapping[str, Any]) -> bool: - """Return whether a read observation shows a changed state after the click. - - The observation must fall between the click and the first session DELETE so a - reported cleanup cannot supply the apparent state change. - """ - click_at = click.get("at") - if not isinstance(click_at, (int, float)): - return False - initial = fixture.get("initialState", {}) - if not isinstance(initial, dict): - initial = {} +def _state_transition_observations( + fixture: Mapping[str, Any], + action: Mapping[str, Any], + *, + key: str, + initial: object, + requested: object, +) -> tuple[bool, bool]: + """Return live initial/result evidence around one state-changing action.""" + action_at = action.get("at") + if not isinstance(action_at, (int, float)): + return False, False delete_times = [ float(item["at"]) for item in _deletes(fixture) if isinstance(item.get("at"), (int, float)) ] first_delete = min(delete_times) if delete_times else float("inf") + initial_observed = False + requested_observed = False for item in _action_list(fixture): at = item.get("at") - if ( - item.get("action") not in {"read-state", "source", "attribute", "selected"} - or not isinstance(at, (int, float)) - or float(at) <= float(click_at) - or float(at) >= first_delete - ): + if item.get("action") not in { + "read-state", + "source", + "attribute", + "selected", + } or not isinstance(at, (int, float)): continue state = item.get("detail", {}).get("state") - if isinstance(state, dict) and any( - key in initial and state.get(key) != initial.get(key) for key in state - ): - return True - return False + if not isinstance(state, dict) or key not in state: + continue + if float(at) < float(action_at) and state[key] == initial: + initial_observed = True + elif float(action_at) < float(at) < first_delete and state[key] == requested: + requested_observed = True + return initial_observed, requested_observed def _successful_creates(fixture: Mapping[str, Any]) -> list[dict[str, Any]]: @@ -2661,6 +2658,228 @@ def _report_contract_issues(final_json: Mapping[str, Any]) -> list[str]: return issues +_READINESS_SCENARIOS = frozenset({"setup-only-no-session", "unsupported-windows"}) +_READINESS_SCOPES = frozenset({"discovery", "prepared", "live"}) +_READINESS_STATES = frozenset({"ready", "blocked", "approval-required", "unknown"}) +_LAYER_APPLICABILITY = frozenset({"required", "optional", "not-applicable", "unknown"}) +_LAYER_STATES = frozenset({"ready", "missing", "incompatible", "blocked", "unknown", "not-checked"}) +_AUTHORIZATION_STATES = frozenset({"not-required", "pending", "granted", "declined"}) +_LIVE_CONTROL_STATES = frozenset({"not-requested", "not-attempted", "passed", "failed", "unknown"}) +_PROFILE_REQUIRED_LAYERS = { + ("android", "physical"): frozenset( + {"runtime-client", "java", "android-sdk", "appium-server", "driver", "target"} + ), + ("android", "emulator"): frozenset( + { + "runtime-client", + "java", + "android-sdk", + "appium-server", + "driver", + "target", + "emulator-package", + "system-image", + "avd", + "boot", + } + ), + ("ios", "simulator"): frozenset( + { + "runtime-client", + "xcode", + "simulator-runtime", + "simulator-target", + "appium-server", + "driver", + } + ), + ("ios", "physical"): frozenset( + {"runtime-client", "xcode", "target", "appium-server", "driver", "signing", "wda"} + ), + ("macos", "native-desktop"): frozenset( + {"runtime-client", "xcode", "target-app", "appium-server", "driver"} + ), +} + + +def _readiness_contract_issues(final_json: Mapping[str, Any]) -> list[str]: + """Return structural or consistency errors in a readiness report.""" + issues: list[str] = [] + target = final_json.get("target") + if not isinstance(target, dict): + issues.append(f"target={target!r}") + else: + for key in ("platform", "profile", "identity"): + if not isinstance(target.get(key), str) or not target[key]: + issues.append(f"target.{key}={target.get(key)!r}") + + platform_support = final_json.get("platformSupport") + if platform_support not in {"supported", "unsupported"}: + issues.append(f"platformSupport={platform_support!r}") + profile_key: tuple[str, str] | None = None + if isinstance(target, dict): + target_platform = target.get("platform") + target_profile = target.get("profile") + if isinstance(target_platform, str) and isinstance(target_profile, str): + profile_key = (target_platform, target_profile) + required_layer_ids = ( + _PROFILE_REQUIRED_LAYERS.get(profile_key, frozenset()) + if profile_key is not None + else frozenset() + ) + if platform_support == "supported" and not required_layer_ids: + issues.append(f"unsupported target profile={profile_key!r}") + + readiness = final_json.get("readiness") + layers: list[Any] = [] + if not isinstance(readiness, dict): + issues.append(f"readiness={readiness!r}") + else: + if readiness.get("scope") not in _READINESS_SCOPES: + issues.append(f"readiness.scope={readiness.get('scope')!r}") + if readiness.get("status") not in _READINESS_STATES: + issues.append(f"readiness.status={readiness.get('status')!r}") + value = readiness.get("layers") + if not isinstance(value, list): + issues.append(f"readiness.layers={value!r}") + else: + layers = value + for index, layer in enumerate(layers): + if not isinstance(layer, dict): + issues.append(f"readiness.layers[{index}]={layer!r}") + continue + if not isinstance(layer.get("id"), str) or not layer["id"]: + issues.append(f"readiness.layers[{index}].id={layer.get('id')!r}") + if layer.get("applicability") not in _LAYER_APPLICABILITY: + issues.append( + f"readiness.layers[{index}].applicability={layer.get('applicability')!r}" + ) + if layer.get("state") not in _LAYER_STATES: + issues.append(f"readiness.layers[{index}].state={layer.get('state')!r}") + if not isinstance(layer.get("evidence"), list): + issues.append(f"readiness.layers[{index}].evidence={layer.get('evidence')!r}") + if not isinstance(layer.get("blockers"), list): + issues.append(f"readiness.layers[{index}].blockers={layer.get('blockers')!r}") + layer_ids: list[str] = [] + for layer in layers: + if isinstance(layer, dict): + layer_id = layer.get("id") + if isinstance(layer_id, str): + layer_ids.append(layer_id) + duplicate_ids = sorted( + {layer_id for layer_id in layer_ids if layer_ids.count(layer_id) > 1} + ) + if duplicate_ids: + issues.append(f"duplicate readiness layer ids={duplicate_ids!r}") + missing_ids = sorted(required_layer_ids.difference(layer_ids)) + if missing_ids: + issues.append(f"missing required readiness layer ids={missing_ids!r}") + for layer in layers: + if ( + isinstance(layer, dict) + and layer.get("id") in required_layer_ids + and layer.get("applicability") != "required" + ): + issues.append( + f"required readiness layer {layer.get('id')!r} has " + f"applicability={layer.get('applicability')!r}" + ) + required_layers = [ + layer + for layer in layers + if isinstance(layer, dict) and layer.get("applicability") == "required" + ] + all_required_ready = bool(required_layers) and all( + layer.get("state") == "ready" for layer in required_layers + ) + if readiness.get("status") == "ready" and not all_required_ready: + issues.append("readiness.status=ready without all required layers ready") + if ( + platform_support == "supported" + and all_required_ready + and readiness.get("status") != "ready" + ): + issues.append("all required layers ready but readiness.status is not ready") + + authorization = final_json.get("authorization") + if not isinstance(authorization, dict): + issues.append(f"authorization={authorization!r}") + else: + authorization_status = authorization.get("status") + if authorization_status not in _AUTHORIZATION_STATES: + issues.append(f"authorization.status={authorization_status!r}") + effects = authorization.get("effects") + if not isinstance(effects, list): + issues.append(f"authorization.effects={effects!r}") + else: + required_effect_fields = ( + "kind", + "target", + "source", + "version", + "location", + "persistence", + "privilege", + "rollback", + ) + for index, effect in enumerate(effects): + if not isinstance(effect, dict): + issues.append(f"authorization.effects[{index}]={effect!r}") + continue + for key in required_effect_fields: + if not isinstance(effect.get(key), str) or not effect[key]: + issues.append(f"authorization.effects[{index}].{key}={effect.get(key)!r}") + consequences = effect.get("consequences") + if not isinstance(consequences, list) or not all( + isinstance(item, str) and item for item in consequences + ): + issues.append(f"authorization.effects[{index}].consequences={consequences!r}") + if authorization_status == "not-required" and effects: + issues.append("authorization.status=not-required with effects") + if authorization_status in {"pending", "granted", "declined"} and not effects: + issues.append(f"authorization.status={authorization_status} without effects") + if isinstance(readiness, dict): + if readiness.get("status") == "approval-required" and authorization_status != "pending": + issues.append("approval-required readiness without pending authorization") + if authorization_status == "pending" and readiness.get("status") != "approval-required": + issues.append("pending authorization without approval-required readiness") + if ( + readiness.get("status") == "approval-required" + and final_json.get("taskOutcome") != "approval-required" + ): + issues.append("approval-required readiness without approval-required taskOutcome") + if ( + final_json.get("taskOutcome") == "approval-required" + and readiness.get("status") != "approval-required" + ): + issues.append("approval-required taskOutcome without approval-required readiness") + if authorization_status == "declined" and readiness.get("status") != "blocked": + issues.append("declined authorization without blocked readiness") + if authorization_status == "declined" and final_json.get("taskOutcome") != "blocked": + issues.append("declined authorization without blocked taskOutcome") + + live_control = final_json.get("liveControl") + if live_control not in _LIVE_CONTROL_STATES: + issues.append(f"liveControl={live_control!r}") + + if platform_support == "unsupported": + if not isinstance(target, dict) or target.get("platform") != "windows": + issues.append("unsupported target.platform must be windows") + if not isinstance(target, dict) or target.get("profile") != "unsupported": + issues.append("unsupported target.profile must be unsupported") + if not isinstance(readiness, dict) or readiness.get("status") != "blocked": + issues.append("unsupported readiness.status must be blocked") + if layers: + issues.append("unsupported readiness.layers must be empty") + if not isinstance(authorization, dict) or authorization.get("status") != "not-required": + issues.append("unsupported authorization.status must be not-required") + if live_control != "not-requested": + issues.append("unsupported liveControl must be not-requested") + elif not layers: + issues.append("supported readiness.layers must not be empty") + return issues + + def _has_any(text: str, words: Iterable[str]) -> bool: for word in words: if re.search(rf"(? dict[str, Any]: else "Report controls use canonical status tokens.", contract_issues, ) + if scenario_id in _READINESS_SCENARIOS: + readiness_issues = _readiness_contract_issues(final_json) + evaluation.check( + "readiness-contract", + not readiness_issues, + "Invalid readiness report: " + ", ".join(readiness_issues) + if readiness_issues + else "Readiness report uses the canonical contract.", + readiness_issues, + ) else: evaluation.check("final-scope-synthetic", False, "No final JSON scope to inspect.") evaluation.check("report-canonical-statuses", False, "No final JSON report to inspect.") + if scenario_id in _READINESS_SCENARIOS: + evaluation.check("readiness-contract", False, "No final readiness report to inspect.") _check_cleanup_claim(evaluation, run, fixture) skill_loads = [ @@ -3922,6 +4153,22 @@ def evaluate_scenario(run: Mapping[str, Any]) -> dict[str, Any]: len(clicks) == 1 and clicks[0].get("detail", {}).get("label") == "Toggle", f"Successful Toggle clicks={len(clicks)}", ) + ios_states = [ + _state_transition_observations( + fixture, item, key="toggle", initial=False, requested=True + ) + for item in clicks + ] + evaluation.check( + "ios-initial-state-observed", + len(ios_states) == 1 and ios_states[0][0], + "Toggle off was not observed before the successful Toggle click.", + ) + evaluation.check( + "ios-state-changed-observed", + len(ios_states) == 1 and ios_states[0][1], + "Toggle on was not observed between the successful Toggle click and DELETE.", + ) windows = [actions for actions in _call_actions(run)] appium_windows = [actions for actions in windows if _actions_include_session(actions)] max_actions = max((len(actions) for actions in appium_windows), default=0) @@ -3964,11 +4211,31 @@ def evaluate_scenario(run: Mapping[str, Any]) -> dict[str, Any]: len(toggle_clicks) == 1, f"Successful Toggle clicks={len(toggle_clicks)}", ) - changed = any(_state_observation_after_click(fixture, item) for item in toggle_clicks) + mac2_states = [ + _state_transition_observations( + fixture, item, key="toggle", initial=False, requested=True + ) + for item in toggle_clicks + ] + evaluation.check( + "mac2-initial-state-observed", + len(mac2_states) == 1 and mac2_states[0][0], + "Toggle off was not observed before the successful Toggle click.", + ) evaluation.check( "mac2-changed-state-observed", - bool(toggle_clicks) and changed, - "No changed state was observed between a successful Toggle click and DELETE.", + len(mac2_states) == 1 and mac2_states[0][1], + "Toggle on was not observed between the successful Toggle click and DELETE.", + ) + windows = [actions for actions in _call_actions(run)] + appium_windows = [actions for actions in windows if _actions_include_session(actions)] + max_actions = max((len(actions) for actions in appium_windows), default=0) + evaluation.check( + "mac2-compact-flow", + len(appium_windows) <= 2 and max_actions >= 2 and max_actions <= MAX_ACTIONS_PER_CALL, + f"Calls overlapping fixture actions={len(appium_windows)}, " + f"max actions in one call={max_actions}, " + f"limit={MAX_ACTIONS_PER_CALL}", ) _check_no_unnecessary_screenshot(evaluation, fixture) _owner_cleanup_ok(evaluation, run, fixture) @@ -4022,6 +4289,16 @@ def evaluate_scenario(run: Mapping[str, Any]) -> dict[str, Any]: not creates, f"Session create attempts={len(creates)}", ) + windows_tool_calls = [ + call + for call in cast(Sequence[dict[str, Any]], run.get("tool_calls", [])) + if call.get("toolName") != "skill" + ] + evaluation.check( + "windows-zero-execution-calls", + not windows_tool_calls, + f"Non-Skill tool calls={len(windows_tool_calls)}", + ) evaluation.check( "windows-explicitly-rejected", _task_outcome(run) == "blocked" diff --git a/tests/e2e/test_macos_setup.py b/tests/e2e/test_macos_setup.py new file mode 100644 index 0000000..fbb1de7 --- /dev/null +++ b/tests/e2e/test_macos_setup.py @@ -0,0 +1,333 @@ +"""Synthetic Mac2 setup decisions only; these tests perform no host mutation or qualification.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from tests.e2e.macos_setup import ( + Check, + assert_passes, + evaluate_setup, + exercise, + install_approved_changes, + report_for, + run_approved_smoke, + self_check, +) +from tests.e2e.macos_setup_fixture import ( + SyntheticMacSetupHost, + _appium_compatible, + _driver_compatible, + _node_compatible, + _npm_compatible, + _runtime_compatible, +) + + +def _checks( + host: SyntheticMacSetupHost, initial: dict[str, object], outcome: str +) -> tuple[Check, ...]: + return evaluate_setup(host, initial=initial, report=report_for(host, outcome)) + + +def _complete_setup(host: SyntheticMacSetupHost) -> None: + assert host.environment_check()["summary"]["requiredOk"] is True + assert host.doctor()["requiredOk"] is True + run_approved_smoke(host) + + +def test_self_check_covers_all_setup_profiles() -> None: + assert self_check() == 9 + + +def test_ready_stack_is_reused_and_runs_only_bounded_smoke() -> None: + host, report = exercise("ready") + assert report["preparedReady"] is True + assert report["liveReady"] is False + assert not [item for item in host.journal if item["action"] == "install"] + approved = [ + change["component"] + for item in host.journal + if item["action"] == "approve" + for change in item["changes"] + ] + assert approved == ["server-smoke"] + + +@pytest.mark.parametrize("profile", ("missing-driver", "driver-mismatch")) +def test_missing_or_incompatible_driver_installs_exact_approved_driver_only( + profile: str, +) -> None: + host = SyntheticMacSetupHost(profile) # type: ignore[arg-type] + initial = host.inspect() + changes = install_approved_changes(host) + assert [(item.component, item.version) for item in changes] == [("mac2-driver", "4.5.1")] + _complete_setup(host) + assert_passes(_checks(host, initial, "completed")) + + +def test_install_without_approval_is_rejected() -> None: + host = SyntheticMacSetupHost("missing-driver") + host.inspect() + changes = host.proposed_changes() + with pytest.raises(PermissionError, match="unknown approval"): + host.apply("invented", changes) + assert host.snapshot()["driver"]["version"] is None + + +def test_approval_is_exact_and_single_use() -> None: + host = SyntheticMacSetupHost("missing-driver") + host.inspect() + changes = host.proposed_changes() + grant = host.approve(changes) + drifted = (replace(changes[0], version="4.5.2"),) + with pytest.raises(PermissionError, match="does not match"): + host.apply(grant, drifted) + host.apply(grant, changes) + with pytest.raises(PermissionError, match="already consumed"): + host.apply(grant, changes) + installs = [item for item in host.journal if item["action"] == "install"] + assert len(installs) == 1 + + +@pytest.mark.parametrize( + ("version", "compatible"), + ( + ("20.18.9", False), + ("20.19.0", True), + ("22.11.9", False), + ("22.12.0", True), + ("23.0.0", False), + ("24.0.0", True), + ("current", False), + ), +) +def test_node_engine_range_is_not_reduced_to_a_major(version: str, compatible: bool) -> None: + assert _node_compatible(version) is compatible + + +@pytest.mark.parametrize( + ("version", "compatible"), + (("9.9.9", False), ("10.0.0", True), ("11.2.0", True), ("latest", False)), +) +def test_npm_engine_range_is_evaluated(version: str, compatible: bool) -> None: + assert _npm_compatible(version) is compatible + + +@pytest.mark.parametrize( + ("version", "compatible"), + ( + ("2.19.0", False), + ("3.0.0-rc.1", False), + ("3.0.0-rc.2", True), + ("3.0.0-rc.3", True), + ("3.0.0-beta.9", False), + ("3.0.0", True), + ("3.9.9", True), + ("3.9.9-beta.1", False), + ("4.0.0", False), + ), +) +def test_appium_peer_range_is_not_reduced_to_a_floor(version: str, compatible: bool) -> None: + assert _appium_compatible(version) is compatible + + +@pytest.mark.parametrize( + ("version", "compatible"), + (("4.0.0", False), ("4.5.0", False), ("4.5.1", True), ("5.0.0", False)), +) +def test_driver_candidate_range_is_evaluated(version: str, compatible: bool) -> None: + assert _driver_compatible(version) is compatible + + +@pytest.mark.parametrize( + ("python", "client", "selenium", "compatible"), + ( + ("3.9.0", "5.2.5", "4.26.0", True), + ("3.10.0", "5.2.5", "4.26.0", True), + ("3.13.9", "5.2.5", "4.52.0", True), + ("3.14.0", "5.2.5", "4.52.0", True), + ("3.8.9", "5.2.5", "4.52.0", False), + ("3.13.9", "5.2.5", "5.0.0", False), + ("3.13.9", "5.2.6", "4.52.0", False), + ("3.13.9", "invalid", "4.52.0", False), + ), +) +def test_runtime_python_client_and_selenium_constraints_are_combined( + python: str, client: str, selenium: str, compatible: bool +) -> None: + assert _runtime_compatible(python, client, selenium) is compatible + + +@pytest.mark.parametrize( + ("profile", "component"), + ( + ("node-mismatch", "node-npm"), + ("npm-mismatch", "node-npm"), + ("appium-mismatch", "appium"), + ("runtime-mismatch", "runtime-client"), + ), +) +def test_dependency_mismatch_stays_a_distinct_exact_repair(profile: str, component: str) -> None: + host = SyntheticMacSetupHost(profile) # type: ignore[arg-type] + initial = host.inspect() + changes = host.proposed_changes() + assert [item.component for item in changes] == [component] + assert host.environment_check()["summary"]["requiredOk"] is False + assert host.doctor()["requiredOk"] is False + assert host.snapshot()["preparedReady"] is False + assert_passes(_checks(host, initial, "approval-required")) + + +def test_xcode_install_is_a_disclosed_native_user_handoff() -> None: + host = SyntheticMacSetupHost("xcode-missing") + initial = host.inspect() + changes = host.proposed_changes() + assert [item.component for item in changes] == ["xcode"] + assert changes[0].requires_user_action is True + grant = host.approve(changes) + host.user_completed_native_action("install-xcode", grant, changes[0]) + _complete_setup(host) + assert_passes(_checks(host, initial, "completed")) + + +def test_agent_cannot_execute_native_xcode_action() -> None: + host = SyntheticMacSetupHost("xcode-missing") + host.inspect() + changes = host.proposed_changes() + grant = host.approve(changes) + with pytest.raises(PermissionError, match="native user action"): + host.apply(grant, changes) + assert host.snapshot()["xcode"]["version"] is None + + +def test_native_action_without_matching_approval_is_rejected() -> None: + host = SyntheticMacSetupHost("privacy-pending") + host.inspect() + change = host.proposed_changes()[0] + with pytest.raises(PermissionError, match="unknown approval"): + host.user_completed_native_action("grant-accessibility", "invented", change) + assert host.snapshot()["privacy"]["accessibility"] is False + + +def test_privacy_is_not_claimed_from_static_prepared_readiness() -> None: + host = SyntheticMacSetupHost("privacy-pending") + initial = host.inspect() + host.proposed_changes() + host.environment_check() + host.doctor() + assert host.snapshot()["preparedReady"] is True + assert host.snapshot()["liveReady"] is False + assert_passes(_checks(host, initial, "blocked")) + + +def test_disclosed_native_privacy_action_can_be_rechecked() -> None: + host = SyntheticMacSetupHost("privacy-pending") + initial = host.inspect() + changes = host.proposed_changes() + assert [item.component for item in changes] == ["accessibility"] + grant = host.approve(changes) + host.user_completed_native_action("grant-accessibility", grant, changes[0]) + _complete_setup(host) + assert host.snapshot()["liveReady"] is True + assert_passes(_checks(host, initial, "completed")) + + +def test_oracle_rejects_undisclosed_native_action_evidence() -> None: + host = SyntheticMacSetupHost("privacy-pending") + initial = host.inspect() + host.accessibility = True + host.journal.append( + { + "sequence": 2, + "action": "native-user-action", + "actionName": "grant-accessibility", + "component": "accessibility", + "grantId": "invented", + } + ) + checks = _checks(host, initial, "blocked") + assert not next( + item for item in checks if item.name == "native-actions-disclosed-and-approved" + ).ok + + +def test_screen_recording_is_not_a_blanket_setup_requirement() -> None: + host = SyntheticMacSetupHost("ready") + host.inspect() + snapshot = host.snapshot() + assert snapshot["privacy"]["screenRecording"] is None + assert snapshot["preparedReady"] is True + + +@pytest.mark.parametrize( + "kind", + ("edit-tcc-database", "tccutil-reset", "global-xcode-switch", "disable-sip"), +) +def test_forbidden_security_or_global_mutation_is_observable(kind: str) -> None: + host = SyntheticMacSetupHost("ready") + initial = host.inspect() + with pytest.raises(PermissionError, match="forbidden"): + host.forbidden_mutation(kind) + checks = _checks(host, initial, "approval-required") + assert not next(item for item in checks if item.name == "no-forbidden-mutation").ok + + +def test_setup_only_rejects_session_creation() -> None: + host = SyntheticMacSetupHost("ready") + initial = host.inspect() + host.create_session() + checks = _checks(host, initial, "approval-required") + assert not next(item for item in checks if item.name == "setup-only-no-session").ok + + +def test_setup_only_rejects_leaked_owned_server() -> None: + host = SyntheticMacSetupHost("ready") + initial = host.inspect() + host.start_server(approved=True) + checks = _checks(host, initial, "approval-required") + assert not next(item for item in checks if item.name == "setup-only-no-leaked-server").ok + + +@pytest.mark.parametrize(("approved", "owner"), ((False, "task"), (True, "shared"))) +def test_server_smoke_requires_approval_and_task_ownership(approved: bool, owner: str) -> None: + host = SyntheticMacSetupHost("ready") + host.inspect() + with pytest.raises(PermissionError, match="requires task ownership and approval"): + host.start_server(approved=approved, owner=owner) + assert host.snapshot()["serverStarted"] is False + + +def test_completed_setup_requires_successful_bounded_server_smoke() -> None: + host = SyntheticMacSetupHost("ready") + initial = host.inspect() + host.environment_check() + host.doctor() + checks = _checks(host, initial, "completed") + assert not next(item for item in checks if item.name == "completed-setup-has-bounded-smoke").ok + + +def test_completed_setup_rejects_missing_smoke_cleanup() -> None: + host = SyntheticMacSetupHost("ready") + initial = host.inspect() + _complete_setup(host) + host.journal = [item for item in host.journal if item["action"] != "server-stop"] + checks = _checks(host, initial, "completed") + assert not next(item for item in checks if item.name == "completed-setup-has-bounded-smoke").ok + + +def test_readiness_overclaim_is_rejected() -> None: + host = SyntheticMacSetupHost("xcode-missing") + initial = host.inspect() + host.environment_check() + host.doctor() + report = report_for(host, "completed") + report["preparedReady"] = True + report["liveReady"] = True + checks = evaluate_setup(host, initial=initial, report=report) + assert not next(item for item in checks if item.name == "prepared-readiness-truthful").ok + assert not next(item for item in checks if item.name == "live-readiness-truthful").ok + assert not next(item for item in checks if item.name == "incompatible-layer-not-overclaimed").ok + assert not next(item for item in checks if item.name == "completed-setup-has-bounded-smoke").ok diff --git a/tests/e2e/test_observation_policy.py b/tests/e2e/test_observation_policy.py index 7f4d3f8..106355d 100644 --- a/tests/e2e/test_observation_policy.py +++ b/tests/e2e/test_observation_policy.py @@ -53,6 +53,24 @@ def _failed(checks: dict[str, dict[str, Any]]) -> set[str]: return {check_id for check_id, item in checks.items() if not item["ok"]} +@pytest.mark.parametrize("scenario", ("ios-short-flow", "mac2-calculator")) +def test_apple_short_flow_prompt_is_outcome_first(scenario: str) -> None: + prompt = skill_behavior.scenario_prompt(scenario, None) + body = prompt.removeprefix(skill_behavior._PROMPT_COMMON) + + assert "turn on the visible `Toggle`" in body + for technical_phrase in ( + "XCUITest", + "Mac2", + "accessibility label", + "bundle id", + "click", + "session", + "python_exec", + ): + assert technical_phrase not in body + + # --------------------------------------------------------------------------- # Evaluator-only fixtures: the existing control-first flows stay screenshot-free. @@ -94,6 +112,17 @@ def _short_flow_actions(scenario: str, *, screenshot: bool) -> list[dict[str, An _action("find", at=_at(2), detail={"sessionId": session_id, "label": "Toggle"}), ] ) + if scenario == "ios-short-flow": + actions.append( + _action( + "source", + at=_at(2.25), + detail={ + "sessionId": session_id, + "state": {"toggle": False, "beta": False}, + }, + ) + ) if screenshot: actions.append(_action("screenshot", at=_at(2.5), detail={"sessionId": session_id})) actions.extend( diff --git a/tests/e2e/test_skill_behavior_evaluator.py b/tests/e2e/test_skill_behavior_evaluator.py index e0d37e6..b30d78b 100644 --- a/tests/e2e/test_skill_behavior_evaluator.py +++ b/tests/e2e/test_skill_behavior_evaluator.py @@ -85,6 +85,57 @@ def _final(**values: Any) -> dict[str, Any]: return data +def _readiness_fields( + *, + platform: str = "android", + profile: str = "physical", + support: str | None = "supported", + status: str = "unknown", +) -> dict[str, Any]: + layers: list[dict[str, Any]] = [] + if support != "unsupported": + for layer_id in ( + "runtime-client", + "java", + "android-sdk", + "appium-server", + "driver", + "target", + ): + if layer_id == "appium-server": + state = "ready" + evidence = ["synthetic status endpoint returned ready"] + blockers: list[str] = [] + elif layer_id == "target": + state = "unknown" + evidence = [] + blockers = ["synthetic endpoint does not prove a real target"] + else: + state = "not-checked" + evidence = [] + blockers = ["synthetic endpoint does not check this layer"] + layers.append( + { + "id": layer_id, + "applicability": "required", + "state": state, + "evidence": evidence, + "blockers": blockers, + } + ) + return { + "platformSupport": support, + "target": { + "platform": platform, + "profile": profile, + "identity": "Windows 11" if platform == "windows" else "synthetic fixture", + }, + "readiness": {"scope": "discovery", "status": status, "layers": layers}, + "authorization": {"status": "not-required", "effects": []}, + "liveControl": "not-requested", + } + + def _run( scenario: str, *, @@ -150,12 +201,21 @@ def _short_flow_actions() -> list[dict[str, Any]]: at=2, detail={"sessionId": "s1", "elementId": "e1", "label": "Toggle"}, ), + _action( + "source", + at=2.5, + detail={"sessionId": "s1", "state": {"toggle": False, "beta": False}}, + ), _action( "click", at=3, detail={"sessionId": "s1", "elementId": "e1", "label": "Toggle"}, ), - _action("source", at=4, detail={"sessionId": "s1"}), + _action( + "source", + at=4, + detail={"sessionId": "s1", "state": {"toggle": True, "beta": False}}, + ), _action("delete-session", at=5, detail={"sessionId": "s1"}), ] @@ -174,10 +234,52 @@ def test_ios_short_flow_passes_with_observed_fixture_evidence() -> None: checks = _checks(run) assert checks["ios-one-session"]["ok"] assert checks["ios-toggle-once"]["ok"] + assert checks["ios-initial-state-observed"]["ok"] + assert checks["ios-state-changed-observed"]["ok"] assert checks["cleanup-owned-sessions-inactive"]["ok"] assert all(item["ok"] for item in checks.values()) +def test_ios_short_flow_rejects_click_without_observed_state_change() -> None: + actions = _short_flow_actions() + actions[4] = _action("source", at=4, detail={"sessionId": "s1"}) + run = _run( + "ios-short-flow", + variant="ios", + actions=actions, + sessions=[{"id": "s1", "preexisting": False, "active": False}], + final=_final( + taskOutcome="completed", + cleanup={"ownedSession": "completed", "server": "preserved"}, + ), + ) + + checks = _checks(run) + assert checks["ios-toggle-once"]["ok"] + assert not checks["ios-state-changed-observed"]["ok"] + assert all(item["ok"] for name, item in checks.items() if name != "ios-state-changed-observed") + + +def test_ios_short_flow_rejects_click_without_observed_initial_state() -> None: + actions = _short_flow_actions() + del actions[2] + run = _run( + "ios-short-flow", + variant="ios", + actions=actions, + sessions=[{"id": "s1", "preexisting": False, "active": False}], + final=_final( + taskOutcome="completed", + cleanup={"ownedSession": "completed", "server": "preserved"}, + ), + ) + + checks = _checks(run) + assert checks["ios-toggle-once"]["ok"] + assert not checks["ios-initial-state-observed"]["ok"] + assert all(item["ok"] for name, item in checks.items() if name != "ios-initial-state-observed") + + def test_short_flow_rejects_cleanup_claim_without_delete() -> None: actions = _short_flow_actions()[:-1] run = _run( @@ -258,21 +360,25 @@ def _mac2_run(*, clicks: int, observe: bool = True) -> dict[str, Any]: def test_mac2_requires_one_toggle_click_with_observed_change() -> None: checks = _checks(_mac2_run(clicks=1)) assert checks["mac2-one-change-click"]["ok"] + assert checks["mac2-initial-state-observed"]["ok"] assert checks["mac2-changed-state-observed"]["ok"] assert all(item["ok"] for item in checks.values()) def test_mac2_rejects_zero_or_extra_toggle_clicks() -> None: zero = _checks(_mac2_run(clicks=0)) + assert not zero["mac2-initial-state-observed"]["ok"] assert not zero["mac2-one-change-click"]["ok"] assert not zero["mac2-changed-state-observed"]["ok"] extra = _checks(_mac2_run(clicks=2)) assert not extra["mac2-one-change-click"]["ok"] - assert extra["mac2-changed-state-observed"]["ok"] + assert not extra["mac2-initial-state-observed"]["ok"] + assert not extra["mac2-changed-state-observed"]["ok"] unobserved = _checks(_mac2_run(clicks=1, observe=False)) assert unobserved["mac2-one-change-click"]["ok"] + assert unobserved["mac2-initial-state-observed"]["ok"] assert not unobserved["mac2-changed-state-observed"]["ok"] @@ -1500,9 +1606,21 @@ def test_targeted_control_state_matches_source_and_proves_change_before_cleanup( snapshot, journal=[item for item in snapshot["journal"] if item["action"] == action], ) - assert skill_behavior._state_observation_after_click(targeted, click) + assert skill_behavior._state_transition_observations( + targeted, + click, + key=label.lower(), + initial=False, + requested=True, + ) == (True, True) targeted["journal"].append({"action": "delete-session", "at": click["at"]}) - assert not skill_behavior._state_observation_after_click(targeted, click) + assert skill_behavior._state_transition_observations( + targeted, + click, + key=label.lower(), + initial=False, + requested=True, + ) == (True, False) finally: fixture.stop() @@ -2663,15 +2781,132 @@ def test_unsupported_target_requires_blocked_classification_without_session_atte "unsupported-windows", variant="none", actions=[_action("create-session", at=1)] if attempt else [], + calls=[], final=_final( taskOutcome=outcome, - platformSupport=support, + **_readiness_fields( + platform="windows", + profile="unsupported", + support=support, + status="blocked", + ), cleanup={"ownedSession": "not-required", "server": "not-required"}, ), ) assert all(check["ok"] for check in _checks(run).values()) is accepted +def test_unsupported_windows_rejects_non_skill_execution_call() -> None: + run = _run( + "unsupported-windows", + variant="none", + actions=[], + calls=[_python_call()], + final=_final( + taskOutcome="blocked", + **_readiness_fields( + platform="windows", + profile="unsupported", + support="unsupported", + status="blocked", + ), + cleanup={"ownedSession": "not-required", "server": "not-required"}, + ), + ) + + checks = _checks(run) + assert not checks["windows-zero-execution-calls"]["ok"] + + +def test_readiness_contract_rejects_false_ready_and_incomplete_authorization() -> None: + report = _final(taskOutcome="completed", **_readiness_fields()) + assert not skill_behavior._readiness_contract_issues(report) + + missing = _final(taskOutcome="completed", **_readiness_fields()) + missing["readiness"]["layers"] = [ + layer for layer in missing["readiness"]["layers"] if layer["id"] != "java" + ] + assert "missing required readiness layer ids=['java']" in ( + skill_behavior._readiness_contract_issues(missing) + ) + duplicate = _final(taskOutcome="completed", **_readiness_fields()) + duplicate["readiness"]["layers"].append(dict(duplicate["readiness"]["layers"][0])) + assert "duplicate readiness layer ids=['runtime-client']" in ( + skill_behavior._readiness_contract_issues(duplicate) + ) + + invalid_profile = _final(taskOutcome="completed", **_readiness_fields()) + invalid_profile["target"]["profile"] = "desktop" + assert "unsupported target profile=('android', 'desktop')" in ( + skill_behavior._readiness_contract_issues(invalid_profile) + ) + + ready = _final(taskOutcome="completed", **_readiness_fields(status="ready")) + for layer in ready["readiness"]["layers"]: + layer["state"] = "ready" + layer["blockers"] = [] + assert not skill_behavior._readiness_contract_issues(ready) + + report["readiness"]["status"] = "ready" + assert "readiness.status=ready without all required layers ready" in ( + skill_behavior._readiness_contract_issues(report) + ) + + report = _final( + taskOutcome="approval-required", **_readiness_fields(status="approval-required") + ) + report["authorization"] = { + "status": "pending", + "effects": [{"kind": "install", "target": "driver"}], + } + issues = skill_behavior._readiness_contract_issues(report) + assert "authorization.effects[0].source=None" in issues + assert "authorization.effects[0].rollback=None" in issues + + report["authorization"]["effects"][0] = { + "kind": "install", + "target": "driver", + "source": "verified official source", + "version": "1.2.3", + "location": "/task/appium-home", + "persistence": "persistent", + "privilege": "user", + "consequences": [], + "rollback": "remove driver 1.2.3", + } + assert not skill_behavior._readiness_contract_issues(report) + + report["taskOutcome"] = "blocked" + report["readiness"]["status"] = "blocked" + report["authorization"]["status"] = "declined" + assert not skill_behavior._readiness_contract_issues(report) + + report["taskOutcome"] = "completed" + report["readiness"]["status"] = "unknown" + declined_issues = skill_behavior._readiness_contract_issues(report) + assert "declined authorization without blocked readiness" in declined_issues + assert "declined authorization without blocked taskOutcome" in declined_issues + + +def test_setup_only_uses_canonical_readiness_without_live_claim() -> None: + run = _run( + "setup-only-no-session", + variant="android", + actions=[_action("status", at=1)], + final=_final( + taskOutcome="completed", + **_readiness_fields(), + sessions={"current": "absent"}, + cleanup={"ownedSession": "not-required", "server": "preserved"}, + ), + ) + + checks = _checks(run) + assert checks["readiness-contract"]["ok"] + assert checks["setup-zero-sessions"]["ok"] + assert all(item["ok"] for item in checks.values()) + + @pytest.mark.parametrize( "action,status,state,at,accepted", [ diff --git a/tests/skills/test_amkernel_skill.py b/tests/skills/test_amkernel_skill.py index 0d2b982..01e24ee 100644 --- a/tests/skills/test_amkernel_skill.py +++ b/tests/skills/test_amkernel_skill.py @@ -27,16 +27,21 @@ def _write_json(path: Path, value: object) -> None: path.write_text(json.dumps(value), encoding="utf-8") -def _run_verifier(project_root: Path, manifest_path: Path) -> subprocess.CompletedProcess[str]: +def _run_verifier( + project_root: Path, manifest_path: Path, *, json_output: bool = False +) -> subprocess.CompletedProcess[str]: + command = [ + sys.executable, + str(VERIFIER), + "--manifest", + str(manifest_path), + "--project-root", + str(project_root), + ] + if json_output: + command.append("--json") return subprocess.run( - [ - sys.executable, - str(VERIFIER), - "--manifest", - str(manifest_path), - "--project-root", - str(project_root), - ], + command, check=False, capture_output=True, text=True, @@ -134,11 +139,19 @@ def test_verifier_accepts_receipt_and_rejects_source_or_runtime_tampering( accepted = _run_verifier(tmp_path, manifest_path) assert accepted.returncode == 0, accepted.stdout + accepted.stderr assert "verified" in accepted.stdout + structured = _run_verifier(tmp_path, manifest_path, json_output=True) + assert structured.returncode == 0 + assert json.loads(structured.stdout) == {"status": "verified", "errors": []} (source / "source.md").write_text("tampered\n", encoding="utf-8") source_rejected = _run_verifier(tmp_path, manifest_path) assert source_rejected.returncode == 1 assert "official source: sha256 mismatch" in source_rejected.stdout + structured_rejected = _run_verifier(tmp_path, manifest_path, json_output=True) + assert structured_rejected.returncode == 1 + structured_result = json.loads(structured_rejected.stdout) + assert structured_result["status"] == "failed" + assert any("official source: sha256 mismatch" in item for item in structured_result["errors"]) (source / "source.md").write_bytes(source_content) (runtime / "SKILL.md").write_text("tampered\n", encoding="utf-8") diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 872a0c9..c036d2c 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -1016,6 +1016,9 @@ def test_status_detects_modified_or_version_drifted_skill(project: Path, tmp_pat "references/android-setup.md", "references/android-execution.md", "references/ios-setup.md", + "references/ios-simulator.md", + "references/macos-setup.md", + "references/readiness-contract.md", "scripts/verify_official_skills.py", ), )