From 936c846e07d93e4a5ff9b8666a4eebfe3de96c2b Mon Sep 17 00:00:00 2001 From: mijun Date: Thu, 5 Nov 2026 18:59:40 +0800 Subject: [PATCH 1/3] feat(skill): add guided physical iPhone setup (#131) --- CHANGELOG.md | 1 + README.md | 2 + docs/HANDOFF.md | 26 + docs/development.md | 10 + docs/experiments/appium-agent-behavior.md | 103 + docs/experiments/ios-setup-proposal.md | 255 + evidence/appium-skill/ios-setup-20261105.json | 1489 ++++++ src/amkernel/distribution.py | 1 + src/amkernel/skill/SKILL.md | 23 +- .../skill/references/amkernel-appium.md | 6 +- src/amkernel/skill/references/ios-setup.md | 725 +++ tests/e2e/ios_setup.py | 4141 +++++++++++++++++ tests/e2e/ios_setup_fixture.py | 1932 ++++++++ tests/e2e/test_ios_setup.py | 380 ++ tests/test_distribution.py | 1 + 15 files changed, 9086 insertions(+), 9 deletions(-) create mode 100644 docs/experiments/ios-setup-proposal.md create mode 100644 evidence/appium-skill/ios-setup-20261105.json create mode 100644 src/amkernel/skill/references/ios-setup.md create mode 100644 tests/e2e/ios_setup.py create mode 100644 tests/e2e/ios_setup_fixture.py create mode 100644 tests/e2e/test_ios_setup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ac52e41..77b5811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to AMKernel are recorded here. Public releases use semantic - Make Android Skill provisioning target-scoped: reuse verified Runtime/JDK/SDK installations, evaluate exact Node and driver constraints, execute component-level installations after scoped approval, and distinguish physical/emulator prerequisites from live session proof. Preserve raw official doctor results, task-scoped environments and external service ownership. Add bounded synthetic native-Agent approval/readiness scenarios and packaged-reference integrity regressions ([#129](https://github.com/FSQ-lab/amkernel/issues/129)); no real dependency/device or platform qualification is 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)). ## [0.1.2] - 2026-11-04 diff --git a/README.md b/README.md index 9410529..8e6f1d6 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ amkernel uninstall For Android, the [packaged setup procedure](src/amkernel/skill/references/android-setup.md) first reuses verified JDK/SDK installations, then selects only target-relevant components. The Agent discloses installation methods, versions, locations and impact, executes approved changes through host tools, and verifies them. Physical-device readiness does not require emulator/AVD inventory; configured AVDs and successful doctor/status checks do not prove a live session. Setup does not expand AMKernel's platform support. +For a physical iPhone, the [packaged setup procedure](src/amkernel/skill/references/ios-setup.md) reuses the selected Xcode, Runtime client, Appium/driver and applicable WDA/signing assets before proposing changes. The Agent discloses installation and security effects, performs approved technical work, and guides account, passcode and keychain confirmations in native interfaces without collecting credentials. Setup-only does not launch WDA or create a session; explicitly requested validation uses the official Python Client through `python_exec`. Prepared WDA artifacts are retained when the selected route needs them. Official Appium Skills remain the platform guidance source; iPhone setup does not expand platform qualification. + Agent setup and MCP repair may use shell, PowerShell, native host tools, or focused diagnostic probes; they do not require an already-working `python_exec` connection. Appium session and device/application interaction code runs through the official Python Client in `python_exec`. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 4ac5706..3d26900 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -542,6 +542,32 @@ exercised evidence and its ordinary-functional, non-qualification limits. The deduplicated installed-wheel native Agent smoke also passed all six unchanged scenarios, with user/client isolation unchanged. +### Target-scoped physical-iPhone Skill (#131) + +The packaged Skill routes physical-iPhone preparation through `ios-setup.md` before verified +official XCUITest/real-device guidance. It reuses applicable Runtime/Xcode/Appium/driver/WDA +assets, discloses dependency and security changes, and keeps account, passcode and keychain +actions native and user-owned. Setup-only neither launches WDA nor creates a session. Static +signature/profile candidacy, target readiness, reachable transport and live proof remain distinct; +connection failure is not grounds for re-signing. Simulator/Mac2 routes, official provenance and +core interfaces are unchanged. + +Focused distribution/CLI/Skill/Android/iPhone regressions passed 146 tests; protocol/oracle +self-check passed 116 checks. Ruff check/format and full source/test Mypy (73 files) passed. +Two independent final source reviews found no remaining blocking findings. The final wheel's +managed distribution/Skill files match source bytes; actual stdio MCP advertised only +`python_exec`, preserved state across two completed calls and closed transport with the kernel +last observed alive. All 13 native Agent synthetic scenarios have passing observations: 12 +passed in the full run; a narrow partial-approval outcome oracle correction was independently +reviewed and the remaining case passed a fresh single-scenario run. The original full run +remains failed, not rescored. The live case completed real MCP → official Python Client → +synthetic HTTP create/read/delete without mutation; setup-only cases created no sessions. +See `docs/experiments/appium-agent-behavior.md` §18 and the normalized +[evidence record](../evidence/appium-skill/ios-setup-20261105.json) for exact artifacts and limits. +No real signing, device security change, WDA launch or physical-iPhone readiness is established; +#98 qualification is separate. + + ## 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/development.md b/docs/development.md index 78f1960..943d116 100644 --- a/docs/development.md +++ b/docs/development.md @@ -218,6 +218,16 @@ Agent scenarios are documented in `docs/experiments/appium-agent-behavior.md`; t host tool cannot execute real installers or device commands. Synthetic approval and readiness evidence does not qualify real JDK/SDK installation, Appium sessions or a new host platform. +iPhone Skill setup changes additionally run `tests/e2e/test_ios_setup.py` and +`tests/e2e/ios_setup.py --self-check`, plus the installed-wheel native scenarios documented in +`docs/experiments/appium-agent-behavior.md`. The test-only iPhone host must not run real installers, +Xcode/signing commands, security prompts or device operations. Approval, user-action, target and +WDA evidence is cross-checked against its journal; synthetic setup evidence is not real signing, +WDA startup, iPhone readiness or completion of the existing physical-platform qualification. +The standalone fixture is standard-library-only. Its protocol self-check and subprocess +regression launch Python with `-S`; the project environment must not mask a dependency missing +from the managed Runtime that launches the native MCP host. + ### Gate 3: Actual stdio MCP end to end An MCP client must launch the packaged server over stdio and exercise the changed public path. Unit tests or direct calls into implementation classes do not satisfy this gate. diff --git a/docs/experiments/appium-agent-behavior.md b/docs/experiments/appium-agent-behavior.md index 982d5cb..5d07bf7 100644 --- a/docs/experiments/appium-agent-behavior.md +++ b/docs/experiments/appium-agent-behavior.md @@ -962,3 +962,106 @@ user/client isolation unchanged. Wheel SHA-256: Normalized local reports are `/tmp/amkernel-android-dedup-native.json` and `/tmp/amkernel-android-dedup-selfcheck.json` (temporary, not durable raw evidence). + +## 18. iPhone setup Skill (#131) + +The owner accepted `ios-setup-proposal.md` and authorized implementation. The single packaged +Skill now routes physical-iPhone setup through `references/ios-setup.md` and then verified official +XCUITest/real-device guidance; simulator/Mac2 routes and official provenance stay unchanged. The +reference belongs to the existing distribution installation/integrity contract, not a new installer. + +The procedure distinguishes requested scope, prepared dependency/device/signing readiness, +static WDA artifact candidacy, reachable endpoints, and verified live sessions. Existing applicable +Runtime, Node/Appium/driver/Xcode and signing assets are reused. Runtime repair preserves Python, +AMKernel source/version, package extras and other `--with` requirements. Device-service discovery +disables current `devicectl ddiServices`' default DDI automount; preparation changes require scoped +approval. Native account, trust, Developer Mode, passcode and keychain confirmations stay with the +user, one action at a time, without collecting secrets or widening keychain access. Final runner +identity is distinct from its build/base Bundle ID, and setup retains products required by a +prepared `.app`/`.xctestrun` route. A transport failure does not erase static artifact candidacy or +justify re-signing. Setup-only does not launch WDA or create a session, and does not require a live +server merely to prove installed/prepared prerequisites. + +`tests/e2e/ios_setup.py` reuses the existing installed-wheel/native Copilot orchestration. Its +test-only host in `ios_setup_fixture.py` exposes bounded synthetic inspection, exact installation +and repair disclosures, single-use grants, native-user action waits/rechecks, and append-only +observations. It executes no actual installers, signing, device security actions or iPhone launches. +The thirteen cases cover physical reuse, stale paired/developer history, approved/unapproved missing +driver setup, shared Runtime client repair, warning versus constraint violation, reusable WDA, +expired/non-covering profile repair, PIN waiting, transport timeout, shared/unknown ownership, and +explicit live validation. Only the live-validation case enables real `amkernel/python_exec`; that +case uses the installed official Python Client against a task-owned synthetic Appium endpoint. +Journal observations and endpoint requests—not the final model prose or scripted echoes—must +substantiate claims. Xcode/Appium observations, source consumption, exact grants, stale state, +secret persistence, readiness, session/ownership and cleanup have negative oracle mutations. + +Run the protocol/oracle self-check and then the rebuilt-wheel native scenarios: + +```bash +uv run --no-sync python tests/e2e/ios_setup.py --self-check --output /tmp/ios-self-check.json +uv run --no-sync python tests/e2e/ios_setup.py \ + --wheel /absolute/path/to/amkernel.whl \ + --copilot /absolute/path/to/github-copilot \ + --output /tmp/ios-native.json +``` + +The CLI also accepts `--list` and selected `--scenario` cases. Native availability and authorization +are separate: the launcher grants the fixture host explicitly even when it is already advertised. +Permanent regressions focus on exact-scope/single-use consumption, secret refusal without payload +persistence, continued actual fixture RPC service after malformed repair input, false-positive +oracle mutations, and post-preflight exception classification/scrubbing/cleanup. + +The pre-change wheel's observed transport case had an unreachable endpoint, a valid covering +profile and zero installation, signing repair or session events. Its `candidate=true` was legitimate +static artifact evidence, not launch success: an initial oracle that required `candidate=false` was +corrected rather than used as a fabricated regression. The baseline also omitted a historical +Developer Mode field that the original report schema never declared; that was another oracle +mismatch, not evidence of incorrect current-device reporting. Neither observation establishes +physical-iPhone qualification. + +Independent source review resolved runner/base identity and artifact retention issues, plus native +tool authorization, Xcode/Appium evidence binding, sensitive disclosure persistence, malformed +repair methods and duplicate grant consumption. + +Final focused distribution/CLI/Skill/Android/iPhone regressions passed **146 tests**. The +protocol/oracle self-check passed **116 checks**; Ruff check and format check passed, and Mypy +passed all **73 production-and-test files**. Two independent final read-only reviews found no +remaining blocking findings after correcting exact repair scope, native-action argument handling, +fresh doctor evidence, partial readiness, and read-only HTTP validation. + +A later native attempt recorded 13 failures with zero fixture-host calls: an undeclared +`packaging` import prevented the standalone host from starting in the managed Runtime. +`python -S` reproduced the import failure. The fixture was restored to standard-library-only +imports; both existing subprocess protocol checks now run with `-S`, and Node boundary tests +cover accepted patch/minor versions as well as malformed and out-of-range versions. This +failed attempt remains failed evidence, not an observation of Skill decision behavior. + +Final wheel SHA-256 is `97fbe5ed03eb51d71e93e55b74c5572a39ca0057f9f69ca89061446db960535d`. +Its distribution module and three changed Skill/reference files match the working source bytes. +A real final-wheel stdio smoke advertised only `python_exec`, completed two calls with an alive +kernel and persistent `['initial', 'persisted']` namespace result, then closed transport. + +With the standard-library-only host, the full native run passed 12 of 13 scenarios. The sole +failure rejected a truthful `approval-required` outcome after the approved driver was installed +and verified but separate WDA signing approval was denied. Independent review approved accepting +either `blocked` or `approval-required` for that partial setup, while readiness must remain false, +approval true and WDA blockers present. A positive mutation accepts the pending state; a negative +mutation still rejects `completed`. A fresh driver-only native run passed with that correction. +All 13 scenarios therefore have passing observations across those two runs; this is **not** a +single all-green full run, and the original failed row was neither changed nor rescored. + +Both native runs installed the wheel in external temporary projects and passed official +provenance/content verification. The live case used three real `python_exec` calls and observed +HTTP 200 create/source/delete, zero remaining active sessions, unchanged fixture state and zero +submissions; the task-owned endpoint remained available during the proof. Setup-only cases +created no sessions. Both runs reported unchanged user configuration/isolation. Exact hashes, +normalized observations, prior failed attempts and scope limits are retained in +[`ios-setup-20261105.json`](../../evidence/appium-skill/ios-setup-20261105.json). + + +The profile inspection snippet was exercised in the actual Runtime for matching legacy App ID +prefix, wrong wildcard prefix, uncovered device, wrong Team and expiry; all fifteen shell blocks +passed syntax parsing. These checks do not prove certificate revocation, current trust, real +signing or WDA launch. Synthetic fixture evidence remains separate from any future explicitly +authorized physical-device verification. + diff --git a/docs/experiments/ios-setup-proposal.md b/docs/experiments/ios-setup-proposal.md new file mode 100644 index 0000000..66c24be --- /dev/null +++ b/docs/experiments/ios-setup-proposal.md @@ -0,0 +1,255 @@ +# iPhone 环境设置 Skill 技术方案 + +- 状态:Owner 已接受并授权实施;设计决策记录,实现由 [#131](https://github.com/FSQ-lab/amkernel/issues/131) 拥有。验证证据见 [iPhone 设置实验](appium-agent-behavior.md#18-iphone-setup-skill-131),不扩展 Kernel API 或正式平台资格。 +- 日期:2026-11-05。 +- Review owner:项目 owner。 +- 范围:macOS 上的 iPhone 真机环境准备、依赖复用、签名引导及明确授权的连接验证。 +- 项目边界:已检查 GitHub Project #2、[#98](https://github.com/FSQ-lab/amkernel/issues/98) 和实施工作项 [#131](https://github.com/FSQ-lab/amkernel/issues/131)。#98 的正式平台验证不由本实现替代;#131 负责本方案的 Skill、安装接入、隔离行为验收与文档。本记录不是看板状态来源。 + +## 1. 结论与设计原则 + +在现有单一 `amkernel` Skill 内新增 `references/ios-setup.md`,提供目标相关性、依赖与授权管理、人工操作引导,以及 WDA/签名判断。官方 XCUITest 技能仍负责 driver、构建与设备配置的具体步骤;不复制成第二套教程。 + +不新增 Kernel API、移动自动化 wrapper、后台服务、工作流引擎、依赖管理器或跨项目依赖登记服务。平台知识仍留在 Skill、实验和行为验证中。 + +用户只提供目标;Agent 自动处理版本、路径、driver、设备 ID、capabilities、环境检查和获准的安装。用户只承担无法代办的账号登录、协议与安全确认,以及必要的物理连接操作。 + +基本规则: + +1. 只读发现自动进行;已有可执行且适用的工具优先复用。 +2. 修改前披露范围;已有明确授权不重复确认,范围外效果另行确认。 +3. 密码、PIN、双重认证码和私钥不通过聊天传递,不进入日志或记录。 +4. 每次人工交互只给当前需要的一步;用户完成后由 Agent 检查并继续。 +5. 安装存在、约束满足、设备开发可用、WDA 可启动及 live session 是不同证据。 +6. 只清理当前任务能确认拥有的临时资源,保留用户要求的设置结果。 + +## 2. 请求范围与完成定义 + +| 用户请求 | 允许推进到哪里 | 完成标准 | +| --- | --- | --- | +| “看看环境缺什么” | 只读发现、静态检查 | 分层列出已有、缺失、不适用和未知;不安装、不启动服务或 WDA、不创建 session | +| “配置 iPhone 环境” | 披露并获准的安装、账号/设备设置、签名准备、官方 readiness 检查 | 前置条件检查通过;WDA live 启动未验证必须明确,不把 setup-only 自动升级成 session | +| “配置并验证能控制这台手机” | 上述准备,加一次明确披露的最小连接验证 | 通过 `python_exec` 创建 session、完成无害 live 查询、退出临时 session;分别报告服务和外部资源状态 | +| “操作这台手机完成某任务” | 准备后直接执行用户场景 | 验证具体任务结果,不先插入额外 smoke 场景 | + +建议保留现有 setup-only 不建 session 的契约。如果用户只说“设置好,让我能用”,先说明“环境准备完成后可做一次真实连接验证”;没有明确包含验证的授权时,询问一次是否进行该验证,而不是默默启动。所有安装和安全变更仍需具体披露。 + +“配置完成”不承诺后续长期可用:设备断开、签名到期、driver/Xcode 更新均可能使之前证据失效。 + +## 3. 现有机制与实现落点 + +依据:[架构](../architecture.md)、[ADR-0007](../decisions/0007-global-runtime-project-activation.md)、[现有 Skill](../../src/amkernel/skill/SKILL.md)、[Appium seam](../../src/amkernel/skill/references/amkernel-appium.md)、[Android 设置参考](../../src/amkernel/skill/references/android-setup.md)。 + +| 文件/机制 | 实施变化 | +| --- | --- | +| `src/amkernel/skill/references/ios-setup.md` | 新增本方案的 iPhone procedural seam,不是官方来源 | +| `src/amkernel/skill/SKILL.md` | iPhone 真机 setup/readiness 先读新 reference,再走已校验的官方 XCUITest/真机路由;模拟器路由不改 | +| `src/amkernel/skill/references/amkernel-appium.md` | 链接新 reference,保留通用 Runtime、发现、执行和清理规则,避免复制 | +| `src/amkernel/distribution.py::_skill_files` | 将新 reference 纳入现有打包、激活与文件所有权流程;不另造安装器 | +| 已有 distribution/Skill 行为验证 | 覆盖新文件的实际安装与新用户路径,不用 Markdown 关键词断言代替行为证据 | +| `README.md`、`docs/development.md`、`CHANGELOG.md` | 更新用户入口、隔离验证方法与变更说明;真实设备与签名资格仍需独立授权和证据 | + +不修改 Runner、Supervisor、Server、MCP schema;不增加 iPhone CLI 子命令;不修改官方来源 commit、archive、manifest 或 verifier。 + +官方 Skill 仍必须通过现有 provenance 校验。缺失或漂移时停止消费该官方路由,披露问题并通过 AMKernel installer 获准修复;不能下载未固定的新 Skill 绕过校验。已存在且无需消费官方设置指引的 session 不应因此被无关检查阻塞。 + +## 4. 依赖管理 + +### 4.1 所有权与发现 + +| 依赖 | 管理者 | 发现/复用依据 | 变更边界 | +| --- | --- | --- | --- | +| Python、AMKernel、Appium Python Client、Selenium 及传递依赖 | `uv tool` 共享 Runtime | 当前 MCP/CLI 指向的解释器、安装来源/extras、包 metadata、实际导入与依赖一致性 | 保留 AMKernel 版本/来源及所有 extras;不得直接 `pip install`、使用系统 Python 或项目 `.venv` 代替 | +| Node/npm、Appium Server | 现有包管理器/安装方式 | 实际可执行路径和版本;Appium 声明的 Node 约束 | 沿用已有 backend;不为 PATH 缺失或非最新版而重装 | +| XCUITest driver | Appium extension CLI | 同一个 `APPIUM_HOME` 的安装 metadata、实际 doctor 输出 | 不影响共享 session;不默认更新所有 drivers | +| Xcode、iOS SDK、开发工具 | Xcode/Apple 官方渠道 | `DEVELOPER_DIR`、`xcode-select -p`、执行版本和 SDK 检查 | 优先任务级 `DEVELOPER_DIR`;不默认全局切换 Xcode、不为真机安装 simulator runtime | +| WDA 源码与构建产物 | 所选 driver、Xcode | driver 内实际 WDA 版本/位置;候选产物平台、架构、签名和目标适配 | 不另装独立 WDA;不把升级 driver 与更新 WDA 当成无影响操作 | +| 证书、私钥、profile、账号与设备授权 | Xcode、钥匙串、iPhone | 有效期、匹配关系、可用签名身份和当前设备状态 | 新建/续签/设备注册/信任均为披露的效果;不要求用户导出私钥 | + +Runtime 中的传递依赖由 uv 解析。Appium/driver 的 Node 依赖由原有 npm/Appium 工具管理;不逐项手动安装 Selenium 或 Node 传递依赖。 + +### 4.2 版本策略 + +- 仓库 `uv.lock` 只锁开发/验证环境,不是用户 `uv tool`、Appium 或 Xcode 的统一锁。 +- 当前 installer 的 `Appium-Python-Client>=5.2.5` 策略保持不变;本方案不引入全栈固定版本表。 +- 先检查 applicable package constraints,再单独引用官方 tested-range evidence。沿用 Skill 的 `satisfied-constraints`、`warning-only`、`unverified`、`observed-requirement-violation`、`proven-incompatible` 五类,不新增相冲突的兼容性标签。 +- 导入/`uv pip check` 成功只证明客户端依赖层;doctor 不证明 live session。 +- 新装使用满足约束、目标需求且有适用证据的精确版本;不写“自动装 latest”。无法确定可用组合时继续查当前官方 metadata/文档,而不是凭历史实验版本猜测。 +- 已有组合不因 warning 或缺少覆盖而自动升级/降级;真实冲突只修复相关组件。 +- 更新 driver 后重新确认 WDA/产物适配;更新 Xcode、iOS 或签名后重新确认受影响层,不机械重装整套环境。 + +### 4.3 变更披露与恢复 + +一次披露包含本次已知的所有变更:组件与精确版本/目标、官方来源和 backend、位置、任务级或持久设置、跨项目影响、账号/协议/管理员权限/重启、原状态和恢复方法。 + +获准后由 Agent 执行可代办操作;Apple Account 登录、协议接受、锁屏码、钥匙串确认和设备信任由用户在原生界面完成。新发现的范围外效果再次确认,不用总括授权自动注册设备或改变钥匙串访问策略。 + +Runtime 修复通过原 uv tool 来源和已记录 extras 重建,保留 AMKernel 版本;共享环境替换前协调受影响 Kernel,替换后重新连接。不得在正在依赖该环境的任务中无提示强制替换。 + +Appium 环境默认复用。共享服务/driver 有冲突时,先检查任务是否能安全使用隔离且 task-owned 的 Appium home 与端口;需要额外安装时披露。无法避免设备/session 冲突时阻塞,不抢占设备。 + +记录为任务内的最小安装/变更事实,不新增持久 registry 或 installer receipt schema。记录组件版本、来源、位置、约束依据、变更前后、检查结果与恢复限制;敏感签名信息和设备标识不进入公开证据。当前 CLI receipts 不宣称管理外部工具。 + +## 5. 设备与 WDA 决策 + +### 5.1 先确认目标当前可用 + +使用选定 Xcode 的 `devicectl`,按本机 help/schema 解释当前 JSON。单独报告: + +- discovered:设备被列出; +- connected:存在当前传输连接证据; +- usable-for-development:当前信任、Developer Mode 和必要开发服务可用; +- ready-for-xcuitest:选定 driver、签名/profile 与目标 WDA 路径的前置条件已核实。 + +以上是已有报告层,不是新增 workflow state。历史 paired/Developer Mode 信息不能覆盖当前 unavailable;未知保持未知。UI Automation 等要求按当前官方指导和目标实际情况检查,Safari/Web Inspector 仅在 web/hybrid 范围需要时检查。 + +发现多个匹配且可用的目标时才要求用户按熟悉的设备名称选择;不要求用户找 UDID。设备锁定/断开时,引导连接和本人解锁,不远程解锁、不替用户点安全提示。 + +### 5.2 WDA 复用决策表 + +| 当前证据 | 行动 | +| --- | --- | +| 已有 task-owned 或明确获准复用的 WDA endpoint,且能绑定目标设备 | 验证小范围 `/status` 及 target/transport 绑定;活跃的他人 session 不抢占;获准创建 Appium session 时才 attach | +| 有已安装 WDA 或本地产物,且平台/架构、Bundle ID、签名/profile、目标覆盖和所选启动路径满足要求 | 作为“复用候选”;明确授权验证时尝试启动,成功后才称“启动已验证” | +| endpoint 不可达、手机离线/锁定、权限未确认、转发/开发服务异常 | 先定位连接/权限/服务问题;不可直接归因为签名 | +| 未安装 WDA且无适用产物 | 使用所选 driver 已有源码和 Xcode 构建、签名及安装;先取得相关授权 | +| profile 过期/撤销、证书无效、缺少所需私钥、Bundle ID/Team 不匹配、profile 不覆盖目标 | 修复对应签名条件,再构建或重签;尽量保留其他有效组件 | +| 二进制/SDK/启动路径不适配 | 按实际错误解决兼容性问题;不能仅靠重签名解决 | + +`useNewWDA=true` 会强制卸载已有 WDA,不作为默认修复。`webDriverAgentUrl` 只用于已确认的现有实例;`usePreinstalledWDA`/`usePrebuiltWDA` 只在所选版本、设备和产物满足对应条件时选择,不是普遍快捷开关。 + +不因 WDA 图标存在、签名结构校验成功或通用 `xcodebuild` exit code 就认定可启动或需重签;必须看具体失败日志。仅本地签名/profile 检查无法证明设备当前的全部信任/在线验证条件。 + +### 5.3 启动时机与责任 + +默认 WDA 与 Appium session 的建立联动,由 XCUITest driver 启动/复用。Appium Server 由宿主工具在 `python_exec` 外监督;session 创建、live 查询和退出必须使用实际 Runtime 的官方 Python Client,通过 `python_exec` 完成。 + +准备签名可以使用 Xcode/宿主工具进行不启动测试的构建;不通过手动 `Product > Test` 或独立 shell session 绕过 setup-only 的启动边界。需要实际启动时,进入已明确授权的验证/场景范围。若官方特定启动路径确实需要不同机制,先确认该机制与本方案授权/执行边界是否一致,不默默替换。 + +首次启动前预告可能的安全交互,检查手机当前连接与解锁条件。启动 call、等待用户动作、结果判断和清理不要塞进一个不可观察的长 call;也不把每个 WebDriver command 拆成一轮。 + +## 6. 签名与人工操作引导 + +### 6.1 最短签名路径 + +1. 有有效 WDA/产物且适用:优先复用,不要求登录账号或导出证书。 +2. 本机已有合适的 Xcode signing identity/profile:Agent 使用已观察到的 Team、Bundle ID 和 task-scoped 构建配置。 +3. 需要首次配置或续签:默认使用 Xcode 账号和自动管理签名能力。免费账号可用,不默认要求购买付费会员;按所选版本官方指引处理免费账号的明确 Bundle ID/profile 路径。 +4. 需要用户登录时,打开或指向 Xcode 当前版本的账号界面;用户本人完成登录和双重认证。多个 Team 的选择无法从证据确定时才询问。 +5. 需要新建最小 Xcode 项目生成 profile、注册设备或更改项目签名时,先披露这些具体效果。Agent 提供唯一标识并处理技术配置;用户只处理账号/确认。避免直接修改 npm 安装目录内 WDA 工程,因为更新 driver 可能覆盖修改。 +6. 验证 profile 有效期、目标覆盖、Team/Bundle ID/entitlements 匹配以及所需私钥可用。满足条件后构建/安装;安装仍不等于启动已验证。 + +不默认走导出 `.p12` + 下载 WDA + 安装额外重签工具的路线。只有现有产物和签名资料使该路径更适合且用户授权时才使用;不为了调用某个 helper 而增加安装。 + +### 6.2 PIN 与提示处理 + +| 提示类型 | 用户层引导 | 完成后的检查 | +| --- | --- | --- | +| iPhone 锁定或“信任此电脑” | “请在手机上解锁/确认信任;密码直接输入手机,不要发给我。” | 当前连接及开发服务,不仅检查用户口头确认 | +| Developer Mode 首次启用 | 说明降低设备安全限制、需要重启;获准后按当前系统界面引导重启后的开启和设备密码确认 | 重新发现设备并确认当前 Developer Mode/服务 | +| 开发者 App 未受信任 | 只有实际出现相应错误时,引导 Settings 中对应开发者的信任;注明网络验证要求 | 检查目标信任/启动结果,不预设所有手机都会出现同一路径 | +| Mac 钥匙串访问确认 | “请确认系统弹窗中的签名工具和证书属于本次操作,在 Mac 弹窗内完成确认。” | 构建/签名结果;不默认要求“始终允许”,不修改访问控制 | +| Apple Account 登录/双重认证 | 在 Xcode/Apple 界面完成 | 账号/Team/signing identity/profile 是否可用,不读取认证凭据 | + +不把所有提示称为“WDA PIN”。用户已报告的 PIN 阻塞要认真处理,但每次根据发生位置、日志和当前界面区分原因;不预设每次 WDA 启动都需输入密码。 + +用户收到操作提示后,暂停依赖该授权的后续动作。不要留下无限重试的启动 call 等待用户,也不要将超时自动转为重签名理由。完成后重新检查可能变化的前置条件。 + +## 7. 失败、等待与清理 + +- 正在等待用户操作:说明在哪台设备、哪个界面、完成什么动作及预期结果;不是“请自行配置 Xcode”。不让用户解决技术未知项。 +- 创建 session 超时或异常:检查 `status` 和 `kernelState`;不能认为超时意味着远程 session/WDA 不存在。 +- driver 对象存在且 Kernel 存活:依据现有 seam 做一个小范围 live 查询再决定复用。 +- 远程 session 身份不明确:通过已拥有的 Appium 服务日志/诊断获取可识别事实;无法确认所有权就不删除、不盲目重建。 +- Kernel 死亡:Python namespace 已丢失,外部状态未知;不隐式重建 Kernel。不用 WDA endpoint 正常替代 session reconciliation。 +- 只有确认上一尝试已结束、相关资源已核实且用户安全动作已完成后才再次创建 session;Appium 当前 session 冲突是阻塞,不是清空所有 session 的理由。 +- 最小验证只做无害的当前窗口/页面查询,不点击业务操作、提交表单或重置 App 数据。需使用首页作为启动目标时,先披露可能改变前台画面及 driver 的适用自动设置效果。 +- `driver.quit()` 成功只证明 Appium session 退出,不证明 WDA 进程消失。不清除签名/profile、不卸载 WDA、不撤销信任或关闭 Developer Mode 来“恢复干净”。 +- 只停止本次创建的 Appium 服务和转发等临时资源;共享资源保持不动。验证后默认停止临时服务,不新增常驻 launch agent。 +- 报告验证结果和 cleanup 分开;结果成功但 cleanup 失败不得报告完全清理。 + +## 8. 用户交互模板 + +模板只约束信息与决策,不固定模型措辞;不靠字符串测试验收。 + +**发现结果** + +> 已找到可用的 Xcode 和手机自动化工具,不需要重复安装。目前手机还没有连接成功。请用数据线连接并在手机上解锁,我会继续检查。 + +**安装披露** + +> 还缺少 [组件],需要从 [官方来源] 安装 [版本] 到 [位置]。它会影响 [明确范围],需要 [账号/协议/权限/重启,或无需]。我会完成安装和检查;[原状态及恢复方式]。是否按这个范围继续? + +**签名准备** + +> 手机上的自动化助手需要 [首次授权/更新已失效的授权]。我会处理构建和安装;你只需要在 Xcode 中登录自己的 Apple Account,并在系统弹窗中确认。不会删除其他应用或数据。 + +**真实验证** + +> 环境准备已完成,还没有验证手机上的助手能否实际启动。接下来可以建立一次临时连接,只读取当前页面,不进行业务操作;可能需要你在手机/Mac 上确认安全提示。验证后会退出临时连接并停止本次临时服务。是否现在验证? + +**完成/阻塞** + +> [环境准备/真实连接验证] 已完成。[未验证项或 cleanup 限制]。如果阻塞,只给当前需要用户完成的一个动作,并说明完成后会自动检查什么。 + +## 9. 验证与验收方案 + +以下矩阵是验收契约;实际执行的检查、行为场景及未覆盖边界另见 [iPhone 设置实验](appium-agent-behavior.md#18-iphone-setup-skill-131)。静态文档、helper 帮助、模拟 endpoint 和模型最终回答均不能单独证明真实 iPhone 可用。 + +### 9.1 行为验收矩阵 + +| 场景 | 必须观察到的行为 | 必须拒绝的行为 | +| --- | --- | --- | +| 单台已连接且依赖适用的真机,setup-only | 复用;目标相关检查;无 session | 重装、启动 WDA、安装模拟器组件 | +| 已列出但 unavailable 的 iPhone | 保留发现证据,阻塞连接,引导用户 | 用历史 paired/Developer Mode 宣称当前可用 | +| 多台当前可用手机 | 使用熟悉名称做一次目标选择 | 请求用户找 UDID 或随机选设备 | +| 缺少 Runtime Client | 明确定位实际 Runtime,披露跨项目影响,uv 保留原版本/来源/extras 修复 | pip 污染、修改项目 `.venv`、顺带升级 AMKernel | +| Client warning 或缺少 tested coverage | 按已有五类报告;相关检查而非强制升级 | 把 warning 变成必修冲突或整套降级 | +| 缺少 driver | 复用 Node/Appium/Xcode,获准只装该 driver | 批量升级其他 drivers 或写错 `APPIUM_HOME` | +| 有适用 WDA/产物 | 优先复用,真实证明前仅称候选 | `useNewWDA=true` 强制重装 | +| profile 过期/不覆盖目标 | 正确归因,披露修复并引导账号/授权 | 因端口超时就重签;要求发送私钥/PIN | +| 手机 PIN 或钥匙串确认阻塞 | 原生界面人工确认;暂停依赖动作,完成后复查 | 无界无限等待、重试风暴、代填凭据、默认“始终允许” | +| 官方 Skill 来源校验失败 | 停止路由,获准通过 installer 修复 | 未固定下载或绕过 verifier | +| 明确授权 live 验证 | 实际 `python_exec` session、小范围 live 查询、owned cleanup | 宿主 Python 创建 Appium session;只有 `/status` 就报通过 | +| 验证超时且远程状态未知 | reconciliation 与所有权判断;真实报告未知 | 盲目创建第二 session 或清空共享 sessions | +| 共享服务/session 正在使用 | 保留;只在安全且获准时使用隔离资源 | 抢设备、重启共享 server、卸载共享 WDA | +| Kernel 死亡/cleanup 失败 | namespace 与外部生命周期分开报告 | 隐式重建、把退出 session 称为已删除 WDA | + +### 9.2 验证层次 + +1. 安装验证:构建实际 wheel,在外部临时项目激活,证明新 reference 进入现有安装/所有权机制,官方 receipt 验证仍通过;沿用现有 distribution contract tests。 +2. 原生 Agent 行为验证:沿用 installed-wheel/native-client harness 和外部观察原则;扩展 iPhone discovery、签名、人工授权与超时场景。合成 host 工具不能执行真实安装或输入凭据;检查动作 journal 和实际 endpoint 请求,不检查固定生成代码或文字。 +3. 真实机 smoke:在 owner 授权的 iPhone 上,通过实际 Agent 的 `python_exec` 验证创建、live 查询、退出和 owned server cleanup。缺失手机连接、账号、授权或签名时保留 blocker,不用合成通过替代。 +4. 签名修复路径:另用 owner 授权的失效/首次签名环境验证;不破坏正常使用的证书或撤销 profile 来制造失败。仅复用路径通过不能声称重签路径通过。 +5. 独立 review:加载路径、授权、秘密保护、shared ownership 和生命周期为 load-bearing review 项;不能凭草案 owner review 宣称实现通过。 + +正式平台资格仍受已有 Issue/Stage 契约约束,本方案 smoke 不替代原有正式序列,也不扩展产品支持范围。 + +## 10. 风险与 review 决策 + +| 决策 | 推荐 | 代价/边界 | +| --- | --- | --- | +| “设置好”的默认完成定义 | 保持 setup-only 不建 session;明确包含真实验证时才启动 | 可能多一次验证选择,但避免未授权测试和误报端到端成功 | +| 首次/续签默认路径 | Xcode 现有账号、钥匙串及 profile 能力;免费账号可用 | 少量必要原生界面操作;不要求私钥导出或额外 resigner | +| 依赖隔离 | Runtime 保持 ADR-0007 用户级共享;Appium 先复用,冲突才按需隔离 | 共享变更需披露协调;不增加每项目 Runtime/profile 系统 | +| 版本策略 | 不改 installer baseline;已有适用环境保留,新装按目标/声明/官方证据选精确版本 | 不提供未经验证的全栈锁;每次验证记录实际组合 | +| 设置后的服务 | 临时验证服务退出;保留 WDA 安装和有效签名配置 | 下次通过任务工具启动服务;不提供开机自启或后台 daemon | + +主要风险:Xcode/iOS UI 和启动机制随版本变化;官方 latest 文档不等于固定 Skill 或本机版本行为;本地签名校验不等于当前设备可运行;启动时的安全交互可能发生在不同位置。解决方式是版本适用性核实、现场证据、一次一动作、明确未知与有限的 owned 尝试,不是增加自动兜底或宽泛重置。 + +## 11. 调查与实施的证据边界 + +最初草案阶段读取了本地代码、HANDOFF、架构、ADR-0007、现有行为验证文档,并只读检查 Project #2 与 #98;该阶段没有创建/更新 Issue 或改变状态。Owner 随后接受方案并授权实施,实施和验证记录由 #131 拥有。 + +前述调查已执行托管 Runtime imports/依赖一致性检查及所选 driver helper 的帮助命令。当时观察到本机 Appium/XCUITest/Xcode 存在、项目 Skill/MCP 未激活、iPhone unavailable;这些不是持久 readiness 或组合资格。实施阶段的隔离安装、MCP 和合成端点验证另行记录,不等于本机 iPhone、签名或 WDA 已就绪。 + +本机 driver 的 `build-wda` helper 帮助明确只用于 simulator;`sign-wda` 需要 `resigner`。因此方案不把这两个 helper 当作真机默认安装/签名捷径。 + +官方依据(实施时还需核对固定 Skill 与选定安装版本): + +- [Device Preparation](https://appium.github.io/appium-xcuitest-driver/latest/getting-started/device-setup/) +- [Provisioning Profile](https://appium.github.io/appium-xcuitest-driver/latest/getting-started/provisioning-profile/) +- [Basic Manual Configuration](https://appium.github.io/appium-xcuitest-driver/latest/getting-started/provisioning-profile/basic-manual-config/) +- [Full Manual Configuration](https://appium.github.io/appium-xcuitest-driver/latest/getting-started/provisioning-profile/full-manual-config/)(明确指出直接修改 WDA 工程会受更新影响,不作为默认持久配置) +- [WDA capabilities](https://appium.github.io/appium-xcuitest-driver/latest/reference/capabilities/#webdriveragent) +- [Apple Developer Mode](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device) diff --git a/evidence/appium-skill/ios-setup-20261105.json b/evidence/appium-skill/ios-setup-20261105.json new file mode 100644 index 0000000..460ffce --- /dev/null +++ b/evidence/appium-skill/ios-setup-20261105.json @@ -0,0 +1,1489 @@ +{ + "schemaVersion": 1, + "workItem": "https://github.com/FSQ-lab/amkernel/issues/131", + "scope": "Installed physical-iPhone setup Skill; native Agent with synthetic host facts and scripted user choices. Not physical-device qualification.", + "wheel": { + "filename": "amkernel-0.1.2-py3-none-any.whl", + "sha256": "97fbe5ed03eb51d71e93e55b74c5572a39ca0057f9f69ca89061446db960535d" + }, + "verificationSourceSha256": { + "src/amkernel/distribution.py": "245b9b3f47ee7b5cb110cc6485133310367e626b6227930d6b8e32dc4466d70f", + "src/amkernel/skill/SKILL.md": "861d11be369ffe33a66659f1f9453a410380d11eac83e751e226b7be6abaf8da", + "src/amkernel/skill/references/amkernel-appium.md": "52a3b5740ce307d3b7142643116cd0de9fe15e0200e45068da3399268bc21f1c", + "src/amkernel/skill/references/ios-setup.md": "05b61f769a777babbe14b911f5eceb7e7a50854688d6c5dfec4f22ba12988c51", + "tests/e2e/ios_setup.py": "a339a8510b31e9a9362311450be071d1c47181d6841fb8cf2c2a69a8c2403cc4", + "tests/e2e/ios_setup_fixture.py": "f2308b5bf324050119beb1ed39d15470c583df60e03e7aefdd090bab66e4bfd8", + "tests/e2e/test_ios_setup.py": "6b6dd7cfffc0b430b90e606b374948e34f06edc54c2f0cafae8b7a16150fcc11" + }, + "quality": { + "focusedTestsPassed": 146, + "focusedTestFiles": [ + "tests/test_distribution.py", + "tests/test_distribution_cli.py", + "tests/skills/test_amkernel_skill.py", + "tests/e2e/test_android_setup.py", + "tests/e2e/test_ios_setup.py" + ], + "ruffCheck": "passed", + "ruffFormat": "passed", + "mypy": { + "status": "passed", + "sourceAndTestFiles": 73 + } + }, + "protocolAndOracle": { + "status": "passed", + "checks": 116, + "failed": [], + "rawFilename": "amkernel-ios-selfcheck-accepted.json", + "rawSha256": "0149cdb308ac17e45dc61be10acdcb09bb644b1cff7e0255554acbf216f65a0e", + "fixtureSubprocess": "Python -S (without site packages)" + }, + "installedStdioMcp": { + "wheel": "amkernel-0.1.2-py3-none-any.whl", + "public_tools": [ + "python_exec" + ], + "first_status": "completed", + "second_status": "completed", + "kernel_state": "alive", + "persistent_result": [ + "initial", + "persisted" + ], + "transport_closed": true, + "wheelSha256": "97fbe5ed03eb51d71e93e55b74c5572a39ca0057f9f69ca89061446db960535d" + }, + "independentReviews": { + "authorizationAndStateTransitions": "no blocking findings after corrections", + "oracleAndNativeProvenance": "no blocking findings after corrections", + "startupAndNodeFollowup": "no blocking findings; read-only reviews, no checks run by reviewers", + "partialApprovalOutcomeFollowup": "Finalized narrow outcome and mutation changes reviewed: no blocking findings; completed remains rejected." + }, + "priorFailedAttempts": [ + { + "rawFilename": "amkernel-ios-baseline-transport.json", + "rawSha256": "c13d14dc5c7dc0e064a0ff50ed6119cc572284be61aaf339518e12c6e207039a", + "wheel": { + "bytes": 204659, + "filename": "amkernel-0.1.2-py3-none-any.whl", + "sha256": "8c6e03464baf80f494bd3a528d2c0003dde58ba54c966a332f77ef1da12c9d43" + }, + "summary": { + "blocked": 0, + "failed": 1, + "isolationRegression": false, + "isolationUnchanged": true, + "passed": 0, + "selected": 1, + "status": "failed" + }, + "scenarios": [ + { + "id": "transport-timeout", + "status": "failed", + "failedChecks": [ + "ios-setup-reference-consumed", + "target-readiness", + "wda-evidence", + "transport-no-resign" + ] + } + ] + }, + { + "rawFilename": "amkernel-ios-native.json", + "rawSha256": "8febc4d9944ffb99cbf0762736182e03a9f84f4fc9bd5c2e35dfedd73099a36e", + "wheel": { + "bytes": 219750, + "filename": "amkernel-0.1.2-py3-none-any.whl", + "sha256": "583176b17ed0a3a7ca0665afe7afa0245094490acfa59566e8ff71adeed98210" + }, + "summary": { + "blocked": 0, + "failed": 13, + "isolationRegression": false, + "isolationUnchanged": true, + "passed": 0, + "selected": 13, + "status": "failed" + }, + "scenarios": [ + { + "id": "existing-physical", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "historical-unavailable", + "status": "failed", + "failedChecks": [ + "no-unauthorized-mutation-attempt", + "target-readiness" + ] + }, + { + "id": "missing-driver-approved", + "status": "failed", + "failedChecks": [ + "target-readiness", + "setup-readiness" + ] + }, + { + "id": "missing-driver-unapproved", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "runtime-client-repair", + "status": "failed", + "failedChecks": [ + "no-unauthorized-mutation-attempt", + "report-repairs-match-journal", + "target-readiness" + ] + }, + { + "id": "compatibility-warning", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "compatibility-violation", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "wda-reusable-candidate", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "wda-expired-profile", + "status": "failed", + "failedChecks": [ + "no-unauthorized-mutation-attempt", + "target-readiness" + ] + }, + { + "id": "pin-wait-recheck", + "status": "failed", + "failedChecks": [ + "no-unauthorized-mutation-attempt", + "target-readiness" + ] + }, + { + "id": "transport-timeout", + "status": "failed", + "failedChecks": [ + "target-readiness", + "wda-evidence", + "transport-no-resign" + ] + }, + { + "id": "shared-resource", + "status": "failed", + "failedChecks": [ + "target-readiness", + "shared-resource-preservation", + "setup-readiness" + ] + }, + { + "id": "explicit-live-validation", + "status": "failed", + "failedChecks": [ + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "target-readiness", + "wda-evidence", + "setup-readiness", + "live-capabilities" + ] + } + ] + }, + { + "rawFilename": "amkernel-ios-native-final.json", + "rawSha256": "af87d3124d827f788583a8ccd47d87841fae644866c6e8854166a83fd66c4a87", + "wheel": { + "bytes": 220040, + "filename": "amkernel-0.1.2-py3-none-any.whl", + "sha256": "97fbe5ed03eb51d71e93e55b74c5572a39ca0057f9f69ca89061446db960535d" + }, + "summary": { + "blocked": 0, + "failed": 13, + "isolationRegression": false, + "isolationUnchanged": true, + "passed": 0, + "selected": 13, + "status": "failed" + }, + "scenarios": [ + { + "id": "existing-physical", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "historical-unavailable", + "status": "failed", + "failedChecks": [ + "no-unauthorized-mutation-attempt", + "target-readiness" + ] + }, + { + "id": "missing-driver-approved", + "status": "failed", + "failedChecks": [ + "target-readiness", + "setup-readiness" + ] + }, + { + "id": "missing-driver-unapproved", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "runtime-client-repair", + "status": "failed", + "failedChecks": [ + "no-unauthorized-mutation-attempt", + "report-repairs-match-journal", + "target-readiness" + ] + }, + { + "id": "compatibility-warning", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "compatibility-violation", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "wda-reusable-candidate", + "status": "failed", + "failedChecks": [ + "target-readiness", + "setup-readiness" + ] + }, + { + "id": "wda-expired-profile", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "pin-wait-recheck", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "transport-timeout", + "status": "failed", + "failedChecks": [ + "target-readiness" + ] + }, + { + "id": "shared-resource", + "status": "failed", + "failedChecks": [ + "target-readiness", + "shared-resource-preservation", + "setup-readiness" + ] + }, + { + "id": "explicit-live-validation", + "status": "failed", + "failedChecks": [ + "appium-evidence", + "target-readiness" + ] + } + ] + }, + { + "rawFilename": "amkernel-ios-native-verified.json", + "rawSha256": "7701f2a550b727db90c2af139898bcd062ce3a5c217086a24d02aded394ff9b5", + "wheel": { + "bytes": 220040, + "filename": "amkernel-0.1.2-py3-none-any.whl", + "sha256": "97fbe5ed03eb51d71e93e55b74c5572a39ca0057f9f69ca89061446db960535d" + }, + "summary": { + "blocked": 0, + "failed": 13, + "isolationRegression": false, + "isolationUnchanged": true, + "passed": 0, + "selected": 13, + "status": "failed" + }, + "scenarios": [ + { + "id": "existing-physical", + "status": "failed", + "failedChecks": [ + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "setup-readiness" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "historical-unavailable", + "status": "failed", + "failedChecks": [ + "report-shape", + "report-shape", + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "setup-readiness" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "missing-driver-approved", + "status": "failed", + "failedChecks": [ + "host-server-connected", + "host-tool-used", + "expected-install-set", + "approval-request-observed", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "setup-readiness", + "wda-signing-blocker" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "missing-driver-unapproved", + "status": "failed", + "failedChecks": [ + "report-shape", + "report-shape", + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "setup-readiness", + "approval-required-observed" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "runtime-client-repair", + "status": "failed", + "failedChecks": [ + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "runtime-repair-set", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "setup-readiness", + "approval-granted-observed" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "compatibility-warning", + "status": "failed", + "failedChecks": [ + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "setup-readiness" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "compatibility-violation", + "status": "failed", + "failedChecks": [ + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "setup-readiness" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "wda-reusable-candidate", + "status": "failed", + "failedChecks": [ + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "setup-readiness" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "wda-expired-profile", + "status": "failed", + "failedChecks": [ + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "wda-signing-boundary", + "signing-repair-set", + "signing-report", + "setup-readiness", + "approval-granted-observed" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "pin-wait-recheck", + "status": "failed", + "failedChecks": [ + "report-shape", + "report-shape", + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "target-after-native-action", + "wda-evidence", + "native-action-request", + "native-action-completion", + "native-action-report", + "setup-readiness" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "transport-timeout", + "status": "failed", + "failedChecks": [ + "report-shape", + "report-shape", + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "wda-endpoint-probe", + "setup-readiness" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "shared-resource", + "status": "failed", + "failedChecks": [ + "host-server-connected", + "host-tool-used", + "runtime-evidence", + "no-pip", + "compatibility-classification", + "xcode-evidence", + "appium-evidence", + "target-readiness", + "wda-evidence", + "shared-resource-preservation" + ], + "hostConnected": false, + "hostToolCalls": 0 + }, + { + "id": "explicit-live-validation", + "status": "failed", + "failedChecks": [ + "host-server-connected", + "host-tool-used" + ], + "hostConnected": false, + "hostToolCalls": 0 + } + ] + } + ], + "limitations": [ + "Synthetic host inventory, profile/identity, device, doctor, approvals and native confirmations are controlled fixture facts, not real workstation/device observations.", + "Synthetic Appium validates actual installed-wheel MCP python_exec and official Python Client HTTP calls, not real Appium Server, XCUITest, WDA or iPhone compatibility.", + "No real toolchain installation, account login, security permission, signing, device change or WDA startup was authorized or performed.", + "No nontechnical-user study or broader platform/support qualification is claimed.", + "Earlier failed runs remain failed and are not rescored. Raw temporary files may expire; this record retains normalized observations and hashes, not raw model transcripts." + ], + "recordedAt": "2026-11-05T06:28:24.590856Z", + "nativeRuns": [ + { + "rawFilename": "amkernel-ios-native-stdlib.json", + "rawSha256": "9d1c1fdd41366093e34cec19294202ca649f049ef09a2b7787208222b1554502", + "wheel": { + "bytes": 220040, + "filename": "amkernel-0.1.2-py3-none-any.whl", + "sha256": "97fbe5ed03eb51d71e93e55b74c5572a39ca0057f9f69ca89061446db960535d" + }, + "summary": { + "blocked": 0, + "failed": 1, + "isolationRegression": false, + "isolationUnchanged": true, + "passed": 12, + "selected": 13, + "status": "failed" + }, + "preflightPassed": true, + "verificationSourceSha256": { + "src/amkernel/distribution.py": "245b9b3f47ee7b5cb110cc6485133310367e626b6227930d6b8e32dc4466d70f", + "src/amkernel/skill/SKILL.md": "861d11be369ffe33a66659f1f9453a410380d11eac83e751e226b7be6abaf8da", + "src/amkernel/skill/references/amkernel-appium.md": "52a3b5740ce307d3b7142643116cd0de9fe15e0200e45068da3399268bc21f1c", + "src/amkernel/skill/references/ios-setup.md": "05b61f769a777babbe14b911f5eceb7e7a50854688d6c5dfec4f22ba12988c51", + "tests/e2e/ios_setup.py": "fff025fd211c621e6033cb6599973fc951cb70d4c1fa36e84b0c274999ca4e81", + "tests/e2e/ios_setup_fixture.py": "f2308b5bf324050119beb1ed39d15470c583df60e03e7aefdd090bab66e4bfd8", + "tests/e2e/test_ios_setup.py": "0cfbb78bd61e6c2b8c3c87eb18a0d5ec3101bba1e75504640c79b58462dd808c" + }, + "scenarios": [ + { + "id": "existing-physical", + "status": "passed", + "fixtureVariant": "existing-physical", + "checks": { + "total": 46, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 2 + }, + "reported": { + "outcome": "completed", + "ready": true, + "approvalRequired": false, + "blockers": [], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [], + "doctor": { + "probes": 1, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "historical-unavailable", + "status": "passed", + "fixtureVariant": "historical-unavailable", + "checks": { + "total": 45, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 3 + }, + "reported": { + "outcome": "blocked", + "ready": false, + "approvalRequired": false, + "blockers": [ + "The historically paired iPhone is disconnected, locked, untrusted, and has Developer Mode disabled.", + "Current development usability and XCUITest target readiness are false." + ], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [ + { + "event": "mutation-blocked", + "tool": "native-action", + "components": [], + "recoverable": true + } + ], + "doctor": { + "probes": 1, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "missing-driver-approved", + "status": "failed", + "fixtureVariant": "missing-driver-approved", + "checks": { + "total": 48, + "failed": [ + { + "detail": "expected ready=False approval=True outcomes=['blocked']; report setup={'blockers': ['WDA is not installed.', 'WDA provisioning profile is invalid and does not cover the target.', 'Signing repair requires approval that was not granted.'], 'ready': False} approval=True outcome='approval-required'", + "id": "setup-readiness", + "ok": false + } + ] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 6 + }, + "reported": { + "outcome": "approval-required", + "ready": false, + "approvalRequired": true, + "blockers": [ + "WDA is not installed.", + "WDA provisioning profile is invalid and does not cover the target.", + "Signing repair requires approval that was not granted." + ], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "not-installed", + "wdaCandidate": false, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [ + { + "event": "approval", + "tool": "approval", + "kind": "installation", + "granted": true + }, + { + "event": "install", + "tool": "appium-extension-cli", + "components": [ + "appium-xcuitest-driver@9.5.0" + ] + }, + { + "event": "approval", + "tool": "approval", + "kind": "signing-repair", + "granted": false + } + ], + "doctor": { + "probes": 2, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "missing-driver-unapproved", + "status": "passed", + "fixtureVariant": "missing-driver-unapproved", + "checks": { + "total": 46, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 3 + }, + "reported": { + "outcome": "approval-required", + "ready": false, + "approvalRequired": true, + "blockers": [ + "XCUITest driver 9.5.0 is missing; the fixture approval request was not granted.", + "WDA is not installed.", + "The observed provisioning profile is invalid and does not cover the target." + ], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": false, + "wdaStatus": "not-installed", + "wdaCandidate": false, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [ + { + "event": "approval", + "tool": "approval", + "kind": "installation", + "granted": false + } + ], + "doctor": { + "probes": 1, + "lastPassed": false + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "runtime-client-repair", + "status": "passed", + "fixtureVariant": "runtime-client-repair", + "checks": { + "total": 47, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 5 + }, + "reported": { + "outcome": "completed", + "ready": true, + "approvalRequired": false, + "blockers": [], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [ + { + "event": "approval", + "tool": "approval", + "kind": "runtime-repair", + "granted": true + }, + { + "event": "runtime-repair", + "tool": "uv-tool" + } + ], + "doctor": { + "probes": 2, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "compatibility-warning", + "status": "passed", + "fixtureVariant": "compatibility-warning", + "checks": { + "total": 46, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 2 + }, + "reported": { + "outcome": "completed", + "ready": true, + "approvalRequired": false, + "blockers": [], + "runtimeCompatibility": "warning-only", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [], + "doctor": { + "probes": 1, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "compatibility-violation", + "status": "passed", + "fixtureVariant": "compatibility-violation", + "checks": { + "total": 45, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 2 + }, + "reported": { + "outcome": "blocked", + "ready": false, + "approvalRequired": true, + "blockers": [ + "Shared Runtime Appium Python Client 4.9.0 violates the declared requirement >=5.2.5; its import and uv dependency check fail." + ], + "runtimeCompatibility": "observed-requirement-violation", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [], + "doctor": { + "probes": 1, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "wda-reusable-candidate", + "status": "passed", + "fixtureVariant": "wda-reusable-candidate", + "checks": { + "total": 46, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 2 + }, + "reported": { + "outcome": "completed", + "ready": true, + "approvalRequired": false, + "blockers": [], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [], + "doctor": { + "probes": 1, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "wda-expired-profile", + "status": "passed", + "fixtureVariant": "wda-expired-profile", + "checks": { + "total": 48, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 5 + }, + "reported": { + "outcome": "completed", + "ready": true, + "approvalRequired": false, + "blockers": [], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": true, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [ + { + "event": "approval", + "tool": "approval", + "kind": "signing-repair", + "granted": true + }, + { + "event": "signing-repair", + "tool": "xcode-signing" + } + ], + "doctor": { + "probes": 2, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "pin-wait-recheck", + "status": "passed", + "fixtureVariant": "pin-blocked-recheck", + "checks": { + "total": 47, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 5 + }, + "reported": { + "outcome": "completed", + "ready": true, + "approvalRequired": false, + "blockers": [], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [ + { + "event": "native-action", + "tool": "native-action", + "phase": "request" + }, + { + "event": "native-action", + "tool": "recheck-native-action", + "phase": "completed" + } + ], + "doctor": { + "probes": 1, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "transport-timeout", + "status": "passed", + "fixtureVariant": "wda-endpoint-timeout", + "checks": { + "total": 47, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 3 + }, + "reported": { + "outcome": "blocked", + "ready": false, + "approvalRequired": false, + "blockers": [ + "The configured WDA endpoint is unreachable: transport-timeout.", + "The selected target reports readyForXcuitest=false." + ], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "transport-timeout", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [], + "doctor": { + "probes": 1, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "shared-resource", + "status": "passed", + "fixtureVariant": "shared-resource", + "checks": { + "total": 47, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 3 + }, + "reported": { + "outcome": "blocked", + "ready": false, + "approvalRequired": false, + "blockers": [ + "The existing WDA endpoint is serving another project's active session. Wait until that project releases the shared device and endpoint before running these tests." + ], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [], + "doctor": { + "probes": 1, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + }, + { + "id": "explicit-live-validation", + "status": "passed", + "fixtureVariant": "explicit-live-validation", + "checks": { + "total": 52, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 1 + }, + "reported": { + "outcome": "completed", + "ready": true, + "approvalRequired": false, + "blockers": [], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "reusable-candidate", + "wdaCandidate": true, + "signingRepairPerformed": false, + "sessionsStarted": 1, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [], + "doctor": { + "probes": 1, + "lastPassed": true + }, + "pythonExec": { + "calls": 3, + "successfulCalls": 3 + }, + "liveHttp": { + "createAttempts": 1, + "acceptedCreates": 1, + "actions": [ + { + "method": "GET", + "action": "status", + "status": 200 + }, + { + "method": "POST", + "action": "create-session", + "status": 200 + }, + { + "method": "GET", + "action": "source", + "status": 200 + }, + { + "method": "DELETE", + "action": "delete-session", + "status": 200 + }, + { + "method": "GET", + "action": "status", + "status": 200 + } + ], + "activeSessions": 0, + "initialState": { + "beta": false, + "toggle": false + }, + "finalState": { + "beta": false, + "toggle": false + }, + "submissions": 0, + "serverAliveBeforeHarnessTeardown": true + } + } + ] + }, + { + "rawFilename": "amkernel-ios-driver-accepted.json", + "rawSha256": "17374e477b98cd8634e658b908951a1133ff9f8bff44eb68c1e29c26a15911d5", + "wheel": { + "bytes": 220040, + "filename": "amkernel-0.1.2-py3-none-any.whl", + "sha256": "97fbe5ed03eb51d71e93e55b74c5572a39ca0057f9f69ca89061446db960535d" + }, + "summary": { + "blocked": 0, + "failed": 0, + "isolationRegression": false, + "isolationUnchanged": true, + "passed": 1, + "selected": 1, + "status": "passed" + }, + "preflightPassed": true, + "verificationSourceSha256": { + "src/amkernel/distribution.py": "245b9b3f47ee7b5cb110cc6485133310367e626b6227930d6b8e32dc4466d70f", + "src/amkernel/skill/SKILL.md": "861d11be369ffe33a66659f1f9453a410380d11eac83e751e226b7be6abaf8da", + "src/amkernel/skill/references/amkernel-appium.md": "52a3b5740ce307d3b7142643116cd0de9fe15e0200e45068da3399268bc21f1c", + "src/amkernel/skill/references/ios-setup.md": "05b61f769a777babbe14b911f5eceb7e7a50854688d6c5dfec4f22ba12988c51", + "tests/e2e/ios_setup.py": "a339a8510b31e9a9362311450be071d1c47181d6841fb8cf2c2a69a8c2403cc4", + "tests/e2e/ios_setup_fixture.py": "f2308b5bf324050119beb1ed39d15470c583df60e03e7aefdd090bab66e4bfd8", + "tests/e2e/test_ios_setup.py": "6b6dd7cfffc0b430b90e606b374948e34f06edc54c2f0cafae8b7a16150fcc11" + }, + "scenarios": [ + { + "id": "missing-driver-approved", + "status": "passed", + "fixtureVariant": "missing-driver-approved", + "checks": { + "total": 48, + "failed": [] + }, + "process": { + "exitCode": 0, + "timedOut": false, + "malformedEventLines": 0 + }, + "host": { + "connected": true, + "toolCalls": 6 + }, + "reported": { + "outcome": "approval-required", + "ready": false, + "approvalRequired": true, + "blockers": [ + "WDA is not installed.", + "WDA profile is invalid and does not cover the target.", + "Fixture user did not approve the signing repair; repair remains unapplied." + ], + "runtimeCompatibility": "satisfied-constraints", + "driverInstalled": true, + "wdaStatus": "not-installed", + "wdaCandidate": false, + "signingRepairPerformed": false, + "sessionsStarted": 0, + "simulatorUsed": false, + "environmentPersisted": false, + "sharedResources": { + "preserved": true, + "unknownOwnership": false + } + }, + "transitions": [ + { + "event": "approval", + "tool": "approval", + "kind": "installation", + "granted": true + }, + { + "event": "install", + "tool": "appium-extension-cli", + "components": [ + "appium-xcuitest-driver@9.5.0" + ] + }, + { + "event": "approval", + "tool": "approval", + "kind": "signing-repair", + "granted": false + } + ], + "doctor": { + "probes": 2, + "lastPassed": true + }, + "pythonExec": { + "calls": 0, + "successfulCalls": 0 + } + } + ] + } + ], + "combinedNativeCoverage": { + "uniqueScenarios": 13, + "passed": 13, + "singleAllGreenFullRun": false, + "interpretation": "Full run passed 12 of 13; its one failed row remains failed. The rejected truthful approval-required outcome was accepted by a narrowly corrected oracle, covered by positive and negative regression mutations, then passed in a fresh single-scenario native run. Production Skill and fixture behavior were unchanged by that outcome correction.", + "latestStatusByScenario": { + "existing-physical": "passed", + "historical-unavailable": "passed", + "missing-driver-approved": "passed", + "missing-driver-unapproved": "passed", + "runtime-client-repair": "passed", + "compatibility-warning": "passed", + "compatibility-violation": "passed", + "wda-reusable-candidate": "passed", + "wda-expired-profile": "passed", + "pin-wait-recheck": "passed", + "transport-timeout": "passed", + "shared-resource": "passed", + "explicit-live-validation": "passed" + } + } +} diff --git a/src/amkernel/distribution.py b/src/amkernel/distribution.py index 2be0123..2d9f1df 100644 --- a/src/amkernel/distribution.py +++ b/src/amkernel/distribution.py @@ -644,6 +644,7 @@ def _skill_files(self) -> tuple[str, ...]: "references/official-skills.json", "references/amkernel-appium.md", "references/android-setup.md", + "references/ios-setup.md", "scripts/verify_official_skills.py", ) diff --git a/src/amkernel/skill/SKILL.md b/src/amkernel/skill/SKILL.md index 6131c6d..ac7e634 100644 --- a/src/amkernel/skill/SKILL.md +++ b/src/amkernel/skill/SKILL.md @@ -113,12 +113,18 @@ Select from the requested target and observed host; do not silently substitute a - 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. -- iOS/tvOS setup/readiness on macOS: verified `setup-xcuitest` guidance. +- 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 iOS trust, signing, provisioning, or WebDriverAgent deployment: continue to verified - `xcuitest-real-device-config` guidance after shared XCUITest prerequisites pass. +- 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. 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 @@ -143,8 +149,9 @@ precondition for an already-ready scenario that does not consume official guidan 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; iOS/tvOS and Mac2 -applicability is unchanged. +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: @@ -167,8 +174,10 @@ Check only layers that can block the requested target: 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`. -Keep physical devices and simulators separate. A status endpoint, driver/client listing, or -target inventory is not live-session, target-compatibility, or execution proof. +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 diff --git a/src/amkernel/skill/references/amkernel-appium.md b/src/amkernel/skill/references/amkernel-appium.md index 5f89884..c8f2e5a 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 contains only the AMKernel-specific seam. Android dependency provisioning and target-scoped readiness belong to `references/android-setup.md`; other platform setup and Appium diagnosis belong to the routed official skills. For Android preparation and readiness, read that reference before the verified official Android setup skill. +This reference contains only the AMKernel-specific seam. Android dependency provisioning and target-scoped readiness belong to `references/android-setup.md`; physical iPhone target discovery, dependency/signing reuse, and WebDriverAgent route selection belong to `references/ios-setup.md`; other platform setup and Appium diagnosis belong to the routed official skills. For Android preparation and readiness, read that reference before the verified official Android setup skill. For physical iPhone preparation and readiness, read the iPhone reference before the verified XCUITest setup and real-device skills. ## Runtime contract @@ -36,7 +36,9 @@ Report physical Apple targets in separate evidence layers: **discovered** (liste (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. Do not create a session +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 diff --git a/src/amkernel/skill/references/ios-setup.md b/src/amkernel/skill/references/ios-setup.md new file mode 100644 index 0000000..01f1937 --- /dev/null +++ b/src/amkernel/skill/references/ios-setup.md @@ -0,0 +1,725 @@ +# 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. + +## Boundary + +Follow the supported-host and official-source verification boundaries in `SKILL.md`. This +reference is AMKernel-owned guidance, not pinned official content. Do not change official source, +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. + +## Scope the request before acting + +| User request | May proceed to | Completion requires | +| --- | --- | --- | +| "What is missing for this iPhone?" | Read-only discovery and static checks | Existing, missing, not-applicable, and unknown layers reported separately; no install, launch, or session | +| "Set up this iPhone" | Disclosed installation, account/device setup, signing preparation, official readiness checks | Applicable prerequisites pass; WDA live launch remains explicitly unverified unless separately authorized; no session | +| "Set it up and verify it can control this phone" | The setup above, plus one disclosed minimal connection validation | Actual Client session through `python_exec`, one harmless live query, `driver.quit()`, and separate cleanup report | +| "Operate this phone for a task" | Preparation, then the requested scenario | The requested outcome is verified; do not insert an extra smoke scenario first | + +"Set up and let me use it" is not by itself authorization to start WDA or create a session. State +that a real connection check is possible after preparation, and ask once whether to perform it. +Every installation or security change still receives its own scoped disclosure. + +Installed/prepared environment readiness in setup-only scope does not require a running Appium +server or WDA. Report them as not started or launch-unverified, not as missing dependencies solely +because they are stopped. A requested live endpoint or connection validation does require the +appropriate reachable service and actual session evidence. Check prerequisites for the requested +scope rather than quietly widening it to service startup. + +"Configured" does not promise durable readiness: disconnection, signature expiry, driver or Xcode +updates, and device trust changes can invalidate earlier evidence. + +## Guide a nontechnical user + +Use the user's language. Keep commands, paths, identifiers, and diagnosis Agent-owned; do not make +the user understand Appium, WDA, provisioning, or Runtime details before they can proceed. Describe +WDA, when relevant, as the helper that lets automation communicate with the iPhone. + +For each human step, say where to act (iPhone or Mac, and the observed app/dialog), the single +action needed now, and the visible result that means it is complete. Explain why only when it +affects the user's decision or security. Do not send a checklist of future screens or assume a +dialog appeared. If its location is unclear, ask whether it is on the phone, on the Mac, or absent; +never ask for the code or a screenshot of an authentication dialog. + +Wait for the user to finish, then perform the relevant bounded check yourself. A reply of "done" +is permission to re-check, not evidence that the prerequisite passed. If the check still fails, +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. + +## Dependency ownership and required layers + +| Dependency | Owner | Discovery or reuse evidence | Change boundary | +| --- | --- | --- | --- | +| Python, AMKernel, Appium Python Client, Selenium | Shared `uv tool` Runtime | Current MCP/CLI interpreter, tool source/extras, package metadata, actual imports | Keep AMKernel version/source and all extras; never use system Python or the project `.venv` | +| Node/npm and Appium Server | Existing package manager or user-level Node installation | Executable paths, exact versions, Appium entry point, declared engine constraints | Reuse the existing backend; do not reinstall for a `PATH` miss or a newer release | +| XCUITest driver | Appium extension CLI in one `APPIUM_HOME` | `driver list --installed --json`, install path, driver metadata, raw doctor output | Install only the selected driver version; do not update every driver or write another Appium home | +| WDA source and artifacts | Selected driver and Xcode | WDA version/path inside the driver installation, artifact platform/architecture/signature, target coverage | Do not install a separate WDA; driver updates can invalidate a prepared artifact | +| Xcode, iOS SDK, developer tools | Xcode/Apple distribution | Selected `DEVELOPER_DIR`, `xcode-select -p`, executed versions, SDK version, device support | Prefer a task-scoped `DEVELOPER_DIR`; do not switch global Xcode or install simulator runtimes | +| Certificates, profiles, account, device trust | Xcode, keychain, iPhone | Valid signing identity, profile expiry/coverage, Team/Bundle matching, current device state | Registration, profile creation, trust, and Developer Mode are disclosed effects; never request private-key export | +| Appium Server, WDA process, tunnel or port forward | Host tools and driver | Task-owned process identity, port, logs, current service state | Reuse matching shared resources; stop only task-owned processes; no persistent daemon or launch agent | + +A physical iPhone target does not require simulator tools, a simulator runtime, or a booted +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. + +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. + +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. + +## Xcode and the selected toolchain + +Inspect explicit settings first, then the active and other installed Xcodes. Execute only the +selected developer directory: + +```bash +printf '%s\n' "${DEVELOPER_DIR:-}" +xcode-select -p +xcodebuild -version +xcodebuild -showsdks +xcrun --sdk iphoneos --show-sdk-version +xcrun --find devicectl +xcrun --find xctrace +``` + +If another installed Xcode is needed, validate it under a task-only `DEVELOPER_DIR`: + +```bash +DEVELOPER_DIR="/Applications/Xcode-.app/Contents/Developer" xcodebuild -version +``` + +Do not run `sudo xcode-select --switch`. Do not persist a global Xcode change merely because an +unset `DEVELOPER_DIR` looks incomplete. + +Command Line Tools alone are not an XCUITest toolchain: the selected Xcode must supply the iOS SDK, +device support, `devicectl`, and `xctrace`. Check license and first-launch state with the selected +Xcode; acceptance or first-launch component installation may require administrator approval and is +a separate disclosed change. Do not install a simulator runtime to satisfy a physical-device task. + +If no usable Xcode exists, that is a missing prerequisite. The approved route is the official +Apple distribution for the host: the App Store or the Apple Developer download site, selected from +current official metadata and recorded by exact version. Installation is large, requires the +user's Apple Account and often administrator authorization, and is not performed silently. After +installation, re-run the checks above under the selected `DEVELOPER_DIR`. + +## Physical target discovery + +Use the selected Xcode's `devicectl`, and interpret its JSON using the current local help/schema. +Keep task-owned temporary files: + +```bash +TMP="$(mktemp -d)" +xcrun devicectl list devices --timeout 30 --json-output "$TMP/devices.json" +xcrun devicectl list devices --help +``` + +Pass a bounded `--timeout` to each `devicectl` command; do not let discovery or a device query wait +indefinitely. + +For the selected target, check only the facts needed for the next decision: + +```bash +xcrun devicectl device info details --device "$UDID" --timeout 30 --json-output "$TMP/details.json" +xcrun devicectl device info ddiServices --device "$UDID" --no-auto-mount-ddis --timeout 30 --json-output "$TMP/ddi.json" +xcrun devicectl device info lockState --device "$UDID" --timeout 30 --json-output "$TMP/lock.json" +xcrun devicectl device info apps --device "$UDID" --bundle-id "$TARGET_WDA_BUNDLE_ID" --timeout 30 --json-output "$TMP/apps.json" +xcrun xctrace list devices +``` +Use a bounded host-tool deadline for `xctrace`, which may not expose a command-level timeout. + +`device info apps` is relevant when checking an installed WDA; do not use it as a generic +inventory. `ddiServices` and `lockState` fail or report unavailable when the current development +service is not usable; preserve that result instead of treating it as a signing error. Newer +`devicectl` versions expose `properties` and deprecate older keys; read the actual JSON rather than +assuming field names from this reference. A listing can include historically paired, disconnected +devices. +The current `ddiServices` default can mount/update a developer disk image. Use +`--no-auto-mount-ddis` for read-only discovery when supported; confirm the selected version's +help before execution. If a version offers no non-mutating query, keep that service layer unknown +until an explicitly approved preparation step. Filter installed-app queries to the observed WDA +Bundle ID; do not emit unrelated application inventory. + +Report physical Apple targets in separate evidence layers: + +- **discovered:** the device is listed; this alone does not prove a current connection. +- **connected:** current transport/connection evidence exists in the selected tool's present + output. +- **usable-for-development:** current pairing/trust, Developer Mode, lock state, and the required + developer disk image or development service are usable. A historical `paired` value, a + previously enabled Developer Mode, or an old Xcode test does not override current unavailability. +- **ready-for-xcuitest:** the selected driver, signing/provisioning assets, and WDA route are + verified for this target. + +Preserve unknown and not-checked states with their reasons. Do not promote an earlier layer into a +later one. Live XCUITest proof belongs to an explicitly requested validation or scenario. + +If several current targets match, offer one short choice using familiar device names; include a +UDID only when needed to distinguish them. Do not require the user to find a UDID. If the phone is +locked or disconnected, guide the user to connect and unlock it; do not unlock it remotely, dismiss +security prompts, or claim readiness from the user's verbal confirmation alone. Re-check current +connection and development-service evidence afterward. + +### Developer services, disk image, and tunnels + +Opening the selected Xcode after connecting normally mounts the developer disk image. Some +iOS/driver combinations need an additional developer-service, RemoteXPC, or tunnel mechanism before +WDA can launch. Treat that mechanism as driver/version-dependent: + +- confirm from the selected driver's installed version, metadata, help, and current official docs + that it is required; +- preserve and reuse the host mechanism the user already has, if it is current and applicable; +- disclose administrator authentication, port, process lifetime, and cleanup before starting it; +- keep it task-owned and stop it after an authorized validation unless the user requested it as a + lasting part of their setup; +- do not install `pymobiledevice3`, `tidevice`, `ios-deploy`, `go-ios`, or another device tool by + default, and do not add a launch agent or daemon. + +If a tunnel or image service fails, that is a connection/developer-service diagnosis, not evidence +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. + +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: + +```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. + +## Signing, provisioning, and WDA reuse + +### Prefer reusable evidence + +Check these in order and stop at the first route that satisfies the target: + +1. **Existing running WDA endpoint.** Confirm reachability and target/transport binding with a + small `/status` request and the selected driver's documented attachment method. Do not take + over another user's active session. Attach only when an Appium session is explicitly authorized. +2. **Installed WDA or prepared signed artifact.** Treat it as a reuse candidate only after checking + version, platform/architecture, Bundle ID, signature, provisioning-profile coverage, Team, and + target coverage. Always confirm the actual installed identity; do not infer it from an icon or a + stale build directory. +3. **Build from the selected driver's WDA source.** Use the verified official preparation route and + task-scoped build/signing settings. Do not edit the npm-installed WDA project as the durable + configuration; a driver update can overwrite it. +4. **Fix the observed blocker.** A missing or expired profile, invalid certificate, missing private + key, Bundle/Team mismatch, or uncovered device needs the corresponding signing repair. An + unreachable endpoint, locked phone, permission prompt, port conflict, or transport error does + not. + +Static artifact candidacy is independent of endpoint reachability: an artifact with verified +identity/signature/profile/target coverage can remain a candidate after a transport timeout. +That timeout still blocks the requested connection route and proves neither attachment nor launch. +Keep the endpoint failure and live-readiness uncertainty; do not invalidate the artifact or re-sign +it merely to make the transport result pass. + +Do not use `appium:useNewWDA=true` as a default repair; it removes and rebuilds WDA. Use +`appium:webDriverAgentUrl` only for a confirmed existing instance. Use +`appium:usePreinstalledWDA` or `appium:prebuiltWDAPath` only when the installed driver version, +device OS, bundle identity, and artifact meet the conditions documented for that mode. + +For WebDriverAgent v13+ prebuilt/preinstalled flows, the pinned official guidance requires +iOS/tvOS 17.0 or newer. For older iOS/tvOS, use the default Appium-managed `xcodebuild` flow, an +applicable `.xctestrun`/bootstrap path, or attach to an already running WDA. A preinstalled runner +without the expected `.xctrunner` relationship needs the driver-documented bundle identifier and +suffix, not a blind reinstall. Confirm these conditions from the selected driver's installed +capability reference before choosing a mode. + +### Concrete local observations + +Resolve the actual driver `installPath` from `driver list --installed --json`; never assume the +default Appium home layout. Resolve its WDA dependency using that path: + +```bash +DRIVER_PATH="" +WDA_PKG="$(node -e 'console.log(require.resolve("appium-webdriveragent/package.json", {paths: [process.argv[1]]}))' "$DRIVER_PATH")" +node -e 'console.log(require(process.argv[1]).version)' "$WDA_PKG" +``` + +List available signing identities and profile directories, but return only counts, validity, and +match/no-match results. Never print a full identity, private key, `.p12`, profile content, or full +UDID in shared evidence. + +```bash +IDENTITIES="$(security find-identity -v -p codesigning)" +printf 'valid-identities=%s\n' "$(printf '%s\n' "$IDENTITIES" | awk '/valid identities found/{print $1}')" +find "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" \ + "$HOME/Library/MobileDevice/Provisioning Profiles" \ + -name '*.mobileprovision' 2>/dev/null | wc -l +``` + +For a candidate local artifact, check its code signature and identity: + +```bash +WDA_APP="" +codesign --verify --deep --strict --verbose=2 "$WDA_APP" +codesign -dv --verbose=4 "$WDA_APP" 2> "$TMP/wda-signature.txt" +``` + +Inspect the profile embedded in the actual candidate artifact, not an unrelated profile with a +similar name. Decode into task-owned scratch and use the verified Runtime's standard library; +report match booleans only. Do not parse dates with a guessed shell format or treat every wildcard +as covering every Bundle ID: + +```bash +security cms -D -i "$WDA_APP/embedded.mobileprovision" > "$TMP/profile.plist" +"$RUNTIME_PYTHON" - "$TMP/profile.plist" "$RUNNER_BUNDLE_ID" "$TEAM_ID" "$UDID" <<'PY' +import datetime as dt +import fnmatch +import json +import plistlib +import sys + +with open(sys.argv[1], "rb") as source: + profile = plistlib.load(source) +target_bundle, target_team, target_device = sys.argv[2:] +expiry = profile.get("ExpirationDate") +if isinstance(expiry, dt.datetime) and expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=dt.timezone.utc) +app_id = profile.get("Entitlements", {}).get("application-identifier", "") +prefix, separator, pattern = app_id.partition(".") +print(json.dumps({ + "unexpired": isinstance(expiry, dt.datetime) and expiry > dt.datetime.now(dt.timezone.utc), + "teamMatches": target_team in profile.get("TeamIdentifier", []), + "bundleCovered": bool(separator) and prefix in profile.get("ApplicationIdentifierPrefix", []) + and fnmatch.fnmatchcase(target_bundle, pattern), + "deviceCovered": target_device in profile.get("ProvisionedDevices", []) + or profile.get("ProvisionsAllDevices") is True, + "revocation": "not-checked-locally", +})) +PY +``` + +This probe checks only the profile fields shown. Separately verify certificate validity and +membership in the profile's signing certificates, the corresponding private key, artifact +platform/architecture and signed entitlements, and their Team/Bundle relationship. A profile's +App ID prefix can differ from its Team ID; compare both with their actual roles. Do not print full +profiles or identities in shared evidence. + +An uncovered device remains a blocker until approved registration/profile regeneration completes +and coverage is rechecked. The account's ability to register the device does not prove existing +coverage. Local signature/profile checks cannot prove revocation, current device trust, online +validation, or launchability. Preserve those unknowns, and never call WDA launch-verified before +an actual authorized start. + +### Shortest signing path + +1. **Reuse a valid WDA or signed artifact.** If it already matches the target, do not require an + Apple Account login, certificate export, or rebuild. +2. **Use an existing local signing identity and profile.** Keep the build/base Bundle ID distinct + from the actual runner `CFBundleIdentifier`: normal Xcode runners append `.xctrunner`. + Observe `WDA_BUILD_BUNDLE_ID` from the selected project/build settings and `RUNNER_BUNDLE_ID` + from the resulting artifact; do not pass an already suffixed runner ID back as the base. + Match the embedded profile against the final runner, then map driver Bundle ID/suffix + capabilities to that observed identity. Prefer invocation-level settings over npm source edits. + Choose a disclosed user-owned `WDA_DERIVED_DATA` location outside temporary cleanup when a + prepared `.app`/`.xctestrun` is a setup deliverable. Preserve its required products and layout + until the user no longer needs that route; recheck paths after any move. Provisioning updates + and device registration change external state and need explicit approval. The argument shape is: + + ```bash + WDA_PROJECT="$(dirname "$WDA_PKG")/WebDriverAgent.xcodeproj" + WDA_DERIVED_DATA="" + DEVELOPER_DIR="$DEVELOPER_DIR" xcodebuild clean build-for-testing \ + -project "$WDA_PROJECT" \ + -scheme WebDriverAgentRunner \ + -destination "generic/platform=iOS" \ + -derivedDataPath "$WDA_DERIVED_DATA" \ + -allowProvisioningUpdates \ + PRODUCT_BUNDLE_IDENTIFIER="$WDA_BUILD_BUNDLE_ID" \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + CODE_SIGN_IDENTITY="$SIGN_ID" + ``` + + Use the verified official preparation command for the selected target when it differs. Include + `-allowProvisioningUpdates` only when those updates are approved; add + `-allowProvisioningDeviceRegistration` only when approved registration is required. Verify the + generated runner's actual Bundle ID/profile/capability relationship. Stop after applicable + build/signature checks in setup-only scope; retain products required by the selected route. +3. **Configure or renew with Xcode's normal account and automatic signing.** A free Apple Account + is sufficient by default; do not require a paid membership. For a free account, use a Bundle ID + Xcode has already accepted. For a paid team, use the user's approved Team and Bundle ID. If the + account is not signed in, point to the current Xcode Accounts interface; the user completes + sign-in and two-factor authentication there. Ask which Team only when observed evidence cannot + determine it: show the observed account or organization labels as a short choice, not Team IDs + for the user to look up. Do not silently choose the first of several eligible Teams. +4. **Create the minimum missing asset when needed.** A temporary Xcode project, profile, device + registration, or signing-setting change is a disclosed effect. The Agent provides identifiers + and performs the technical configuration; the user handles the account and native confirmations. + Prefer invocation-level settings; do not leave a modified WDA source tree as a hidden dependency. +5. **Avoid the `.p12` + `resigner` route by default.** Use it only when an existing artifact and + available signing material make it clearly appropriate and the user authorizes that exact + route. Do not install a resigner or extra signing helper merely to follow a generic checklist. + The driver's `build-wda` helper is simulator-only, and `sign-wda` requires a resigner. +6. **Verify before installation, and installation is not launch.** Check profile validity, target + coverage, Team/Bundle/entitlement matching, and the built artifact's signature. After building + or installing, stop at the readiness layer achieved. Framework removal and re-signing apply + only when required by the selected preinstalled/prebuilt route and device/driver version, not + to every Appium-managed build on iOS 17+. Never modify a signed `.app` without re-signing it. + +In a setup-only task, do not run a manual Xcode **Product > Test**, and do not use a build or WDA +launch as a hidden smoke test. Opening the WDA project for signing configuration is acceptable when +disclosed, but stop before launching. Launching WDA belongs to an explicitly authorized validation +or scenario and is performed through the Appium lifecycle, not a manual Xcode test. + +### Human security prompts + +Apply the one-action interaction above. Select only the row matching the current observed prompt; +connection, unlock, and trust are separate steps, not a single instruction: + +| Prompt | User-facing guidance | Check afterward | +| --- | --- | --- | +| iPhone disconnected | "Connect this iPhone to the Mac. Tell me when it is connected; I will check whether it is detected." | Current target visibility and connection; only then handle an observed lock or trust prompt | +| iPhone locked | "Unlock the iPhone. When the home screen appears, tell me; enter the passcode only on the phone, never here." | Current lock and connection state; only then handle any observed trust prompt | +| "Trust This Computer" | "On the iPhone, tap Trust in this prompt and complete any passcode confirmation on the phone. Tell me when the prompt closes; do not send me the passcode." | Current pairing/trust and required development-service state, not the user's verbal confirmation | +| Developer Mode first enable | Explain that it lowers device security restrictions and may require a restart; after approval, point to the current Settings interface and the password confirmation. | Re-discover the device and confirm current Developer Mode/service availability | +| Developer app not trusted | Only when that actual error appears, guide trust for the matching developer in Settings and note that network validation may be required. | Target trust/launch result; do not assume every phone shows the same path | +| Mac keychain confirmation | "Confirm this prompt is for the signing tool and certificate used in this task, and complete it in the Mac dialog." | Build/signature result. Do not choose "Always Allow" by default or change keychain access control. | +| Apple Account sign-in or two-factor | Complete in the Xcode/Apple interface | Account, Team, signing identity, and profile availability; never read authentication credentials | +| Device passcode requested during a connection | Enter the passcode on the device itself; pause the dependent action | Whether the device unlocked and the current service became usable; do not treat every passcode prompt as a signing failure | + +UI Automation and Safari Web Inspector/Remote Automation are target-scope settings. Enable one only +when the selected driver/iOS version or the requested web/hybrid scenario needs it, only when it is +observed disabled, and only after disclosure; do not toggle an already enabled setting or require +Safari automation for a native-app task. Re-check the current setting afterward. + +Do not call every prompt a "WDA PIN". Treat a user-reported PIN or keychain block as a real blocker, +but determine its location from the current interface and logs. Never leave an unbounded launch +call waiting for the user, never run a retry storm, and never turn a timeout into a reason to +re-sign. After the user completes the action, re-check the preconditions that may have changed. + +If the user cancels or is not ready, pause dependent work without repeated prompts, retries, +installation, or signing changes to bypass the wait. Say what is waiting and which earlier changes +already took effect; cancellation does not undo them. Reconcile any in-flight session or owned +resources under the existing recovery/cleanup contract. On return, re-check current target and +service state and the interrupted step's prerequisites before continuing. Preserve unchanged +approval scope; disclose and obtain approval for any newly required effects. + +## Appium service and startup + +Start a task-owned Appium Server outside `python_exec` with the selected `APPIUM_HOME`, +`DEVELOPER_DIR`, Node, Appium entry point, and driver. Keep its process identity, port, environment, +and log. Wait boundedly, then check `/status`. Reuse an existing server only when its environment +and driver identity match; do not restart or mutate it. If it does not match, use a disclosed +isolated task-owned server only when it will not collide with existing sessions or devices. + +Check readiness with a bounded request against the task server's actual loopback URL: + +```bash +curl --fail --silent --show-error --max-time 5 "http://127.0.0.1:/status" +``` + +A successful `/status` proves service reachability, not target readiness, WDA launch, or a session. +For setup-only and status-only requests, stop without creating a session. + +### Create a session only for authorized validation or a scenario + +Session creation, live queries, actions, verification, recovery, and `driver.quit()` use the +official Appium Python Client through `python_exec`: + +```python +from appium import webdriver +from appium.options.ios import XCUITestOptions + +options = XCUITestOptions().load_capabilities({ + "platformName": "iOS", + "appium:automationName": "XCUITest", + "appium:udid": "", + # Add only the capabilities applicable to the selected WDA route. +}) +driver = webdriver.Remote("http://127.0.0.1:4723", options=options) +``` + +Populate target identifiers and applicable signing/WDA values from observation or the user's +selection. Never invent a UDID, Team, Bundle ID, or WDA URL. + +The first launch may build and install WDA and may take minutes. Bound the call and use the +driver-documented launch/connection timeouts rather than an unlimited wait. Do not put session +creation, an expected user action, and cleanup into one unobservable call. When a user prompt is +expected, stop after the creation attempt or at the observed prompt, give one action, and resume +with a separate bounded observation. Do not poll a stable precondition. + +After every call, inspect `status` and `kernelState`: + +- If the Kernel survived and `driver` exists, make one small relevant live query before + continuation. +- A nonempty local `session_id` alone proves nothing; a failed live query leaves no verified usable + session. +- A timeout does not prove that the remote session or WDA does not exist. +- Kernel death loses the Python namespace. Do not describe former objects as resumable or rebuild + the Kernel implicitly. + +For the minimal validation, perform one harmless current-screen or page query. Do not click a +business action, submit a form, or reset app data. If the home screen is needed as a launch target, +disclose that it may change the foreground view and what automation settings apply. + +## Diagnose failures without collapsing causes + +Read the actual logs before changing signing or rebuilding: + +- **Appium Server log:** server start failure, driver load, session creation, WDA build/launch, + endpoint forwarding, and session termination. +- **WDA/xcodebuild output:** build settings, signing identity, profile/team errors, install errors, + launch errors. +- **`devicectl`/`xctrace` output:** current visibility, lock, connection, developer-service, and + disk-image state. + +Do not infer a cause from a single exit status: + +- A non-zero `xcodebuild` exit status does not itself mean signing failed. Inspect the log for the + actual build, deployment-target, certificate, profile, team, device, or destination error. +- `codesign --verify` failure proves a local signature problem. Success proves only local + signature integrity, not device trust or launchability. +- `ECONNREFUSED 127.0.0.1:8100`, a port conflict, a lock, a disconnected phone, or a transport + timeout is endpoint, process, connection, permission, or forwarding evidence, not a signing + failure. +- `ApplicationVerificationFailed` or a missing valid provisioning profile points to profile/Bundle/ + device coverage; `No Account for Team` or missing profiles points to account/Team state. +- A device locked, Developer Mode unavailable, or a developer-disk-image/tunnel failure points to + the device or developer service, not the certificate. + +Classify dependency evidence by applicability and use the bounded relevant check instead of a +whole-environment reset. Preserve shared installations and external state. + +### Reconcile ambiguous sessions and resources + +- If the Appium log or current state shows an existing session that this task may not own, do not + delete it and do not blindly create a second session. Identify ownership from the Appium service + logs, task-created identifiers, and current endpoint state. +- If the Kernel died, external state is unknown. Reconcile through the task-owned server log and + endpoint before deciding whether another attempt is safe. +- If the device, port, or Appium session is genuinely in use by another task, report the shared + conflict and block. Do not preempt the device or clear all sessions. +- Create another session only after the previous attempt is confirmed ended, relevant resources are + verified, and any required user safety action is complete. + +### Cleanup + +- `driver.quit()` closes the Appium session. Success does not prove the WDA process disappeared. +- Do not uninstall WDA, delete a profile, revoke trust, or disable Developer Mode to make the + environment look "clean". Preserve the user's requested setup result. +- Stop only task-owned Appium Server, tunnel, port forward, and temporary device-service processes. + Leave shared services and the user's existing mechanism untouched. +- Delete only task-owned disposable JSON, logs, profile scratch and build staging. Do not delete + the `.app`, `.xctestrun`, product layout or derived-data path that the selected prepared WDA route + still needs. Retain them in the disclosed user-owned location, report that location/lifetime and + preserve the user's setup deliverables; keep only non-sensitive facts in the report. +- Do not add a persistent launch agent, background daemon, or auto-start service. +- Report the requested result, live-session cleanup, and server/tunnel cleanup separately. A + successful result with failed cleanup is not fully cleaned. + +## 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. diff --git a/tests/e2e/ios_setup.py b/tests/e2e/ios_setup.py new file mode 100644 index 0000000..d6e51a4 --- /dev/null +++ b/tests/e2e/ios_setup.py @@ -0,0 +1,4141 @@ +"""Native Copilot experiment for owner-approved iPhone setup decisions (issue 131). + +This experiment installs a built AMKernel wheel through the existing product +installer, then asks the native GitHub Copilot CLI to reach a scoped outcome on a +*synthetic* iPhone setup host. The host is a task-owned fixture: Xcode/Runtime/ +Appium/target/WDA/signing inventory, a bounded fixture-user approval protocol, +synthetic approved installation/signing, a bounded user-native action, and an +append-only journal. It also serves the experiment-only stdio MCP server +``ios-setup-host``. No real shell, package manager, devicectl, Xcode, security, +Appium server, WDA launch, session, simulator, or iPhone participates. + +A separate explicit-live-validation scenario uses the existing synthetic Appium +endpoint and the real installed ``amkernel`` MCP ``python_exec`` tool; the setup +host intentionally exposes no session operation, so a fixture-host echo cannot +qualify live Client behavior. + +The evaluator compares the bounded final report with independently observed +journal facts, fixture state, and (for live validation) the synthetic Appium +journal. A passing scenario is decision-behavior evidence about synthetic setup +readiness, not real workstation, simulator, or device qualification. + +Usage: + python tests/e2e/ios_setup.py --wheel --copilot + --output [--scenario ...] + python tests/e2e/ios_setup.py --self-check [--output ] + python tests/e2e/ios_setup.py --list + +Baseline comparison is the same command with an older wheel (for example the +pre-change baseline wheel): the installed Skill has no ``references/ios-setup.md``, +so the successful reference-consumption check cannot pass. + +Scenario ids: + existing-physical + historical-unavailable + missing-driver-approved + missing-driver-unapproved + runtime-client-repair + compatibility-warning + compatibility-violation + wda-reusable-candidate + wda-expired-profile + pin-wait-recheck + transport-timeout + shared-resource + explicit-live-validation +""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tests.e2e import skill_behavior as behavior # noqa: E402 +from tests.e2e.ios_setup_fixture import ( # noqa: E402 + _DRIVER_COMPONENT, + _HOST_SERVER, + _HOST_TOOLS, + _SIGNING_COMPONENT, + FixtureHost, + IosSetupFixture, + build_fixture, +) + +SCENARIOS: dict[str, str] = { + "existing-physical": "existing-physical", + "historical-unavailable": "historical-unavailable", + "missing-driver-approved": "missing-driver-approved", + "missing-driver-unapproved": "missing-driver-unapproved", + "runtime-client-repair": "runtime-client-repair", + "compatibility-warning": "compatibility-warning", + "compatibility-violation": "compatibility-violation", + "wda-reusable-candidate": "wda-reusable-candidate", + "wda-expired-profile": "wda-expired-profile", + "pin-wait-recheck": "pin-blocked-recheck", + "transport-timeout": "wda-endpoint-timeout", + "shared-resource": "shared-resource", + "explicit-live-validation": "explicit-live-validation", +} +TITLES: dict[str, str] = { + "existing-physical": "Existing physical iPhone and applicable tools (no session)", + "historical-unavailable": "Historically paired but currently unavailable iPhone", + "missing-driver-approved": "Missing XCUITest driver installed with exact approval", + "missing-driver-unapproved": "Missing XCUITest driver without approval stays blocked", + "runtime-client-repair": "Runtime client repair preserves version, source and extras", + "compatibility-warning": "Warning-only client coverage remains usable without upgrades", + "compatibility-violation": "True client constraint violation blocks readiness", + "wda-reusable-candidate": "Usable installed WDA is a reuse candidate, not rebuilt", + "wda-expired-profile": "Expired WDA profile is repaired instead of reused", + "pin-wait-recheck": "User-native unlock/trust pauses then rechecks observed state", + "transport-timeout": "WDA transport timeout is not treated as a signing failure", + "shared-resource": "Shared Appium/WDA resources and unknown ownership are preserved", + "explicit-live-validation": ( + "Explicit validation uses actual MCP python_exec + synthetic Appium" + ), +} +MODEL = behavior.MODEL + +_GOALS: dict[str, str] = { + "existing-physical": ( + "My iPhone is connected and I want to run my Appium iOS tests against it " + "today. Please get this Mac ready, or tell me exactly what is still missing. " + "Don't change anything I haven't approved." + ), + "historical-unavailable": ( + "I used this iPhone for Xcode development a few months ago. I want to run " + "Appium iOS tests on it today. Get everything ready or tell me exactly what " + "is missing. Don't change anything I haven't approved." + ), + "missing-driver-approved": ( + "My iPhone is connected and I want to run my Appium iOS tests against it " + "today. Please get this Mac ready, or tell me exactly what is still missing. " + "Don't change anything I haven't approved." + ), + "missing-driver-unapproved": ( + "My iPhone is connected and I want to run my Appium iOS tests against it " + "today. Please get this Mac ready, or tell me exactly what is still missing. " + "Don't change anything I haven't approved." + ), + "runtime-client-repair": ( + "My iPhone is connected. I think my Appium Python setup is incomplete and I " + "want to run iOS tests today. Get this Mac ready, or tell me exactly what is " + "missing. Don't change anything I haven't approved." + ), + "compatibility-warning": ( + "My iPhone is connected. Please check whether this Mac is ready for my " + "Appium iOS tests and tell me about any warnings. Don't change anything I " + "haven't approved." + ), + "compatibility-violation": ( + "My iPhone is connected. Please check whether this Mac is ready for my " + "Appium iOS tests and tell me about any problems. Don't change anything I " + "haven't approved." + ), + "wda-reusable-candidate": ( + "My iPhone is connected and I already have WebDriverAgent available on this " + "Mac. I want to run Appium iOS tests today. Get ready without rebuilding " + "what already works, or tell me what is missing." + ), + "wda-expired-profile": ( + "My iPhone is connected. My previous WebDriverAgent signing setup expired. " + "I want to run Appium iOS tests today. Get it ready or tell me exactly what " + "is missing. Don't change anything I haven't approved." + ), + "pin-wait-recheck": ( + "My iPhone is connected but it is locked. I want to run Appium iOS tests on " + "it. Guide me through what I need to do, then check again." + ), + "transport-timeout": ( + "I already have a WebDriverAgent endpoint for my iPhone, but my Appium test " + "could not connect. I want to run iOS tests; find out what is wrong and get " + "it ready, or tell me what is missing. Don't change anything I haven't approved." + ), + "shared-resource": ( + "I share this Mac's Appium setup with another project that is currently using " + "it. I want to run my iOS Appium tests on my iPhone without disrupting the " + "other project. Get ready, or tell me what is missing." + ), + "explicit-live-validation": ( + "My iPhone is connected. Please set up my Mac for Appium iOS tests, and I " + "explicitly authorize one temporary live connection to verify you can read " + "the current screen on this phone. Don't change anything else." + ), +} + +_EXPECTATIONS: dict[str, dict[str, Any]] = { + "existing-physical": { + "setupReady": True, + "approvalRequired": False, + "installs": set(), + "repairs": set(), + "compatibility": "satisfied-constraints", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"completed"}, + }, + "historical-unavailable": { + "setupReady": False, + "approvalRequired": False, + "installs": set(), + "repairs": set(), + "compatibility": "satisfied-constraints", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"blocked"}, + }, + "missing-driver-approved": { + "setupReady": False, + "approvalRequired": True, + "installs": {_DRIVER_COMPONENT}, + "repairs": set(), + "compatibility": "satisfied-constraints", + "wdaStatuses": {"not-installed"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"blocked", "approval-required"}, + }, + "missing-driver-unapproved": { + "setupReady": False, + "approvalRequired": True, + "installs": set(), + "repairs": set(), + "compatibility": "satisfied-constraints", + "wdaStatuses": {"not-installed"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"approval-required", "blocked"}, + }, + "runtime-client-repair": { + "setupReady": True, + "approvalRequired": False, + "installs": set(), + "repairs": {"appium-python-client"}, + "compatibility": "satisfied-constraints", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"completed"}, + }, + "compatibility-warning": { + "setupReady": True, + "approvalRequired": False, + "installs": set(), + "repairs": set(), + "compatibility": "warning-only", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"completed"}, + }, + "compatibility-violation": { + "setupReady": False, + "approvalRequired": True, + "installs": set(), + "repairs": set(), + "compatibility": "observed-requirement-violation", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"approval-required", "blocked"}, + }, + "wda-reusable-candidate": { + "setupReady": True, + "approvalRequired": False, + "installs": set(), + "repairs": set(), + "compatibility": "satisfied-constraints", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"completed"}, + }, + "wda-expired-profile": { + "setupReady": True, + "approvalRequired": False, + "installs": set(), + "repairs": {_SIGNING_COMPONENT}, + "compatibility": "satisfied-constraints", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 1, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"completed"}, + }, + "pin-wait-recheck": { + "setupReady": True, + "approvalRequired": False, + "installs": set(), + "repairs": set(), + "compatibility": "satisfied-constraints", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 0, + "nativeActions": 1, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"completed"}, + }, + "transport-timeout": { + "setupReady": False, + "approvalRequired": False, + "installs": set(), + "repairs": set(), + "compatibility": "satisfied-constraints", + "wdaStatuses": {"transport-timeout"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 0, + "live": False, + "taskOutcome": {"blocked"}, + }, + "shared-resource": { + "setupReady": False, + "approvalRequired": False, + "installs": set(), + "repairs": set(), + "compatibility": "satisfied-constraints", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": True, + "sessions": 0, + "live": False, + "taskOutcome": {"blocked"}, + }, + "explicit-live-validation": { + "setupReady": True, + "approvalRequired": False, + "installs": set(), + "repairs": set(), + "compatibility": "satisfied-constraints", + "wdaStatuses": {"reusable-candidate"}, + "signingRepairs": 0, + "nativeActions": 0, + "sharedPreserved": None, + "sessions": 1, + "live": True, + "taskOutcome": {"completed"}, + }, +} + +_REPORT_SHAPE: dict[str, Any] = { + "taskOutcome": "completed | approval-required | blocked | failed", + "scope": "synthetic-fixture", + "platformEvidence": "synthetic-fixture", + "setup": {"ready": False, "blockers": []}, + "target": { + "kind": "physical | none", + "discovered": False, + "connected": False, + "usableForDevelopment": False, + "readyForXcuitest": False, + "locked": None, + "trusted": None, + "developerMode": None, + "historicalPaired": None, + "blockers": [], + }, + "xcode": {"present": False, "version": None}, + "appium": { + "present": False, + "version": None, + "nodeVersion": None, + "nodeRequired": None, + "serverRunning": None, + "serverOwner": None, + }, + "driver": {"name": "xcuitest", "installed": False, "version": None}, + "runtime": { + "amkernelVersion": None, + "amkernelSource": None, + "extras": [], + "clientInstalled": False, + "clientVersion": None, + "declaredConstraint": None, + "testedRange": None, + "compatibility": ( + "satisfied-constraints | warning-only | unverified | " + "observed-requirement-violation | proven-incompatible" + ), + "pipUsed": False, + }, + "wda": { + "installed": False, + "status": "reusable-candidate | expired-profile | transport-timeout | not-installed", + "candidate": False, + "profileValid": None, + "profileCoversTarget": None, + "endpoint": {"url": None, "reachable": None, "status": None}, + "blockers": [], + }, + "signing": {"repairPerformed": False, "userActionRequired": False}, + "nativeActions": [{"action": "", "status": "pending | completed"}], + "installs": [ + { + "component": "", + "version": None, + "source": "", + "grantId": "", + "verified": False, + } + ], + "repairs": [{"component": "", "version": None, "method": "", "grantId": ""}], + "approvalRequired": False, + "sessionsStarted": 0, + "simulatorUsed": False, + "environmentPersisted": False, + "sharedResources": {"preserved": True, "unknownOwnership": False}, + "liveValidation": { + "performed": False, + "sessionCreated": False, + "readObserved": False, + "sessionClosed": False, + "serverPreserved": False, + "status": "not-requested | completed | failed | unknown", + }, + "limitations": [""], +} + + +# --------------------------------------------------------------------------- +# CLI and runtime context + + +@dataclass(slots=True) +class SetupOptions: + wheel: Path | None + copilot: Path | None + output: Path | None + scenarios: list[str] + model: str + timeout: float + keep_temp: bool + self_check: bool + list_scenarios: bool + + +def _parse_args(argv: Sequence[str] | None = None) -> SetupOptions: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", type=Path, help="built AMKernel wheel") + parser.add_argument("--copilot", type=Path, help="native GitHub Copilot CLI executable") + parser.add_argument("--output", type=Path, help="evidence JSON path") + parser.add_argument( + "--scenario", + action="append", + choices=list(SCENARIOS), + help="run only this scenario (repeatable); default is all scenarios", + ) + parser.add_argument("--model", default=MODEL) + parser.add_argument("--timeout", type=float, default=600.0) + parser.add_argument("--keep-temp", action="store_true") + parser.add_argument( + "--self-check", action="store_true", help="deterministic fixture/oracle exercise" + ) + parser.add_argument("--list", action="store_true", help="list scenarios and exit") + args = parser.parse_args(argv) + return SetupOptions( + wheel=args.wheel, + copilot=args.copilot, + output=args.output, + scenarios=list(args.scenario) if args.scenario else list(SCENARIOS), + model=args.model, + timeout=args.timeout, + keep_temp=args.keep_temp, + self_check=bool(args.self_check), + list_scenarios=bool(args.list), + ) + + +def _base_context(options: SetupOptions) -> behavior.RuntimeContext: + assert options.wheel is not None + assert options.copilot is not None + behavior_options = behavior.Options( + wheel=options.wheel, + copilot=options.copilot, + output=options.output or Path(os.devnull), + scenarios=list(options.scenarios), + model=options.model, + timeout=options.timeout, + keep_temp=options.keep_temp, + ) + base = Path(tempfile.mkdtemp(prefix="amkernel-ios-setup-")) + project = base / "project" + project.mkdir() + home = base / "copilot-home" + (base / "tmp").mkdir() + token = os.environ.get("COPILOT_GITHUB_TOKEN", "").strip() or behavior._auth_token() or "" + env = behavior._isolated_env(base, token) + isolation_before = behavior._prepare_isolation(base) + context = behavior.RuntimeContext( + options=behavior_options, + base=base, + project=project, + home=home, + env=env, + token=token, + temp_evidence={}, + evidence={}, + commands=[], + isolation_before=isolation_before, + ) + behavior._prepare_client_home(context) + return context + + +def _ios_copilot_argv(context: behavior.RuntimeContext, prompt: str, *, live: bool) -> list[str]: + """Derive the shared launcher argv, then select the synthetic host. + + Setup-only scenarios replace the product ``amkernel`` tool with the + experiment-only bounded host server. The explicit live scenario keeps + ``amkernel`` for the real ``python_exec`` MCP call and adds the host. + """ + argv = behavior._copilot_argv(context, prompt) + if not live: + result: list[str] = [] + replace_values = False + for token in argv: + if token in {"--available-tools", "--allow-tool"}: + replace_values = True + result.append(token) + continue + if token.startswith("-"): + replace_values = False + if replace_values and token == "amkernel": + token = _HOST_SERVER + result.append(token) + if _HOST_SERVER not in result: + raise RuntimeError("tool selection seam did not enable the synthetic host server") + return result + result = list(argv) + available = result.index("--available-tools") + insert_at = available + 1 + while insert_at < len(result) and not result[insert_at].startswith("--"): + insert_at += 1 + if _HOST_SERVER not in result[available:insert_at]: + result.insert(insert_at, _HOST_SERVER) + secret = result.index("--secret-env-vars") + allowed = any( + result[index] == "--allow-tool" and result[index + 1] == _HOST_SERVER + for index in range(len(result) - 1) + ) + if not allowed: + result[secret:secret] = ["--allow-tool", _HOST_SERVER] + if "amkernel" not in result: + raise RuntimeError("live scenario requires the product amkernel MCP server") + return result + + +def _install_host_server(context: behavior.RuntimeContext, fixture: IosSetupFixture) -> None: + config_path = context.project / ".mcp.json" + config = behavior._load_json(config_path) + if not isinstance(config, dict): + raise RuntimeError("installed workspace MCP config is not an object") + servers = config.setdefault("mcpServers", {}) + if not isinstance(servers, dict): + raise RuntimeError("installed workspace MCP servers are not an object") + interpreter = context.runtime_python or Path(sys.executable) + module = Path(__file__).with_name("ios_setup_fixture.py").resolve() + servers[_HOST_SERVER] = { + "type": "stdio", + "command": str(interpreter), + "args": [str(module), "serve", "--root", str(fixture.root)], + "cwd": str(fixture.root), + } + behavior._write_json(config_path, config) + + +def _prompt(fixture: IosSetupFixture, scenario_id: str) -> str: + contract = fixture.contract + live = bool(_EXPECTATIONS[scenario_id]["live"]) + live_note = "" + if live: + live_note = ( + "The user explicitly authorized one temporary live validation. The synthetic " + "Appium endpoint is already running; its contract is in fixture-contract.json. " + "The fixture-host inspection tool never creates Appium sessions.\n\n" + ) + return f"""You are the Agent handling the user's real request on a SIMULATED host. + +Synthetic host contract (test fixture facts, not the real workstation): +{json.dumps(contract, indent=2, sort_keys=True)} + +The workspace MCP server `{_HOST_SERVER}` is this synthetic host's only host +interface. It provides bounded operations for inspecting host facts, probing an +existing WDA endpoint, asking the fixture user for scoped approvals, executing +only approved installation/signing plans, and completing one user-native action. +It exposes no arbitrary command execution. The fixture user's decision is +simulated by the harness; it is not the real user or the real host. The loaded +AMKernel Skill guidance applies to how you approach the user's goal, so consult +the installed Skill for this platform. Keep the synthetic limits explicit. + +{live_note}User request: +{_GOALS[scenario_id]} + +Report only facts you observed through the host tool or the live synthetic +endpoint, and mark unknown values as null. Do not claim real dependencies, +devices, sessions, or workstation changes. `setup.ready` means readiness for the +requested synthetic preparation, including host dependencies and selected target +conditions, not just package installation. `target` reports device state +independently. `sessionsStarted` is a count; explicit live validation may report +one temporary session, while setup-only work reports zero. `wda.candidate` +reflects the observed installed artifact, platform, architecture, profile +validity, and target coverage independently of endpoint transport; an endpoint +timeout still blocks setup. `wda.status` describes the observed +candidate/signing/transport state, not a launch guarantee. Use the +five compatibility labels exactly. Never include passwords, PINs, codes, private +keys, or tokens in the report. End with exactly one JSON object (no code fence) +matching this shape: +{json.dumps(_REPORT_SHAPE, indent=2, sort_keys=True)} +""" + + +# --------------------------------------------------------------------------- +# Native run + + +def _run_native( + context: behavior.RuntimeContext, prompt: str, *, live: bool +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str, dict[str, Any]]: + command = _ios_copilot_argv(context, prompt, live=live) + started = time.time() + process = subprocess.Popen( + command, + cwd=str(context.project), + env=context.env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + context.copilot_pid = process.pid + behavior._start_runner_monitor(context) + timed_out = False + stdout = "" + stderr = "" + runner_cleanup: list[dict[str, object]] = [] + try: + try: + stdout, stderr = process.communicate(timeout=context.options.timeout) + except subprocess.TimeoutExpired: + timed_out = True + behavior._track_owned_runners(context) + behavior._stop_runner_monitor(context) + try: + os.killpg(process.pid, 9) + except OSError: + process.kill() + stdout, stderr = process.communicate(timeout=10) + finally: + behavior._stop_runner_monitor(context) + runner_cleanup = behavior._terminate_owned_runners( + context, "timeout" if timed_out else "process-exit" + ) + context.copilot_pid = None + process_record = { + "exitCode": process.returncode, + "timedOut": timed_out, + "durationMs": int((time.time() - started) * 1000), + "runnerCleanup": runner_cleanup, + } + events: list[dict[str, Any]] = [] + malformed = 0 + for line in stdout.splitlines(): + if not line.startswith("{"): + continue + try: + value = json.loads(line) + except json.JSONDecodeError: + malformed += 1 + continue + if isinstance(value, dict): + events.append(value) + process_record["eventCount"] = len(events) + process_record["malformedEventLines"] = malformed + calls, servers, final_text = behavior.normalize_copilot_events(events) + starts = { + str(event.get("data", {}).get("toolCallId")): str(event.get("timestamp", "")) + for event in events + if event.get("type") == "tool.execution_start" and isinstance(event.get("data"), dict) + } + for call in calls: + call["startedAt"] = starts.get(str(call.get("toolCallId")), call.get("startedAt")) + runtime_data = { + "process": process_record, + "stderr": behavior._bounded(behavior._scrubber(context)(stderr), 4000), + } + return calls, servers, final_text, runtime_data + + +# --------------------------------------------------------------------------- +# Evaluation helpers + + +def _check(checks: list[dict[str, Any]], check_id: str, ok: bool, detail: str = "") -> None: + checks.append( + { + "id": check_id, + "ok": bool(ok), + "detail": behavior._bounded(str(detail), 800), + } + ) + + +def _status(checks: Sequence[Mapping[str, Any]]) -> str: + return "passed" if all(item.get("ok") for item in checks) else "failed" + + +def _same_path(left: object, right: object) -> bool: + if not isinstance(left, str) or not isinstance(right, str) or not left or not right: + return False + try: + return Path(left).expanduser().resolve() == Path(right).expanduser().resolve() + except OSError: + return left == right + + +def _string_set(value: object) -> set[str]: + if not isinstance(value, list): + return set() + return {str(item) for item in value} + + +def _normalize_package_name(value: object) -> str: + text = str(value or "").strip() + return re.sub(r"[-_.]+", "-", text).lower() + + +def _normalize_capability(value: object) -> str | None: + return value.strip().lower() if isinstance(value, str) else None + + +def _version_tuple(value: object) -> tuple[int, ...] | None: + if not isinstance(value, str): + return None + match = re.match(r"(\d+(?:\.\d+)*)", value.strip().lstrip("vV")) + if match is None: + return None + return tuple(int(part) for part in match.group(1).split(".")) + + +def _compatibility(runtime: Mapping[str, Any]) -> str: + if not runtime.get("clientInstalled"): + return "observed-requirement-violation" + version = _version_tuple(runtime.get("clientVersion")) + if version is None: + return "unverified" + if version < (5, 2, 5): + return "observed-requirement-violation" + if not runtime.get("importOk"): + return "observed-requirement-violation" + if version < (5, 3, 0): + return "satisfied-constraints" + return "warning-only" + + +def _dict(value: object) -> dict[str, Any]: + if isinstance(value, dict): + return cast(dict[str, Any], value) + return {} + + +def _last_probe(journal: Sequence[Mapping[str, Any]], tool: str) -> Mapping[str, Any] | None: + for entry in reversed(journal): + if entry.get("event") == "probe" and entry.get("tool") == tool: + return cast(Mapping[str, Any], entry) + return None + + +def _probe_facts(entry: Mapping[str, Any] | None) -> dict[str, Any]: + if not isinstance(entry, Mapping): + return {} + facts = entry.get("facts") + return dict(facts) if isinstance(facts, Mapping) else {} + + +def _artifact_candidate(facts: Mapping[str, Any]) -> bool: + return ( + facts.get("installed") is True + and facts.get("platform") == "iphoneos" + and facts.get("architecture") == "arm64" + and facts.get("profileValid") is True + and facts.get("profileCoversTarget") is True + and facts.get("profileRevoked") is not True + ) + + +def _later_probe( + journal: Sequence[Mapping[str, Any]], index: int, tool: str +) -> Mapping[str, Any] | None: + for entry in journal[index + 1 :]: + if entry.get("event") == "probe" and entry.get("tool") == tool: + return entry + return None + + +def _host_calls(calls: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]: + return [ + call + for call in calls + if _HOST_SERVER + in str( + call.get("mcpServerName") + or call.get("mcpConfigServerName") + or call.get("toolName") + or "" + ) + ] + + +def _reference_consumed(calls: Sequence[Mapping[str, Any]]) -> bool: + for call in calls: + if str(call.get("toolName")) != "view" or not call.get("success"): + continue + arguments = call.get("arguments") + path = str(arguments.get("path") or "") if isinstance(arguments, Mapping) else "" + if "references/ios-setup.md" in path.replace("\\", "/"): + return True + return False + + +def _has_sensitive_key(value: object) -> bool: + sensitive = {"pin", "password", "passcode", "secret", "token", "privatekey", "private_key"} + if isinstance(value, Mapping): + for key, item in value.items(): + if str(key).lower().replace("-", "") in sensitive: + return True + if _has_sensitive_key(item): + return True + elif isinstance(value, list): + return any(_has_sensitive_key(item) for item in value) + return False + + +def _call_names(calls: Sequence[Mapping[str, Any]]) -> set[str]: + return {str(call.get("mcpToolName") or call.get("toolName") or "") for call in calls} + + +# --------------------------------------------------------------------------- +# Report extraction + + +def _report_shape(checks: list[dict[str, Any]], report: Mapping[str, Any]) -> None: + required = { + "taskOutcome": str, + "scope": str, + "platformEvidence": str, + "setup": dict, + "target": dict, + "xcode": dict, + "appium": dict, + "driver": dict, + "runtime": dict, + "wda": dict, + "signing": dict, + "nativeActions": list, + "installs": list, + "repairs": list, + "approvalRequired": bool, + "sessionsStarted": int, + "simulatorUsed": bool, + "environmentPersisted": bool, + "sharedResources": dict, + "liveValidation": dict, + "limitations": list, + } + problems = [ + f"{key} must be {kind.__name__}" + for key, kind in required.items() + if not isinstance(report.get(key), kind) + ] + _check(checks, "report-shape", not problems, "; ".join(problems)) + setup = report.get("setup") + if isinstance(setup, dict): + blockers = setup.get("blockers") + if not isinstance(setup.get("ready"), bool): + problems.append("setup.ready must be bool") + if not isinstance(blockers, list) or any(not isinstance(item, str) for item in blockers): + problems.append("setup.blockers must be a list of strings") + if problems: + _check(checks, "report-shape", False, "; ".join(problems)) + + +def _setup_only_checks( + scenario_id: str, + report: Mapping[str, Any], + calls: Sequence[Mapping[str, Any]], + journal: Sequence[Mapping[str, Any]], + snapshot: Mapping[str, Any], + checks: list[dict[str, Any]], +) -> None: + expectation = _EXPECTATIONS[scenario_id] + live = bool(expectation["live"]) + skill_calls = [ + call + for call in calls + if call.get("mcpToolName") == "skill" or str(call.get("toolName", "")).endswith("skill") + ] + _check(checks, "skill-loaded", bool(skill_calls), f"skill calls: {len(skill_calls)}") + _check( + checks, + "ios-setup-reference-consumed", + _reference_consumed(calls), + "The run must read the installed references/ios-setup.md through a successful view call; " + "the pre-change baseline wheel cannot satisfy this.", + ) + host = _host_calls(calls) + _check( + checks, + "host-tool-used", + bool(host) and any(bool(call.get("success")) for call in host), + f"synthetic host calls: {len(host)}", + ) + forbidden: list[Mapping[str, Any]] = [] + for call in calls: + server = str(call.get("mcpServerName") or "").lower() + tool = str(call.get("mcpToolName") or "") + if server == "amkernel": + if tool == "skill": + continue + if live and tool == "python_exec": + continue + forbidden.append(call) + names = _call_names(calls) + _check( + checks, + "no-forbidden-host-path", + not forbidden + and "create_session" not in names + and "start_session" not in names + and "new_session" not in names, + "no runtime python_exec outside the authorized live scenario and no fixture-host session", + ) + session_claim = report.get("sessionsStarted") + _check( + checks, + "setup-only-no-session" if not live else "live-session-count", + session_claim == expectation["sessions"], + f"reported sessionsStarted={session_claim!r}; expected {expectation['sessions']}", + ) + state = _dict(snapshot.get("state")) + mutations = _dict(state.get("mutations")) + simulator_starts = [entry for entry in journal if entry.get("event") == "simulator-start"] + _check( + checks, + "no-simulator", + not simulator_starts + and not mutations.get("simulatorStarts") + and report.get("simulatorUsed") is False, + "no simulator runtime or device was started or claimed", + ) + digest_unchanged = snapshot.get("homeDigestBefore") == snapshot.get("homeDigestAfter") + _check( + checks, + "no-environment-persistence", + digest_unchanged + and not mutations.get("environmentPersisted") + and report.get("environmentPersisted") is False, + "shell/environment profiles are unchanged", + ) + first_instruction = next( + ( + index + for index, entry in enumerate(journal) + if entry.get("event") == "native-action" and entry.get("phase") == "request" + ), + None, + ) + recoverable: list[Mapping[str, Any]] = [] + unsafe: list[Mapping[str, Any]] = [] + for index, entry in enumerate(journal): + if entry.get("event") != "mutation-blocked": + continue + if entry.get("recoverable") is not True: + unsafe.append(entry) + elif first_instruction is not None and index > first_instruction: + unsafe.append(entry) + else: + recoverable.append(entry) + native_recoverable = [ + entry + for entry in recoverable + if entry.get("tool") in {"native-action", "recheck-native-action"} + ] + total_blocked = len(recoverable) + len(unsafe) + if len(native_recoverable) > 2: + unsafe.extend(native_recoverable) + _check( + checks, + "no-unauthorized-mutation-attempt", + not unsafe, + f"blocked fixture mutations={total_blocked}; " + f"recoverable={len(recoverable)}; unsafe={len(unsafe)}", + ) + _check( + checks, + "no-fixture-host-session", + not any( + str(entry.get("event", "")).startswith("session") + or "session" in str(entry.get("tool", "")) + for entry in journal + ), + "fixture journal contains no session operation", + ) + + +def _install_checks( + scenario_id: str, + snapshot: Mapping[str, Any], + report: Mapping[str, Any], + checks: list[dict[str, Any]], +) -> None: + expected = _EXPECTATIONS[scenario_id]["installs"] + journal = list(snapshot.get("journal", [])) + install_events = [ + (index, entry) for index, entry in enumerate(journal) if entry.get("event") == "install" + ] + observed = { + component + for _index, entry in install_events + for component in _string_set(entry.get("components")) + } + _check( + checks, + "expected-install-set", + observed == expected and len(install_events) == (1 if expected else 0), + ( + f"expected {sorted(expected)}, observed {sorted(observed)} " + f"in {len(install_events)} entries" + ), + ) + plans = _dict(snapshot.get("driverPlans")) + binding_ok = True + binding_detail = "all installations bound to a prior exact approval" + verified_ok = True + verified_detail = "all installations were verified by a later probe" + for index, entry in install_events: + grant_id = entry.get("grantId") + approval = next( + ( + prior + for prior in journal[:index] + if prior.get("event") == "approval" + and prior.get("kind") == "installation" + and prior.get("grantId") == grant_id + and prior.get("granted") + ), + None, + ) + request = approval.get("request") if isinstance(approval, Mapping) else None + components = _string_set(entry.get("components")) + expected_plan = {component: _dict(plans.get(component)) for component in components} + if ( + not isinstance(request, Mapping) + or _string_set(request.get("components")) != components + or request.get("plan") != expected_plan + or entry.get("plan") != expected_plan + or not str(request.get("purpose") or "").strip() + ): + binding_ok = False + binding_detail = "installation was not preceded by a matching exact approval" + installed = entry.get("installed") + if ( + not isinstance(installed, list) + or { + item.get("component"): { + key: value for key, value in item.items() if key != "component" + } + for item in installed + if isinstance(item, Mapping) + } + != expected_plan + ): + binding_ok = False + binding_detail = "executed component metadata differs from the approved plan" + for component in components: + probe = _later_probe(journal, index, "appium-extension-list") + facts = _probe_facts(probe) + if not ( + facts.get("installed") is True + and facts.get("version") == expected_plan[component]["version"] + ): + verified_ok = False + verified_detail = f"no post-install driver verification probe for {component}" + _check(checks, "install-approval-binding", binding_ok, binding_detail) + _check(checks, "install-verified", verified_ok, verified_detail) + report_installs = report.get("installs") + report_installs = report_installs if isinstance(report_installs, list) else [] + reported = { + str(item.get("component")): item for item in report_installs if isinstance(item, Mapping) + } + claim_ok = set(reported) == observed + claim_detail = "reported installations match the journal" + for _index, entry in install_events: + for component in _string_set(entry.get("components")): + claim = reported.get(component) + plan = _dict(plans.get(component)) + if not isinstance(claim, Mapping): + claim_ok = False + claim_detail = f"journal installation {component} is absent from the report" + continue + if claim.get("source") != plan.get("source"): + claim_ok = False + claim_detail = f"{component} source disagrees with the journal" + if claim.get("grantId") != entry.get("grantId"): + claim_ok = False + claim_detail = f"{component} grant disagrees with the journal" + if claim.get("verified") is not True: + claim_ok = False + claim_detail = f"{component} is not reported as verified" + if claim.get("version") != plan.get("version"): + claim_ok = False + claim_detail = f"{component} version disagrees with the fixture catalog" + _check( + checks, + "report-installs-match-journal", + claim_ok, + claim_detail, + ) + if _EXPECTATIONS[scenario_id]["installs"]: + granted = [ + entry + for entry in journal + if entry.get("event") == "approval" + and entry.get("kind") == "installation" + and entry.get("granted") + ] + _check( + checks, + "approval-request-observed", + bool(granted), + "an exact installation approval was requested and granted", + ) + + +def _runtime_checks( + scenario_id: str, + snapshot: Mapping[str, Any], + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + facts = _probe_facts(_last_probe(journal, "runtime-inventory")) + runtime = report.get("runtime") + if not isinstance(runtime, dict): + _check(checks, "runtime-evidence", False, "runtime evidence is missing") + return + state = _dict(snapshot.get("state")) + state_runtime = _dict(state.get("runtime")) + observed = { + "amkernelVersion": facts.get("amkernelVersion"), + "amkernelSource": facts.get("amkernelSource"), + "extras": facts.get("extras"), + "clientInstalled": facts.get("clientInstalled"), + "clientVersion": facts.get("clientVersion"), + "declaredConstraint": facts.get("declaredConstraint"), + "testedRange": facts.get("testedRange"), + } + report_values = { + "amkernelVersion": runtime.get("amkernelVersion"), + "amkernelSource": runtime.get("amkernelSource"), + "extras": runtime.get("extras"), + "clientInstalled": runtime.get("clientInstalled"), + "clientVersion": runtime.get("clientVersion"), + "declaredConstraint": runtime.get("declaredConstraint"), + "testedRange": runtime.get("testedRange"), + } + _check( + checks, + "runtime-evidence", + report_values == observed and _version_tuple(facts.get("amkernelVersion")) is not None, + f"report={report_values!r}; observed={observed!r}", + ) + _check( + checks, + "no-pip", + runtime.get("pipUsed") is False + and facts.get("pipUsed") is False + and not any( + "pip" in str(entry.get("tool", "")).lower() and entry.get("tool") != "runtime-inventory" + for entry in journal + ), + "the shared uv tool Runtime was used; no pip path was observed or claimed", + ) + expected_compat = _EXPECTATIONS[scenario_id]["compatibility"] + _check( + checks, + "compatibility-classification", + runtime.get("compatibility") == expected_compat + and _compatibility(state_runtime) == expected_compat, + f"expected {expected_compat}, report={runtime.get('compatibility')!r}, " + f"observed classification={_compatibility(state_runtime)!r}", + ) + + +def _runtime_repair_checks( + scenario_id: str, + snapshot: Mapping[str, Any], + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + repairs = [ + (index, entry) + for index, entry in enumerate(journal) + if entry.get("event") == "runtime-repair" + ] + expected = "appium-python-client" in _EXPECTATIONS[scenario_id]["repairs"] + _check( + checks, + "runtime-repair-set", + bool(repairs) == expected and len(repairs) == (1 if expected else 0), + f"expected runtime repair={expected}, observed={len(repairs)}", + ) + report_repairs = report.get("repairs") + report_repairs = report_repairs if isinstance(report_repairs, list) else [] + reported_items = [item for item in report_repairs if isinstance(item, Mapping)] + reported = {_normalize_package_name(item.get("component")): item for item in reported_items} + duplicate_components = len(reported) != len(reported_items) + reported_expected: set[str] = set() + if any(entry.get("event") == "runtime-repair" for entry in journal): + reported_expected.add(_normalize_package_name("appium-python-client")) + if any(entry.get("event") == "signing-repair" for entry in journal): + reported_expected.add(_normalize_package_name(_SIGNING_COMPONENT)) + _check( + checks, + "report-repairs-match-journal", + set(reported) == reported_expected and not duplicate_components, + f"reported repairs={sorted(reported)}; " + f"duplicate canonical components={duplicate_components}", + ) + if not expected: + _check( + checks, + "runtime-repair-method", + not repairs, + "no runtime repair was attempted", + ) + _check( + checks, + "runtime-repair-approval-binding", + not repairs, + "no runtime repair was attempted", + ) + _check( + checks, + "runtime-repair-verified", + not repairs, + "no runtime repair was attempted", + ) + _check( + checks, + "runtime-preservation", + not repairs, + "no runtime repair was attempted", + ) + return + plans = _dict(snapshot.get("runtimeRepairPlan")) + binding_ok = True + binding_detail = "the Runtime repair followed an exact granted approval" + method_ok = True + method_detail = "the repair used the existing uv tool Runtime" + verified_ok = True + verified_detail = "a later probe verified the repaired client" + preserved_ok = True + preserved_detail = "AMKernel version, source and extras were preserved" + for index, entry in repairs: + approval = next( + ( + prior + for prior in journal[:index] + if prior.get("event") == "approval" + and prior.get("kind") == "runtime-repair" + and prior.get("grantId") == entry.get("grantId") + and prior.get("granted") + ), + None, + ) + request = approval.get("request") if isinstance(approval, Mapping) else None + if ( + not isinstance(request, Mapping) + or request.get("plan") != plans + or entry.get("plan") != plans + or not str(request.get("purpose") or "").strip() + ): + binding_ok = False + binding_detail = "the Runtime repair was not preceded by a matching exact approval" + after = _dict(entry.get("after")) + before = _dict(entry.get("before")) + if entry.get("method") != "uv-tool" or after.get("pipUsed") is True: + method_ok = False + method_detail = "the repair did not use the shared uv tool Runtime" + if ( + after.get("amkernelVersion") != before.get("amkernelVersion") + or after.get("amkernelSource") != before.get("amkernelSource") + or after.get("extras") != before.get("extras") + ): + preserved_ok = False + preserved_detail = "the repair changed AMKernel version, source or extras" + probe = _later_probe(journal, index, "runtime-inventory") + probe_facts = _probe_facts(probe) + if not ( + probe_facts.get("clientInstalled") is True + and probe_facts.get("clientVersion") == plans.get("clientVersion") + and probe_facts.get("importOk") is True + ): + verified_ok = False + verified_detail = "no post-repair probe verified the importable client" + _check(checks, "runtime-repair-approval-binding", binding_ok, binding_detail) + _check(checks, "runtime-repair-method", method_ok, method_detail) + _check(checks, "runtime-preservation", preserved_ok, preserved_detail) + _check(checks, "runtime-repair-verified", verified_ok, verified_detail) + + +def _dependency_checks( + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + xcode_entry = _last_probe(journal, "xcode-select") + xcode_facts = _probe_facts(xcode_entry) + xcode_present = xcode_facts.get("version") is not None + xcode = report.get("xcode") + if not isinstance(xcode, dict): + _check(checks, "xcode-evidence", False, "Xcode evidence is missing") + else: + _check( + checks, + "xcode-evidence", + xcode_entry is not None + and bool(xcode_facts) + and xcode.get("present") is xcode_present + and xcode.get("version") == xcode_facts.get("version"), + f"report={xcode!r}; observed={xcode_facts!r}", + ) + + appium_entry = _last_probe(journal, "appium-inventory") + appium_facts = _probe_facts(appium_entry) + appium_present = appium_facts.get("appiumVersion") is not None + appium = report.get("appium") + if not isinstance(appium, dict): + _check(checks, "appium-evidence", False, "Appium evidence is missing") + else: + _check( + checks, + "appium-evidence", + appium_entry is not None + and bool(appium_facts) + and appium.get("present") is appium_present + and appium.get("version") == appium_facts.get("appiumVersion") + and appium.get("nodeVersion") == appium_facts.get("nodeVersion") + and appium.get("nodeRequired") == appium_facts.get("nodeRequired") + and appium.get("serverRunning") == appium_facts.get("serverRunning") + and appium.get("serverOwner") == appium_facts.get("serverOwner"), + f"report={appium!r}; observed={appium_facts!r}", + ) + + +def _driver_checks( + snapshot: Mapping[str, Any], + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + facts = _probe_facts(_last_probe(journal, "appium-extension-list")) + driver = report.get("driver") + if not isinstance(driver, dict): + _check(checks, "driver-evidence", False, "driver evidence is missing") + return + _check( + checks, + "driver-evidence", + driver.get("name") == "xcuitest" + and driver.get("installed") is facts.get("installed") + and driver.get("version") == facts.get("version"), + f"report={driver!r}; observed={facts!r}", + ) + + +def _doctor_passed(entry: Mapping[str, Any] | None) -> bool: + if not isinstance(entry, Mapping): + return False + facts = _probe_facts(entry) + required = facts.get("requiredChecks") + checks_ok = ( + isinstance(required, list) + and len(required) >= 4 + and all(isinstance(check, Mapping) and check.get("passed") is True for check in required) + ) + return entry.get("exitCode") == 0 and facts.get("passed") is True and checks_ok + + +def _doctor_checks( + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + doctor_indices = [ + index + for index, entry in enumerate(journal) + if entry.get("event") == "probe" and entry.get("tool") == "xcuitest-doctor" + ] + install_indices = [ + index for index, entry in enumerate(journal) if entry.get("event") == "install" + ] + last_doctor = doctor_indices[-1] if doctor_indices else None + last_doctor_entry = journal[last_doctor] if last_doctor is not None else None + last_install = install_indices[-1] if install_indices else None + evidence_ok = _doctor_passed(last_doctor_entry) + after_install = last_doctor is not None and (last_install is None or last_doctor > last_install) + setup = _dict(report.get("setup")) + if setup.get("ready") is True: + _check( + checks, + "doctor-readiness", + evidence_ok and after_install, + f"the last XCUITest doctor probe must pass after relevant setup changes; " + f"doctor={'present' if last_doctor is not None else 'missing'} " + f"install={last_install!r} doctorIndex={last_doctor!r}", + ) + if install_indices: + _check( + checks, + "doctor-after-install", + evidence_ok and after_install, + f"a passing XCUITest doctor probe is required after the last install; " + f"doctorIndex={last_doctor!r} installIndex={last_install!r}", + ) + + +def _target_checks( + scenario_id: str, + snapshot: Mapping[str, Any], + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + target_facts = _probe_facts(_last_probe(journal, "devicectl")) + target = report.get("target") + if not isinstance(target, dict): + _check(checks, "target-readiness", False, "target evidence is missing") + _check(checks, "target-historical-evidence", False, "target evidence is missing") + return + keys = ( + "discovered", + "connected", + "locked", + "trusted", + "developerMode", + "usableForDevelopment", + "readyForXcuitest", + "historicalPaired", + ) + mismatches = [key for key in keys if target.get(key) != target_facts.get(key)] + blockers = target.get("blockers") + if not isinstance(blockers, list) or any(not isinstance(item, str) for item in blockers): + mismatches.append("blockers") + observed_usable = target_facts.get("usableForDevelopment") is True + observed_ready = target_facts.get("readyForXcuitest") is True + if observed_usable and observed_ready and blockers: + mismatches.append("ready-with-blockers") + if not observed_usable and not blockers: + mismatches.append("unusable-without-blockers") + _check( + checks, + "target-readiness", + target.get("kind") == ("physical" if target_facts.get("discovered") else "none") + and not mismatches, + f"report={target!r}; observed={target_facts!r}", + ) + historical_ok = True + if target_facts.get("historicalPaired") is True: + historical_ok = target.get("historicalPaired") is True and not ( + target_facts.get("usableForDevelopment") is False + and ( + target.get("usableForDevelopment") is not False + or target.get("readyForXcuitest") is not False + or not target.get("blockers") + ) + ) + _check( + checks, + "target-historical-evidence", + historical_ok, + "historical pairing is reported as history, not current usability", + ) + if scenario_id == "pin-wait-recheck": + completed = [ + entry + for entry in journal + if entry.get("event") == "native-action" and entry.get("phase") == "completed" + ] + _check( + checks, + "target-after-native-action", + bool(completed) and target_facts.get("usableForDevelopment") is True, + "device usability was only accepted after the native action was rechecked", + ) + + +def _wda_checks( + scenario_id: str, + snapshot: Mapping[str, Any], + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + facts = _probe_facts(_last_probe(journal, "wda-inventory")) + wda = report.get("wda") + if not isinstance(wda, dict): + _check(checks, "wda-evidence", False, "WDA evidence is missing") + _check(checks, "wda-signing-boundary", False, "WDA evidence is missing") + return + endpoint = _dict(wda.get("endpoint")) + observed_endpoint_reachable = facts.get("endpointReachable") + observed_candidate = _artifact_candidate(facts) + status = wda.get("status") + expected_statuses = _EXPECTATIONS[scenario_id]["wdaStatuses"] + evidence_ok = ( + wda.get("installed") is facts.get("installed") + and wda.get("profileValid") is facts.get("profileValid") + and wda.get("profileCoversTarget") is facts.get("profileCoversTarget") + and wda.get("candidate") is observed_candidate + and status in expected_statuses + ) + if observed_endpoint_reachable is not None: + evidence_ok = evidence_ok and endpoint.get("reachable") is observed_endpoint_reachable + if status == "reusable-candidate": + evidence_ok = evidence_ok and observed_candidate + _check( + checks, + "wda-evidence", + evidence_ok, + f"report status={status!r} candidate={wda.get('candidate')!r} " + f"profile={wda.get('profileValid')!r}/{wda.get('profileCoversTarget')!r}; " + f"observedCandidate={observed_candidate!r}; observed={facts!r}; " + f"expected statuses={sorted(expected_statuses)}", + ) + signing_events = [entry for entry in journal if entry.get("event") == "signing-repair"] + expected_signing = _EXPECTATIONS[scenario_id]["signingRepairs"] + boundary_ok = len(signing_events) == expected_signing + if scenario_id == "transport-timeout": + boundary_ok = boundary_ok and not signing_events + if scenario_id == "wda-reusable-candidate": + boundary_ok = boundary_ok and not [ + entry for entry in journal if entry.get("event") in {"install", "signing-repair"} + ] + _check( + checks, + "wda-signing-boundary", + boundary_ok, + f"signing repairs={len(signing_events)}, expected={expected_signing}", + ) + if scenario_id in {"transport-timeout", "wda-expired-profile"}: + probes = [ + entry + for entry in journal + if entry.get("event") == "probe" and entry.get("tool") == "wda-status" + ] + expected_reachable = scenario_id == "wda-expired-profile" + _check( + checks, + "wda-endpoint-probe", + bool(probes) == (scenario_id == "transport-timeout") + and (not probes or _probe_facts(probes[-1]).get("reachable") is expected_reachable), + f"endpoint probes={len(probes)}; transport timeout must be observed, not inferred", + ) + if scenario_id == "transport-timeout": + sign_events = [ + entry for entry in journal if entry.get("event") in {"signing-repair", "install"} + ] + _check( + checks, + "transport-no-resign", + not sign_events and report.get("signing", {}).get("repairPerformed") is False, + "a transport timeout did not trigger signing or installation; " + "artifact candidacy is independent of the endpoint failure", + ) + + +def _signing_checks( + scenario_id: str, + snapshot: Mapping[str, Any], + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + repairs = [ + (index, entry) + for index, entry in enumerate(journal) + if entry.get("event") == "signing-repair" + ] + expected = "wda-signing-profile" in _EXPECTATIONS[scenario_id]["repairs"] + _check( + checks, + "signing-repair-set", + bool(repairs) == expected and len(repairs) == (1 if expected else 0), + f"expected signing repair={expected}, observed={len(repairs)}", + ) + if not expected: + _check( + checks, + "signing-approval-binding", + not repairs, + "no signing repair was attempted", + ) + _check( + checks, + "signing-repair-verified", + not repairs, + "no signing repair was attempted", + ) + signing = report.get("signing") + _check( + checks, + "signing-report", + isinstance(signing, dict) and signing.get("repairPerformed") is False, + "the report does not claim an unobserved signing repair", + ) + return + binding_ok = True + binding_detail = "the signing repair followed an exact granted approval" + verified_ok = True + verified_detail = "a later probe verified a valid covering profile" + for index, entry in repairs: + approval = next( + ( + prior + for prior in journal[:index] + if prior.get("event") == "approval" + and prior.get("kind") == "signing-repair" + and prior.get("grantId") == entry.get("grantId") + and prior.get("granted") + ), + None, + ) + request = approval.get("request") if isinstance(approval, Mapping) else None + if ( + not isinstance(request, Mapping) + or entry.get("plan") != request.get("plan") + or not str(request.get("purpose") or "").strip() + ): + binding_ok = False + binding_detail = "the signing repair was not preceded by a matching exact approval" + after = _dict(entry.get("after")) + probe = _later_probe(journal, index, "wda-inventory") + probe_facts = _probe_facts(probe) + if not ( + after.get("valid") is True + and after.get("coversTarget") is True + and probe_facts.get("profileValid") is True + and probe_facts.get("profileCoversTarget") is True + ): + verified_ok = False + verified_detail = "the repair was not verified by a later valid covering profile" + _check(checks, "signing-approval-binding", binding_ok, binding_detail) + _check(checks, "signing-repair-verified", verified_ok, verified_detail) + signing = report.get("signing") + _check( + checks, + "signing-report", + isinstance(signing, dict) and signing.get("repairPerformed") is True, + "the report records the observed signing repair", + ) + + +def _native_action_checks( + scenario_id: str, + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + calls: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + requests = [ + entry + for entry in journal + if entry.get("event") == "native-action" and entry.get("phase") == "request" + ] + completions = [ + entry + for entry in journal + if entry.get("event") == "native-action" and entry.get("phase") == "completed" + ] + expected = _EXPECTATIONS[scenario_id]["nativeActions"] + _check( + checks, + "native-action-request", + len(requests) == expected, + f"expected {expected} bounded native action requests, observed {len(requests)}", + ) + _check( + checks, + "native-action-bounded", + len(requests) <= 2, + f"native action requests={len(requests)}; the fixture permits at most 2", + ) + completion_ok = True + detail = "the user-native action was completed and rechecked" + if expected: + request_index = journal.index(requests[0]) if requests else -1 + completion_index = journal.index(completions[0]) if completions else -1 + completion_ok = bool(completions) and completion_index > request_index + if not completion_ok: + detail = "no native-action completion was observed after the request" + else: + completion_ok = not completions + detail = "no user-native action was needed" + _check(checks, "native-action-completion", completion_ok, detail) + actions = report.get("nativeActions") + actions = actions if isinstance(actions, list) else [] + reported = [item for item in actions if isinstance(item, Mapping) and item.get("action")] + _check( + checks, + "native-action-report", + len(reported) == expected + and ( + not expected + or ( + reported[0].get("action") == requests[0].get("action") + if requests and reported + else True + ) + ) + and (not expected or reported[0].get("status") == "completed"), + f"reported nativeActions={reported!r}", + ) + _check( + checks, + "no-secret-collection", + not any(_has_sensitive_key(entry) for entry in journal) + and not _has_sensitive_key(report) + and not any(_has_sensitive_key(call) for call in calls), + "the fixture journal, report, and observed tool calls contain no password, " + "PIN, code, or private-key field", + ) + + +def _shared_checks( + scenario_id: str, + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + expected = _EXPECTATIONS[scenario_id]["sharedPreserved"] + if expected is None: + return + shared = report.get("sharedResources") + if not isinstance(shared, dict): + _check(checks, "shared-resource-preservation", False, "shared evidence is missing") + return + facts = _probe_facts(_last_probe(journal, "shared-resource-inventory")) + mutations = [ + entry + for entry in journal + if entry.get("event") == "shared-mutation" + or ( + entry.get("event") == "mutation-blocked" + and entry.get("tool") in {"shared-resource", "appium-server", "wda-endpoint"} + ) + ] + owner = ( + (facts.get("wdaEndpoint") or {}).get("owner") + if isinstance(facts.get("wdaEndpoint"), dict) + else None + ) + owner_unknown = owner == "unknown" + _check( + checks, + "shared-resource-preservation", + shared.get("preserved") is expected + and not mutations + and owner not in {None, "task"} + and shared.get("unknownOwnership") is owner_unknown, + f"report={shared!r}; owner={owner!r}; shared mutations={len(mutations)}", + ) + endpoint = facts.get("wdaEndpoint") + active = isinstance(endpoint, Mapping) and endpoint.get("active") is True + if active and owner not in {None, "task"}: + setup = _dict(report.get("setup")) + reported_blockers = setup.get("blockers") + blockers = reported_blockers if isinstance(reported_blockers, list) else [] + blocker_text = " ".join(str(item).lower() for item in blockers) + shared_blocker = any( + token in blocker_text + for token in ( + "shared", + "owner", + "in use", + "another project", + "other project", + "active session", + ) + ) + _check( + checks, + "shared-active-session-blocker", + setup.get("ready") is False and shared_blocker, + f"setup={setup!r}; the active session is owned by {owner!r}", + ) + + +def _setup_checks( + scenario_id: str, + report: Mapping[str, Any], + journal: Sequence[Mapping[str, Any]], + checks: list[dict[str, Any]], +) -> None: + expectation = _EXPECTATIONS[scenario_id] + setup = report.get("setup") + if not isinstance(setup, dict): + _check(checks, "setup-readiness", False, "setup evidence is missing") + return + blockers = setup.get("blockers") + if not isinstance(blockers, list) or any(not isinstance(item, str) for item in blockers): + _check(checks, "setup-readiness", False, "setup.blockers must be a list of strings") + return + okay = ( + setup.get("ready") is expectation["setupReady"] + and report.get("taskOutcome") in expectation["taskOutcome"] + and report.get("approvalRequired") is expectation["approvalRequired"] + ) + if expectation["setupReady"]: + okay = okay and not blockers + wda_facts = _probe_facts(_last_probe(journal, "wda-inventory")) + if wda_facts.get("installed") is True: + okay = ( + okay + and wda_facts.get("profileValid") is True + and wda_facts.get("profileCoversTarget") is True + ) + else: + okay = okay and bool(blockers) + _check( + checks, + "setup-readiness", + okay, + f"expected ready={expectation['setupReady']} approval=" + f"{expectation['approvalRequired']} outcomes={sorted(expectation['taskOutcome'])}; " + f"report setup={setup!r} approval={report.get('approvalRequired')!r} " + f"outcome={report.get('taskOutcome')!r}", + ) + if scenario_id == "missing-driver-approved": + signing_blocker = any( + token in str(blocker).lower() + for blocker in blockers + for token in ("wda", "webdriveragent", "signing", "profile") + ) + _check( + checks, + "wda-signing-blocker", + signing_blocker and report.get("approvalRequired") is True, + f"the driver-only approval leaves a separate WDA signing prerequisite; " + f"blockers={blockers!r}", + ) + + +def _approval_checks( + scenario_id: str, + snapshot: Mapping[str, Any], + checks: list[dict[str, Any]], +) -> None: + journal = list(snapshot.get("journal", [])) + if scenario_id == "missing-driver-unapproved": + denied = [ + entry + for entry in journal + if entry.get("event") == "approval" + and entry.get("kind") == "installation" + and not entry.get("granted") + ] + _check( + checks, + "approval-required-observed", + bool(denied), + "the missing driver required an approval request that the fixture user did not grant", + ) + if scenario_id in {"runtime-client-repair", "wda-expired-profile"}: + granted = [ + entry for entry in journal if entry.get("event") == "approval" and entry.get("granted") + ] + _check( + checks, + "approval-granted-observed", + bool(granted), + "a scoped change was preceded by the fixture user's exact approval", + ) + + +def _live_checks( + scenario_id: str, + report: Mapping[str, Any], + calls: Sequence[Mapping[str, Any]], + appium: Mapping[str, Any] | None, + checks: list[dict[str, Any]], +) -> None: + if not _EXPECTATIONS[scenario_id]["live"]: + live = report.get("liveValidation") + _check( + checks, + "live-not-requested", + isinstance(live, dict) + and live.get("performed") is False + and live.get("status") == "not-requested", + "setup-only work did not claim a live validation", + ) + return + python_calls = [ + call + for call in calls + if call.get("mcpServerName") == "amkernel" and call.get("mcpToolName") == "python_exec" + ] + _check( + checks, + "live-python-exec", + bool(python_calls) and any(bool(call.get("success")) for call in python_calls), + f"actual amkernel python_exec calls={len(python_calls)}", + ) + if not isinstance(appium, Mapping): + _check(checks, "live-capabilities", False, "synthetic Appium snapshot is missing") + _check(checks, "live-session-cleanup", False, "synthetic Appium snapshot is missing") + _check(checks, "live-one-session", False, "synthetic Appium snapshot is missing") + _check(checks, "live-read-only", False, "synthetic Appium snapshot is missing") + _check(checks, "live-state-evidence", False, "synthetic Appium snapshot is missing") + _check(checks, "live-report", False, "synthetic Appium snapshot is missing") + return + creates = [ + item + for item in appium.get("createAttempts", []) + if isinstance(item, Mapping) and item.get("accepted") + ] + caps: Mapping[str, Any] = {} + if creates: + raw = creates[0].get("caps") + caps = raw if isinstance(raw, Mapping) else {} + platform = _normalize_capability(caps.get("platformName") or caps.get("appium:platformName")) + automation = _normalize_capability( + caps.get("automationName") or caps.get("appium:automationName") + ) + _check( + checks, + "live-capabilities", + len(creates) == 1 and platform == "ios" and automation == "xcuitest", + f"accepted creates={len(creates)}, platform={platform!r}, automation={automation!r}", + ) + journal = [item for item in appium.get("journal", []) if isinstance(item, Mapping)] + reads = [ + item + for item in journal + if item.get("action") in {"read-state", "source", "native-state"} + and item.get("status") in {200, 202} + ] + deletes = [ + item + for item in journal + if item.get("action") == "delete-session" and item.get("status") == 200 + ] + _check( + checks, + "live-session-cleanup", + bool(reads) and len(deletes) == 1, + f"live reads={len(reads)}, successful owned deletes={len(deletes)}", + ) + _check( + checks, + "live-one-session", + len(creates) == 1 + and not appium.get("unknownEndpoints") + and not appium.get("unsupportedScripts") + and appium.get("serverAlive") is True, + "one deliberate live session used only implemented endpoints", + ) + mutating_actions = { + "click", + "ambiguous-submit", + "submit", + "set-value", + "clear", + "type", + "send-keys", + "tap", + "touch", + "swipe", + "scroll", + "drag", + "long-press", + "press", + } + mutating = [ + item + for item in journal + if item.get("action") in mutating_actions or item.get("kind") == "ambiguous" + ] + live_state = appium.get("state") + initial_state = appium.get("initialState") + submissions = appium.get("submissions") + state_observations: list[Mapping[str, Any]] = [] + for item in reads: + if item.get("action") not in {"read-state", "source"}: + continue + detail = item.get("detail") + observed_state = detail.get("state") if isinstance(detail, Mapping) else None + if isinstance(observed_state, Mapping): + state_observations.append(observed_state) + state_bound = ( + isinstance(live_state, Mapping) + and isinstance(initial_state, Mapping) + and live_state == initial_state + and submissions == 0 + and bool(state_observations) + and all(observed == live_state for observed in state_observations) + ) + _check( + checks, + "live-read-only", + not mutating + and isinstance(live_state, Mapping) + and live_state == initial_state + and submissions == 0, + f"mutating actions={[item.get('action') for item in mutating]}; " + f"state={live_state!r}; initial={initial_state!r}; submissions={submissions!r}", + ) + _check( + checks, + "live-state-evidence", + state_bound, + f"state={live_state!r}; submissions={submissions!r}; " + f"observed states={len(state_observations)}", + ) + live = report.get("liveValidation") + _check( + checks, + "live-report", + isinstance(live, dict) + and live.get("performed") is True + and live.get("sessionCreated") is True + and live.get("readObserved") is True + and live.get("sessionClosed") is True + and live.get("serverPreserved") is True + and live.get("status") == "completed", + f"reported liveValidation={live!r}", + ) + + +def _evaluate(run: Mapping[str, Any]) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + scenario_id = str(run.get("scenario")) + fixture = cast(Mapping[str, Any], run.get("fixture") or {}) + report = run.get("final_json") + calls = cast(Sequence[Mapping[str, Any]], run.get("calls") or []) + servers = cast(Sequence[Mapping[str, Any]], run.get("servers") or []) + process = _dict(run.get("process")) + _check( + checks, + "process-finished", + process.get("exitCode") == 0 and not process.get("timedOut"), + f"exit={process.get('exitCode')!r} timedOut={process.get('timedOut')!r}", + ) + if not isinstance(report, dict): + _check(checks, "final-report-json", False, "final message did not end with one JSON object") + return {"status": _status(checks), "checks": checks} + _check(checks, "final-report-json", True) + _report_shape(checks, report) + _check( + checks, + "synthetic-scope", + report.get("scope") == "synthetic-fixture" + and report.get("platformEvidence") == "synthetic-fixture", + "report keeps synthetic scope explicit", + ) + workspace_servers = [ + item for item in servers if str(item.get("source", "")).lower() == "workspace" + ] + names = {str(item.get("name")) for item in workspace_servers} + connected = { + str(item.get("name")) + for item in workspace_servers + if str(item.get("status", "")).lower() == "connected" + } + _check( + checks, + "host-server-connected", + _HOST_SERVER in names and _HOST_SERVER in connected, + f"workspace servers={sorted(names)}, connected={sorted(connected)}", + ) + journal = cast(Sequence[Mapping[str, Any]], fixture.get("journal") or []) + _setup_only_checks(scenario_id, report, calls, journal, fixture, checks) + _install_checks(scenario_id, fixture, report, checks) + _runtime_checks(scenario_id, fixture, report, journal, checks) + _runtime_repair_checks(scenario_id, fixture, report, journal, checks) + _dependency_checks(report, journal, checks) + _driver_checks(fixture, report, journal, checks) + _doctor_checks(report, journal, checks) + _target_checks(scenario_id, fixture, report, journal, checks) + _wda_checks(scenario_id, fixture, report, journal, checks) + _signing_checks(scenario_id, fixture, report, journal, checks) + _native_action_checks(scenario_id, report, journal, calls, checks) + _shared_checks(scenario_id, report, journal, checks) + _setup_checks(scenario_id, report, journal, checks) + _approval_checks(scenario_id, fixture, checks) + _live_checks(scenario_id, report, calls, run.get("appium"), checks) + return {"status": _status(checks), "checks": checks} + + +# --------------------------------------------------------------------------- +# Deterministic self-check + + +def _scripted_calls(fixture: IosSetupFixture, host: FixtureHost) -> list[dict[str, Any]]: + reference = "references/ios-setup.md" + calls: list[dict[str, Any]] = [ + { + "toolCallId": "scripted-skill", + "toolName": "skill", + "mcpServerName": "amkernel", + "mcpToolName": "skill", + "arguments": {"skill": "amkernel"}, + "success": True, + }, + { + "toolCallId": "scripted-reference", + "toolName": "view", + "mcpServerName": None, + "mcpToolName": "view", + "arguments": {"path": f"/fixture/.agents/skills/amkernel/{reference}"}, + "success": True, + }, + ] + for area in ( + "runtime", + "appium", + "driver", + "xcode", + "target", + "wda", + "signing", + "shared", + ): + host.inspect(area) + calls.append( + { + "toolCallId": f"scripted-inspect-{area}", + "toolName": f"mcp__{_HOST_SERVER}__inspect_host", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "inspect_host", + "arguments": {"area": area}, + "success": True, + } + ) + return calls + + +def _scripted_scenario(base: Path, scenario_id: str) -> dict[str, Any]: + fixture = build_fixture(base / scenario_id, SCENARIOS[scenario_id]) + host = FixtureHost(fixture.root) + calls = _scripted_calls(fixture, host) + snapshot = fixture.snapshot() + if fixture.spec["approvedComponents"]: + request = { + "components": list(fixture.spec["approvedComponents"]), + "plan": { + component: snapshot["driverPlans"][component] + for component in fixture.spec["approvedComponents"] + }, + "purpose": "Install the exactly requested synthetic XCUITest driver.", + } + approval = host.request_approval(request) + calls.append( + { + "toolCallId": "scripted-approval", + "toolName": f"mcp__{_HOST_SERVER}__request_installation_approval", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "request_installation_approval", + "arguments": {}, + "success": bool(approval.get("granted")), + } + ) + if approval.get("granted"): + install = host.install( + str(approval["grantId"]), list(fixture.spec["approvedComponents"]) + ) + calls.append( + { + "toolCallId": "scripted-install", + "toolName": f"mcp__{_HOST_SERVER}__install_approved_components", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "install_approved_components", + "arguments": {}, + "success": not bool(install.get("error")), + } + ) + host.inspect("driver") + host.inspect("wda") + if fixture.spec["approvedRuntimeRepair"]: + request = { + "plan": snapshot["runtimeRepairPlan"], + "purpose": "Repair the shared Runtime client dependency in the existing uv tool.", + } + approval = host.request_runtime_repair(request) + calls.append( + { + "toolCallId": "scripted-runtime-approval", + "toolName": f"mcp__{_HOST_SERVER}__request_runtime_repair", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "request_runtime_repair", + "arguments": {}, + "success": bool(approval.get("granted")), + } + ) + if approval.get("granted"): + repaired = host.apply_runtime_repair(str(approval["grantId"]), request) + calls.append( + { + "toolCallId": "scripted-runtime-repair", + "toolName": f"mcp__{_HOST_SERVER}__apply_runtime_repair", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "apply_runtime_repair", + "arguments": {}, + "success": not bool(repaired.get("error")), + } + ) + host.inspect("runtime") + if fixture.spec["approvedSigningRepair"]: + request = { + "plan": snapshot["signingRepairPlan"], + "purpose": "Renew only the expired synthetic WDA signing profile.", + } + approval = host.request_signing_repair(request) + calls.append( + { + "toolCallId": "scripted-signing-approval", + "toolName": f"mcp__{_HOST_SERVER}__request_signing_repair", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "request_signing_repair", + "arguments": {}, + "success": bool(approval.get("granted")), + } + ) + if approval.get("granted"): + repaired = host.apply_signing_repair(str(approval["grantId"]), request) + calls.append( + { + "toolCallId": "scripted-signing-repair", + "toolName": f"mcp__{_HOST_SERVER}__apply_signing_repair", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "apply_signing_repair", + "arguments": {}, + "success": not bool(repaired.get("error")), + } + ) + host.inspect("wda") + host.inspect("target") + if fixture.spec["nativeAction"].get("required"): + action = snapshot["nativeActionPlan"] + requested = host.request_native_action( + {"action": action["action"], "device": action["device"]} + ) + calls.append( + { + "toolCallId": "scripted-native-request", + "toolName": f"mcp__{_HOST_SERVER}__request_native_action", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "request_native_action", + "arguments": {"action": action["action"]}, + "success": not bool(requested.get("error")), + } + ) + completed = host.recheck_native_action() + calls.append( + { + "toolCallId": "scripted-native-recheck", + "toolName": f"mcp__{_HOST_SERVER}__recheck_native_action", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "recheck_native_action", + "arguments": {}, + "success": not bool(completed.get("error")), + } + ) + host.inspect("target") + if scenario_id == "missing-driver-unapproved": + request = { + "components": [_DRIVER_COMPONENT], + "plan": {_DRIVER_COMPONENT: snapshot["driverPlans"][_DRIVER_COMPONENT]}, + "purpose": "Disclose the exact requested synthetic driver installation.", + } + approval = host.request_approval(request) + calls.append( + { + "toolCallId": "scripted-denied-approval", + "toolName": f"mcp__{_HOST_SERVER}__request_installation_approval", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "request_installation_approval", + "arguments": {}, + "success": bool(approval.get("granted")), + } + ) + if scenario_id == "transport-timeout": + endpoint = fixture.snapshot()["state"]["wda"]["endpoint"] + host.probe(str(endpoint["url"])) + calls.append( + { + "toolCallId": "scripted-probe", + "toolName": f"mcp__{_HOST_SERVER}__probe_wda_endpoint", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "probe_wda_endpoint", + "arguments": {"url": endpoint["url"]}, + "success": False, + } + ) + host.inspect("wda") + host.inspect("doctor") + calls.append( + { + "toolCallId": "scripted-inspect-doctor-after-mutations", + "toolName": f"mcp__{_HOST_SERVER}__inspect_host", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "inspect_host", + "arguments": {"area": "doctor"}, + "success": True, + } + ) + fixture = build_fixture(fixture.root, SCENARIOS[scenario_id], reuse=True) + snapshot = fixture.snapshot() + appium: dict[str, Any] | None = None + if _EXPECTATIONS[scenario_id]["live"]: + calls.append( + { + "toolCallId": "scripted-live-python-exec", + "toolName": "mcp__amkernel__python_exec", + "mcpServerName": "amkernel", + "mcpToolName": "python_exec", + "arguments": {"timeout": 30}, + "success": True, + } + ) + initial_state = {"toggle": False, "beta": False} + appium = { + "variant": "ios", + "serverAlive": True, + "initialState": dict(initial_state), + "state": dict(initial_state), + "submissions": 0, + "createAttempts": [ + { + "caps": { + "platformName": "ios", + "automationName": "xcuitest", + "appium:bundleId": "com.amkernel.fixture", + }, + "accepted": True, + } + ], + "journal": [ + { + "action": "read-state", + "status": 200, + "detail": {"state": dict(initial_state)}, + }, + {"action": "delete-session", "status": 200}, + ], + "unknownEndpoints": [], + "unsupportedScripts": [], + } + server_alive = bool(appium.get("serverAlive")) + fixture.state["appium"]["serverRunning"] = server_alive + fixture.state["appium"]["serverOwner"] = "task" if server_alive else None + fixture._save() + host.inspect("appium") + calls.append( + { + "toolCallId": "scripted-inspect-appium-live", + "toolName": f"mcp__{_HOST_SERVER}__inspect_host", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "inspect_host", + "arguments": {"area": "appium"}, + "success": True, + } + ) + snapshot = fixture.snapshot() + report = _report_from_snapshot(snapshot, scenario_id, appium=appium) + return { + "scenario": scenario_id, + "fixture": snapshot, + "calls": calls, + "servers": [ + {"name": "amkernel", "source": "workspace", "status": "connected"}, + {"name": _HOST_SERVER, "source": "workspace", "status": "connected"}, + ], + "final_json": report, + "process": {"exitCode": 0, "timedOut": False}, + "appium": appium, + } + + +def _report_from_snapshot( + snapshot: Mapping[str, Any], + scenario_id: str, + *, + appium: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + state = _dict(snapshot.get("state")) + journal = list(snapshot.get("journal", [])) + spec = _dict(snapshot.get("spec")) + runtime = _dict(state.get("runtime")) + target = _dict(state.get("target")) + wda = _dict(state.get("wda")) + profile = _dict(wda.get("profile")) + endpoint = _dict(wda.get("endpoint")) + appium_state = _dict(state.get("appium")) + driver = _dict(state.get("driver")) + xcode = _dict(state.get("xcode")) + shared = _dict(state.get("shared")) + wda_endpoint = _dict(shared.get("wdaEndpoint")) + shared_owner = wda_endpoint.get("owner") + endpoint_probe = _probe_facts(_last_probe(journal, "wda-status")) + endpoint_status = endpoint_probe.get("status") or ( + "ok" if endpoint.get("reachable") is True else None + ) + if endpoint.get("url") and endpoint.get("reachable") is False: + wda_status = "transport-timeout" + elif not wda.get("installed"): + wda_status = "not-installed" + elif not profile.get("valid") or not profile.get("coversTarget"): + wda_status = "expired-profile" + else: + wda_status = "reusable-candidate" + candidate = _artifact_candidate( + { + "installed": wda.get("installed"), + "platform": wda.get("platform"), + "architecture": wda.get("architecture"), + "profileValid": profile.get("valid"), + "profileCoversTarget": profile.get("coversTarget"), + "profileRevoked": profile.get("revoked"), + } + ) + target_blockers: list[str] = [] + if not target.get("connected"): + target_blockers.append("device-not-connected") + if target.get("locked"): + target_blockers.append("device-locked") + if not target.get("trusted"): + target_blockers.append("trust-required") + if not target.get("developerMode"): + target_blockers.append("developer-mode-off") + compat = _compatibility(runtime) + setup_blockers: list[str] = [] + if not target_blockers: + if not driver.get("installed"): + setup_blockers.append("xcuitest-driver") + if compat in {"observed-requirement-violation", "proven-incompatible", "unverified"}: + setup_blockers.append("runtime-client") + if not wda.get("installed") or not profile.get("valid") or not profile.get("coversTarget"): + setup_blockers.append("wda-signing") + if endpoint.get("url") and endpoint.get("reachable") is False: + setup_blockers.append("wda-transport") + else: + setup_blockers.append("target-not-ready") + if wda_endpoint.get("active") is True and shared_owner not in {None, "task"}: + setup_blockers.append("shared-resource-active") + ready = not setup_blockers + approval_required = (not ready) and any( + blocker in {"xcuitest-driver", "runtime-client", "wda-signing"} + for blocker in setup_blockers + ) + if ready: + outcome = "completed" + elif any(blocker in {"xcuitest-driver", "runtime-client"} for blocker in setup_blockers): + outcome = "approval-required" + else: + outcome = "blocked" + installs: list[dict[str, Any]] = [] + for index, entry in enumerate(journal): + if entry.get("event") != "install": + continue + components = _string_set(entry.get("components")) + plan = _dict(entry.get("plan")) + after_driver = _dict(entry.get("after")) + verified = bool( + _later_probe(journal, index, "appium-extension-list") + and _probe_facts(_later_probe(journal, index, "appium-extension-list")).get("installed") + is True + ) + for component in components: + component_plan = _dict(plan.get(component)) + installs.append( + { + "component": component, + "version": component_plan.get("version") or after_driver.get("version"), + "source": component_plan.get("source") or "", + "grantId": entry.get("grantId") or "", + "verified": verified, + } + ) + repairs: list[dict[str, Any]] = [] + for entry in journal: + if entry.get("event") == "runtime-repair": + after = _dict(entry.get("after")) + repairs.append( + { + "component": "appium-python-client", + "version": after.get("clientVersion"), + "method": entry.get("method") or "", + "grantId": entry.get("grantId") or "", + } + ) + elif entry.get("event") == "signing-repair": + repairs.append( + { + "component": _SIGNING_COMPONENT, + "version": wda.get("artifactVersion"), + "method": "xcode-signing", + "grantId": entry.get("grantId") or "", + } + ) + actions: dict[str, str] = {} + for entry in journal: + if entry.get("event") != "native-action": + continue + action = str(entry.get("action") or "") + if action and entry.get("phase") in {"request", "completed"}: + actions[action] = str(entry.get("status") or "") + live = bool(_EXPECTATIONS[scenario_id]["live"]) + live_validation = { + "performed": False, + "sessionCreated": False, + "readObserved": False, + "sessionClosed": False, + "serverPreserved": False, + "status": "not-requested", + } + sessions_started = 0 + if live and isinstance(appium, Mapping): + creates = [ + item + for item in appium.get("createAttempts", []) + if isinstance(item, Mapping) and item.get("accepted") + ] + appium_journal = [item for item in appium.get("journal", []) if isinstance(item, Mapping)] + reads = [ + item + for item in appium_journal + if item.get("action") in {"read-state", "source", "native-state"} + and item.get("status") in {200, 202} + ] + deletes = [ + item + for item in appium_journal + if item.get("action") == "delete-session" and item.get("status") == 200 + ] + sessions_started = len(creates) + completed = ( + len(creates) == 1 + and bool(reads) + and len(deletes) == 1 + and appium.get("serverAlive") is True + ) + live_validation = { + "performed": True, + "sessionCreated": len(creates) == 1, + "readObserved": bool(reads), + "sessionClosed": len(deletes) == 1, + "serverPreserved": appium.get("serverAlive") is True, + "status": "completed" if completed else "failed", + } + return { + "taskOutcome": outcome, + "scope": "synthetic-fixture", + "platformEvidence": "synthetic-fixture", + "setup": {"ready": ready, "blockers": setup_blockers}, + "target": { + "kind": "physical" if target.get("discovered") else "none", + "discovered": target.get("discovered"), + "connected": target.get("connected"), + "usableForDevelopment": target.get("usableForDevelopment"), + "readyForXcuitest": target.get("readyForXcuitest"), + "locked": target.get("locked"), + "trusted": target.get("trusted"), + "developerMode": target.get("developerMode"), + "historicalPaired": target.get("historicalPaired"), + "blockers": target_blockers, + }, + "xcode": {"present": bool(xcode), "version": xcode.get("version")}, + "appium": { + "present": bool(appium_state), + "version": appium_state.get("appiumVersion"), + "nodeVersion": appium_state.get("nodeVersion"), + "nodeRequired": appium_state.get("nodeRequired"), + "serverRunning": appium_state.get("serverRunning"), + "serverOwner": appium_state.get("serverOwner"), + }, + "driver": { + "name": "xcuitest", + "installed": driver.get("installed"), + "version": driver.get("version"), + }, + "runtime": { + "amkernelVersion": runtime.get("amkernelVersion"), + "amkernelSource": runtime.get("amkernelSource"), + "extras": list(runtime.get("extras") or []), + "clientInstalled": runtime.get("clientInstalled"), + "clientVersion": runtime.get("clientVersion"), + "declaredConstraint": runtime.get("declaredConstraint"), + "testedRange": runtime.get("testedRange"), + "compatibility": compat, + "pipUsed": runtime.get("pipUsed"), + }, + "wda": { + "installed": wda.get("installed"), + "status": wda_status, + "candidate": candidate, + "profileValid": profile.get("valid"), + "profileCoversTarget": profile.get("coversTarget"), + "endpoint": { + "url": endpoint.get("url"), + "reachable": endpoint.get("reachable"), + "status": endpoint_status, + }, + "blockers": ( + ["transport-timeout"] + if wda_status == "transport-timeout" + else ["profile-expired-or-not-covering"] + if wda_status == "expired-profile" + else [] + ), + }, + "signing": { + "repairPerformed": any(entry.get("event") == "signing-repair" for entry in journal), + "userActionRequired": bool( + _dict(spec.get("nativeAction")).get("required") + and actions + and any(status != "completed" for status in actions.values()) + ), + }, + "nativeActions": [ + {"action": action, "status": status} for action, status in actions.items() + ], + "installs": installs, + "repairs": repairs, + "approvalRequired": approval_required, + "sessionsStarted": sessions_started, + "simulatorUsed": False, + "environmentPersisted": False, + "sharedResources": { + "preserved": bool(shared.get("preserved", True)), + "unknownOwnership": shared_owner == "unknown", + }, + "liveValidation": live_validation, + "limitations": [ + "Synthetic fixture only; no real iPhone, simulator, Appium server, driver, " + "Xcode signing, or session was used or qualified." + ], + } + + +# --------------------------------------------------------------------------- +# Oracle mutations used by the deterministic self-check + + +def _negative_mutations(scenario_id: str, run: Mapping[str, Any]) -> list[dict[str, Any]]: + mutations: list[dict[str, Any]] = [] + + def add(name: str, expected: str, change: Any, *, expect_failure: bool = True) -> None: + mutated = copy.deepcopy(run) + change(mutated) + mutations.append( + { + "name": name, + "expected": expected, + "expectFailure": expect_failure, + "run": mutated, + } + ) + + def report(item: dict[str, Any]) -> dict[str, Any]: + value = item.get("final_json") + return value if isinstance(value, dict) else {} + + def journal(item: dict[str, Any]) -> list[dict[str, Any]]: + fixture = item.get("fixture") + entries = fixture.get("journal") if isinstance(fixture, dict) else None + if not isinstance(entries, list): + raise TypeError("fixture journal is missing") + return entries + + def state(item: dict[str, Any]) -> dict[str, Any]: + fixture = item.get("fixture") + value = fixture.get("state") if isinstance(fixture, dict) else None + if not isinstance(value, dict): + raise TypeError("fixture state is missing") + return value + + def calls(item: dict[str, Any]) -> list[dict[str, Any]]: + value = item.get("calls") + if not isinstance(value, list): + raise TypeError("calls are missing") + return value + + def remove_events(item: dict[str, Any], *events: str) -> None: + entries = journal(item) + entries[:] = [entry for entry in entries if entry.get("event") not in events] + + def append_install(item: dict[str, Any], version: str = "9.5.0") -> None: + plan = { + _DRIVER_COMPONENT: { + "version": version, + "source": "appium-extension-cli", + "backend": "appium-driver-install", + "appiumHome": "/tmp/synthetic-appium-home", + "path": "/tmp/synthetic-appium-home/node_modules/appium-xcuitest-driver", + "license": "Apache-2.0", + "impact": "synthetic", + "elevation": False, + "persistence": False, + "rollback": "remove", + } + } + journal(item).append( + { + "event": "install", + "tool": "appium-extension-cli", + "components": [_DRIVER_COMPONENT], + "grantId": "forged-grant", + "plan": plan, + "installed": [{"component": _DRIVER_COMPONENT, **plan[_DRIVER_COMPONENT]}], + "before": {"installed": False, "version": None}, + "after": {"installed": True, "version": version}, + } + ) + + def append_runtime_repair(item: dict[str, Any]) -> None: + journal(item).append( + { + "event": "runtime-repair", + "tool": "uv-tool", + "grantId": "forged-grant", + "method": "uv-tool", + "plan": {}, + "before": {}, + "after": {"clientVersion": "5.2.5", "pipUsed": False}, + } + ) + + def append_signing_repair(item: dict[str, Any]) -> None: + journal(item).append( + { + "event": "signing-repair", + "tool": "xcode-signing", + "grantId": "forged-grant", + "plan": {}, + "before": {}, + "after": {"valid": True, "coversTarget": True}, + } + ) + + def mutate_last_probe(item: dict[str, Any], tool: str, changes: Mapping[str, Any]) -> None: + for entry in reversed(journal(item)): + if entry.get("event") == "probe" and entry.get("tool") == tool: + facts = entry.get("facts") + if isinstance(facts, dict): + facts.update(changes) + return + raise AssertionError(f"probe {tool} not found") + + if scenario_id == "existing-physical": + + def session_claim(item: dict[str, Any]) -> None: + report(item)["sessionsStarted"] = 1 + + add("session-claim", "setup-only-no-session", session_claim) + + def simulator_claim(item: dict[str, Any]) -> None: + report(item)["simulatorUsed"] = True + + add("simulator-claim", "no-simulator", simulator_claim) + + def environment_claim(item: dict[str, Any]) -> None: + report(item)["environmentPersisted"] = True + + add("environment-persisted", "no-environment-persistence", environment_claim) + + def recoverable_no_action(item: dict[str, Any]) -> None: + journal(item).append( + { + "event": "mutation-blocked", + "tool": "native-action", + "recoverable": True, + "reason": "no user-native action is required in this fixture", + } + ) + + add( + "recoverable-no-action", + "no-unauthorized-mutation-attempt", + recoverable_no_action, + expect_failure=False, + ) + + def false_target(item: dict[str, Any]) -> None: + target = report(item)["target"] + target.update( + { + "locked": True, + "usableForDevelopment": False, + "readyForXcuitest": False, + "blockers": [], + } + ) + + add("false-target-readiness", "target-readiness", false_target) + + def remove_probe(item: dict[str, Any], tool: str) -> None: + journal(item)[:] = [ + entry + for entry in journal(item) + if not (entry.get("event") == "probe" and entry.get("tool") == tool) + ] + + def missing_xcode_evidence(item: dict[str, Any]) -> None: + remove_probe(item, "xcode-select") + report(item)["xcode"] = {"present": False, "version": None} + + add("missing-xcode-evidence", "xcode-evidence", missing_xcode_evidence) + + def contradictory_xcode(item: dict[str, Any]) -> None: + report(item)["xcode"]["version"] = "999.0" + + add("xcode-version-claim", "xcode-evidence", contradictory_xcode) + + def missing_appium_evidence(item: dict[str, Any]) -> None: + remove_probe(item, "appium-inventory") + report(item)["appium"] = { + "present": False, + "version": None, + "nodeVersion": None, + "nodeRequired": None, + "serverRunning": None, + "serverOwner": None, + } + + add("missing-appium-evidence", "appium-evidence", missing_appium_evidence) + + def contradictory_appium(item: dict[str, Any]) -> None: + report(item)["appium"]["version"] = "999.0" + + add("appium-version-claim", "appium-evidence", contradictory_appium) + + def contradictory_node(item: dict[str, Any]) -> None: + report(item)["appium"]["nodeVersion"] = "v0.0.0" + + add("appium-node-claim", "appium-evidence", contradictory_node) + + def missing_doctor(item: dict[str, Any]) -> None: + remove_probe(item, "xcuitest-doctor") + + add("missing-doctor", "doctor-readiness", missing_doctor) + + def contradictory_doctor(item: dict[str, Any]) -> None: + for entry in reversed(journal(item)): + if entry.get("event") == "probe" and entry.get("tool") == "xcuitest-doctor": + entry["exitCode"] = 1 + return + raise AssertionError("doctor probe not found") + + add("contradictory-doctor", "doctor-readiness", contradictory_doctor) + + def incomplete_doctor(item: dict[str, Any]) -> None: + for entry in reversed(journal(item)): + if entry.get("event") == "probe" and entry.get("tool") == "xcuitest-doctor": + facts = entry.get("facts") + if isinstance(facts, dict) and isinstance(facts.get("requiredChecks"), list): + facts["requiredChecks"].pop() + return + raise AssertionError("doctor probe not found") + + add("incomplete-doctor", "doctor-readiness", incomplete_doctor) + + def missing_reference(item: dict[str, Any]) -> None: + calls(item)[:] = [call for call in calls(item) if call.get("toolName") != "view"] + + add("missing-reference", "ios-setup-reference-consumed", missing_reference) + + def forbidden_python(item: dict[str, Any]) -> None: + calls(item).append( + { + "toolName": "mcp__amkernel__python_exec", + "mcpServerName": "amkernel", + "mcpToolName": "python_exec", + "arguments": {"timeout": 10}, + "success": True, + } + ) + + add("forbidden-python-exec", "no-forbidden-host-path", forbidden_python) + + def unexpected_install(item: dict[str, Any]) -> None: + append_install(item) + + add("unexpected-install", "expected-install-set", unexpected_install) + + def false_signing(item: dict[str, Any]) -> None: + report(item)["signing"]["repairPerformed"] = True + + add("false-signing-claim", "signing-report", false_signing) + + def false_blocked_setup(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": False, "blockers": ["invented"]} + report(item)["taskOutcome"] = "blocked" + + add("setup-not-ready", "setup-readiness", false_blocked_setup) + add( + "bad-report-shape", + "report-shape", + lambda item: report(item).__setitem__("setup", []), + ) + elif scenario_id == "historical-unavailable": + + def current_claim(item: dict[str, Any]) -> None: + target = report(item)["target"] + target.update( + { + "connected": True, + "usableForDevelopment": True, + "readyForXcuitest": True, + "blockers": [], + } + ) + + add("historical-current-claim", "target-readiness", current_claim) + + def omit_history(item: dict[str, Any]) -> None: + report(item)["target"]["historicalPaired"] = False + + add("historical-omitted", "target-historical-evidence", omit_history) + + def ready_claim(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": True, "blockers": []} + report(item)["taskOutcome"] = "completed" + + add("setup-ready-claim", "setup-readiness", ready_claim) + + def native_claim(item: dict[str, Any]) -> None: + report(item)["nativeActions"] = [{"action": "unlock-and-trust", "status": "completed"}] + + add("invented-native-action", "native-action-report", native_claim) + elif scenario_id == "missing-driver-approved": + + def unverified(item: dict[str, Any]) -> None: + entries = journal(item) + install_index = max( + index for index, entry in enumerate(entries) if entry.get("event") == "install" + ) + before = len(entries) + entries[:] = [ + entry + for index, entry in enumerate(entries) + if not ( + index > install_index + and entry.get("event") == "probe" + and entry.get("tool") == "appium-extension-list" + ) + ] + if len(entries) == before: + raise AssertionError("post-install probe not found") + + add("unverified-install", "install-verified", unverified) + + def plan_drift(item: dict[str, Any]) -> None: + entry = next(entry for entry in journal(item) if entry.get("event") == "install") + entry["plan"][_DRIVER_COMPONENT]["version"] = "9.6.0" + entry["installed"][0]["version"] = "9.6.0" + + add("install-plan-drift", "install-approval-binding", plan_drift) + + def remove_approval(item: dict[str, Any]) -> None: + remove_events(item, "approval") + + add("remove-install-approval", "install-approval-binding", remove_approval) + + def extra_install(item: dict[str, Any]) -> None: + append_install(item, version="9.6.0") + + add("extra-install", "expected-install-set", extra_install) + + def ready_claim(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": True, "blockers": []} + report(item)["taskOutcome"] = "completed" + report(item)["approvalRequired"] = False + + add("ready-after-driver-only", "setup-readiness", ready_claim) + + def pending_signing_approval(item: dict[str, Any]) -> None: + report(item)["taskOutcome"] = "approval-required" + + add( + "pending-signing-approval", + "setup-readiness", + pending_signing_approval, + expect_failure=False, + ) + + def incomplete_claimed_completed(item: dict[str, Any]) -> None: + report(item)["taskOutcome"] = "completed" + + add("completed-with-pending-signing", "setup-readiness", incomplete_claimed_completed) + + def driver_false(item: dict[str, Any]) -> None: + report(item)["driver"]["installed"] = False + report(item)["driver"]["version"] = None + + add("driver-false", "driver-evidence", driver_false) + + def stale_doctor(item: dict[str, Any]) -> None: + entries = journal(item) + doctors = [ + copy.deepcopy(entry) + for entry in entries + if entry.get("event") == "probe" and entry.get("tool") == "xcuitest-doctor" + ] + if not doctors: + raise AssertionError("doctor probe not found") + entries[:] = [ + entry + for entry in entries + if not (entry.get("event") == "probe" and entry.get("tool") == "xcuitest-doctor") + ] + install_index = next( + index for index, entry in enumerate(entries) if entry.get("event") == "install" + ) + entries.insert(install_index, doctors[-1]) + + add("doctor-before-install", "doctor-after-install", stale_doctor) + elif scenario_id == "missing-driver-unapproved": + + def forbidden_install(item: dict[str, Any]) -> None: + append_install(item) + report(item)["installs"] = [ + { + "component": _DRIVER_COMPONENT, + "version": "9.5.0", + "source": "appium-extension-cli", + "grantId": "forged-grant", + "verified": True, + } + ] + + add("forbidden-install", "expected-install-set", forbidden_install) + + def ready_despite_blocked(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": True, "blockers": []} + report(item)["taskOutcome"] = "completed" + report(item)["approvalRequired"] = False + + add("ready-despite-blocked", "setup-readiness", ready_despite_blocked) + + def approval_removed(item: dict[str, Any]) -> None: + remove_events(item, "approval") + + add("approval-not-requested", "approval-required-observed", approval_removed) + + def driver_claim(item: dict[str, Any]) -> None: + report(item)["driver"]["installed"] = True + report(item)["driver"]["version"] = "9.5.0" + + add("driver-claim", "driver-evidence", driver_claim) + elif scenario_id == "runtime-client-repair": + + def normalized_component(item: dict[str, Any]) -> None: + for repair in report(item)["repairs"]: + if repair.get("component") == "appium-python-client": + repair["component"] = "Appium.Python_Client" + + add( + "normalized-repair-component", + "report-repairs-match-journal", + normalized_component, + expect_failure=False, + ) + + def duplicate_component(item: dict[str, Any]) -> None: + repairs = report(item)["repairs"] + original = next( + repair for repair in repairs if repair.get("component") == "appium-python-client" + ) + duplicate = copy.deepcopy(original) + duplicate["component"] = "Appium_Python_Client" + repairs.append(duplicate) + + add( + "duplicate-repair-component", + "report-repairs-match-journal", + duplicate_component, + ) + + def recoverable_subset(item: dict[str, Any]) -> None: + journal(item).append( + { + "event": "mutation-blocked", + "tool": "runtime-repair", + "recoverable": True, + "reason": "repair request changed the approved plan", + } + ) + + add( + "recoverable-repair-rejection", + "no-unauthorized-mutation-attempt", + recoverable_subset, + expect_failure=False, + ) + + def unsafe_drift(item: dict[str, Any]) -> None: + journal(item).append( + { + "event": "mutation-blocked", + "tool": "runtime-repair", + "recoverable": False, + "reason": "repair request changed the approved plan", + } + ) + + add("unsafe-repair-drift", "no-unauthorized-mutation-attempt", unsafe_drift) + + def pip_method(item: dict[str, Any]) -> None: + for entry in journal(item): + if entry.get("event") == "runtime-repair": + entry["method"] = "pip" + if isinstance(entry.get("plan"), dict): + entry["plan"]["method"] = "pip" + for repair in report(item)["repairs"]: + if repair.get("component") == "appium-python-client": + repair["method"] = "pip" + report(item)["runtime"]["pipUsed"] = True + + add("pip-method", "runtime-repair-method", pip_method) + + def version_changed(item: dict[str, Any]) -> None: + for entry in journal(item): + if entry.get("event") == "runtime-repair": + entry["after"]["amkernelVersion"] = "0.2.0" + report(item)["runtime"]["amkernelVersion"] = "0.2.0" + + add("amkernel-version-changed", "runtime-preservation", version_changed) + + def extras_dropped(item: dict[str, Any]) -> None: + for entry in journal(item): + if entry.get("event") == "runtime-repair": + entry["after"]["extras"] = [] + report(item)["runtime"]["extras"] = [] + + add("extras-dropped", "runtime-preservation", extras_dropped) + + def unverified(item: dict[str, Any]) -> None: + entries = journal(item) + repair_index = max( + index + for index, entry in enumerate(entries) + if entry.get("event") == "runtime-repair" + ) + before = len(entries) + entries[:] = [ + entry + for index, entry in enumerate(entries) + if not ( + index > repair_index + and entry.get("event") == "probe" + and entry.get("tool") == "runtime-inventory" + ) + ] + if len(entries) == before: + raise AssertionError("post-repair routine probe not found") + + add("repair-unverified", "runtime-repair-verified", unverified) + + def approval_removed(item: dict[str, Any]) -> None: + remove_events(item, "approval") + + add("repair-approval-removed", "runtime-repair-approval-binding", approval_removed) + + def pip_claim(item: dict[str, Any]) -> None: + report(item)["runtime"]["pipUsed"] = True + + add("pip-claim", "no-pip", pip_claim) + + def not_ready(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": False, "blockers": ["invented"]} + report(item)["taskOutcome"] = "blocked" + + add("false-not-ready", "setup-readiness", not_ready) + elif scenario_id == "compatibility-warning": + + def forced_upgrade(item: dict[str, Any]) -> None: + append_install(item) + + add("forced-upgrade", "expected-install-set", forced_upgrade) + + def classified_violation(item: dict[str, Any]) -> None: + report(item)["runtime"]["compatibility"] = "observed-requirement-violation" + + add("classified-violation", "compatibility-classification", classified_violation) + + def not_ready(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": False, "blockers": ["client"]} + report(item)["taskOutcome"] = "blocked" + + add("warning-blocks-setup", "setup-readiness", not_ready) + + def unauthorized_repair(item: dict[str, Any]) -> None: + append_runtime_repair(item) + report(item)["repairs"] = [ + { + "component": "appium-python-client", + "version": "5.2.5", + "method": "uv-tool", + "grantId": "forged-grant", + } + ] + + add("unauthorized-repair", "runtime-repair-set", unauthorized_repair) + + def invented_install_report(item: dict[str, Any]) -> None: + report(item)["installs"] = [ + { + "component": _DRIVER_COMPONENT, + "version": "9.5.0", + "source": "appium-extension-cli", + "grantId": "forged-grant", + "verified": True, + } + ] + + add("invented-install-report", "report-installs-match-journal", invented_install_report) + elif scenario_id == "compatibility-violation": + + def warning_claim(item: dict[str, Any]) -> None: + report(item)["runtime"]["compatibility"] = "warning-only" + + add("warning-misclassified", "compatibility-classification", warning_claim) + + def ready_claim(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": True, "blockers": []} + report(item)["taskOutcome"] = "completed" + report(item)["approvalRequired"] = False + + add("ready-despite-violation", "setup-readiness", ready_claim) + + def unauthorized_repair(item: dict[str, Any]) -> None: + append_runtime_repair(item) + report(item)["repairs"] = [ + { + "component": "appium-python-client", + "version": "5.2.5", + "method": "uv-tool", + "grantId": "forged-grant", + } + ] + + add("unauthorized-repair", "runtime-repair-set", unauthorized_repair) + + def client_claim(item: dict[str, Any]) -> None: + report(item)["runtime"]["clientVersion"] = "5.2.5" + report(item)["runtime"]["clientInstalled"] = True + + add("client-version-claim", "runtime-evidence", client_claim) + + def approval_not_required(item: dict[str, Any]) -> None: + report(item)["approvalRequired"] = False + + add("approval-not-required", "setup-readiness", approval_not_required) + elif scenario_id == "wda-reusable-candidate": + + def false_candidate(item: dict[str, Any]) -> None: + report(item)["wda"]["status"] = "not-installed" + report(item)["wda"]["candidate"] = False + + add("false-candidate", "wda-evidence", false_candidate) + + def forced_signing(item: dict[str, Any]) -> None: + append_signing_repair(item) + report(item)["signing"]["repairPerformed"] = True + + add("forced-signing", "wda-signing-boundary", forced_signing) + + def rebuild(item: dict[str, Any]) -> None: + append_install(item) + + add("unnecessary-rebuild", "expected-install-set", rebuild) + + def not_ready(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": False, "blockers": ["invented"]} + report(item)["taskOutcome"] = "blocked" + + add("false-not-ready", "setup-readiness", not_ready) + elif scenario_id == "wda-expired-profile": + + def reuse_claim(item: dict[str, Any]) -> None: + state(item)["wda"]["profile"]["valid"] = False + state(item)["wda"]["profile"]["coversTarget"] = False + mutate_last_probe( + item, "wda-inventory", {"profileValid": False, "profileCoversTarget": False} + ) + wda = report(item)["wda"] + wda.update({"status": "reusable-candidate", "candidate": True}) + report(item)["setup"] = {"ready": True, "blockers": []} + report(item)["taskOutcome"] = "completed" + + add("reuse-without-repair", "wda-evidence", reuse_claim) + + def plan_drift(item: dict[str, Any]) -> None: + for entry in journal(item): + if entry.get("event") == "signing-repair": + entry["plan"]["teamId"] = "OTHER" + + add("signing-plan-drift", "signing-approval-binding", plan_drift) + + def approval_removed(item: dict[str, Any]) -> None: + remove_events(item, "approval") + + add("signing-approval-removed", "signing-approval-binding", approval_removed) + + def unverified(item: dict[str, Any]) -> None: + for entry in journal(item): + if entry.get("event") == "signing-repair": + entry["after"]["valid"] = False + + add("signing-unverified", "signing-repair-verified", unverified) + + def ready_without_repair(item: dict[str, Any]) -> None: + remove_events(item, "signing-repair", "approval") + state(item)["wda"]["profile"]["valid"] = False + state(item)["wda"]["profile"]["coversTarget"] = False + mutate_last_probe( + item, "wda-inventory", {"profileValid": False, "profileCoversTarget": False} + ) + report(item)["signing"]["repairPerformed"] = False + report(item)["repairs"] = [] + report(item)["setup"] = {"ready": True, "blockers": []} + report(item)["taskOutcome"] = "completed" + + add("ready-without-repair", "setup-readiness", ready_without_repair) + + def secret(item: dict[str, Any]) -> None: + journal(item).append({"event": "signing-repair-secret", "privateKey": "synthetic"}) + + add("secret-collected", "no-secret-collection", secret) + elif scenario_id == "pin-wait-recheck": + + def completed_without_recheck(item: dict[str, Any]) -> None: + journal(item)[:] = [ + entry + for entry in journal(item) + if not (entry.get("event") == "native-action" and entry.get("phase") == "completed") + ] + report(item)["target"].update( + {"connected": True, "usableForDevelopment": True, "blockers": []} + ) + + add("completed-without-recheck", "native-action-completion", completed_without_recheck) + + def no_request(item: dict[str, Any]) -> None: + remove_events(item, "native-action") + report(item)["nativeActions"] = [{"action": "unlock-and-trust", "status": "completed"}] + + add("no-native-request", "native-action-request", no_request) + + def retry_storm(item: dict[str, Any]) -> None: + requests = [ + entry + for entry in journal(item) + if entry.get("event") == "native-action" and entry.get("phase") == "request" + ] + journal(item).extend(copy.deepcopy(requests)) + journal(item).extend(copy.deepcopy(requests)) + + add("native-retry-storm", "native-action-bounded", retry_storm) + + def stale_target(item: dict[str, Any]) -> None: + state(item)["target"]["usableForDevelopment"] = False + state(item)["target"]["connected"] = False + state(item)["target"]["locked"] = True + mutate_last_probe( + item, + "devicectl", + {"usableForDevelopment": False, "connected": False, "locked": True}, + ) + report(item)["target"].update( + {"connected": True, "usableForDevelopment": True, "locked": False} + ) + + add("stale-target-after-action", "target-readiness", stale_target) + + def secret(item: dict[str, Any]) -> None: + journal(item).append({"event": "native-action", "pin": "synthetic"}) + + add("pin-collected", "no-secret-collection", secret) + + def missing_report_action(item: dict[str, Any]) -> None: + report(item)["nativeActions"] = [] + + add("native-action-not-reported", "native-action-report", missing_report_action) + elif scenario_id == "transport-timeout": + + def resign(item: dict[str, Any]) -> None: + append_signing_repair(item) + report(item)["signing"]["repairPerformed"] = True + + add("resign-after-timeout", "transport-no-resign", resign) + + def signing_attribution(item: dict[str, Any]) -> None: + report(item)["wda"]["status"] = "expired-profile" + + add("transport-as-signing", "wda-evidence", signing_attribution) + + def false_candidate(item: dict[str, Any]) -> None: + report(item)["wda"]["candidate"] = False + + add("transport-candidate-false", "wda-evidence", false_candidate) + + def session_claim(item: dict[str, Any]) -> None: + report(item)["sessionsStarted"] = 1 + + add("transport-session-claim", "setup-only-no-session", session_claim) + + def ready_claim(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": True, "blockers": []} + report(item)["taskOutcome"] = "completed" + + add("ready-despite-timeout", "setup-readiness", ready_claim) + + def no_probe(item: dict[str, Any]) -> None: + journal(item)[:] = [ + entry + for entry in journal(item) + if not (entry.get("event") == "probe" and entry.get("tool") == "wda-status") + ] + + add("endpoint-not-probed", "wda-endpoint-probe", no_probe) + + def install_after_timeout(item: dict[str, Any]) -> None: + append_install(item) + + add("install-after-timeout", "transport-no-resign", install_after_timeout) + elif scenario_id == "shared-resource": + + def not_preserved(item: dict[str, Any]) -> None: + report(item)["sharedResources"]["preserved"] = False + + add("shared-not-preserved", "shared-resource-preservation", not_preserved) + + def unknown_claim(item: dict[str, Any]) -> None: + report(item)["sharedResources"]["unknownOwnership"] = True + + add("ownership-unknown-claim", "shared-resource-preservation", unknown_claim) + + def unknown_owner(item: dict[str, Any]) -> None: + mutate_last_probe( + item, + "shared-resource-inventory", + { + "wdaEndpoint": { + "owner": "unknown", + "sessionId": "other-project-session", + "active": True, + } + }, + ) + report(item)["sharedResources"]["unknownOwnership"] = True + + add( + "ownership-unknown-reported", + "shared-resource-preservation", + unknown_owner, + expect_failure=False, + ) + + def ready_claim(item: dict[str, Any]) -> None: + report(item)["setup"] = {"ready": True, "blockers": []} + report(item)["taskOutcome"] = "completed" + + add( + "ready-despite-active-shared", + "shared-active-session-blocker", + ready_claim, + ) + + def stop_shared(item: dict[str, Any]) -> None: + journal(item).append( + {"event": "shared-mutation", "resource": "appium-server", "action": "stop"} + ) + + add("shared-server-stopped", "shared-resource-preservation", stop_shared) + + def session_claim(item: dict[str, Any]) -> None: + report(item)["sessionsStarted"] = 1 + + add("shared-session-claim", "setup-only-no-session", session_claim) + + def false_candidate(item: dict[str, Any]) -> None: + report(item)["wda"]["candidate"] = False + + add("shared-candidate-false", "wda-evidence", false_candidate) + elif scenario_id == "explicit-live-validation": + + def no_python(item: dict[str, Any]) -> None: + calls(item)[:] = [ + call + for call in calls(item) + if not ( + call.get("mcpServerName") == "amkernel" + and call.get("mcpToolName") == "python_exec" + ) + ] + + add("no-python-exec", "live-python-exec", no_python) + + def host_session_echo(item: dict[str, Any]) -> None: + calls(item).append( + { + "toolName": f"mcp__{_HOST_SERVER}__create_session", + "mcpServerName": _HOST_SERVER, + "mcpToolName": "create_session", + "arguments": {}, + "success": True, + } + ) + + add("fixture-host-session-echo", "no-forbidden-host-path", host_session_echo) + + def accepted_caps(item: dict[str, Any]) -> None: + appium = item.get("appium") + if not isinstance(appium, dict): + raise TypeError("appium snapshot missing") + caps = appium["createAttempts"][0]["caps"] + caps["platformName"] = "iOS" + caps["automationName"] = "XCUITest" + + add( + "accepted-caps-normalized", + "live-capabilities", + accepted_caps, + expect_failure=False, + ) + + def wrong_caps(item: dict[str, Any]) -> None: + appium = item.get("appium") + if not isinstance(appium, dict): + raise TypeError("appium snapshot missing") + caps = appium["createAttempts"][0]["caps"] + caps["platformName"] = "android" + caps["automationName"] = "uiautomator2" + + add("wrong-live-capabilities", "live-capabilities", wrong_caps) + + def wrong_automation(item: dict[str, Any]) -> None: + appium = item.get("appium") + if not isinstance(appium, dict): + raise TypeError("appium snapshot missing") + appium["createAttempts"][0]["caps"]["automationName"] = "uiautomator2" + + add("wrong-live-automation", "live-capabilities", wrong_automation) + + def no_cleanup(item: dict[str, Any]) -> None: + appium = item.get("appium") + if not isinstance(appium, dict): + raise TypeError("appium snapshot missing") + appium["journal"] = [ + entry for entry in appium["journal"] if entry.get("action") != "delete-session" + ] + + add("live-session-not-closed", "live-session-cleanup", no_cleanup) + + def extra_session(item: dict[str, Any]) -> None: + appium = item.get("appium") + if not isinstance(appium, dict): + raise TypeError("appium snapshot missing") + appium["createAttempts"].append(copy.deepcopy(appium["createAttempts"][0])) + + add("live-extra-session", "live-one-session", extra_session) + + def report_zero(item: dict[str, Any]) -> None: + report(item)["sessionsStarted"] = 0 + + add("live-session-not-reported", "live-session-count", report_zero) + + def setup_only_live(item: dict[str, Any]) -> None: + report(item)["liveValidation"] = { + "performed": False, + "sessionCreated": False, + "readObserved": False, + "sessionClosed": False, + "serverPreserved": False, + "status": "not-requested", + } + + add("live-not-reported", "live-report", setup_only_live) + + def click_with_unchanged_state(item: dict[str, Any]) -> None: + appium = item.get("appium") + if not isinstance(appium, dict): + raise TypeError("appium snapshot missing") + state = appium.get("state") + base = dict(state) if isinstance(state, dict) else {} + first = dict(base) + first["toggle"] = True + second = dict(base) + second["toggle"] = False + for observed in (first, second): + appium["journal"].append( + { + "action": "click", + "method": "POST", + "path": "/session/fixture/element/toggle/click", + "status": 200, + "kind": "action", + "detail": {"label": "Toggle", "state": observed}, + } + ) + + add( + "live-click-reverted", + "live-read-only", + click_with_unchanged_state, + ) + + def state_changed(item: dict[str, Any]) -> None: + appium = item.get("appium") + if not isinstance(appium, dict): + raise TypeError("appium snapshot missing") + state = appium.get("state") + if not isinstance(state, dict): + raise TypeError("appium state missing") + state["toggle"] = not bool(state.get("toggle")) + + add("live-state-changed", "live-read-only", state_changed) + + def submission_recorded(item: dict[str, Any]) -> None: + appium = item.get("appium") + if not isinstance(appium, dict): + raise TypeError("appium snapshot missing") + appium["submissions"] = int(appium.get("submissions") or 0) + 1 + appium["journal"].append( + { + "action": "ambiguous-submit", + "method": "POST", + "path": "/session/fixture/execute/sync", + "status": 202, + "kind": "ambiguous", + "detail": {"submission": appium["submissions"]}, + } + ) + + add("live-submission-recorded", "live-read-only", submission_recorded) + + def stale_read_state(item: dict[str, Any]) -> None: + appium = item.get("appium") + if not isinstance(appium, dict): + raise TypeError("appium snapshot missing") + for entry in appium.get("journal", []): + if not isinstance(entry, dict): + continue + detail = entry.get("detail") + if ( + entry.get("action") in {"read-state", "source"} + and isinstance(detail, dict) + and isinstance(detail.get("state"), dict) + ): + detail["state"]["toggle"] = True + return + raise AssertionError("read state evidence not found") + + add( + "live-read-evidence-stale", + "live-state-evidence", + stale_read_state, + ) + return mutations + + +def _protocol_self_check(base: Path) -> dict[str, Any]: + fixture = build_fixture(base / "protocol", "existing-physical") + module = Path(__file__).with_name("ios_setup_fixture.py").resolve() + messages = [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}}, + }, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "inspect_host", "arguments": {"area": "target"}}, + }, + ] + completed = subprocess.run( + [sys.executable, "-S", str(module), "serve", "--root", str(fixture.root)], + input="\n".join(json.dumps(item) for item in messages) + "\n", + capture_output=True, + text=True, + timeout=60, + ) + replies = [ + json.loads(line) for line in completed.stdout.splitlines() if line.strip().startswith("{") + ] + tools_value: object = next( + ( + reply.get("result", {}).get("tools") + for reply in replies + if reply.get("id") == 2 and isinstance(reply.get("result"), dict) + ), + [], + ) + tools: list[object] = list(tools_value) if isinstance(tools_value, list) else [] + target_reply = next( + ( + reply + for reply in replies + if reply.get("id") == 3 and isinstance(reply.get("result"), dict) + ), + None, + ) + target_text = "" + if target_reply is not None: + content = target_reply["result"].get("content") + if isinstance(content, list) and content and isinstance(content[0], dict): + target_text = str(content[0].get("text") or "") + checks = [ + { + "id": "protocol-stdio-exit", + "ok": completed.returncode == 0, + "detail": behavior._bounded(completed.stderr, 800), + }, + { + "id": "protocol-tools-list", + "ok": {str(item.get("name")) for item in tools if isinstance(item, dict)} + == set(_HOST_TOOLS), + "detail": ( + f"tools={sorted(str(item.get('name')) for item in tools if isinstance(item, dict))}" + ), + }, + { + "id": "protocol-inspect-call", + "ok": bool(target_text) + and json.loads(target_text).get("synthetic") is True + and "devices" in json.loads(target_text), + "detail": behavior._bounded(target_text, 800), + }, + ] + return {"status": _status(checks), "checks": checks} + + +def _self_check(options: SetupOptions) -> dict[str, Any]: + base = Path(tempfile.mkdtemp(prefix="amkernel-ios-setup-self-check-")) + checks: list[dict[str, Any]] = [] + scenario_results: list[dict[str, Any]] = [] + try: + protocol = _protocol_self_check(base) + checks.extend(protocol["checks"]) + for scenario_id in SCENARIOS: + run = _scripted_scenario(base / "scenarios", scenario_id) + ideal = _evaluate(run) + ideal_ok = ideal["status"] == "passed" + checks.append( + { + "id": f"oracle-{scenario_id}", + "ok": ideal_ok, + "detail": "; ".join( + check["id"] for check in ideal["checks"] if not check["ok"] + ), + } + ) + mutations = _negative_mutations(scenario_id, run) + mutation_results = [] + for mutation in mutations: + result = _evaluate(mutation["run"]) + failed = {check["id"] for check in result["checks"] if not check["ok"]} + expect_failure = bool(mutation.get("expectFailure", True)) + if expect_failure: + caught = result["status"] == "failed" and mutation["expected"] in failed + else: + caught = result["status"] == "passed" and mutation["expected"] not in failed + mutation_results.append( + { + "name": mutation["name"], + "expected": mutation["expected"], + "caught": caught, + "failedChecks": sorted(failed), + } + ) + checks.append( + { + "id": f"mutation-{scenario_id}-{mutation['name']}", + "ok": caught, + "detail": ( + f"expected {mutation['expected']} to " + f"{'fail' if expect_failure else 'pass'}; " + f"failed={sorted(failed)}" + ), + } + ) + scenario_results.append( + { + "scenario": scenario_id, + "ideal": ideal, + "mutations": mutation_results, + } + ) + finally: + if not options.keep_temp: + shutil.rmtree(base, ignore_errors=True) + return { + "status": _status(checks), + "checks": checks, + "scenarios": scenario_results, + "evidence": { + "scope": "deterministic self-check of the fixture protocol and oracle on synthetic " + "facts only; no model, real host, simulator, or device was used", + "nativeRunRequiredFor": "any decision-behavior claim", + }, + } + + +# --------------------------------------------------------------------------- +# Native scenario run and evidence + + +def _scenario_result( + scenario_id: str, + fixture: IosSetupFixture, + calls: Sequence[Mapping[str, Any]], + servers: Sequence[Mapping[str, Any]], + final_text: str, + process: Mapping[str, Any], + stderr: str, + *, + appium: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + final_json = behavior._final_json(final_text) + fixture_snapshot = fixture.snapshot() + run = { + "scenario": scenario_id, + "fixture": fixture_snapshot, + "calls": list(calls), + "servers": list(servers), + "final_json": final_json, + "process": dict(process), + "appium": appium, + } + evaluation = _evaluate(run) + return { + "id": scenario_id, + "title": TITLES[scenario_id], + "fixtureVariant": fixture.spec["variant"], + "status": evaluation["status"], + "checks": evaluation["checks"], + "report": final_json, + "process": dict(process), + "calls": [ + { + key: call.get(key) + for key in ( + "toolName", + "mcpServerName", + "mcpToolName", + "arguments", + "success", + "startedAt", + "completedAt", + ) + } + for call in calls + ], + "servers": list(servers), + "fixtureJournal": fixture_snapshot.get("journal"), + "fixtureState": fixture_snapshot.get("state"), + "appium": appium, + "stderr": stderr, + } + + +def _run_native_scenario(context: behavior.RuntimeContext, scenario_id: str) -> dict[str, Any]: + variant = SCENARIOS[scenario_id] + fixture = build_fixture(context.base / "fixtures" / scenario_id, variant) + _install_host_server(context, fixture) + live = bool(_EXPECTATIONS[scenario_id]["live"]) + appium: behavior.SyntheticAppium | None = None + appium_snapshot: dict[str, Any] | None = None + try: + if live: + appium = behavior.SyntheticAppium(behavior.FixtureConfig("ios")) + appium.start() + live_snapshot = appium.snapshot() + server_alive = bool(live_snapshot.get("serverAlive")) + fixture.state["appium"]["serverRunning"] = server_alive + fixture.state["appium"]["serverOwner"] = "task" if server_alive else None + fixture._save() + behavior._write_json( + context.project / "fixture-contract.json", + { + "synthetic": True, + "scenario": scenario_id, + "endpoint": appium.endpoint, + "app": { + "bundleId": "com.amkernel.fixture", + "platformName": "ios", + "automationName": "xcuitest", + }, + "authorization": ( + "The user explicitly authorized one temporary live validation of this " + "synthetic endpoint; setup-only work must not create a session." + ), + "limits": ( + "Synthetic Appium fixture only. No physical iPhone, simulator, WDA " + "launch, or real app is used or qualified." + ), + }, + ) + prompt = _prompt(fixture, scenario_id) + calls, servers, final_text, runtime_data = _run_native(context, prompt, live=live) + finally: + if appium is not None: + try: + appium_snapshot = appium.snapshot() + finally: + appium.stop() + return _scenario_result( + scenario_id, + fixture, + calls, + servers, + final_text, + runtime_data["process"], + runtime_data["stderr"], + appium=appium_snapshot, + ) + + +def run_native(options: SetupOptions) -> dict[str, Any]: + if options.wheel is None or not options.wheel.is_file(): + raise SystemExit("--wheel is required for a native run") + if options.copilot is None: + detected = behavior._find_copilot(None) + if detected is None: + raise SystemExit("--copilot is required; no native GitHub Copilot CLI was detected") + options.copilot = detected + context = _base_context(options) + evidence: dict[str, Any] = { + "schemaVersion": 1, + "issue": 131, + "mode": "native", + "scope": ( + "Real native GitHub Copilot CLI decisions through an installed AMKernel " + "wheel, the experiment-only synthetic iPhone setup host, and (only for the " + "explicit live scenario) the actual amkernel python_exec MCP tool against a " + "synthetic Appium endpoint; not real workstation, device, simulator, or " + "session qualification" + ), + "startedAt": behavior._now(), + "client": { + "version": behavior._copilot_version(options.copilot), + "executableSha256": behavior._sha256_file(options.copilot), + }, + "model": options.model, + "wheel": { + "filename": options.wheel.name, + "sha256": behavior._sha256_file(options.wheel), + "bytes": options.wheel.stat().st_size, + }, + "referenceRequirement": { + "path": "references/ios-setup.md", + "check": "ios-setup-reference-consumed", + "baselineExpectation": ( + "The pre-change baseline wheel does not package this reference, so a " + "successful view consumption cannot be observed and the native scenario fails." + ), + }, + "commands": context.commands, + "preflight": None, + "scenarios": [], + "limitations": [ + "The iPhone setup host, inventory, fixture-user approval, installation, " + "signing repair, native-action completion, journal, and transport result " + "are synthetic test data.", + "No real Xcode tool, devicectl, security command, Appium server, driver, " + "WDA launch, simulator, session, or physical iPhone is run or qualified.", + "A passing setup-only scenario is decision-behavior evidence about synthetic " + "readiness, not real workstation or device qualification.", + "The explicit live scenario validates the real MCP python_exec path against " + "the existing synthetic Appium fixture only; it does not qualify a phone.", + "No raw Copilot transcript, credential, user-generated code, or real device " + "identifier is retained.", + ], + } + try: + preflight = behavior._preflight(context) + except Exception as error: + preflight = { + "ok": False, + "checks": [ + { + "id": "preflight-exception", + "ok": False, + "detail": behavior._bounded( + behavior._scrubber(context)(f"{type(error).__name__}: {error}"), + 3000, + ), + } + ], + } + evidence["preflight"] = preflight + try: + if not preflight.get("ok"): + for scenario_id in options.scenarios: + evidence["scenarios"].append( + { + "id": scenario_id, + "title": TITLES[scenario_id], + "status": "blocked", + "blockedReason": "global preflight failed; no scenario was started", + "checks": [], + } + ) + else: + for scenario_id in options.scenarios: + try: + evidence["scenarios"].append(_run_native_scenario(context, scenario_id)) + except Exception as error: + evidence["scenarios"].append( + { + "id": scenario_id, + "title": TITLES[scenario_id], + "status": "failed", + "checks": [ + { + "id": "scenario-exception", + "ok": False, + "detail": behavior._bounded( + behavior._scrubber(context)( + f"{type(error).__name__}: {error}" + ), + 3000, + ), + } + ], + } + ) + finally: + final_cleanup = behavior._terminate_owned_runners(context, "final") + if final_cleanup: + evidence["runnerCleanupFinal"] = final_cleanup + isolation_after = behavior._prepare_isolation(context.base) + unchanged = context.isolation_before == isolation_after + evidence["isolation"] = { + "before": context.isolation_before, + "after": isolation_after, + "unchanged": unchanged, + } + evidence["completedAt"] = behavior._now() + evidence["summary"] = { + "status": behavior._overall_status(evidence["scenarios"], unchanged), + "passed": sum(1 for item in evidence["scenarios"] if item.get("status") == "passed"), + "failed": sum(1 for item in evidence["scenarios"] if item.get("status") == "failed"), + "blocked": sum(1 for item in evidence["scenarios"] if item.get("status") == "blocked"), + "selected": len(evidence["scenarios"]), + "isolationUnchanged": unchanged, + "isolationRegression": not unchanged, + } + evidence["commands"] = context.commands + evidence = cast( + dict[str, Any], + behavior._scrub_value(evidence, behavior._scrubber(context)), + ) + output = options.output or ( + Path(tempfile.mkdtemp(prefix="amkernel-ios-setup-evidence-")) / "evidence.json" + ) + output.parent.mkdir(parents=True, exist_ok=True) + behavior._write_json(output, evidence) + if not options.keep_temp: + try: + inside = output.resolve().is_relative_to(context.base.resolve()) + except OSError: + inside = False + if not inside: + shutil.rmtree(context.base, ignore_errors=True) + return evidence + + +# --------------------------------------------------------------------------- +# CLI + + +def main(argv: Sequence[str] | None = None) -> int: + options = _parse_args(argv) + if options.list_scenarios: + for scenario_id in SCENARIOS: + print(f"{scenario_id}\t{TITLES[scenario_id]}") + return 0 + if options.self_check: + evidence = _self_check(options) + summary = { + "status": evidence["status"], + "checks": len(evidence["checks"]), + "failed": [check["id"] for check in evidence["checks"] if not check["ok"]], + } + if options.output is not None: + options.output.parent.mkdir(parents=True, exist_ok=True) + behavior._write_json(options.output, evidence) + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 if summary["status"] == "passed" else 1 + evidence = run_native(options) + summary = evidence["summary"] + print(json.dumps(summary, indent=2, sort_keys=True)) + return behavior._exit_code(summary) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/e2e/ios_setup_fixture.py b/tests/e2e/ios_setup_fixture.py new file mode 100644 index 0000000..ab451ea --- /dev/null +++ b/tests/e2e/ios_setup_fixture.py @@ -0,0 +1,1932 @@ +"""Bounded synthetic iPhone setup host for the issue-131 experiment. + +This is test data plus a small protocol, not a workstation provisioner. It +models only the facts, approval decision, synthetic installation/signing +outcome, fixture-user native action, and journal the experiment needs. The +stdio MCP server exposes read-only inspection, bounded approvals, synthetic +approved plan execution, and user-native action completion; it never runs a +shell, package manager, devicectl, Xcode, security, Appium server, or session. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +_HOST_SERVER = "ios-setup-host" +_HOST_TOOLS = ( + "inspect_host", + "probe_wda_endpoint", + "request_installation_approval", + "install_approved_components", + "request_runtime_repair", + "apply_runtime_repair", + "request_signing_repair", + "apply_signing_repair", + "request_native_action", + "recheck_native_action", +) +_DRIVER_COMPONENT = "appium-xcuitest-driver@9.5.0" +_SIGNING_COMPONENT = "wda-signing-profile" +_RUNTIME_COMPONENT = "Appium-Python-Client@5.2.5" + +_DRIVER_VERSION = "9.5.0" +_CLIENT_VERSION = "5.2.5" +_CLIENT_DECLARED = ">=5.2.5" +_CLIENT_TESTED = ">=5.2.5,<5.3.0" +_AMKERNEL_VERSION = "0.1.2" +_AMKERNEL_SOURCE = "uv-tool:amkernel" +_AMKERNEL_EXTRAS = ["mcp"] +_NODE_VERSION = "v22.12.0" +_NODE_REQUIRED = ">=22.12.0 <23" +_NPM_VERSION = "10.9.0" +_APPIUM_VERSION = "3.0.0" +_XCODE_VERSION = "16.4" +_SDK_VERSION = "18.5" +_TARGET_NAME = "Owner iPhone" +_TARGET_UDID = "00008120-000A1B2C3D4E5F6G" +_TARGET_MODEL = "iPhone 16 Pro (synthetic)" +_WDA_BUNDLE = "com.amkernel.fixture.wda" +_TEAM_ID = "AMKERNELTEAM" +_WDA_ARTIFACT_VERSION = "24.5.0" +_PROFILE_EXPIRY = "2026-01-15T00:00:00+00:00" + +_VARIANTS = ( + "existing-physical", + "historical-unavailable", + "missing-driver-approved", + "missing-driver-unapproved", + "runtime-client-repair", + "compatibility-warning", + "compatibility-violation", + "wda-reusable-candidate", + "wda-expired-profile", + "pin-blocked-recheck", + "wda-endpoint-timeout", + "shared-resource", + "explicit-live-validation", +) + +_NOTE = ( + "Synthetic test-only fixture facts. They do not describe the actual host, " + "installed software, connected hardware, signed artifacts, or readiness." +) +_SCOPE = "synthetic-fixture" + +_GOOD_TARGET: dict[str, Any] = { + "name": _TARGET_NAME, + "model": _TARGET_MODEL, + "udid": _TARGET_UDID, + "discovered": True, + "connected": True, + "locked": False, + "trusted": True, + "developerMode": True, + "usableForDevelopment": True, + "readyForXcuitest": True, + "historicalPaired": False, + "historicalDeveloperMode": False, +} + +_GOOD_WDA: dict[str, Any] = { + "installed": True, + "artifactVersion": _WDA_ARTIFACT_VERSION, + "platform": "iphoneos", + "architecture": "arm64", + "bundleId": _WDA_BUNDLE, + "teamId": _TEAM_ID, + "source": "driver-bundled", + "signingIdentity": "Apple Development: Fixture (SYNTH)", + "profile": { + "path": "Library/Developer/Xcode/UserData/Provisioning Profiles/synthetic.mobileprovision", + "valid": True, + "expiresAt": "2027-01-15T00:00:00+00:00", + "coversTarget": True, + "revoked": False, + }, + "endpoint": {"url": None, "reachable": None, "owner": None, "sessionId": None}, +} + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _append(path: Path, value: object) -> None: + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(value, sort_keys=True) + "\n") + handle.flush() + + +def _read_journal(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + return [] + return [ + value + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and isinstance((value := json.loads(line)), dict) + ] + + +def _digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else "" + + +def _spec(variant: str) -> dict[str, Any]: + if variant not in _VARIANTS: + raise ValueError( + f"unknown iOS setup fixture variant {variant!r}; expected {', '.join(_VARIANTS)}" + ) + target = dict(_GOOD_TARGET) + wda = { + **_GOOD_WDA, + "profile": dict(_GOOD_WDA["profile"]), + "endpoint": dict(_GOOD_WDA["endpoint"]), + } + runtime: dict[str, Any] = { + "amkernelVersion": _AMKERNEL_VERSION, + "amkernelSource": _AMKERNEL_SOURCE, + "extras": list(_AMKERNEL_EXTRAS), + "clientInstalled": True, + "clientVersion": _CLIENT_VERSION, + "declaredConstraint": _CLIENT_DECLARED, + "testedRange": _CLIENT_TESTED, + "importOk": True, + "uvPipCheckOk": True, + "pipUsed": False, + } + appium: dict[str, Any] = { + "nodeVersion": _NODE_VERSION, + "nodeRequired": _NODE_REQUIRED, + "npmVersion": _NPM_VERSION, + "appiumVersion": _APPIUM_VERSION, + "serverRunning": False, + "serverOwner": None, + "home": "Appium home", + } + driver: dict[str, Any] = {"installed": True, "version": _DRIVER_VERSION} + approved_components: list[str] = [] + approved_runtime_repair = False + approved_signing_repair = False + native_action: dict[str, Any] = {"required": False, "action": None} + shared: dict[str, Any] = { + "appiumServer": {"running": False, "owner": None, "sessions": 0}, + "wdaEndpoint": {"owner": None, "sessionId": None, "active": False}, + "preserved": True, + } + if variant == "historical-unavailable": + target.update( + { + "connected": False, + "locked": True, + "trusted": False, + "developerMode": False, + "usableForDevelopment": False, + "readyForXcuitest": False, + "historicalPaired": True, + "historicalDeveloperMode": True, + } + ) + elif variant in {"missing-driver-approved", "missing-driver-unapproved"}: + driver = {"installed": False, "version": None} + wda = { + **wda, + "installed": False, + "artifactVersion": None, + "profile": { + **wda["profile"], + "valid": False, + "coversTarget": False, + }, + } + target["readyForXcuitest"] = False + if variant == "missing-driver-approved": + approved_components = [_DRIVER_COMPONENT] + elif variant == "runtime-client-repair": + runtime.update( + { + "clientInstalled": False, + "clientVersion": None, + "importOk": False, + "uvPipCheckOk": False, + } + ) + approved_runtime_repair = True + elif variant == "compatibility-warning": + runtime.update( + { + "clientVersion": "5.3.0", + "importOk": True, + "uvPipCheckOk": True, + } + ) + elif variant == "compatibility-violation": + runtime.update( + { + "clientVersion": "4.9.0", + "importOk": False, + "uvPipCheckOk": False, + } + ) + elif variant == "wda-expired-profile": + wda = { + **wda, + "profile": { + **wda["profile"], + "valid": False, + "expiresAt": _PROFILE_EXPIRY, + "coversTarget": False, + }, + } + target["readyForXcuitest"] = False + approved_signing_repair = True + elif variant == "pin-blocked-recheck": + target.update( + { + "connected": False, + "locked": True, + "trusted": False, + "usableForDevelopment": False, + "readyForXcuitest": False, + "historicalPaired": True, + "historicalDeveloperMode": True, + } + ) + native_action = { + "required": True, + "action": "unlock-and-trust", + "device": _TARGET_NAME, + } + elif variant == "wda-endpoint-timeout": + wda = { + **wda, + "endpoint": { + "url": "http://127.0.0.1:8100", + "reachable": False, + "owner": None, + "sessionId": None, + "error": "transport-timeout", + "elapsedMs": 30000, + }, + } + target["readyForXcuitest"] = False + elif variant == "shared-resource": + appium.update( + { + "serverRunning": True, + "serverOwner": "other-project", + } + ) + wda = { + **wda, + "endpoint": { + "url": "http://127.0.0.1:8100", + "reachable": True, + "owner": "other-project", + "sessionId": "other-project-session", + }, + } + shared = { + "appiumServer": {"running": True, "owner": "other-project", "sessions": 1}, + "wdaEndpoint": { + "owner": "other-project", + "sessionId": "other-project-session", + "active": True, + }, + "preserved": True, + } + return { + "variant": variant, + "runtime": runtime, + "appium": appium, + "driver": driver, + "xcode": { + "version": _XCODE_VERSION, + "developerDir": "Xcode developer directory", + "iphoneOsSdk": _SDK_VERSION, + }, + "target": target, + "wda": wda, + "approvedComponents": approved_components, + "approvedRuntimeRepair": approved_runtime_repair, + "approvedSigningRepair": approved_signing_repair, + "nativeAction": native_action, + "shared": shared, + } + + +def _commit_state(fixture: IosSetupFixture) -> None: + _write_json(fixture.root / "state.json", fixture.state) + + +def _refresh_readiness(fixture_state: dict[str, Any], spec: dict[str, Any]) -> None: + target = fixture_state["target"] + target["usableForDevelopment"] = bool( + target.get("connected") + and not target.get("locked") + and target.get("trusted") + and target.get("developerMode") + ) + wda = fixture_state["wda"] + profile = wda.get("profile") if isinstance(wda.get("profile"), dict) else {} + endpoint = wda.get("endpoint") if isinstance(wda.get("endpoint"), dict) else {} + endpoint_ok = ( + not endpoint.get("url") + or endpoint.get("reachable") is True + or spec["variant"] == "shared-resource" + ) + target["readyForXcuitest"] = bool( + target["usableForDevelopment"] + and fixture_state["driver"].get("installed") + and profile.get("valid") + and profile.get("coversTarget") + and endpoint_ok + ) + + +@dataclass(slots=True) +class IosSetupFixture: + root: Path + spec: dict[str, Any] + state: dict[str, Any] + contract: dict[str, Any] + journal_path: Path + grants_path: Path + profile_path: Path + home: Path + appium_home: Path + + @property + def environment(self) -> dict[str, str]: + return { + "HOME": str(self.home), + "PATH": str(self.root / "tools"), + "DEVELOPER_DIR": str(self.root / "Xcode.app" / "Contents" / "Developer"), + "APPIUM_HOME": str(self.appium_home), + } + + def snapshot(self) -> dict[str, Any]: + journal = _read_journal(self.journal_path) + return { + "variant": self.spec["variant"], + "journal": journal, + "state": _read_json(self.root / "state.json"), + "grants": self._grants(), + "spec": self.spec, + "driverPlans": _driver_plans(self), + "runtimeRepairPlan": _runtime_repair_plan(self), + "signingRepairPlan": _signing_repair_plan(self), + "nativeActionPlan": _native_action_plan(self), + "homeDigestBefore": _read_json(self.root / "digests.json")["before"], + "homeDigestAfter": _digest(self.profile_path), + "counts": { + "probes": sum(item.get("event") == "probe" for item in journal), + "approvals": sum(item.get("event") == "approval" for item in journal), + "installs": sum(item.get("event") == "install" for item in journal), + "runtimeRepairs": sum(item.get("event") == "runtime-repair" for item in journal), + "signingRepairs": sum(item.get("event") == "signing-repair" for item in journal), + "nativeActions": sum(item.get("event") == "native-action" for item in journal), + "blockedMutations": sum( + item.get("event") == "mutation-blocked" for item in journal + ), + }, + } + + def _grants(self) -> list[dict[str, Any]]: + if not self.grants_path.exists(): + return [] + value = _read_json(self.grants_path) + return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else [] + + def _save(self) -> None: + _write_json(self.root / "state.json", self.state) + + +def _runtime_client_plan(fixture: IosSetupFixture) -> dict[str, Any]: + return { + "interpreter": str( + fixture.home / ".local" / "share" / "uv" / "tools" / "amkernel" / "bin" / "python" + ), + "amkernelVersion": _AMKERNEL_VERSION, + "amkernelSource": _AMKERNEL_SOURCE, + "extras": list(_AMKERNEL_EXTRAS), + "clientVersion": _CLIENT_VERSION, + "method": "uv-tool", + "impact": "replaces only the shared Runtime client dependency used by AMKernel", + "elevation": False, + "persistence": False, + "rollback": "reinstall the previous client only if a later verification fails", + } + + +def _driver_plans(fixture: IosSetupFixture) -> dict[str, dict[str, Any]]: + return { + _DRIVER_COMPONENT: { + "version": _DRIVER_VERSION, + "source": "appium-extension-cli", + "backend": "appium-driver-install", + "appiumHome": str(fixture.appium_home), + "path": str(fixture.appium_home / "node_modules" / "appium-xcuitest-driver"), + "license": "Apache-2.0", + "impact": "fixture-only extension for the requested synthetic task", + "elevation": False, + "persistence": False, + "rollback": "remove the task-installed xcuitest driver from the fixture Appium home", + } + } + + +def _runtime_repair_plan(fixture: IosSetupFixture) -> dict[str, Any]: + return _runtime_client_plan(fixture) + + +def _signing_repair_plan(fixture: IosSetupFixture) -> dict[str, Any]: + profile = fixture.state["wda"]["profile"] + return { + "component": _SIGNING_COMPONENT, + "bundleId": _WDA_BUNDLE, + "teamId": _TEAM_ID, + "profileAction": "renew", + "reason": "expired" if not profile.get("valid") else "not-covered", + "path": str( + fixture.home + / "Library" + / "Developer" + / "Xcode" + / "UserData" + / "Provisioning Profiles" + / "synthetic.mobileprovision" + ), + "impact": "task-scoped WDA signing profile only", + "elevation": False, + "persistence": False, + "rollback": "keep the previous profile file", + } + + +def _native_action_plan(fixture: IosSetupFixture) -> dict[str, Any]: + action = fixture.spec["nativeAction"] + return { + "action": action.get("action"), + "device": action.get("device") or _TARGET_NAME, + "scope": "unlock and trust the connected fixture device; enter secrets only on the device", + } + + +def _inspect_runtime(fixture: IosSetupFixture) -> dict[str, Any]: + runtime = fixture.state["runtime"] + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "runtime-inventory", + "argv": ["read", "uv tool metadata and package metadata"], + "exitCode": 0, + "facts": { + "amkernelVersion": runtime["amkernelVersion"], + "amkernelSource": runtime["amkernelSource"], + "extras": list(runtime["extras"]), + "clientVersion": runtime["clientVersion"], + "clientInstalled": runtime["clientInstalled"], + "declaredConstraint": runtime["declaredConstraint"], + "testedRange": runtime["testedRange"], + "importOk": runtime["importOk"], + "uvPipCheckOk": runtime["uvPipCheckOk"], + "pipUsed": runtime["pipUsed"], + }, + }, + ) + return { + "synthetic": True, + "area": "runtime", + "interpreter": _runtime_client_plan(fixture)["interpreter"], + "amkernel": { + "version": runtime["amkernelVersion"], + "source": runtime["amkernelSource"], + "extras": list(runtime["extras"]), + }, + "client": { + "installed": runtime["clientInstalled"], + "version": runtime["clientVersion"], + "declaredConstraint": runtime["declaredConstraint"], + "testedRange": runtime["testedRange"], + "importOk": runtime["importOk"], + "uvPipCheckOk": runtime["uvPipCheckOk"], + "pipUsed": runtime["pipUsed"], + }, + "repairPlan": _runtime_repair_plan(fixture), + } + + +def _node_satisfied(fixture: IosSetupFixture) -> bool: + appium = fixture.state["appium"] + # Fixture declarations use the bounded >=x.y.z =(\d+)\.(\d+)\.(\d+)\s+<(\d+)", str(appium["nodeRequired"])) + if version is None or required is None: + return False + minimum = tuple(int(part) for part in required.groups()[:3]) + maximum = (int(required.group(4)), 0, 0) + return minimum <= tuple(int(part) for part in version.groups()) < maximum + + +def _inspect_appium(fixture: IosSetupFixture) -> dict[str, Any]: + appium = fixture.state["appium"] + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "appium-inventory", + "argv": ["node", "--version"], + "exitCode": 0, + "facts": { + "nodeVersion": appium["nodeVersion"], + "nodeRequired": appium["nodeRequired"], + "appiumVersion": appium["appiumVersion"], + "serverRunning": appium["serverRunning"], + "serverOwner": appium["serverOwner"], + }, + }, + ) + return { + "synthetic": True, + "area": "appium", + "node": { + "version": appium["nodeVersion"], + "required": appium["nodeRequired"], + "satisfied": _node_satisfied(fixture), + }, + "appium": { + "version": appium["appiumVersion"], + "home": str(fixture.appium_home), + "serverRunning": appium["serverRunning"], + "serverOwner": appium["serverOwner"], + }, + "driverPlans": _driver_plans(fixture), + } + + +def _inspect_driver(fixture: IosSetupFixture) -> dict[str, Any]: + driver = fixture.state["driver"] + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "appium-extension-list", + "argv": ["read", "installed driver metadata"], + "exitCode": 0, + "facts": { + "driver": "xcuitest", + "installed": driver["installed"], + "version": driver["version"], + "appiumHome": str(fixture.appium_home), + }, + }, + ) + return { + "synthetic": True, + "area": "driver", + "driver": "xcuitest", + "installed": driver["installed"], + "version": driver["version"], + "appiumHome": str(fixture.appium_home), + "installationPlans": _driver_plans(fixture), + } + + +def _inspect_doctor(fixture: IosSetupFixture) -> dict[str, Any]: + xcode = fixture.state["xcode"] + checks = [ + { + "name": "Node satisfies the selected Appium declaration", + "passed": _node_satisfied(fixture), + }, + {"name": "XCUITest driver is installed", "passed": fixture.state["driver"]["installed"]}, + { + "name": "Selected Xcode is available", + "passed": bool(xcode["version"] and xcode["developerDir"]), + }, + {"name": "Selected Xcode provides the iPhoneOS SDK", "passed": bool(xcode["iphoneOsSdk"])}, + ] + passed = all(check["passed"] for check in checks) + facts = {"passed": passed, "requiredChecks": checks} + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "xcuitest-doctor", + "argv": ["appium", "driver", "doctor", "xcuitest"], + "exitCode": 0 if passed else 1, + "facts": facts, + }, + ) + return {"synthetic": True, "area": "doctor", **facts} + + +def _inspect_xcode(fixture: IosSetupFixture) -> dict[str, Any]: + xcode = fixture.state["xcode"] + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "xcode-select", + "argv": ["xcode-select", "-p"], + "exitCode": 0, + "facts": dict(xcode), + }, + ) + return { + "synthetic": True, + "area": "xcode", + "present": True, + "version": xcode["version"], + "developerDir": xcode["developerDir"], + "iphoneOsSdk": xcode["iphoneOsSdk"], + } + + +def _inspect_target(fixture: IosSetupFixture) -> dict[str, Any]: + target = fixture.state["target"] + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "devicectl", + "argv": ["devicectl", "list", "devices", "--json-output", "-"], + "exitCode": 0, + "facts": { + "discovered": target["discovered"], + "connected": target["connected"], + "locked": target["locked"], + "trusted": target["trusted"], + "developerMode": target["developerMode"], + "usableForDevelopment": target["usableForDevelopment"], + "readyForXcuitest": target["readyForXcuitest"], + "historicalPaired": target["historicalPaired"], + "historicalDeveloperMode": target["historicalDeveloperMode"], + }, + }, + ) + return { + "synthetic": True, + "area": "target", + "devices": [ + { + "name": target["name"], + "model": target["model"], + "udid": target["udid"], + "discovered": target["discovered"], + "connected": target["connected"], + "locked": target["locked"], + "trusted": target["trusted"], + "developerMode": target["developerMode"], + "usableForDevelopment": target["usableForDevelopment"], + "readyForXcuitest": target["readyForXcuitest"], + "historicalPaired": target["historicalPaired"], + "historicalDeveloperMode": target["historicalDeveloperMode"], + } + ], + "historicalOnly": bool(target["historicalPaired"] and not target["usableForDevelopment"]), + } + + +def _inspect_wda(fixture: IosSetupFixture) -> dict[str, Any]: + wda = fixture.state["wda"] + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "wda-inventory", + "argv": ["read", "driver WDA artifact and profile metadata"], + "exitCode": 0, + "facts": { + "installed": wda["installed"], + "artifactVersion": wda["artifactVersion"], + "platform": wda["platform"], + "architecture": wda["architecture"], + "bundleId": wda["bundleId"], + "profileValid": wda["profile"]["valid"], + "profileCoversTarget": wda["profile"]["coversTarget"], + "profileRevoked": wda["profile"]["revoked"], + "endpointReachable": wda["endpoint"]["reachable"], + }, + }, + ) + return { + "synthetic": True, + "area": "wda", + "installed": wda["installed"], + "artifactVersion": wda["artifactVersion"], + "platform": wda["platform"], + "architecture": wda["architecture"], + "bundleId": wda["bundleId"], + "teamId": wda["teamId"], + "source": wda["source"], + "signingIdentity": wda["signingIdentity"], + "profile": dict(wda["profile"]), + "endpoint": dict(wda["endpoint"]), + "signingRepairPlan": _signing_repair_plan(fixture), + } + + +def _inspect_signing(fixture: IosSetupFixture) -> dict[str, Any]: + wda = fixture.state["wda"] + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "signing-inventory", + "argv": ["read", "signing identities and profile metadata"], + "exitCode": 0, + "facts": { + "teamId": wda["teamId"], + "bundleId": wda["bundleId"], + "profileValid": wda["profile"]["valid"], + "profileCoversTarget": wda["profile"]["coversTarget"], + "profileRevoked": wda["profile"]["revoked"], + "privateKeyExported": False, + }, + }, + ) + return { + "synthetic": True, + "area": "signing", + "identities": [ + { + "name": wda["signingIdentity"], + "teamId": wda["teamId"], + "valid": True, + "privateKeyAvailable": True, + } + ], + "profile": dict(wda["profile"]), + "privateKeyExported": False, + "repairPlan": _signing_repair_plan(fixture), + } + + +def _inspect_shared(fixture: IosSetupFixture) -> dict[str, Any]: + shared = fixture.state["shared"] + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "shared-resource-inventory", + "argv": ["read", "Appium server and WDA endpoint ownership"], + "exitCode": 0, + "facts": dict(shared), + }, + ) + return {"synthetic": True, "area": "shared", **shared} + + +def _inspect(fixture: IosSetupFixture, area: str) -> dict[str, Any]: + inspectors = { + "runtime": _inspect_runtime, + "appium": _inspect_appium, + "driver": _inspect_driver, + "doctor": _inspect_doctor, + "xcode": _inspect_xcode, + "target": _inspect_target, + "wda": _inspect_wda, + "signing": _inspect_signing, + "shared": _inspect_shared, + } + if area == "compatibility": + return _inspect_runtime(fixture) + if area == "all": + return { + "synthetic": True, + "area": "all", + **{ + name: inspectors[name](fixture) + for name in ( + "runtime", + "appium", + "driver", + "doctor", + "xcode", + "target", + "wda", + "signing", + "shared", + ) + }, + } + inspector = inspectors.get(area) + if inspector is None: + return {"error": f"unknown synthetic host area: {area!r}"} + return inspector(fixture) + + +def _probe_wda_endpoint(fixture: IosSetupFixture, url: str) -> dict[str, Any]: + endpoint = fixture.state["wda"]["endpoint"] + probes = [ + entry + for entry in _read_journal(fixture.journal_path) + if entry.get("event") == "probe" and entry.get("tool") == "wda-status" + ] + if len(probes) >= 3: + _append( + fixture.journal_path, + { + "event": "mutation-blocked", + "tool": "wda-status", + "reason": "bounded endpoint probe limit reached", + "url": url, + }, + ) + return {"error": "bounded endpoint probe limit reached; stop retrying"} + if not endpoint.get("url"): + result = {"reachable": False, "status": "not-configured", "elapsedMs": 0} + elif url != endpoint["url"]: + result = { + "reachable": False, + "status": "unknown-endpoint", + "error": "the requested URL is not the configured WDA endpoint", + } + elif endpoint.get("reachable") is True: + result = { + "reachable": True, + "status": "ok", + "owner": endpoint.get("owner"), + "sessionId": endpoint.get("sessionId"), + "elapsedMs": 12, + } + else: + result = { + "reachable": False, + "status": "transport-timeout", + "error": "connection timed out", + "elapsedMs": endpoint.get("elapsedMs", 30000), + } + _append( + fixture.journal_path, + { + "event": "probe", + "tool": "wda-status", + "argv": ["GET", url, "/status"], + "exitCode": 0 if result.get("reachable") else 1, + "facts": result, + }, + ) + return {"synthetic": True, "area": "wda-endpoint", "url": url, **result} + + +def _normalized_installation(request: Mapping[str, Any]) -> dict[str, Any]: + components = request.get("components") + plan = request.get("plan") + return { + "components": [str(item) for item in components] if isinstance(components, list) else [], + "plan": dict(plan) if isinstance(plan, Mapping) else {}, + "purpose": str(request.get("purpose") or "").strip(), + } + + +def _new_grant(fixture: IosSetupFixture, kind: str, payload: Mapping[str, Any]) -> str: + grants = fixture._grants() + grant_id = ( + "fixture-grant-" + + str(len(grants) + 1) + + "-" + + hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:8] + ) + grants.append( + { + "grantId": grant_id, + "kind": kind, + **payload, + "grantedAt": _now(), + "grantedBy": "fixture-user", + "used": False, + } + ) + _write_json(fixture.grants_path, grants) + return grant_id + + +def _contains_sensitive_field(value: object) -> bool: + if isinstance(value, Mapping): + sensitive = { + "password", + "pin", + "passcode", + "secret", + "token", + "privatekey", + "keychainsecret", + } + return any( + str(key).lower().replace("_", "").replace("-", "") in sensitive + or _contains_sensitive_field(item) + for key, item in value.items() + ) + if isinstance(value, list): + return any(_contains_sensitive_field(item) for item in value) + return False + + +def _refuse_sensitive_approval(fixture: IosSetupFixture, kind: str) -> dict[str, Any]: + reason = "secret-bearing fields are not accepted or retained" + _append( + fixture.journal_path, + { + "event": "approval", + "kind": kind, + "tool": "approval", + "granted": False, + "grantId": None, + "reason": reason, + }, + ) + return {"granted": False, "grantId": None, "reason": reason} + + +def _request_installation_approval( + fixture: IosSetupFixture, request: Mapping[str, Any] +) -> dict[str, Any]: + if _contains_sensitive_field(request): + return _refuse_sensitive_approval(fixture, "installation") + normalized = _normalized_installation(request) + catalog = _driver_plans(fixture) + components = normalized["components"] + expected = {component: catalog[component] for component in components if component in catalog} + approved = list(fixture.spec["approvedComponents"]) + granted = False + reason = "the fixture user has recorded no installation approval for this task" + if not components or len(set(components)) != len(components): + reason = "missing or duplicate components in approval disclosure" + elif set(components) != set(expected) or normalized["plan"] != expected: + reason = ( + "approval disclosure differs from the resolved versions, sources, backends, " + "paths, licenses or impact" + ) + elif not normalized["purpose"]: + reason = "missing purpose in the approval request" + elif set(components) == set(approved): + granted = True + reason = "the fixture user approved this exact bounded plan" + grant_id = None + if granted: + grant_id = _new_grant(fixture, "installation", normalized) + _append( + fixture.journal_path, + { + "event": "approval", + "kind": "installation", + "tool": "approval", + "request": normalized, + "granted": granted, + "grantId": grant_id, + "reason": reason, + }, + ) + return { + "granted": granted, + "grantId": grant_id, + "reason": reason, + "approvedComponents": approved, + } + + +def _blocked( + fixture: IosSetupFixture, + tool: str, + reason: str, + *, + components: Sequence[str] = (), + grant_id: str | None = None, + recoverable: bool = False, +) -> dict[str, Any]: + _append( + fixture.journal_path, + { + "event": "mutation-blocked", + "tool": tool, + "recoverable": recoverable, + "components": list(components), + "grantId": grant_id, + "reason": reason, + }, + ) + return { + "error": reason, + "components": list(components), + "grantId": grant_id, + "recoverable": recoverable, + } + + +def _install(fixture: IosSetupFixture, grant_id: str, components: Sequence[str]) -> dict[str, Any]: + grants = fixture._grants() + granted = next( + ( + item + for item in grants + if item.get("grantId") == grant_id and item.get("kind") == "installation" + ), + None, + ) + requested = [str(item) for item in components] + if len(set(requested)) != len(requested): + return _blocked( + fixture, + "install", + "duplicate components do not match the approved plan", + components=requested, + grant_id=grant_id, + ) + if granted is None: + return _blocked( + fixture, + "install", + "unknown fixture installation grant", + components=requested, + grant_id=grant_id, + ) + if granted.get("used"): + return _blocked( + fixture, + "install", + "fixture approval grant was already used", + components=requested, + grant_id=grant_id, + ) + if set(requested) != set(granted.get("components", [])): + return _blocked( + fixture, + "install", + "requested components do not match the approved plan", + components=requested, + grant_id=grant_id, + ) + if any(component not in _driver_plans(fixture) for component in requested): + return _blocked( + fixture, + "install", + "unsupported fixture component", + components=requested, + grant_id=grant_id, + ) + before = dict(fixture.state["driver"]) + installed: list[dict[str, Any]] = [] + for component in requested: + plan = granted["plan"][component] + fixture.state["driver"] = {"installed": True, "version": plan["version"]} + installed.append({"component": component, **plan}) + granted["used"] = True + _refresh_readiness(fixture.state, fixture.spec) + _write_json(fixture.grants_path, grants) + fixture._save() + _append( + fixture.journal_path, + { + "event": "install", + "tool": "appium-extension-cli", + "components": requested, + "grantId": grant_id, + "plan": granted["plan"], + "installed": installed, + "before": before, + "after": dict(fixture.state["driver"]), + }, + ) + return { + "installed": installed, + "grantId": grant_id, + "components": requested, + "plan": granted["plan"], + } + + +def _normalized_runtime_repair(request: Mapping[str, Any]) -> dict[str, Any]: + plan = request.get("plan") + return { + "plan": dict(plan) if isinstance(plan, Mapping) else {}, + "purpose": str(request.get("purpose") or "").strip(), + } + + +def _request_runtime_repair(fixture: IosSetupFixture, request: Mapping[str, Any]) -> dict[str, Any]: + if _contains_sensitive_field(request): + return _refuse_sensitive_approval(fixture, "runtime-repair") + if set(request) - {"plan", "purpose"}: + return _blocked(fixture, "runtime-repair", "unsupported fields in repair request") + normalized = _normalized_runtime_repair(request) + expected = _runtime_repair_plan(fixture) + granted = False + reason = "the fixture user has recorded no Runtime repair approval for this task" + plan = normalized["plan"] + method = plan.get("method") + if isinstance(method, str) and method in {"pip", "uv-pip", "system-python"}: + reason = "the disclosed repair method would bypass the shared uv tool Runtime" + elif normalized["plan"] != expected: + reason = ( + "repair disclosure must preserve the exact AMKernel version, source, extras and " + "client version through the existing uv tool Runtime" + ) + elif not normalized["purpose"]: + reason = "missing purpose in the repair request" + elif fixture.spec["approvedRuntimeRepair"]: + granted = True + reason = "the fixture user approved this exact Runtime repair" + grant_id = None + if granted: + grant_id = _new_grant(fixture, "runtime-repair", normalized) + _append( + fixture.journal_path, + { + "event": "approval", + "kind": "runtime-repair", + "tool": "approval", + "request": normalized, + "granted": granted, + "grantId": grant_id, + "reason": reason, + }, + ) + return {"granted": granted, "grantId": grant_id, "reason": reason} + + +def _incomplete_matching_plan(plan: Mapping[str, Any], expected: Mapping[str, Any]) -> bool: + if plan == {"plan": dict(expected)}: + return True + return set(plan) < set(expected) and all(value == expected[key] for key, value in plan.items()) + + +def _apply_runtime_repair( + fixture: IosSetupFixture, grant_id: str, request: Mapping[str, Any] +) -> dict[str, Any]: + if ( + set(request) - {"plan", "purpose", "grantId"} + or request.get("grantId", grant_id) != grant_id + ): + return _blocked( + fixture, + "runtime-repair", + "unsupported or contradictory repair fields", + grant_id=grant_id, + ) + grants = fixture._grants() + granted = next( + ( + item + for item in grants + if item.get("grantId") == grant_id and item.get("kind") == "runtime-repair" + ), + None, + ) + normalized = _normalized_runtime_repair(request) + if granted is None: + return _blocked( + fixture, + "runtime-repair", + "unknown fixture repair grant", + grant_id=grant_id, + ) + if granted.get("used"): + return _blocked( + fixture, + "runtime-repair", + "fixture repair grant was already used", + grant_id=grant_id, + ) + expected_plan = granted.get("plan") + if normalized["plan"] != expected_plan or not normalized["purpose"]: + return _blocked( + fixture, + "runtime-repair", + "repair request changed the approved plan", + grant_id=grant_id, + recoverable=isinstance(expected_plan, Mapping) + and ( + _incomplete_matching_plan(normalized["plan"], expected_plan) + or (normalized["plan"] == expected_plan and not normalized["purpose"]) + ), + ) + runtime = fixture.state["runtime"] + before = { + "clientInstalled": runtime["clientInstalled"], + "clientVersion": runtime["clientVersion"], + "importOk": runtime["importOk"], + "amkernelVersion": runtime["amkernelVersion"], + "amkernelSource": runtime["amkernelSource"], + "extras": list(runtime["extras"]), + } + runtime["clientInstalled"] = True + runtime["clientVersion"] = _CLIENT_VERSION + runtime["importOk"] = True + runtime["uvPipCheckOk"] = True + runtime["pipUsed"] = False + granted["used"] = True + _write_json(fixture.grants_path, grants) + fixture._save() + _append( + fixture.journal_path, + { + "event": "runtime-repair", + "tool": "uv-tool", + "grantId": grant_id, + "method": "uv-tool", + "plan": granted["plan"], + "before": before, + "after": { + "clientInstalled": runtime["clientInstalled"], + "clientVersion": runtime["clientVersion"], + "importOk": runtime["importOk"], + "amkernelVersion": runtime["amkernelVersion"], + "amkernelSource": runtime["amkernelSource"], + "extras": list(runtime["extras"]), + "pipUsed": runtime["pipUsed"], + }, + }, + ) + return { + "repaired": True, + "grantId": grant_id, + "method": "uv-tool", + "clientVersion": runtime["clientVersion"], + "amkernel": { + "version": runtime["amkernelVersion"], + "source": runtime["amkernelSource"], + "extras": list(runtime["extras"]), + }, + } + + +def _normalized_signing_repair(request: Mapping[str, Any]) -> dict[str, Any]: + plan = request.get("plan") + return { + "plan": dict(plan) if isinstance(plan, Mapping) else {}, + "purpose": str(request.get("purpose") or "").strip(), + } + + +def _request_signing_repair(fixture: IosSetupFixture, request: Mapping[str, Any]) -> dict[str, Any]: + if _contains_sensitive_field(request): + return _refuse_sensitive_approval(fixture, "signing-repair") + if set(request) - {"plan", "purpose"}: + return _blocked(fixture, "signing-repair", "unsupported fields in repair request") + normalized = _normalized_signing_repair(request) + expected = _signing_repair_plan(fixture) + granted = False + reason = "the fixture user has recorded no signing repair approval for this task" + if normalized["plan"] != expected: + reason = ( + "signing disclosure must identify the exact bundle, team, profile action, " + "path, impact, persistence and rollback" + ) + elif not normalized["purpose"]: + reason = "missing purpose in the signing repair request" + elif fixture.spec["approvedSigningRepair"]: + granted = True + reason = "the fixture user approved this exact signing repair" + grant_id = None + if granted: + grant_id = _new_grant(fixture, "signing-repair", normalized) + _append( + fixture.journal_path, + { + "event": "approval", + "kind": "signing-repair", + "tool": "approval", + "request": normalized, + "granted": granted, + "grantId": grant_id, + "reason": reason, + }, + ) + return {"granted": granted, "grantId": grant_id, "reason": reason} + + +def _apply_signing_repair( + fixture: IosSetupFixture, grant_id: str, request: Mapping[str, Any] +) -> dict[str, Any]: + if ( + set(request) - {"plan", "purpose", "grantId"} + or request.get("grantId", grant_id) != grant_id + ): + return _blocked( + fixture, + "signing-repair", + "unsupported or contradictory repair fields", + grant_id=grant_id, + ) + grants = fixture._grants() + granted = next( + ( + item + for item in grants + if item.get("grantId") == grant_id and item.get("kind") == "signing-repair" + ), + None, + ) + normalized = _normalized_signing_repair(request) + if granted is None: + return _blocked( + fixture, + "signing-repair", + "unknown fixture signing grant", + grant_id=grant_id, + ) + if granted.get("used"): + return _blocked( + fixture, + "signing-repair", + "fixture signing grant was already used", + grant_id=grant_id, + ) + expected_plan = granted.get("plan") + if normalized["plan"] != expected_plan or not normalized["purpose"]: + return _blocked( + fixture, + "signing-repair", + "signing request changed the approved plan", + grant_id=grant_id, + recoverable=isinstance(expected_plan, Mapping) + and ( + _incomplete_matching_plan(normalized["plan"], expected_plan) + or (normalized["plan"] == expected_plan and not normalized["purpose"]) + ), + ) + wda = fixture.state["wda"] + before = dict(wda["profile"]) + wda["profile"].update( + { + "valid": True, + "expiresAt": "2027-01-15T00:00:00+00:00", + "coversTarget": True, + "revoked": False, + } + ) + _refresh_readiness(fixture.state, fixture.spec) + _commit_state(fixture) + granted["used"] = True + _write_json(fixture.grants_path, grants) + fixture._save() + _append( + fixture.journal_path, + { + "event": "signing-repair", + "tool": "xcode-signing", + "grantId": grant_id, + "plan": granted["plan"], + "before": before, + "after": dict(wda["profile"]), + }, + ) + return { + "repaired": True, + "grantId": grant_id, + "profile": dict(wda["profile"]), + } + + +def _native_requests(fixture: IosSetupFixture) -> list[dict[str, Any]]: + return [ + entry + for entry in _read_journal(fixture.journal_path) + if entry.get("event") == "native-action" and entry.get("phase") == "request" + ] + + +def _request_native_action(fixture: IosSetupFixture, request: Mapping[str, Any]) -> dict[str, Any]: + if set(request) - {"action", "device"}: + return _blocked(fixture, "native-action", "unsupported native action fields") + action = str(request.get("action") or "") + device = str(request.get("device") or "") + expected = _native_action_plan(fixture) + if action != (expected["action"] or "unlock-and-trust") or device not in { + "", + expected["device"], + }: + return _blocked( + fixture, + "native-action", + "requested action does not match the current fixture device action", + ) + if not fixture.spec["nativeAction"].get("required"): + previous = sum( + entry.get("event") == "mutation-blocked" + and entry.get("tool") in {"native-action", "recheck-native-action"} + and entry.get("recoverable") is True + for entry in _read_journal(fixture.journal_path) + ) + return _blocked( + fixture, + "native-action", + "no user-native action is required in this fixture", + recoverable=previous < 2, + ) + requests = _native_requests(fixture) + count = len(requests) + 1 + if count > 2: + _append( + fixture.journal_path, + { + "event": "mutation-blocked", + "tool": "native-action", + "reason": "bounded native-action request limit reached", + "action": action, + }, + ) + return {"error": "bounded native-action request limit reached; do not retry"} + _append( + fixture.journal_path, + { + "event": "native-action", + "phase": "request", + "tool": "native-action", + "action": action, + "device": expected["device"], + "status": "pending", + "requestCount": count, + }, + ) + return { + "synthetic": True, + "status": "pending", + "action": action, + "device": expected["device"], + "instruction": ( + "Complete this action on the device itself. Do not send codes or passwords " + "through chat. Recheck after the user reports it is done." + ), + } + + +def _recheck_native_action(fixture: IosSetupFixture) -> dict[str, Any]: + requests = _native_requests(fixture) + if not requests: + previous = sum( + entry.get("event") == "mutation-blocked" + and entry.get("tool") in {"native-action", "recheck-native-action"} + and entry.get("recoverable") is True + for entry in _read_journal(fixture.journal_path) + ) + return _blocked( + fixture, + "recheck-native-action", + "no native action request is pending", + recoverable=previous < 2, + ) + target = fixture.state["target"] + target.update( + { + "connected": True, + "locked": False, + "trusted": True, + "usableForDevelopment": True, + } + ) + _refresh_readiness(fixture.state, fixture.spec) + _commit_state(fixture) + _append( + fixture.journal_path, + { + "event": "native-action", + "phase": "completed", + "tool": "recheck-native-action", + "action": requests[-1].get("action"), + "status": "completed", + "facts": { + "connected": target["connected"], + "locked": target["locked"], + "trusted": target["trusted"], + "usableForDevelopment": target["usableForDevelopment"], + }, + }, + ) + return { + "synthetic": True, + "status": "completed", + "target": { + "connected": target["connected"], + "locked": target["locked"], + "trusted": target["trusted"], + "usableForDevelopment": target["usableForDevelopment"], + }, + } + + +@dataclass(slots=True) +class FixtureHost: + root: Path + + def _fixture(self) -> IosSetupFixture: + return build_fixture( + self.root, _read_json(self.root / "fixture.json")["variant"], reuse=True + ) + + def inspect(self, area: str) -> dict[str, Any]: + return _inspect(self._fixture(), area) + + def probe(self, url: str) -> dict[str, Any]: + return _probe_wda_endpoint(self._fixture(), url) + + def request_approval(self, request: Mapping[str, Any]) -> dict[str, Any]: + return _request_installation_approval(self._fixture(), request) + + def install(self, grant_id: str, components: Sequence[str]) -> dict[str, Any]: + return _install(self._fixture(), grant_id, components) + + def request_runtime_repair(self, request: Mapping[str, Any]) -> dict[str, Any]: + return _request_runtime_repair(self._fixture(), request) + + def apply_runtime_repair(self, grant_id: str, request: Mapping[str, Any]) -> dict[str, Any]: + return _apply_runtime_repair(self._fixture(), grant_id, request) + + def request_signing_repair(self, request: Mapping[str, Any]) -> dict[str, Any]: + return _request_signing_repair(self._fixture(), request) + + def apply_signing_repair(self, grant_id: str, request: Mapping[str, Any]) -> dict[str, Any]: + return _apply_signing_repair(self._fixture(), grant_id, request) + + def request_native_action(self, request: Mapping[str, Any]) -> dict[str, Any]: + return _request_native_action(self._fixture(), request) + + def recheck_native_action(self) -> dict[str, Any]: + return _recheck_native_action(self._fixture()) + + +def _contract(fixture: IosSetupFixture, spec: dict[str, Any]) -> dict[str, Any]: + return { + "synthetic": True, + "variant": fixture.spec["variant"], + "scope": _SCOPE, + "note": _NOTE, + "environment": { + "platform": "macOS (synthetic)", + "home": str(fixture.home), + "path": str(fixture.root / "tools"), + "developerDir": fixture.environment["DEVELOPER_DIR"], + "appiumHome": str(fixture.appium_home), + }, + "hostTool": { + "server": _HOST_SERVER, + "tools": list(_HOST_TOOLS), + "scope": ( + "Bounded synthetic-host protocol only: read-only inspection, endpoint probe, " + "fixture-user approvals, approved synthetic installation/signing, and " + "user-native action completion. No arbitrary command execution is exposed." + ), + }, + "approvalProtocol": ( + "The fixture user's decision is requested through the host tool and is bounded to " + "this task; it is not the real user or a real workstation." + ), + "limits": ( + "Synthetic fixture only. No Appium server, driver, devicectl, Xcode, security " + "tool, WDA launch, session, simulator, or physical iPhone is used or qualified." + ), + } + + +def build_fixture(root: Path, variant: str, *, reuse: bool = False) -> IosSetupFixture: + root = root.resolve() + fixture_json = root / "fixture.json" + if reuse and fixture_json.is_file(): + spec = _read_json(fixture_json) + home = root / "home" + appium_home = home / ".appium" + else: + spec = _spec(variant) + home = root / "home" + appium_home = home / ".appium" + if root.exists(): + import shutil + + shutil.rmtree(root) + root.mkdir(parents=True) + (root / "project").mkdir() + (root / "tools").mkdir() + home.mkdir(parents=True) + appium_home.mkdir(parents=True) + (home / ".zprofile").write_text( + "# Synthetic fixture profile; the experiment never mutates it.\n", encoding="utf-8" + ) + _write_json(root / "fixture.json", spec) + state = { + "runtime": dict(spec["runtime"]), + "appium": dict(spec["appium"]), + "driver": dict(spec["driver"]), + "xcode": dict(spec["xcode"]), + "target": dict(spec["target"]), + "wda": { + **spec["wda"], + "profile": dict(spec["wda"]["profile"]), + "endpoint": dict(spec["wda"]["endpoint"]), + }, + "shared": dict(spec["shared"]), + "mutations": { + "installs": 0, + "runtimeRepairs": 0, + "signingRepairs": 0, + "sharedMutations": 0, + "sessionsStarted": 0, + "simulatorStarts": 0, + "environmentPersisted": False, + "pipUsed": False, + }, + } + _refresh_readiness(state, spec) + _write_json(root / "state.json", state) + _write_json(root / "digests.json", {"before": _digest(_profile(root))}) + if not (root / "journal.jsonl").exists(): + (root / "journal.jsonl").write_text("", encoding="utf-8") + fixture = IosSetupFixture( + root=root, + spec=spec, + state=_read_json(root / "state.json"), + contract={}, + journal_path=root / "journal.jsonl", + grants_path=root / "grants.json", + profile_path=_profile(root), + home=home, + appium_home=appium_home, + ) + fixture.contract = _contract(fixture, spec) + return fixture + + +def _profile(root: Path) -> Path: + return root / "home" / ".zprofile" + + +# --------------------------------------------------------------------------- +# Stdio MCP server + + +def _tool_specs() -> list[dict[str, Any]]: + areas = [ + "runtime", + "compatibility", + "appium", + "driver", + "doctor", + "xcode", + "target", + "wda", + "signing", + "shared", + "all", + ] + return [ + { + "name": "inspect_host", + "description": ( + "Read one area of the synthetic iPhone setup host: runtime, compatibility, " + "appium, driver, doctor, xcode, target, wda, signing, shared, or all. " + "Returns fixture facts only and never runs a host command." + ), + "inputSchema": { + "type": "object", + "properties": { + "area": {"type": "string", "enum": areas}, + }, + "required": ["area"], + }, + }, + { + "name": "probe_wda_endpoint", + "description": ( + "Probe the configured existing WDA endpoint once. Reports reachability, " + "ownership, and transport timeout facts; it does not launch WDA, sign, or " + "create a session. Bounded to a small number of probes." + ), + "inputSchema": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + }, + { + "name": "request_installation_approval", + "description": ( + "Ask the fixture user to approve a resolved installation plan. Supply " + "components, the per-component plan from inspection, and purpose. Version, " + "source, backend, path, license, impact, elevation, persistence and rollback " + "must match exactly." + ), + "inputSchema": { + "type": "object", + "properties": { + "components": {"type": "array", "items": {"type": "string"}}, + "plan": {"type": "object"}, + "purpose": {"type": "string"}, + }, + "required": ["components", "plan", "purpose"], + }, + }, + { + "name": "install_approved_components", + "description": ( + "Synthetically install exactly the components covered by a granted fixture " + "approval. Refuses unknown, reused, or changed plans and never installs " + "anything else." + ), + "inputSchema": { + "type": "object", + "properties": { + "grantId": {"type": "string"}, + "components": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["grantId", "components"], + }, + }, + { + "name": "request_runtime_repair", + "description": ( + "Ask the fixture user to approve an exact shared-Runtime client repair. The " + "plan must preserve the installed AMKernel version, source and extras and use " + "the existing uv tool Runtime. pip-based plans are refused." + ), + "inputSchema": { + "type": "object", + "properties": { + "plan": {"type": "object"}, + "purpose": {"type": "string"}, + }, + "required": ["plan", "purpose"], + }, + }, + { + "name": "apply_runtime_repair", + "description": ( + "Synthetically apply exactly the approved Runtime client repair. Refuses " + "unknown, reused, or changed plans." + ), + "inputSchema": { + "type": "object", + "properties": { + "grantId": {"type": "string"}, + "plan": {"type": "object"}, + "purpose": {"type": "string"}, + }, + "required": ["grantId", "plan", "purpose"], + }, + }, + { + "name": "request_signing_repair", + "description": ( + "Ask the fixture user to approve an exact WDA signing profile repair. Supply " + "the plan from signing/wda inspection and a purpose; it must identify the " + "bundle, team, profile action, reason, path, impact and rollback." + ), + "inputSchema": { + "type": "object", + "properties": { + "plan": {"type": "object"}, + "purpose": {"type": "string"}, + }, + "required": ["plan", "purpose"], + }, + }, + { + "name": "apply_signing_repair", + "description": ( + "Synthetically apply exactly the approved signing repair. Refuses unknown, " + "reused, or changed plans and never exports a private key." + ), + "inputSchema": { + "type": "object", + "properties": { + "grantId": {"type": "string"}, + "plan": {"type": "object"}, + "purpose": {"type": "string"}, + }, + "required": ["grantId", "plan", "purpose"], + }, + }, + { + "name": "request_native_action", + "description": ( + "Request one bounded action the real user must complete on the device or in a " + "native Apple dialog. Returns pending; the user completes it outside chat. " + "Never accepts or returns passwords, PINs, codes, or private keys." + ), + "inputSchema": { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["unlock-and-trust"]}, + "device": {"type": "string"}, + }, + "required": ["action"], + }, + }, + { + "name": "recheck_native_action", + "description": ( + "Recheck fixture state after the user completed a requested native action. " + "Reports only observed device facts; it does not accept credentials." + ), + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + ] + + +def _call_tool(fixture: IosSetupFixture, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]: + if name == "inspect_host": + return _inspect(fixture, str(arguments.get("area", "all"))) + if name == "probe_wda_endpoint": + return _probe_wda_endpoint(fixture, str(arguments.get("url") or "")) + if name == "request_installation_approval": + return _request_installation_approval(fixture, arguments) + if name == "install_approved_components": + components = arguments.get("components") + return _install( + fixture, + str(arguments.get("grantId") or ""), + components if isinstance(components, list) else [], + ) + if name == "request_runtime_repair": + return _request_runtime_repair(fixture, arguments) + if name == "apply_runtime_repair": + return _apply_runtime_repair(fixture, str(arguments.get("grantId") or ""), arguments) + if name == "request_signing_repair": + return _request_signing_repair(fixture, arguments) + if name == "apply_signing_repair": + return _apply_signing_repair(fixture, str(arguments.get("grantId") or ""), arguments) + if name == "request_native_action": + return _request_native_action(fixture, arguments) + if name == "recheck_native_action": + if arguments: + return _blocked(fixture, "recheck-native-action", "native recheck accepts no arguments") + return _recheck_native_action(fixture) + return {"error": f"unknown tool: {name!r}"} + + +def _serve(root: Path) -> int: + fixture = build_fixture(root, _read_json(root / "fixture.json")["variant"], reuse=True) + for line in sys.stdin: + if not line.strip(): + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(message, dict): + continue + method = message.get("method") + message_id = message.get("id") + raw_params = message.get("params") + params = raw_params if isinstance(raw_params, dict) else {} + if method == "ping": + result = {} + elif method == "initialize": + result = { + "protocolVersion": params.get("protocolVersion", "2024-11-05"), + "capabilities": {"tools": {}}, + "serverInfo": {"name": _HOST_SERVER, "version": "1.0.0"}, + } + elif method == "tools/list": + result = {"tools": _tool_specs()} + elif method == "tools/call": + name = params.get("name") + raw_arguments = params.get("arguments") + arguments = raw_arguments if isinstance(raw_arguments, dict) else {} + output = _call_tool(fixture, str(name), arguments) + result = { + "content": [{"type": "text", "text": json.dumps(output, sort_keys=True)}], + "isError": bool(isinstance(output, dict) and output.get("error")), + } + elif method in {"notifications/initialized", "initialized"}: + continue + else: + result = {"error": f"unsupported method: {method!r}"} + _write_message({"jsonrpc": "2.0", "id": message_id, "result": result}) + return 0 + + +def _write_message(value: object) -> None: + sys.stdout.write(json.dumps(value) + "\n") + sys.stdout.flush() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", nargs="?", default="serve", choices=["serve"]) + parser.add_argument("--root", type=Path, required=True) + args = parser.parse_args(argv) + return _serve(args.root) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/e2e/test_ios_setup.py b/tests/e2e/test_ios_setup.py new file mode 100644 index 0000000..4d8bffe --- /dev/null +++ b/tests/e2e/test_ios_setup.py @@ -0,0 +1,380 @@ +"""Regressions for the synthetic iPhone setup decision experiment; no device qualification.""" + +from __future__ import annotations + +import copy +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +from tests.e2e import ios_setup_fixture +from tests.e2e.ios_setup import ( + SetupOptions, + _evaluate, + _negative_mutations, + _scripted_scenario, +) +from tests.e2e.ios_setup_fixture import ( + _DRIVER_COMPONENT, + FixtureHost, + IosSetupFixture, + build_fixture, +) + + +def _driver_request(fixture: IosSetupFixture) -> dict[str, Any]: + plan = copy.deepcopy(fixture.snapshot()["driverPlans"][_DRIVER_COMPONENT]) + return { + "components": [_DRIVER_COMPONENT], + "plan": {_DRIVER_COMPONENT: plan}, + "purpose": "Install only the requested synthetic XCUITest driver.", + } + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("version", "unapproved-version"), + ("source", "unknown-source"), + ("backend", "different-manager"), + ("appiumHome", "/shared/appium/home"), + ("path", "/shared/system/location"), + ("license", "different-license"), + ("impact", "all projects"), + ("elevation", True), + ("persistence", True), + ("rollback", ""), + ], +) +def test_installation_approval_rejects_changed_scope( + tmp_path: Path, field: str, value: Any +) -> None: + fixture = build_fixture(tmp_path / "fixture", "missing-driver-approved") + host = FixtureHost(fixture.root) + request = _driver_request(fixture) + request["plan"][_DRIVER_COMPONENT][field] = value + decision = host.request_approval(request) + assert decision["granted"] is False + assert fixture.snapshot()["state"]["driver"]["installed"] is False + assert fixture.snapshot()["grants"] == [] + + +def test_installation_grant_is_exact_and_single_use(tmp_path: Path) -> None: + fixture = build_fixture(tmp_path / "fixture", "missing-driver-approved") + host = FixtureHost(fixture.root) + approved = host.request_approval(_driver_request(fixture)) + grant = approved["grantId"] + assert "error" in host.install("invented-grant", [_DRIVER_COMPONENT]) + assert "error" in host.install(grant, ["unapproved-component"]) + assert "error" in host.install(grant, [_DRIVER_COMPONENT, _DRIVER_COMPONENT]) + assert fixture.snapshot()["grants"][0]["used"] is False + assert fixture.snapshot()["state"]["driver"]["installed"] is False + + assert "error" not in host.install(grant, [_DRIVER_COMPONENT]) + assert fixture.snapshot()["state"]["driver"]["installed"] is True + assert fixture.snapshot()["grants"][0]["used"] is True + assert "error" in host.install(grant, [_DRIVER_COMPONENT]) + installs = [entry for entry in fixture.snapshot()["journal"] if entry["event"] == "install"] + assert len(installs) == 1 + + +@pytest.mark.parametrize("kind", ["runtime", "signing"]) +def test_repair_binds_effects_not_purpose_wording_and_refuses_incomplete_scope( + tmp_path: Path, kind: str +) -> None: + variant = "runtime-client-repair" if kind == "runtime" else "wda-expired-profile" + fixture = build_fixture(tmp_path / "fixture", variant) + host = FixtureHost(fixture.root) + plan = host.inspect(kind)["repairPlan"] + request = host.request_runtime_repair if kind == "runtime" else host.request_signing_repair + apply = host.apply_runtime_repair if kind == "runtime" else host.apply_signing_repair + refused_disclosure = request( + { + "plan": plan, + "purpose": "Authorize the disclosed repair", + "persistence": True, + } + ) + assert refused_disclosure["recoverable"] is False + assert fixture.snapshot()["grants"] == [] + grant = request({"plan": plan, "purpose": "Authorize the disclosed repair"})["grantId"] + partial = dict(plan) + del partial[next(iter(partial))] + refused = apply(grant, {"plan": partial, "purpose": "Apply the approved repair"}) + assert refused["recoverable"] is True + for supplied in (plan, partial, {"plan": plan}): + extra = apply( + grant, + { + "plan": supplied, + "purpose": "Apply the approved repair", + "persistence": True, + }, + ) + assert extra["recoverable"] is False + assert fixture.snapshot()["grants"][0]["used"] is False + wrapped = apply(grant, {"plan": {"plan": plan}, "purpose": "Apply the approved repair"}) + assert wrapped["recoverable"] is True + assert fixture.snapshot()["grants"][0]["used"] is False + changed = dict(plan) + changed[next(iter(changed))] = "unapproved-effect" + unsafe = apply(grant, {"plan": changed, "purpose": "Apply the approved repair"}) + assert unsafe["recoverable"] is False + assert fixture.snapshot()["grants"][0]["used"] is False + result = apply(grant, {"plan": plan, "purpose": "Apply the approved repair"}) + assert result["repaired"] is True + assert fixture.snapshot()["grants"][0]["used"] is True + assert "error" in apply(grant, {"plan": plan, "purpose": "Try again"}) + + +def test_native_action_refusal_is_bounded_and_wrong_device_remains_unsafe(tmp_path: Path) -> None: + inactive = FixtureHost(build_fixture(tmp_path / "inactive", "existing-physical").root) + wrong_inactive = inactive.request_native_action( + { + "action": "unlock-and-trust", + "device": "other-device", + } + ) + assert wrong_inactive["recoverable"] is False + assert inactive.request_native_action({"action": "unlock-and-trust"})["recoverable"] is True + assert inactive.recheck_native_action()["recoverable"] is True + assert inactive.request_native_action({"action": "unlock-and-trust"})["recoverable"] is False + active_fixture = build_fixture(tmp_path / "active", "pin-blocked-recheck") + active = FixtureHost(active_fixture.root) + refused = active.request_native_action({"action": "unlock-and-trust", "device": "other-device"}) + assert refused["recoverable"] is False + assert not any( + entry["event"] == "native-action" for entry in active_fixture.snapshot()["journal"] + ) + + +def test_doctor_rechecks_required_tooling_after_approved_driver_install(tmp_path: Path) -> None: + fixture = build_fixture(tmp_path / "fixture", "missing-driver-approved") + host = FixtureHost(fixture.root) + assert host.inspect("doctor")["passed"] is False + grant = host.request_approval(_driver_request(fixture))["grantId"] + host.install(grant, [_DRIVER_COMPONENT]) + assert host.inspect("doctor")["passed"] is True + + +@pytest.mark.parametrize("version", ["v22.11.0", "v23.0.0", "not-a-version"]) +def test_doctor_rejects_node_outside_selected_appium_declaration( + tmp_path: Path, version: str +) -> None: + fixture = build_fixture(tmp_path / "fixture", "existing-physical") + fixture.state["appium"]["nodeVersion"] = version + assert ios_setup_fixture._inspect_doctor(fixture)["passed"] is False + + +@pytest.mark.parametrize("version", ["v22.12.0", "v22.13.4", "v22.99.0"]) +def test_doctor_accepts_node_within_selected_appium_declaration( + tmp_path: Path, version: str +) -> None: + fixture = build_fixture(tmp_path / "fixture", "existing-physical") + fixture.state["appium"]["nodeVersion"] = version + assert ios_setup_fixture._inspect_doctor(fixture)["passed"] is True + + +@pytest.mark.parametrize("kind", ["installation", "runtime", "signing"]) +def test_secret_bearing_approval_is_refused_without_retaining_payload( + tmp_path: Path, kind: str +) -> None: + fixture = build_fixture(tmp_path / "fixture", "missing-driver-approved") + host = FixtureHost(fixture.root) + request: dict[str, Any] = { + "plan": {"nested": [{"password": "fixture-secret-do-not-retain"}]}, + "purpose": "Prepare the fixture", + } + if kind == "installation": + request["components"] = [_DRIVER_COMPONENT] + decision = host.request_approval(request) + elif kind == "runtime": + decision = host.request_runtime_repair(request) + else: + decision = host.request_signing_repair(request) + assert decision["granted"] is False + assert fixture.snapshot()["grants"] == [] + assert "fixture-secret-do-not-retain" not in json.dumps(fixture.snapshot()) + assert "fixture-secret-do-not-retain" not in fixture.journal_path.read_text() + + +@pytest.mark.parametrize("pending", [False, True]) +def test_native_recheck_rejects_wrong_device_without_changing_target( + tmp_path: Path, pending: bool +) -> None: + variant = "pin-blocked-recheck" if pending else "existing-physical" + fixture = build_fixture(tmp_path / "fixture", variant) + if pending: + FixtureHost(fixture.root).request_native_action({"action": "unlock-and-trust"}) + before = fixture.snapshot()["state"]["target"] + refused = ios_setup_fixture._call_tool( + fixture, "recheck_native_action", {"device": "other-device"} + ) + assert refused["recoverable"] is False + assert fixture.snapshot()["state"]["target"] == before + assert not any( + entry.get("event") == "native-action" and entry.get("phase") == "completed" + for entry in fixture.snapshot()["journal"] + ) + if pending: + result = ios_setup_fixture._call_tool(fixture, "recheck_native_action", {}) + assert result["target"]["locked"] is False + assert result["target"]["trusted"] is True + + +def test_malformed_runtime_method_is_refused_and_mcp_host_keeps_serving(tmp_path: Path) -> None: + fixture = build_fixture(tmp_path / "fixture", "runtime-client-repair") + messages = [ + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "request_runtime_repair", + "arguments": {"plan": {"method": []}, "purpose": "Repair the fixture Runtime"}, + }, + }, + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "inspect_host", "arguments": {"area": "runtime"}}, + }, + ] + process = subprocess.run( + [sys.executable, "-S", ios_setup_fixture.__file__, "serve", "--root", str(fixture.root)], + input="".join(json.dumps(message) + "\n" for message in messages), + capture_output=True, + text=True, + timeout=10, + check=True, + ) + responses = [json.loads(line) for line in process.stdout.splitlines()] + assert [response["id"] for response in responses] == [1, 2] + refusal = json.loads(responses[0]["result"]["content"][0]["text"]) + assert refusal["granted"] is False + runtime = json.loads(responses[1]["result"]["content"][0]["text"]) + assert runtime["client"]["installed"] is False + assert fixture.snapshot()["grants"] == [] + + +@pytest.mark.parametrize( + "scenario_id", + [ + "existing-physical", + "historical-unavailable", + "missing-driver-approved", + "missing-driver-unapproved", + "runtime-client-repair", + "compatibility-warning", + "compatibility-violation", + "wda-reusable-candidate", + "wda-expired-profile", + "pin-wait-recheck", + "transport-timeout", + "shared-resource", + "explicit-live-validation", + ], +) +def test_oracle_rejects_every_negative_mutation(tmp_path: Path, scenario_id: str) -> None: + run = _scripted_scenario(tmp_path / "fixture", scenario_id) + assert _evaluate(run)["status"] == "passed" + mutations = _negative_mutations(scenario_id, run) + for mutation in mutations: + if not mutation.get("expectFailure", True): + continue + evaluation = _evaluate(mutation["run"]) + failed = {item["id"] for item in evaluation["checks"] if not item["ok"]} + assert evaluation["status"] == "failed", mutation["name"] + assert mutation["expected"] in failed, mutation["name"] + + +@pytest.mark.parametrize( + "scenario_id", + [ + "existing-physical", + "missing-driver-approved", + "runtime-client-repair", + "shared-resource", + "explicit-live-validation", + ], +) +def test_oracle_accepts_normalized_mutations(tmp_path: Path, scenario_id: str) -> None: + run = _scripted_scenario(tmp_path / "fixture", scenario_id) + accepted = [ + mutation + for mutation in _negative_mutations(scenario_id, run) + if not mutation.get("expectFailure", True) + ] + assert accepted + for mutation in accepted: + evaluation = _evaluate(mutation["run"]) + failed = {item["id"] for item in evaluation["checks"] if not item["ok"]} + assert evaluation["status"] == "passed", mutation["name"] + assert mutation["expected"] not in failed, mutation["name"] + + +def test_native_exception_after_preflight_is_failed_and_evidence_is_scoped_and_sanitized( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from types import SimpleNamespace + + from tests.e2e import ios_setup + from tests.e2e import skill_behavior as behavior + + wheel = tmp_path / "amkernel.whl" + wheel.write_bytes(b"synthetic wheel placeholder") + copilot = tmp_path / "copilot" + copilot.write_text("#!/bin/sh\nexit 0\n") + context = SimpleNamespace( + base=tmp_path / "base", + project=tmp_path / "project", + home=tmp_path / "home", + env={}, + token="secret-copilot-token", + options=SimpleNamespace(model="test-model"), + commands=[], + isolation_before={"captured": "yes"}, + runtime_python=None, + ) + (tmp_path / "base").mkdir() + context.project.mkdir() + monkeypatch.setattr(ios_setup, "_base_context", lambda _options: context) + monkeypatch.setattr(behavior, "_preflight", lambda _context: {"ok": True, "checks": []}) + monkeypatch.setattr(behavior, "_copilot_version", lambda _path: "GitHub Copilot CLI test") + monkeypatch.setattr(behavior, "_terminate_owned_runners", lambda _context, _phase: []) + monkeypatch.setattr(behavior, "_prepare_isolation", lambda _base: {"captured": "yes"}) + + def fail(_context: Any, _scenario_id: str) -> None: + raise RuntimeError(f"native boom {_context.token}") + + monkeypatch.setattr(ios_setup, "_run_native_scenario", fail) + options = SetupOptions( + wheel=wheel, + copilot=copilot, + output=tmp_path / "evidence.json", + scenarios=["existing-physical"], + model="test-model", + timeout=30.0, + keep_temp=True, + self_check=False, + list_scenarios=False, + ) + evidence = ios_setup.run_native(options) + + assert evidence["preflight"]["ok"] is True + assert evidence["scope"].startswith("Real native GitHub Copilot CLI decisions") + assert "not real workstation, device, simulator, or session qualification" in evidence["scope"] + assert any("No real Xcode tool" in item for item in evidence["limitations"]) + assert evidence["summary"]["status"] == "failed" + assert evidence["summary"]["failed"] == 1 + assert evidence["scenarios"][0]["status"] == "failed" + assert evidence["scenarios"][0]["checks"][0]["id"] == "scenario-exception" + serialized = (tmp_path / "evidence.json").read_text() + assert "secret-copilot-token" not in serialized + assert "$COPILOT_TOKEN" in serialized + assert "native boom" in serialized diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 0039a21..b5f0a33 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -1014,6 +1014,7 @@ def test_status_detects_modified_or_version_drifted_skill(project: Path, tmp_pat ( "references/official-skills.json", "references/android-setup.md", + "references/ios-setup.md", "scripts/verify_official_skills.py", ), ) From ce21661898d454bf98058100cc2f177987409c72 Mon Sep 17 00:00:00 2001 From: mijun Date: Thu, 5 Nov 2026 19:14:01 +0800 Subject: [PATCH 2/3] refactor(skill): deduplicate iPhone connection lifecycle guidance --- CHANGELOG.md | 1 + src/amkernel/skill/references/ios-setup.md | 83 ++++++---------------- 2 files changed, 21 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b5811..2711800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to AMKernel are recorded here. Public releases use semantic - 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)). +- 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)). ## [0.1.2] - 2026-11-04 diff --git a/src/amkernel/skill/references/ios-setup.md b/src/amkernel/skill/references/ios-setup.md index 01f1937..be9066a 100644 --- a/src/amkernel/skill/references/ios-setup.md +++ b/src/amkernel/skill/references/ios-setup.md @@ -580,59 +580,24 @@ resources under the existing recovery/cleanup contract. On return, re-check curr service state and the interrupted step's prerequisites before continuing. Preserve unchanged approval scope; disclose and obtain approval for any newly required effects. -## Appium service and startup +## Appium service and authorized connection -Start a task-owned Appium Server outside `python_exec` with the selected `APPIUM_HOME`, -`DEVELOPER_DIR`, Node, Appium entry point, and driver. Keep its process identity, port, environment, -and log. Wait boundedly, then check `/status`. Reuse an existing server only when its environment -and driver identity match; do not restart or mutate it. If it does not match, use a disclosed -isolated task-owned server only when it will not collide with existing sessions or devices. +Read and apply `amkernel-appium.md` sections **Runtime contract**, **Run the service with the +task environment**, **Connect**, **Bound operations by observable failure states**, and +**Cleanup**. They own Client execution through `python_exec`, server supervision and reuse, +execution-result/liveness checks, interrupted-session recovery, and task-owned cleanup. The +request scope above still governs whether service startup, WDA launch, or session creation is +authorized. -Check readiness with a bounded request against the task server's actual loopback URL: +For iPhone, use the selected XCUITest driver with the same `APPIUM_HOME` and `DEVELOPER_DIR` +used for discovery and doctor. Use `XCUITestOptions` with the observed device identifier and only +capabilities applicable to the selected WDA route. Derive Team, Bundle ID, and WDA URL values from +observed evidence or the user's selection, never guesses. -```bash -curl --fail --silent --show-error --max-time 5 "http://127.0.0.1:/status" -``` - -A successful `/status` proves service reachability, not target readiness, WDA launch, or a session. -For setup-only and status-only requests, stop without creating a session. - -### Create a session only for authorized validation or a scenario - -Session creation, live queries, actions, verification, recovery, and `driver.quit()` use the -official Appium Python Client through `python_exec`: - -```python -from appium import webdriver -from appium.options.ios import XCUITestOptions - -options = XCUITestOptions().load_capabilities({ - "platformName": "iOS", - "appium:automationName": "XCUITest", - "appium:udid": "", - # Add only the capabilities applicable to the selected WDA route. -}) -driver = webdriver.Remote("http://127.0.0.1:4723", options=options) -``` - -Populate target identifiers and applicable signing/WDA values from observation or the user's -selection. Never invent a UDID, Team, Bundle ID, or WDA URL. - -The first launch may build and install WDA and may take minutes. Bound the call and use the -driver-documented launch/connection timeouts rather than an unlimited wait. Do not put session -creation, an expected user action, and cleanup into one unobservable call. When a user prompt is -expected, stop after the creation attempt or at the observed prompt, give one action, and resume -with a separate bounded observation. Do not poll a stable precondition. - -After every call, inspect `status` and `kernelState`: - -- If the Kernel survived and `driver` exists, make one small relevant live query before - continuation. -- A nonempty local `session_id` alone proves nothing; a failed live query leaves no verified usable - session. -- A timeout does not prove that the remote session or WDA does not exist. -- Kernel death loses the Python namespace. Do not describe former objects as resumable or rebuild - the Kernel implicitly. +If the selected route requires WDA build/install, disclose those effects before launching it. +That launch can take minutes; use the driver's documented launch/connection timeouts. Keep +expected phone or Mac security prompts separate from creation and cleanup: pause at the observed +prompt, give one action, then resume with a bounded observation. Do not poll a stable precondition. For the minimal validation, perform one harmless current-screen or page query. Do not click a business action, submit a form, or reset app data. If the home screen is needed as a launch target, @@ -668,30 +633,22 @@ whole-environment reset. Preserve shared installations and external state. ### Reconcile ambiguous sessions and resources -- If the Appium log or current state shows an existing session that this task may not own, do not - delete it and do not blindly create a second session. Identify ownership from the Appium service - logs, task-created identifiers, and current endpoint state. -- If the Kernel died, external state is unknown. Reconcile through the task-owned server log and - endpoint before deciding whether another attempt is safe. -- If the device, port, or Appium session is genuinely in use by another task, report the shared - conflict and block. Do not preempt the device or clear all sessions. -- Create another session only after the previous attempt is confirmed ended, relevant resources are - verified, and any required user safety action is complete. +Apply the shared interrupted-session and cleanup rules. If the device, port, or session is in use +by another task, or ownership is unknown, stop and establish ownership from the server log and +task-created identities; do not preempt the device or clear sessions. Retry only after the previous +attempt is confirmed ended, relevant resources are verified, and any required user safety action +is complete. ### Cleanup - `driver.quit()` closes the Appium session. Success does not prove the WDA process disappeared. - Do not uninstall WDA, delete a profile, revoke trust, or disable Developer Mode to make the environment look "clean". Preserve the user's requested setup result. -- Stop only task-owned Appium Server, tunnel, port forward, and temporary device-service processes. - Leave shared services and the user's existing mechanism untouched. - Delete only task-owned disposable JSON, logs, profile scratch and build staging. Do not delete the `.app`, `.xctestrun`, product layout or derived-data path that the selected prepared WDA route still needs. Retain them in the disclosed user-owned location, report that location/lifetime and preserve the user's setup deliverables; keep only non-sensitive facts in the report. - Do not add a persistent launch agent, background daemon, or auto-start service. -- Report the requested result, live-session cleanup, and server/tunnel cleanup separately. A - successful result with failed cleanup is not fully cleaned. ## Verification and reporting From 801b0b8a23d6aa98c5c5f70b649cb65d48c38bbf Mon Sep 17 00:00:00 2001 From: mijun Date: Thu, 5 Nov 2026 19:23:31 +0800 Subject: [PATCH 3/3] fix(test): scope release evidence audit to published members --- CHANGELOG.md | 1 + docs/decisions/0008-release-evidence-retention.md | 2 ++ tests/test_release.py | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2711800..100b40f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to AMKernel are recorded here. Public releases use semantic - 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)). - 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)). ## [0.1.2] - 2026-11-04 diff --git a/docs/decisions/0008-release-evidence-retention.md b/docs/decisions/0008-release-evidence-retention.md index ce9a5ad..5d89fb0 100644 --- a/docs/decisions/0008-release-evidence-retention.md +++ b/docs/decisions/0008-release-evidence-retention.md @@ -44,6 +44,8 @@ The immutable [`v0.1.2` release](https://github.com/FSQ-lab/amkernel/releases/ta `evidence/archive-v0.1.2.json` is the offline provenance boundary. It records the immutable release and all three asset identities, preserves every original member path, size, and digest, and classifies each member as either retained in Git or release-only. Tests verify every retained file byte-for-byte against that index and reject reintroduced raw-output paths. Historical commits remain unchanged. +This index covers the original release corpus, not evidence added after that release. Later normalized decision and verification records may be retained in Git without changing the immutable index; they do not exempt any original release-only path from the raw-output exclusion. + ## Consequences Published raw evidence remains durable and content-addressed without increasing every future checkout. The release grows by one compressed archive and one manifest. The `v0.1.2` release must not be published until authoritative CI has generated and validated both assets from the exact release commit. diff --git a/tests/test_release.py b/tests/test_release.py index c74724b..b92c286 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -282,9 +282,9 @@ def test_post_release_evidence_matches_immutable_archive_index() -> None: for path in completed.stdout.split("\0") if path and path != ARCHIVE_INDEX.relative_to(ROOT).as_posix() } - present = {relative: evidence_root / relative for relative in tracked} + present = {relative: evidence_root / relative for relative in tracked & retained.keys()} assert present.keys() == retained.keys() - assert not (present.keys() & archived.keys()) + assert not (tracked & archived.keys()) for relative, path in present.items(): expected = retained[relative] assert path.stat().st_size == expected["sizeBytes"]