Build BASE_COMMAND_CAPABILITY_MATRIX from deriveCapabilityMatrix(commandDescriptors)
and delete the hand-authored literal (ADR-0008, Phase 1 step 3). Behaviorless: #906
proved the derived matrix is byte-equal to the literal, including the
supports/unsupportedHint closures across the sample-device matrix.
Delete the now-dead local predicate helpers and capability bundles that only the
literal referenced (isNotMacOs, isMacOsOrAppleSimulator, isIosMobileSimulator,
synthesisGestureUnsupportedHint, ALL_DEVICE_COMMAND_CAPABILITY, APP_* bundles,
LINUX_DEVICE, LINUX_NONE); their copies now live in registry.ts. WEB_* command
sets and addWebCommandCapabilities are kept.
The registry/derive/types modules only type-import CommandCapability from
capabilities.ts, so the new value-level dependency forms no runtime cycle (tsc
clean + runtime import() of capabilities.ts loads with no TDZ/circular-init error).
Rewrite the capability parity tests (now tautologies) into an admission invariant:
every matrix entry is selectable (platform bucket or supports predicate) and the
public-command coverage floor is unchanged. Daemon and batch-name parity tests kept.
Introduce a single dormant CommandDescriptor registry as the strangler-fig
spine for ADR-0008. Per command it carries the daemon route + request-policy
traits (incl. closure traits), an optional capability entry, and
batchable/mcpExposed flags — copied verbatim from today's hand tables.
Pure derive folds reconstruct DAEMON_COMMAND_DESCRIPTORS, the capability
matrix, and the structured-batch names; parity tests prove each derivation
byte-equal to the live hand table. Nothing reads the registry yet.
Purely additive: no consumers changed, nothing deleted, no import inversion.
The only edits outside the new directory expose three existing tables
(DAEMON_COMMAND_DESCRIPTORS, DaemonCommandDescriptor, BASE_COMMAND_CAPABILITY_MATRIX)
so the parity tests can read them.
Locks the two axis decisions and starts retiring plans/ into ADRs. ADR 0008
(Proposed) records the command-descriptor registry composing domain-owned facets
and deriving the ~10 tables, bound by ADR 0003's four invariants; ADR 0009
(Accepted, groundwork shipped in #896) records the AppleOS leaf axis under one
'apple' Platform. perfect-shape.md links both and marks Phase 0 + Tier-A dedup as
merged.
Collapse three repeated daemon-handler patterns into shared helpers in
src/daemon/handlers/response.ts and handler-utils.ts:
- requireCommandSupported(command, device, { message?, hint? }) replaces ~21
verbatim isCommandSupportedOnDevice UNSUPPORTED_OPERATION guards. The richest
variant (generic dispatch) folds the unsupportedHintForDevice hint behind the
`hint` option; custom-message sites pass `message`.
- noActiveSessionError() / NO_ACTIVE_SESSION_MESSAGE replace the duplicated
'No active session. Run open first.' SESSION_NOT_FOUND literal across handlers
(and the inline router/AppError variants).
- recordSessionAction gains optional positionals/flags overrides so ~15 inline
sessionStore.recordAction({ command, positionals, flags, result }) literals
(find, record-trace, record-trace-recording, session, session-close,
session-perf-xctrace, install-source) route through the one wrapper.
behaviorless: every site keeps its exact error code/message (and hint where
present) and recorded action shape; helpers reproduce each variant.
Both the snapshot helper and the multi-touch helper drive 'am instrument -w'
and parse the same INSTRUMENTATION_STATUS/INSTRUMENTATION_RESULT key/value
records, and both validate a bundled JSON manifest with the same integer and
literal field rules.
Extract the genuinely-identical parts into a shared
src/platforms/android/instrumentation-helper.ts:
- parseInstrumentationRecords (status + result record parsing) and the
shared key/value reader
- readInstrumentationResultNumber / readInstrumentationResultBoolean
- readAndroidHelperManifestInteger / readAndroidHelperManifestLiteral
(label-parameterized so each helper's exact error message is preserved)
multitouch-helper now consumes the shared record parser (was its own
~25-line copy) and the shared numeric reader; snapshot-helper-capture
re-exports the shared metadata readers under their existing public names so
snapshot-helper-session keeps importing them unchanged.
Behaviorless: the divergent, behavior-bearing parts are intentionally left
in each consumer (install caches Set vs Map + install-policy/forget/retry,
installed-versionCode regex + timeout, sha256File stream-vs-read,
readString trim semantics and the sha256 validator that depends on it,
artifact resolution + per-helper manifest schemas).
* refactor: validate JSON-RPC at the MCP/HTTP boundary
Wire the previously dead jsonRpcRequestSchema into the MCP stdio decode path and
add a sibling commandRpcParamsSchema for the daemon HTTP command params, replacing
unchecked casts of attacker-controllable wire input with real boundary parsing.
- mcp/server.ts: each inbound payload (and each batch element) is parsed via
jsonRpcRequestSchema instead of being force-cast to JsonRpcMessage. Valid
requests (with id), notifications (no id), and batches are accepted and routed
exactly as before; only genuinely malformed input (non-object, or wrong-typed
jsonrpc/method/id) is rejected with the standard -32600 Invalid Request error.
JSON.parse failures still surface as -32700, and malformed input never crashes.
- contracts.ts: add commandRpcParamsSchema next to jsonRpcRequestSchema validating
the command RPC params; optionalStringArray helper added.
- daemon/http-server.ts: replace the 'params as unknown as Partial<DaemonRequest>'
double-cast with commandRpcParamsSchema.parse(params).
* fix(daemon): reject malformed command params as 400/-32602
methodToDaemonRequest validated command params via commandRpcParamsSchema.parse, which throws a plain Error on malformed input. That mapped to UNKNOWN -> HTTP 500 / -32000 and leaked the internal '$.x' schema path on the wire. Reporting malformed client input as a server error is the wrong signal.
Wrap the parse so failures throw AppError('INVALID_ARGS', 'Invalid params: ...') with the schema path sigil stripped, and map normalized INVALID_ARGS to JSON-RPC -32602 in the boundary catch so malformed command params now surface as 400/-32602, matching the explicit sibling checks.
* feat: add AppleOS discriminant to the device model (additive)
Add an explicit, stored AppleOS ('ios' | 'ipados' | 'tvos' | 'watchos' |
'visionos' | 'macos') and an optional appleOs field on DeviceInfo so Apple
operating systems are first-class instead of inferred late from DeviceTarget.
- device.ts: add AppleOS type, optional DeviceInfo.appleOs, and make
resolveApplePlatformName prefer device.appleOs while falling back to the
existing target-based inference for legacy records. iOS and iPadOS both map
to the single iOS runner profile; tvOS -> tvOS; macOS -> macOS.
- Populate appleOs at discovery only (no widened filters): iPhone/iPod -> ios,
iPad -> ipados, tvOS -> tvos, host Mac -> macos.
- resolveRunnerPlatformName threads device.appleOs through.
Non-breaking groundwork for the platforms/apple consolidation: records without
appleOs resolve byte-identically, and no runner SDK/destination selection
changes (iPad still resolves to the iOS runner profile).
* fix: keep appleOs internal and derive ipados from simctl device type
The set of platforms was hand-restated in three places that must agree:
the canonical tuples in src/utils/device.ts, the --platform flag's
enumValues in cli-flags.ts, and the leaf-platform validation in
client-normalizers.ts. The two non-canonical copies could drift.
- Export PLATFORMS from device.ts and add an isPlatform() leaf-platform
type guard derived from it (excludes the `apple` selector).
- Derive the --platform enumValues and usageLabel from PLATFORM_SELECTORS.
- Derive normalizeOpenDevice's leaf-platform check from isPlatform();
per-platform udid/serial identifier shaping is unchanged.
Behaviorless: the derived sets equal the previous hardcoded sets.
* docs: add perfect-shape architecture roadmap
Captures the target architecture (two-registry thesis: CommandDescriptor +
PlatformPlugin over a clean folder DAG with a typed-result spine) and a sequenced,
strangler-fig migration path, grounded in a survey of the current codebase.
This PR implements the first two behaviorless Phase-0 items from that roadmap; the
larger registry work is deliberately deferred to later, independently shippable PRs.
* refactor: parametrize RecordingBackend by recording tag
RecordingBackend is now generic over the recording's platform tag, so each
backend's stop() receives an already-narrowed recording. This deletes all five
'recording as Extract<ActiveRecording, { platform: ... }>' casts — the textbook
discriminated-union-narrowing-by-cast anti-pattern — and makes a backend/tag
mismatch unrepresentable.
start() stays wide (DaemonResponse | ActiveRecording) because a device platform
does not map 1:1 to a recording tag (an iOS device resolves to either the 'ios' or
'ios-device-runner' recording). Device resolution returns a stop-less view
(RecordingStartBackend); stop is dispatched per active recording via the new
exhaustive stopActiveRecording(), replacing resolveRecordingBackendForRecording().
Behaviorless: pure type-level change, no runtime behavior change.
* refactor: make capability platform selection exhaustive
isCommandSupportedOnDevice resolved the per-platform capability bucket with an
if/else ladder whose final branch funneled every unmatched platform into
capability.web. That silently absorbs a future Platform with no compile error.
Replace it with selectCapabilityForPlatform(), an exhaustive switch over the
Platform union with a 'never' guard, so adding a new platform is a compile error
here instead of a silent web mis-gate. Identical behavior for all five current
platforms (ios/macos -> apple, android, linux, web).
* docs(adr): amend ADR 0003 for the single-declaration/derivation model
Ratifies the PR review caveat into the ADR itself: the daemon command registry
boundary is about ownership + the predicate interface, not the physical file a trait
is typed in. A derived/projected daemon registry is permitted only if it preserves
four invariants (daemon-owned declaration, unchanged predicate interface, no leakage
into public projections, one declaration per concern enforced by types). The original
decision stands; collapsing daemon policy into a public command registry remains
forbidden.
* docs: refine command axis to facet composition (ADR 0003-aligned)
- §2/§5.2: CommandDescriptor composes domain-owned facets (surface@commands,
capability@core, daemon@src/daemon) and projects them — compose-with, not
collapse-into. Adds the four ADR-0003 invariants.
- §6: mark the two shipped Phase-0 items (generic RecordingBackend<P>, exhaustive
capability selection); link the Apple plan from Phase 3.
- §5.1: Apple as the first PlatformPlugin instance, owning an AppleOS leaf axis.
- §8: before/after diagrams for the command axis + the two-axis summary.
* docs: add apple-platform-consolidation plan (AppleOS leaf axis)
One 'apple' Platform with an AppleOS discriminant (ios/ipados/tvos/watchos/
visionos/macos) rather than six Platform literals (which would collide with the
cross-platform 'target' axis). Captures the 4-investigator survey: ~85% of
platforms/ios is already the OS-agnostic Apple engine; the XCTest runner already
builds ios|macos|tvos; macOS is included as a distinct AppKit leaf (already
entangled). visionOS is scoped net-new work; watchOS is an unsupported sentinel
(XCUITest can't drive it). Before/after diagrams, per-OS readiness, sequencing.
* feat: add integrated device leasing
* fix: keep metro bearer token out of generated proxy profile
The proxy connect profile is written to disk as a non-secret remote config,
but it unconditionally copied `metroBearerToken` into that file, leaking the
secret at rest. Mirror the cloud path, which keeps `daemonAuthToken` in-memory
only: the token still flows through this connect via the returned flags, and
later commands re-supply it via AGENT_DEVICE_METRO_BEARER_TOKEN. Extend the
non-secret-profile test to assert the bearer token is absent from disk.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPa5Z9GBkeqoxVctC85N7e
* fix: always release device lease on session close
releaseSessionLease + sessionStore.delete ran only on the happy path, after
several awaits (app-log/perf/snapshot teardown, platform close dispatch,
runner stop) that can throw. A failed close therefore stranded the device
lease until the inactivity expiry. Wrap teardown in try/finally so ownership
is always freed; the original error still propagates after finally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPa5Z9GBkeqoxVctC85N7e
* fix: reconcile integrated device leasing
* docs: simplify remote lease guidance
* refactor: satisfy leasing fallow checks
* fix: harden integrated device leasing
* refactor: deepen device lease lifecycle
* refactor: centralize lease scope projection
* fix: harden proxy lease e2e flow
* fix: address lease review feedback
* refactor: tighten lease release cleanup
* fix: simplify proxy startup output
* fix: harden cloud lease identity
* fix: color proxy startup output
* fix: simplify proxy tunnel placeholder
---------
Co-authored-by: Claude <noreply@anthropic.com>
* test: migrate test app to expo dev client
* docs: align test app device targeting
* docs: clarify dev client setup tradeoffs
* docs: remove stale sdk reference