Files
Szymon Dziedzic 3e4828d68d feat: add scale-only screenshot sizing (#1617)
* feat: add scale-only screenshot sizing

* fix: refuse retired --max-size inputs on every released surface

Released sizing inputs must fail closed with migration guidance instead of
silently producing native-size artifacts:

- contracts: RETIRED_SCREENSHOT_MAX_SIZE declaration + SCREENSHOT_SCALE_LIMITS
  as the single source for the scale bounds and migration messages
- .ad parser: released 'screenshot ... --max-size N' and 'record start ...
  --max-size N' lines now refuse at parse time (frozen replay-compat witnesses)
- daemon: screenshot rejects old-client screenshotMaxSize like recording does;
  the recording guard now shares the same contract data
- Node client: screenshot/record daemon writers refuse the removed { maxSize }
  option before transport
- CLI: --max-size unknown-flag error carries the migration guidance
- config/env: stale screenshotMaxSize config keys and the retired
  AGENT_DEVICE_SCREENSHOT_MAX_SIZE env var are refused for sizing commands
  (other commands keep working)

Quality: numberField now reuses the canonical readOptionalNumber contract
helper (AppError bounds instead of plain Error); png-resize inlines one-use
wrappers and restores the worker-thread rationale; docs typo fixed.

* test: drop retired maxSize entries from the MCP undocumented-input allowlist

* fix: refuse retired maxSize at the MCP field-projection seam + release-provenance corpus witnesses

- readFieldInput silently dropped undeclared keys before the daemon writers
  could refuse them, so an MCP call carrying { maxSize } reached transport and
  returned native-size success. New retiredField() combinator declares the
  removed key in the field map: the projection seam refuses it with the
  canonical migration message and the JSON schema no longer advertises it.
  Real-route MCP executor regressions cover screenshot and record.
- replay-compat corpus: derived v0.20.5 witnesses for the released screenshot
  and record --max-size forms (SHA-256 pinned, new retired-capture-size
  coverage surface) so check:replay-compat proves the shipped syntax refuses
  with migration guidance instead of degrading silently.

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
2026-08-06 15:11:00 +02:00

404 lines
13 KiB
Swift

// MARK: - Wire Models
enum CommandType: String, Codable {
case tap
case mouseClick
case longPress
case drag
case remotePress
case type
case swipe
case scroll
case desktopScroll
case findText
case querySelector
case readText
case snapshot
case screenshot
case back
case backInApp
case backSystem
case home
case rotate
case appSwitcher
case keyboardDismiss
case keyboardReturn
case alert
case sequence
case gesture
case gestureViewport
case recordStart
case recordStop
case status
case uptime
case activate
case terminate
case targetReset
case shutdown
}
/// Runner command traits — see CONTEXT.md ("Runner command traits").
///
/// Single source of truth for how the runner classifies a command across three
/// independent axes, replacing the three hand-maintained switches that used to live
/// in RunnerTests+Lifecycle.swift (isInteractionCommand / isReadOnlyCommand /
/// isRunnerLifecycleCommand). The classification is load-bearing for ADR-0002 session
/// invalidation: `readOnly` gates the retry that nulls currentApp/currentBundleId.
struct CommandTraits {
/// Whether the command needs the foreground-guard + stabilization preflight before running.
let isInteraction: Bool
/// Whether the command is eligible for the session-invalidating retry.
/// `.conditional` is resolved against the request (alert is read-only only for its `get` action).
let readOnly: ReadOnly
/// Whether the command skips the app-activation preflight entirely.
let isLifecycle: Bool
enum ReadOnly {
case always
case never
/// Alert-only today. Resolved in `isReadOnlyCommand` with alert's rule (read-only for the
/// `get` action, mutating otherwise). A new `.conditional` command would inherit that rule
/// until the resolver is generalized — give it explicit handling there if its semantics differ.
case conditional
}
}
extension CommandType {
/// The classification for this command. Exhaustive by construction: a new CommandType
/// cannot compile without being classified here, so commands can no longer silently drift
/// out of classification the way the parallel switches allowed.
var traits: CommandTraits {
switch self {
// Interaction commands: require the foreground-guard + stabilization preflight.
// keyboardReturn is the sibling of keyboardDismiss (missing from the historical switch —
// drift the table now prevents). .scroll is the fused frame-resolve + drag scroll; same
// classification as .drag. .desktopScroll is the macOS frame-resolve + wheel event sibling.
// .sequence is the fused multi-step gesture batch.
case .tap, .longPress, .drag, .remotePress, .type, .swipe, .scroll, .desktopScroll,
.back, .backInApp, .backSystem, .rotate, .appSwitcher,
.keyboardDismiss, .keyboardReturn, .sequence, .gesture:
return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false)
// Read-only reads: eligible for the session-invalidating retry.
case .findText, .readText, .snapshot, .gestureViewport:
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: false)
// Screenshot is both a read and a runner-lifecycle command (skips app-activation preflight).
case .screenshot:
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true)
// Alert is read-only only for its `get` action (resolved by isReadOnlyCommand).
case .alert:
return CommandTraits(isInteraction: false, readOnly: .conditional, isLifecycle: false)
// Runner-lifecycle commands: skip the app-activation preflight.
case .recordStop, .uptime, .terminate, .targetReset, .shutdown:
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true)
case .status:
return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true)
// Normal preflight, not retried.
// NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground
// guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke
// check first (tracked as a follow-up). Also preserved: querySelector is NOT read-only;
// recordStart is NOT a lifecycle command; home/alert remain non-interaction by design.
case .mouseClick, .querySelector, .home, .recordStart, .activate:
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: false)
}
}
}
struct Command: Codable {
let command: CommandType
let commandId: String?
let statusCommandId: String?
let appBundleId: String?
let text: String?
let selectorKey: String?
let selectorValue: String?
let allowNonHittableCoordinateFallback: Bool?
let delayMs: Int?
let textEntryMode: String?
let action: String?
let x: Double?
let y: Double?
let button: String?
let remoteButton: String?
let x2: Double?
let y2: Double?
let durationMs: Double?
let timeoutMs: Double?
let direction: String?
let amount: Double?
let pixels: Double?
let orientation: String?
let gesturePlan: RunnerGesturePlan?
let outPath: String?
let fps: Int?
let interactiveOnly: Bool?
let preferredBackend: String?
let depth: Int?
let scope: String?
let raw: Bool?
let fullscreen: Bool?
let inlineScreenshot: Bool?
let synthesized: Bool?
let steps: [SequenceStep]?
}
/// Canonical one- or two-pointer plan produced by the portable TypeScript planner.
struct RunnerGesturePlan: Codable {
let topology: String
let intent: String
let executionProfile: String?
let durationMs: Double
let viewport: RunnerGestureViewport
let pointers: [RunnerGesturePointer]
}
struct RunnerGestureViewport: Codable {
let x: Double
let y: Double
let width: Double
let height: Double
}
struct RunnerGesturePointer: Codable {
let pointerId: Int
let samples: [RunnerGestureSample]
}
struct RunnerGestureSample: Codable {
let offsetMs: Double
let point: RunnerGesturePoint
}
struct RunnerGesturePoint: Codable {
let x: Double
let y: Double
}
/// One allowlisted coordinate gesture step inside a fused `sequence` command.
/// `kind` is decoded as a raw String (not an enum) so the runner can return a clear
/// INVALID_ARGS for an unknown kind instead of a generic decode failure.
struct SequenceStep: Codable {
let kind: String
let x: Double?
let y: Double?
let durationMs: Double?
let pauseMs: Double?
/// For `tap` steps on iOS non-tv: use the synthesized HID fast path instead of the
/// drag-based XCUICoordinate path, matching the individual command behavior.
let synthesized: Bool?
}
/// Per-step result for a `sequence` response. `ok:false` carries the failing step's
/// errorCode/errorMessage; execution stops at the first failed step.
struct SequenceStepResult: Codable {
let ok: Bool
let kind: String
let errorCode: String?
let errorMessage: String?
let gestureStartUptimeMs: Double?
let gestureEndUptimeMs: Double?
}
struct Response: Codable {
let ok: Bool
let data: DataPayload?
let error: ErrorPayload?
init(ok: Bool, data: DataPayload? = nil, error: ErrorPayload? = nil) {
self.ok = ok
self.data = data
self.error = error
}
}
extension Response {
// The daemon pairs this gesture-clock anchor with its own receipt time to map
// gesture uptimes onto wall-clock for the recording touch overlay. Error responses
// carry no anchor so the daemon falls back instead of pairing a stale value.
func stampingCurrentUptimeMs(_ value: Double) -> Response {
guard ok else { return self }
var payload = data ?? DataPayload()
payload.currentUptimeMs = value
return Response(ok: ok, data: payload, error: error)
}
}
struct DataPayload: Codable {
let message: String?
let imageBase64: String?
let text: String?
let found: Bool?
let items: [String]?
let nodes: [SnapshotNode]?
let truncated: Bool?
let snapshotQuality: SnapshotQuality?
let gestureStartUptimeMs: Double?
let gestureEndUptimeMs: Double?
let x: Double?
let y: Double?
let x2: Double?
let y2: Double?
let referenceWidth: Double?
let referenceHeight: Double?
var currentUptimeMs: Double?
let commandId: String?
let lifecycleState: String?
let lifecycleCommand: String?
let lifecycleResponseOk: Bool?
let lifecycleResponseJson: String?
let lifecycleErrorCode: String?
let lifecycleErrorMessage: String?
let lifecycleErrorHint: String?
let visible: Bool?
let wasVisible: Bool?
let dismissed: Bool?
let keyboardDismissMechanism: String?
let orientation: String?
let gestureFallback: String?
let gestureFallbackMessage: String?
let gestureFallbackHint: String?
let maestroNonHittableCoordinateFallbackUsed: Bool?
let textEntryRoute: String?
let runnerFatal: Bool?
let runnerFatalReason: String?
let completedSteps: Int?
let failedStepIndex: Int?
let sequenceResults: [SequenceStepResult]?
init(
message: String? = nil,
imageBase64: String? = nil,
text: String? = nil,
found: Bool? = nil,
items: [String]? = nil,
nodes: [SnapshotNode]? = nil,
truncated: Bool? = nil,
snapshotQuality: SnapshotQuality? = nil,
gestureStartUptimeMs: Double? = nil,
gestureEndUptimeMs: Double? = nil,
x: Double? = nil,
y: Double? = nil,
x2: Double? = nil,
y2: Double? = nil,
referenceWidth: Double? = nil,
referenceHeight: Double? = nil,
currentUptimeMs: Double? = nil,
commandId: String? = nil,
lifecycleState: String? = nil,
lifecycleCommand: String? = nil,
lifecycleResponseOk: Bool? = nil,
lifecycleResponseJson: String? = nil,
lifecycleErrorCode: String? = nil,
lifecycleErrorMessage: String? = nil,
lifecycleErrorHint: String? = nil,
visible: Bool? = nil,
wasVisible: Bool? = nil,
dismissed: Bool? = nil,
keyboardDismissMechanism: String? = nil,
orientation: String? = nil,
gestureFallback: String? = nil,
gestureFallbackMessage: String? = nil,
gestureFallbackHint: String? = nil,
maestroNonHittableCoordinateFallbackUsed: Bool? = nil,
textEntryRoute: String? = nil,
runnerFatal: Bool? = nil,
runnerFatalReason: String? = nil,
completedSteps: Int? = nil,
failedStepIndex: Int? = nil,
sequenceResults: [SequenceStepResult]? = nil
) {
self.message = message
self.imageBase64 = imageBase64
self.text = text
self.found = found
self.items = items
self.nodes = nodes
self.truncated = truncated
self.snapshotQuality = snapshotQuality
self.gestureStartUptimeMs = gestureStartUptimeMs
self.gestureEndUptimeMs = gestureEndUptimeMs
self.x = x
self.y = y
self.x2 = x2
self.y2 = y2
self.referenceWidth = referenceWidth
self.referenceHeight = referenceHeight
self.currentUptimeMs = currentUptimeMs
self.commandId = commandId
self.lifecycleState = lifecycleState
self.lifecycleCommand = lifecycleCommand
self.lifecycleResponseOk = lifecycleResponseOk
self.lifecycleResponseJson = lifecycleResponseJson
self.lifecycleErrorCode = lifecycleErrorCode
self.lifecycleErrorMessage = lifecycleErrorMessage
self.lifecycleErrorHint = lifecycleErrorHint
self.visible = visible
self.wasVisible = wasVisible
self.dismissed = dismissed
self.keyboardDismissMechanism = keyboardDismissMechanism
self.orientation = orientation
self.gestureFallback = gestureFallback
self.gestureFallbackMessage = gestureFallbackMessage
self.gestureFallbackHint = gestureFallbackHint
self.maestroNonHittableCoordinateFallbackUsed = maestroNonHittableCoordinateFallbackUsed
self.textEntryRoute = textEntryRoute
self.runnerFatal = runnerFatal
self.runnerFatalReason = runnerFatalReason
self.completedSteps = completedSteps
self.failedStepIndex = failedStepIndex
self.sequenceResults = sequenceResults
}
}
struct ErrorPayload: Codable {
let code: String?
let message: String
let hint: String?
init(code: String? = nil, message: String, hint: String? = nil) {
self.code = code
self.message = message
self.hint = hint
}
}
struct SnapshotRect: Codable {
let x: Double
let y: Double
let width: Double
let height: Double
}
struct SnapshotNode: Codable {
let index: Int
let type: String
let label: String?
let identifier: String?
let value: String?
let rect: SnapshotRect
let enabled: Bool
let focused: Bool?
let selected: Bool?
let hittable: Bool
let depth: Int
let parentIndex: Int?
let hiddenContentAbove: Bool?
let hiddenContentBelow: Bool?
}
struct SnapshotOptions {
let interactiveOnly: Bool
let depth: Int?
let scope: String?
let raw: Bool
/// Internal daemon ask: capture with this backend first regardless of channel
/// health ("private-ax"). Same-backend evidence probes (tap-outcome
/// corroboration) must be captured the way their baseline was.
var preferredBackend: String? = nil
}