Release 0.20.12 — iOS device surface + window/context hardening#125
Conversation
Bumps every tracked surface to 0.20.12. This release adds a fifth control surface — Interceptor iOS, driving any installed app on an owned, unlocked iPhone — and rolls up the window-mechanics and context-registration hardening from 0.19.2/0.19.3 (users updating from 0.19.1 get all of it). Interceptor iOS — drive a real iPhone (new surface) - New `interceptor ios *` surface: drive any installed app on an owned, unlocked, Developer-Mode iPhone, addressed as --on <phone> / ios:<udid>. - Runs Interceptor's own on-device XCUITest runner (InterceptorRunner) — not WebDriverAgent. The runner dials into the daemon over WiFi, so no cable is needed once paired, and phones auto-connect on the first verb. - Full verb set: tree, find, inspect, click, type, keys, scroll, drag, press, screenshot, apps, and app launch|activate|terminate. Element refs carry frames, so actuation is a deterministic coordinate tap robust against handle staleness. - Two install paths: an Xcode self-service setup, or a no-Xcode login that re-signs the runner with the user's own Apple ID (token in the Keychain, never the password). A background timer re-signs before certificate expiry. - The shipped package carries an unsigned runner and no signing material — signing is delegated at runtime (capability-blind). iOS reliability - Concurrent verbs on a not-yet-connected phone share one in-flight launch, so they can't double-launch and orphan a runner. - Runner registration is keyed case-insensitively; the per-session token gate rejects any mismatch outright. - The lockdown connect path is timeout-bounded and closes on error, so a stalled device can't hang a command indefinitely. - The userspace tunnel releases its channels on locked-retry. - `interceptor ios apps` resolves the device correctly, and `interceptor ios status` reflects installed-but-idle phones instead of returning empty. Window mechanics resilience (rolled up from 0.19.2) - Bound chrome.windows operations with cleanup-safe timeouts. - Route window close, focus, and resize without requiring an active tab. - Add --left/--top/--width/--height/--state to window resize with validation. Context registration lifecycle (rolled up from 0.19.3) - Reject duplicate browser and native context claims without stealing routing. - Emit explicit context_registered acks before the extension marks routing ready. - Coalesce overlapping extension WebSocket connection attempts; recover storage-driven re-registration failures by reconnecting. Release prep - Bump package, CLI source version, MV3 manifest, and tracked MV2 manifest to 0.20.12. - Add the interceptor-ios agent-operator skill; document the iOS surface in ARCHITECTURE.md / README.md. Validation - Typecheck clean. Suite: 597/0, with 10 existing skips. - iOS verbs validated live on a paired iPhone through the installed package. - Both pkgs signed, notarized, and stapled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR introduces a new "iOS device surface" enabling Interceptor to drive real iPhone apps via an on-device XCUITest runner (InterceptorRunner) that connects to the daemon over WebSocket. It adds daemon-side management (IosManager, RunnerChannel), low-level device protocols (lockdown, usbmux, AFC installer, keychain, testmanagerd, tunnels), a Swift-based Xcode runner project, CLI commands, shared context types, documentation, and release packaging updates. ChangesiOS Device Automation Surface
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 20
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (7)
daemon/ios/manager.ts-839-851 (1)
839-851: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winUnknown scroll direction silently no-ops instead of erroring.
If
dirisn't one ofdown|up|left|right,toX/toYstay equal tocenter, sodrag()runs a zero-length "drag" at the same point rather than surfacing a usage error.🩹 Proposed fix
private async verbScroll(ctx: IosDeviceContext, action: { [k: string]: unknown }): Promise<IosResult> { const dir = typeof action.dir === "string" ? action.dir.toLowerCase() : "down" + if (!["down", "up", "left", "right"].includes(dir)) { + return { success: false, error: `unknown scroll direction '${dir}' (down|up|left|right)` } + } const pt = this.resolvePoint(ctx, action)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@daemon/ios/manager.ts` around lines 839 - 851, The verbScroll handler in IosManager currently falls through for unsupported action.dir values and performs a zero-length drag instead of reporting invalid input. Update verbScroll to validate the normalized dir value before computing the drag target, and return an error result for anything other than down, up, left, or right. Keep the fix localized to verbScroll and its use of resolvePoint, screenCenter, and ctx.channel.drag so unknown directions are rejected instead of silently treated as a no-op.daemon/ios/state.ts-67-69 (1)
67-69: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winSilent persistence failures are hard to diagnose.
saveIosStatediscards any write error. Downstream callers (markInstalled,setAlias,setAppleAccount, …) have no way to know the state didn't actually persist, which could surface later as confusing "device not found" / "no Apple-ID account" errors with no clue why.📝 Proposed fix to at least surface the failure
export function saveIosState(s: IosState): void { - try { writeFileSync(statePath(), JSON.stringify(s, null, 2)) } catch {} + try { writeFileSync(statePath(), JSON.stringify(s, null, 2)) } + catch (err) { console.error(`[ios] failed to persist state: ${(err as Error).message}`) } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@daemon/ios/state.ts` around lines 67 - 69, saveIosState is swallowing write failures, so persistence errors never reach callers and later state-dependent operations can fail mysteriously. Update saveIosState to surface or propagate the write error instead of catching and ignoring it, then adjust callers like markInstalled, setAlias, and setAppleAccount to handle the failure path explicitly so they can report or react to a failed state save.ios/InterceptorRunner/README.md-51-54 (1)
51-54: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winUse
./dist/interceptorin repo-local examples.These commands invoke the CLI directly as
interceptor ..., which assumes the binary is onPATH. As per coding guidelines,**/*.{sh,md}files should "Use./dist/interceptor ...inside this repo when the binary isn't onPATH".📝 Proposed fix
export INTERCEPTOR_RUNNER_PROJECT="$PWD/InterceptorRunner.xcodeproj" export DEVELOPMENT_TEAM=<YOUR_TEAM_ID> # automatic signing -interceptor ios enable <UDID> --yes +./dist/interceptor ios enable <UDID> --yesexport INTERCEPTOR_RUNNER_DIR="/tmp/runner-dd/Build/Products/Debug-iphoneos" -interceptor ios enable <UDID> --yes +./dist/interceptor ios enable <UDID> --yesAlso applies to: 65-72
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ios/InterceptorRunner/README.md` around lines 51 - 54, The repo-local usage example in the README still invokes the CLI as interceptor directly, which assumes it is on PATH. Update the examples around the InterceptorRunner setup and the related sections to use ./dist/interceptor instead, keeping the commands otherwise unchanged and consistent with the existing repo-local guidance for the interceptor CLI.Source: Coding guidelines
ios/InterceptorRunner/README.md-89-93 (1)
89-93: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winAdd a language to the fenced code block.
markdownlint flags this block (MD040) for missing a language identifier.
📝 Proposed fix
-``` +```text daemon → runner : { id, op, ...args } op ∈ source|screenshot|windowSize|tap|drag|keys|press|app|ping runner → daemon : { id, result: { success, data?, error? } } register : { type:"ios", udid, token, contextId } (runner → daemon, once)</details> <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.In
@ios/InterceptorRunner/README.mdaround lines 89 - 93, Add a language
identifier to the fenced block in the README so markdownlint MD040 passes;
update the code fence around the protocol example in the InterceptorRunner
documentation to use a text-style language tag, keeping the existing content
unchanged. Locate the block by the daemon/runner/register entries and adjust
only the fence syntax.</details> <!-- cr-comment:v1:275c3aadf93eae05070f6ce1 --> _Source: Linters/SAST tools_ </blockquote></details> <details> <summary>ARCHITECTURE.md-295-306 (1)</summary><blockquote> `295-306`: _📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_ **Wire-frame format is wrong: `{id, action}` should be `{id, op, ...args}`.** `shared/ios-device.ts` documents the RunnerChannel wire format as `{ id, op, ...args }` → `{ id, result }`, and `docs/ios/app-route.md`'s own architecture diagram confirms `RunnerChannel: { id, op, …args } ⇄ { id, result }`. The Swift runner's `handle()` also reads `obj["op"]`, not `obj["action"]`. This section's `{id, action}` phrasing misdescribes the actual daemon↔runner socket protocol (the `action` field belongs to the higher-level CLI→daemon dispatch layer, not the RunnerChannel frame). <details> <summary>📝 Proposed fix</summary> ```diff -tag. Concurrent verbs -on a not-yet-connected device share one in-flight launch (dedup by `contextId`), -so they can't double-launch and orphan a runner. +tag. Concurrent verbs +on a not-yet-connected device share one in-flight launch (dedup by `contextId`), +so they can't double-launch and orphan a runner.-(`daemon/ios/channel.ts`), and drives the runner over that socket with `{id, -action}` → `{id, result}` frames. +(`daemon/ios/channel.ts`), and drives the runner over that socket with `{id, +op, ...args}` → `{id, result}` frames.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ARCHITECTURE.md` around lines 295 - 306, Update the RunnerChannel protocol description in ARCHITECTURE.md to match the actual daemon↔runner wire format. The section around IosManager and RunnerChannel should describe frames as {id, op, ...args} → {id, result}, not {id, action}. Use the existing symbols shared/ios-device.ts, daemon/ios/channel.ts, and IosManager to align the text with the real socket protocol and keep the higher-level CLI→daemon “action” terminology separate.cli/commands/ios.ts-301-318 (1)
301-318: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winUnguarded
Bun.writecan throw uncaught.
Bun.writehere isn't wrapped in try/catch, andrunIosCommandisn't called within a try/catch at its call sites incli/index.ts. A disk-write failure (permissions, disk full) surfaces as an unhandled rejection instead of a clean CLI error.🛡️ Proposed fix
if (d.dataUrl) { const base64 = d.dataUrl.split(",")[1] ?? "" const ext = d.format === "png" ? "png" : "jpg" const filename = `interceptor-ios-screenshot-${Date.now()}.${ext}` - await Bun.write(filename, Buffer.from(base64, "base64")) - const filePath = `${process.cwd()}/${filename}` - if (jsonMode) console.log(JSON.stringify({ filePath, format: d.format })) - else console.log(`saved: ${filePath}`) - return + try { + await Bun.write(filename, Buffer.from(base64, "base64")) + } catch (err) { + console.error(`error: failed to save screenshot: ${(err as Error).message}`) + process.exit(1) + } + const filePath = `${process.cwd()}/${filename}` + if (jsonMode) console.log(JSON.stringify({ filePath, format: d.format })) + else console.log(`saved: ${filePath}`) + return }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/commands/ios.ts` around lines 301 - 318, The screenshot branch in `runIosCommand` can fail on `Bun.write` without being handled, so wrap the write-and-emit path in error handling and surface a clean CLI failure instead of letting the rejection escape. Update the `case "screenshot"` flow to catch disk-write errors around `Bun.write` and the subsequent `console.log`/return path, and route failures through the existing CLI error/exit handling used by `emitExit` so callers from `cli/index.ts` don’t see an unhandled rejection.cli/transport.ts-27-38 (1)
27-38: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winInconsistent iOS timeout overrides across drive verbs.
ios_click,ios_keys,ios_scroll,ios_drag,ios_press, andios_inspectfall back to the generic 15sINTERCEPTOR_TIMEOUT_MS, whileios_tree/ios_find/ios_type/ios_app/ios_screenshotget 60s. Per the accompanying docs, "The runner drops on idle and re-dials per verb, so chain a launch and its follow-up verbs closely." This reconnect/re-dial cost isn't specific to the five overridden verbs — it applies to any verb hitting an idle runner. Consider extending the elevated timeout to the remaining drive verbs for consistency, or confirm they're reliably fast enough to stay under 15s including reconnect overhead.🛠️ Suggested addition
ios_tree: 60_000, ios_find: 60_000, ios_app: 60_000, ios_type: 60_000, ios_screenshot: 60_000, + ios_click: 60_000, + ios_keys: 60_000, + ios_scroll: 60_000, + ios_drag: 60_000, + ios_press: 60_000, + ios_inspect: 60_000, ios_setup: 600_000, ios_refresh: 600_000, ios_enable: 120_000, ios_install: 240_000,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/transport.ts` around lines 27 - 38, The iOS timeout overrides in transport handling are only applied to a subset of drive verbs, leaving `ios_click`, `ios_keys`, `ios_scroll`, `ios_drag`, `ios_press`, and `ios_inspect` on the default interceptor timeout. Update the timeout mapping in `cli/transport.ts` so the elevated iOS deadline is applied consistently to all iOS drive verbs, using the existing `ios_tree`/`ios_find`/`ios_type`/`ios_app`/`ios_screenshot` entries as the reference point. If any verb should remain at 15s, document that exception explicitly in the same timeout config.
🧹 Nitpick comments (9)
daemon/ios/manager.ts (1)
673-690: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRedundant branching — all paths return the same value.
In the
if (u)block, every branch (resolveDescriptorhit, offline-alias check, and fallthrough) returnsu. The intermediate checks have no effect on control flow, so the logic is effectivelyif (u) return u. Given the comments imply differing intent per branch, this is likely leftover from an incomplete refactor and should be simplified for clarity.🧹 Proposed simplification
private pickDeviceUdid(ref?: string): string | undefined { if (ref && ref.trim()) { - const u = resolveUdid(ref) - if (u) { - // exact udid/alias hit; verify it's a real device when discoverable - if (this.resolveDescriptor(u)) return u - // alias may point at a device that's temporarily offline — still return it - if (resolveUdid(ref) !== ref.toUpperCase()) return u - return u - } - return undefined + // alias/udid may point at a device that's temporarily offline — still return it. + return resolveUdid(ref) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@daemon/ios/manager.ts` around lines 673 - 690, The pickDeviceUdid logic has redundant branching because every path inside the resolveUdid(ref) success case returns the same udid. Simplify the if (u) block in pickDeviceUdid to a single return u after the lookup, and remove the unused resolveDescriptor/offline-alias checks unless you plan to restore distinct control flow for them. Keep the rest of the device-selection logic unchanged.daemon/ios/installer.ts (2)
292-306: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated ad-hoc
Pathextraction regex.
lookupAppPathre-implements a<key>Path</key><string>...</string>regex inline instead of reusing the already-exportedxmlStringFieldhelper from./lockdown(imported here forplistToXmlalready). Consolidating avoids parsing-logic drift between the two.♻️ Proposed fix
-import { connectServiceSocket, getValue, encodeLockdownFrame, tryReadLockdownFrame, plistToObject, plistToXml, type PlistDict } from "./lockdown" +import { connectServiceSocket, getValue, encodeLockdownFrame, tryReadLockdownFrame, plistToObject, plistToXml, xmlStringField, type PlistDict } from "./lockdown" ... const xml = plistToXml(f.body) - const m = xml.match(/<key>Path<\/key>\s*<string>([^<]+)<\/string>/) - if (m) { done(m[1]); return } + const path = xmlStringField(xml, "Path") + if (path) { done(path); return }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@daemon/ios/installer.ts` around lines 292 - 306, lookupAppPath is duplicating XML field parsing for Path instead of reusing the shared helper. Update lookupAppPath in installer.ts to use xmlStringField from ./lockdown for extracting the Path value, alongside plistToXml, so the parsing logic stays centralized and consistent.
207-227: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueRemove the reverse-order
FileOpenfallback. AFCFileOpenusesmodefollowed by the NUL-terminated path; the second payload retries with a malformed request and can mask real failures.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@daemon/ios/installer.ts` around lines 207 - 227, The openFile fallback in the AFC FileOpen flow is retrying with a reversed payload order that produces a malformed request and can hide the real failure. Update openFile in the installer’s AFC request path to send only the correct mode-then-path payload, and remove the candidates loop/retry logic so request(AFC_OP.FileOpen, ...) is attempted once with the valid format and any error is surfaced directly.ios/InterceptorRunner/Sources/ObjCSupport.m (2)
5-6: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueImplicit reliance on transitive includes for
usleep/pid_t.
usleepandpid_tare used without an explicit#import <unistd.h>/#import <sys/types.h>; this currently compiles via transitive Foundation includes but is a bit fragile against SDK changes.Also applies to: 93-93
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ios/InterceptorRunner/Sources/ObjCSupport.m` around lines 5 - 6, The ObjCSupport.m imports rely on transitive Foundation headers for usleep and pid_t, which is fragile. Add the explicit system headers needed for those symbols in the import block of ObjCSupport.m, alongside the existing objc/message.h and objc/runtime.h imports, so the file does not depend on indirect includes. Also verify the use sites around the sleep/pid_t references in the file still compile cleanly after making the imports explicit.
64-96: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winUp to 500ms blocking retry loop on the foreground-app lookup path.
ICActiveApplicationBundleIDretries 5× with a 100ms sleep between attempts, and this executes on everysource/windowSize-style verb whenever no explicitapp activatehas been issued (seeforegroundApp()inInterceptorRunnerUITests.swift). There's no caching of a previously-resolved bundle id, so repeated verb calls during an app-transition window each re-pay the full retry cost, and worst case adds ~500ms latency per call if resolution keeps failing.Worth confirming this doesn't run on the thread that also services the WebSocket verb queue — if so, it would stall processing of subsequent queued verbs for the duration of the retry loop.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ios/InterceptorRunner/Sources/ObjCSupport.m` around lines 64 - 96, ICActiveApplicationBundleID currently does a blocking 5-attempt retry with usleep on every foreground lookup, which can add up to 500ms per verb call. Reduce or eliminate the repeated wait by caching the last resolved bundle id and/or making the lookup non-blocking, and ensure the retry loop in ICActiveApplicationBundleID does not run on the WebSocket verb-processing thread used by foregroundApp() so queued verbs are not stalled.ios/InterceptorRunner/InterceptorRunner.xcodeproj/project.pbxproj (1)
103-307: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueGenerated project file — verify it stays in sync with
project.yml.Settings here (bundle id, deployment target, Swift version, bridging header,
USES_XCTRUNNER) correctly mirrorproject.yml. Since this generated file is checked into git alongside its XcodeGen source, consider a CI check (xcodegen generate --spec project.yml+git diff --exit-code) to catch drift ifproject.ymlis edited without regenerating.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ios/InterceptorRunner/InterceptorRunner.xcodeproj/project.pbxproj` around lines 103 - 307, The generated Xcode project file is checked in, so it can drift from project.yml if the spec changes without regeneration. Add a CI validation step that runs xcodegen generate with project.yml and then verifies the workspace is clean with git diff --exit-code. Put this in the existing build/test pipeline that owns project generation so the sync check runs automatically whenever the InterceptorRunner project is validated.cli/commands/ios.ts (1)
264-271: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDead branch in
typeargument parsing.The
textfallback (args[2]whenrefis falsy) can never execute — it re-evaluates the identical condition already used to computeref. This makes it look like a ref-lessios type "text"form is supported, but it isn't;ios typealways requires a ref as documented. Simplify to remove the misleading dead branch.♻️ Proposed simplification
case "type": { const ref = args[2] && !args[2].startsWith("--") ? args[2] : undefined - // text is the last non-flag arg (or the only one when no ref is given) - const text = ref ? (args[3] && !args[3].startsWith("--") ? args[3] : undefined) : (args[2] && !args[2].startsWith("--") ? args[2] : undefined) + const text = args[3] && !args[3].startsWith("--") ? args[3] : undefined if (text === undefined) { console.error('error: ios type requires text, e.g. ios type e5 "hello"'); process.exit(1) } - emitExit(await send({ type: "ios_type", ref: ref && args[3] !== undefined ? ref : undefined, text }, contextId), jsonMode) + emitExit(await send({ type: "ios_type", ref, text }, contextId), jsonMode) return }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/commands/ios.ts` around lines 264 - 271, The `type` argument parsing in `ios.ts` has a dead fallback branch that re-checks `args[2]` even though `ref` already consumed the same condition, making the code misleading about supporting ref-less input. Simplify the `case "type"` handling in the command parser by removing the unreachable `text` fallback and keeping only the documented `ios type <ref> <text>` flow, while preserving the existing `send({ type: "ios_type", ... })` call and validation for missing text.daemon/ios/usertunnel.ts (1)
51-770: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftLarge protocol stack duplicated from
rsd.ts.The header comments repeatedly note this IPv6/TCP mux, XPC codec, HTTP/2 framing, and DTX codec are copied "verbatim from rsd.ts" (and the CDTunnel handshake here duplicates
daemon/ios/tunnel-helper/helper.ts'scdtunnelHandshake). This is several hundred lines of intricate binary protocol logic maintained in parallel across files — a bugfix or protocol-version bump in one copy won't propagate to the others. Consider extracting the shared pieces (IPv6/TCP mux, XPC/H2 codec, CDTunnel handshake) into a common module imported by both the privileged helper and this userspace path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@daemon/ios/usertunnel.ts` around lines 51 - 770, This file contains large duplicated protocol logic that is also implemented in rsd.ts and the CDTunnel handshake helper, so changes will drift between copies. Move the shared IPv6/TCP mux, XPC/HTTP2/DTX codecs, and cdTunnelHandshake flow into a common module, then update Tun, XpcService, and DtxConnection here to import and reuse that shared implementation instead of maintaining parallel copies.daemon/ios/tree.ts (1)
119-170: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winSilent truncation on
maxDepth/maxChars.When the walk hits
maxDepthormaxChars(line 130), it simply stops with no marker in the output. Callers offormatWdaTree(e.g.,ios tree/ios find) get a partial tree with no way to know results were cut off, which could cause agents to conclude an element doesn't exist when it's simply beyond the truncation point.♻️ Proposed fix: emit a truncation marker
const walk = (node: WdaSourceNode | undefined, depth: number): void => { - if (!node || depth > maxDepth || out.length > maxChars) return + if (!node) return + if (depth > maxDepth || out.length > maxChars) { + if (!out.endsWith("… (truncated)\n")) out += "… (truncated)\n" + return + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@daemon/ios/tree.ts` around lines 119 - 170, The tree output in formatWdaTree is silently cut off when walk stops at maxDepth or maxChars, so add an explicit truncation marker to out when traversal is skipped due to either limit. Update the walk helper in formatWdaTree to detect these cutoff cases and append a clear “truncated”/“more content omitted” line so callers like ios tree and ios find can tell the result is partial.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@daemon/ios/installer.ts`:
- Around line 194-205: The recursive delete in removePathRecursive is swallowing
all errors from readDir, not just the expected “not a directory” case. Update
the catch block so only AfcStatusError with AFC_STATUS.ObjectNotFound is
ignored, and rethrow any other error before calling removePath(path). Preserve
the existing recursive behavior in daemon/ios/installer.ts by keeping the check
near readDir and removePathRecursive.
- Around line 331-345: `walk()` in the iOS installer is doing synchronous
filesystem work inside an async upload path, which blocks the daemon event loop.
Replace `readdirSync`, `lstatSync`, and `readFileSync` with the async
`fs.promises` equivalents (or another non-blocking traversal approach), and
await each step within `walk()` so app bundle upload stays non-blocking while
preserving the existing `afc.makeDir` and `afc.writeFile` behavior.
In `@daemon/ios/lockdown.ts`:
- Around line 271-293: openLockdownSession leaks the underlying lockdown socket
when QueryType, StartSession, or the TLS preconditions fail before the session
is returned. Update openLockdownSession to ensure raw and the current
LockdownChannel are closed on every throw path after connectUsbmuxPort, ideally
with a try/finally around the handshake steps and cleanup of chan/raw on
failure. Keep the fix localized to openLockdownSession and preserve the
successful return behavior for startService/getValue/connectServiceSocket
callers.
- Around line 241-253: The upgradeTls handshake currently disables verification
by setting rejectUnauthorized to false, which leaves the lockdownd connection
unauthenticated. Update upgradeTls in lockdown.ts to use rejectUnauthorized:
true and add a checkServerIdentity override that validates the paired device
certificate appropriately, since the pair certs lack a DNS SAN/servername. Keep
the existing socket/cert wiring in upgradeTls, but ensure the TLS peer is
actually verified instead of relying on the ignored ca setting.
In `@daemon/ios/manager.ts`:
- Around line 941-950: The shutdown path in `manager.ts` is swallowing async
failures from `ctx.channel.deleteSession()` because the current `try/catch` only
handles synchronous throws. Update `shutdown()` to handle `deleteSession()` the
same way `teardownContext` does: await the promise inside a try/catch (or
otherwise attach explicit rejection handling) before killing child processes.
Keep the fix localized around `shutdown()` and the `ctx.channel.deleteSession`
call so rejections from `WdaClient.deleteSession` do not become unhandled.
- Around line 190-203: daemonWsUrl currently returns the first non-internal IPv4
from networkInterfaces(), which can select an unreachable VPN/bridge adapter on
multi-homed Macs. Update daemonWsUrl to prefer a routable LAN-facing interface
for physical devices by skipping common virtual/VPN adapters (for example utun,
docker, bridge, thunderbolt/secondary adapters) and only falling back to
127.0.0.1 if no suitable address is found. Keep the simulator branch unchanged
and make the selection logic explicit within daemonWsUrl so it reliably chooses
an address the phone can reach.
In `@daemon/ios/tools.ts`:
- Around line 472-483: installRunnerApp currently installs the staged runner
without verifying it is signed and provisioned, which can break the
unsigned-runner contract described in daemon/ios/signer.ts. Update
installRunnerApp to check the staged artifact’s signing/provisioning state
before calling installer.installApp or xcrun devicectl, and if it is not ready,
return a clear ios setup/build-required error instead of attempting
installation. Use the existing stageRunner, findRunnerApp, and
preferNoXcodeIosPath flow to keep the guard close to the install decision.
- Around line 439-443: stageRunner() is reusing RUNNER_STAGE_DIR solely based on
existing staged files, which can leave a stale runner in place after bundle or
override changes. Update stageRunner to track the current source artifact
identity (for example the bundled runner path or override path plus
mtime/version marker) and compare it against the cached stage before returning
it. If the source marker has changed, invalidate the cached .app/.xctestrun,
restage via the existing signed-stage workflow, and keep the reuse path only
when the source marker still matches.
- Line 21: The launch staging logic is reusing the same .xctestrun filenames for
every device, which lets concurrent runs overwrite each other’s token/UDID data.
Update the helper in daemon/ios/tools.ts that writes .interceptor-xctestrun.json
and InterceptorRunner-interceptor.xctestrun to generate per-launch unique
filenames (for example by including a launch-specific suffix) and ensure the
same unique names are used consistently when passing the file to xcodebuild.
- Around line 176-184: In the iOS device parsing logic inside the device entry
construction, `pairingState` is being matched too loosely and treats values like
unpaired as paired. Update the `paired` assignment in the `entry` object to
parse the `connectionProperties.pairingState` value exactly so only truly paired
states are marked supported, and leave unpaired or other non-paired states as
false.
In `@daemon/ios/tree.ts`:
- Around line 99-145: The `formatWdaTree` output is too noisy because
`LANDMARK_TYPES` treats `Other` as a landmark, so `filter: "all"` includes
generic `XCUIElementTypeOther` wrappers and fills the tree too early. Remove
`Other` from `LANDMARK_TYPES` and keep `all` limited to meaningful nodes in
`walk` via the existing `interactive`, `label`, and `value` checks. Also make
truncation explicit in `formatWdaTree` when `maxDepth` or `maxChars` stops
traversal, so callers can tell the tree was cut off instead of receiving a
silent partial result.
In `@daemon/ios/tunnel-helper/helper.ts`:
- Around line 176-186: The message dispatcher in helper.ts is missing support
for the "relay" operation, which causes getServiceEndpoint() in tunnel.ts to
fail when it sends { op: "relay", udid, service }. Add a relay branch alongside
the existing ping/discover/tunnel/remotectl handling that returns the expected {
ok, host, port } response, or remove/update getServiceEndpoint and its callers
if this helper path is no longer used. Use the existing message switch in the
socket handler and the getServiceEndpoint()/usertunnel.ts symbols to locate the
affected flow.
- Around line 158-193: The `runTunnelHelper` socket is exposed too broadly:
`HELPER_SOCKET` is chmod’d world-writable and `net.createServer` accepts
privileged `remotectl` and `tunnel` operations from any local user. Tighten
access by removing the `666` permission in the `listen` callback and restricting
the socket to the intended daemon user/group, and add peer credential validation
before handling requests (for example in the `runTunnelHelper` request path
before `msg.op` dispatch). Use the existing `runTunnelHelper`, `HELPER_SOCKET`,
and `remotectl` handling as the main points to update.
- Around line 129-141: The CDP forwarding loop in pump is writing a Buffer view
that shares rbuf’s backing memory, so later reads can overwrite data still
queued on cdp.write. Update the write path in helper.ts to copy the payload
before queuing it, using a fresh buffer derived from the read slice rather than
Buffer.from(rbuf.buffer, 4, n - 4), and keep the change localized around the
pump function and cdp.write call.
In `@daemon/ios/usbmux-forward.ts`:
- Around line 240-277: The openDeviceChannel usbmux Connect flow can hang
forever if usbmuxd never replies, since it currently has no timeout like other
one-shot requests. Add a bounded timeout inside openDeviceChannel around the
Bun.connect Connect handshake, and make sure it rejects via the existing fail
path if the device never responds; clear the timeout once the handshake settles
successfully. Use the existing openDeviceChannel, fail, and Bun.connect socket
handlers as the place to wire this in so forwarded clients don’t stall
indefinitely.
In `@daemon/ios/usertunnel.ts`:
- Around line 971-987: Transient handshake/stdio/tunnel failures from
attemptLaunch() are escaping the retry loop in launchRunnerOverUserspaceTunnel,
so wrap the await attemptLaunch() call in the loop with a try/catch and treat
those exceptions as retryable rather than aborting immediately. Use the existing
attemptLaunch, MAX_ATTEMPTS, and locked-device retry flow to distinguish
launch-denied responses from transient exceptions, log the failure details, and
continue retrying until the limit or a successful pid is returned.
In `@docs/ios/app-route.md`:
- Around line 70-119: Close the stray markdown code fence in the app-route
documentation after the Legacy/internal paragraph so the rest of the sections
render normally. In the docs content around the legacy
`enable`/`disable`/`status`/`discover` paragraph, remove or balance the
unmatched fence so the following `interceptor contexts`, `Capability boundary`,
`Failure modes`, and `Actuation primitives` sections are treated as regular
markdown instead of being inside a code block.
In `@ios/InterceptorRunner/Sources/InterceptorRunnerUITests.swift`:
- Around line 82-172: WSAgent has unsynchronized cross-thread access to shared
state, causing races across the delegate queue, reconnect path, and main test
thread. Fix this by serializing all reads/writes of finished, task, and
reconnects through one private serial queue or an actor, and update connect(),
send(), receive(), and handleDisconnect() to use that single coordination point.
Make sure testRunner()’s finished polling and any task reassignment/read happen
through the same synchronized path.
In `@README.md`:
- Around line 161-165: Update the section heading and intro around the surface
overview so they match the new iOS addition: change the “The Two Surfaces”
heading and the sentence in that intro that says the CLI ships with “two”
product surfaces to reflect three surfaces. Keep the wording consistent with the
existing Browser, macOS, and iOS references in the same section so the overview,
the table, and the deeper surface sections all agree.
In `@scripts/release.sh`:
- Around line 393-407: The release flow in the interceptor runner build block is
bypassing the script’s normal dry-run handling, so `DRY_RUN=1` still allows real
Xcode work to run. Update the `if [[ "${INTERCEPTOR_SKIP_RUNNER:-0}" != "1" ]]`
/ `RUNNER_PRODUCTS` block to use the same dry-run mechanism as `run()` instead
of calling `xcodegen generate` and `xcrun xcodebuild` directly, and make the
guard consistent with the rest of `scripts/release.sh` so
`INTERCEPTOR_DRY_RUN`/`DRY_RUN` behavior is honored everywhere.
---
Minor comments:
In `@ARCHITECTURE.md`:
- Around line 295-306: Update the RunnerChannel protocol description in
ARCHITECTURE.md to match the actual daemon↔runner wire format. The section
around IosManager and RunnerChannel should describe frames as {id, op, ...args}
→ {id, result}, not {id, action}. Use the existing symbols shared/ios-device.ts,
daemon/ios/channel.ts, and IosManager to align the text with the real socket
protocol and keep the higher-level CLI→daemon “action” terminology separate.
In `@cli/commands/ios.ts`:
- Around line 301-318: The screenshot branch in `runIosCommand` can fail on
`Bun.write` without being handled, so wrap the write-and-emit path in error
handling and surface a clean CLI failure instead of letting the rejection
escape. Update the `case "screenshot"` flow to catch disk-write errors around
`Bun.write` and the subsequent `console.log`/return path, and route failures
through the existing CLI error/exit handling used by `emitExit` so callers from
`cli/index.ts` don’t see an unhandled rejection.
In `@cli/transport.ts`:
- Around line 27-38: The iOS timeout overrides in transport handling are only
applied to a subset of drive verbs, leaving `ios_click`, `ios_keys`,
`ios_scroll`, `ios_drag`, `ios_press`, and `ios_inspect` on the default
interceptor timeout. Update the timeout mapping in `cli/transport.ts` so the
elevated iOS deadline is applied consistently to all iOS drive verbs, using the
existing `ios_tree`/`ios_find`/`ios_type`/`ios_app`/`ios_screenshot` entries as
the reference point. If any verb should remain at 15s, document that exception
explicitly in the same timeout config.
In `@daemon/ios/manager.ts`:
- Around line 839-851: The verbScroll handler in IosManager currently falls
through for unsupported action.dir values and performs a zero-length drag
instead of reporting invalid input. Update verbScroll to validate the normalized
dir value before computing the drag target, and return an error result for
anything other than down, up, left, or right. Keep the fix localized to
verbScroll and its use of resolvePoint, screenCenter, and ctx.channel.drag so
unknown directions are rejected instead of silently treated as a no-op.
In `@daemon/ios/state.ts`:
- Around line 67-69: saveIosState is swallowing write failures, so persistence
errors never reach callers and later state-dependent operations can fail
mysteriously. Update saveIosState to surface or propagate the write error
instead of catching and ignoring it, then adjust callers like markInstalled,
setAlias, and setAppleAccount to handle the failure path explicitly so they can
report or react to a failed state save.
In `@ios/InterceptorRunner/README.md`:
- Around line 51-54: The repo-local usage example in the README still invokes
the CLI as interceptor directly, which assumes it is on PATH. Update the
examples around the InterceptorRunner setup and the related sections to use
./dist/interceptor instead, keeping the commands otherwise unchanged and
consistent with the existing repo-local guidance for the interceptor CLI.
- Around line 89-93: Add a language identifier to the fenced block in the README
so markdownlint MD040 passes; update the code fence around the protocol example
in the InterceptorRunner documentation to use a text-style language tag, keeping
the existing content unchanged. Locate the block by the daemon/runner/register
entries and adjust only the fence syntax.
---
Nitpick comments:
In `@cli/commands/ios.ts`:
- Around line 264-271: The `type` argument parsing in `ios.ts` has a dead
fallback branch that re-checks `args[2]` even though `ref` already consumed the
same condition, making the code misleading about supporting ref-less input.
Simplify the `case "type"` handling in the command parser by removing the
unreachable `text` fallback and keeping only the documented `ios type <ref>
<text>` flow, while preserving the existing `send({ type: "ios_type", ... })`
call and validation for missing text.
In `@daemon/ios/installer.ts`:
- Around line 292-306: lookupAppPath is duplicating XML field parsing for Path
instead of reusing the shared helper. Update lookupAppPath in installer.ts to
use xmlStringField from ./lockdown for extracting the Path value, alongside
plistToXml, so the parsing logic stays centralized and consistent.
- Around line 207-227: The openFile fallback in the AFC FileOpen flow is
retrying with a reversed payload order that produces a malformed request and can
hide the real failure. Update openFile in the installer’s AFC request path to
send only the correct mode-then-path payload, and remove the candidates
loop/retry logic so request(AFC_OP.FileOpen, ...) is attempted once with the
valid format and any error is surfaced directly.
In `@daemon/ios/manager.ts`:
- Around line 673-690: The pickDeviceUdid logic has redundant branching because
every path inside the resolveUdid(ref) success case returns the same udid.
Simplify the if (u) block in pickDeviceUdid to a single return u after the
lookup, and remove the unused resolveDescriptor/offline-alias checks unless you
plan to restore distinct control flow for them. Keep the rest of the
device-selection logic unchanged.
In `@daemon/ios/tree.ts`:
- Around line 119-170: The tree output in formatWdaTree is silently cut off when
walk stops at maxDepth or maxChars, so add an explicit truncation marker to out
when traversal is skipped due to either limit. Update the walk helper in
formatWdaTree to detect these cutoff cases and append a clear “truncated”/“more
content omitted” line so callers like ios tree and ios find can tell the result
is partial.
In `@daemon/ios/usertunnel.ts`:
- Around line 51-770: This file contains large duplicated protocol logic that is
also implemented in rsd.ts and the CDTunnel handshake helper, so changes will
drift between copies. Move the shared IPv6/TCP mux, XPC/HTTP2/DTX codecs, and
cdTunnelHandshake flow into a common module, then update Tun, XpcService, and
DtxConnection here to import and reuse that shared implementation instead of
maintaining parallel copies.
In `@ios/InterceptorRunner/InterceptorRunner.xcodeproj/project.pbxproj`:
- Around line 103-307: The generated Xcode project file is checked in, so it can
drift from project.yml if the spec changes without regeneration. Add a CI
validation step that runs xcodegen generate with project.yml and then verifies
the workspace is clean with git diff --exit-code. Put this in the existing
build/test pipeline that owns project generation so the sync check runs
automatically whenever the InterceptorRunner project is validated.
In `@ios/InterceptorRunner/Sources/ObjCSupport.m`:
- Around line 5-6: The ObjCSupport.m imports rely on transitive Foundation
headers for usleep and pid_t, which is fragile. Add the explicit system headers
needed for those symbols in the import block of ObjCSupport.m, alongside the
existing objc/message.h and objc/runtime.h imports, so the file does not depend
on indirect includes. Also verify the use sites around the sleep/pid_t
references in the file still compile cleanly after making the imports explicit.
- Around line 64-96: ICActiveApplicationBundleID currently does a blocking
5-attempt retry with usleep on every foreground lookup, which can add up to
500ms per verb call. Reduce or eliminate the repeated wait by caching the last
resolved bundle id and/or making the lookup non-blocking, and ensure the retry
loop in ICActiveApplicationBundleID does not run on the WebSocket
verb-processing thread used by foregroundApp() so queued verbs are not stalled.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8be8c13c-65ea-40a9-9500-5256b16c26de
⛔ Files ignored due to path filters (1)
ios/InterceptorRunner/Generated/InterceptorRunner-Info.plistis excluded by!**/generated/**
📒 Files selected for processing (57)
.agents/skills/interceptor-ios/SKILL.md.agents/skills/interceptor-ios/references/command-catalog.md.agents/skills/interceptor-ios/workflows/drive-iphone-app.md.gitignoreARCHITECTURE.mdREADME.mdcli/commands/ios.tscli/help.tscli/index.tscli/transport.tscli/version.tsdaemon/index.tsdaemon/ios/channel.tsdaemon/ios/ddi.tsdaemon/ios/installer.tsdaemon/ios/keychain.tsdaemon/ios/lockdown.tsdaemon/ios/manager.tsdaemon/ios/signer.tsdaemon/ios/state.tsdaemon/ios/testmanagerd.tsdaemon/ios/tools.tsdaemon/ios/tree.tsdaemon/ios/tunnel-helper/com.interceptor.ios-tunnel.plistdaemon/ios/tunnel-helper/helper.tsdaemon/ios/tunnel.tsdaemon/ios/usbmux-forward.tsdaemon/ios/usertunnel.tsdaemon/ios/wda-client.tsdaemon/outbound-routing.tsdocs/ios/app-route.mdextension/dist-mv2/manifest.jsonextension/manifest.jsonios/InterceptorRunner/InterceptorRunner.xcodeproj/project.pbxprojios/InterceptorRunner/InterceptorRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedataios/InterceptorRunner/InterceptorRunner.xcodeproj/xcshareddata/xcschemes/InterceptorRunner.xcschemeios/InterceptorRunner/README.mdios/InterceptorRunner/Sources/InterceptorRunner-Bridging-Header.hios/InterceptorRunner/Sources/InterceptorRunnerUITests.swiftios/InterceptorRunner/Sources/ObjCSupport.hios/InterceptorRunner/Sources/ObjCSupport.mios/InterceptorRunner/project.ymlpackage.jsonscripts/audit-capability-blind.shscripts/release.shscripts/release/postinstall-fullshared/ios-device.tstest/ios-channel.test.tstest/ios-device.test.tstest/ios-installer.test.tstest/ios-keychain.test.tstest/ios-lockdown.test.tstest/ios-signer.test.tstest/ios-testmanagerd.test.tstest/ios-tree.test.tstest/ios-usbmux.test.tstest/ios-xcode-provisioning.test.ts
| async removePathRecursive(path: string): Promise<void> { | ||
| try { | ||
| for (const name of await this.readDir(path)) { | ||
| await this.removePathRecursive(`${path}/${name}`) | ||
| } | ||
| } catch (err) { | ||
| if (!(err instanceof AfcStatusError) || err.status !== AFC_STATUS.ObjectNotFound) { | ||
| // Not a directory is fine; the final RemovePath handles files. | ||
| } | ||
| } | ||
| await this.removePath(path) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Empty catch branch silently swallows all readDir errors, not just "not a directory".
The if body at Lines 200-202 is empty — regardless of whether the caught error is a genuine AfcStatusError with ObjectNotFound, some other status, or an unrelated transport/socket error, nothing happens and the function proceeds to removePath(path). Any transient/real failure during recursive delete (e.g. connection reset) is silently discarded rather than surfaced, which can mask install failures.
🐛 Proposed fix
async removePathRecursive(path: string): Promise<void> {
try {
for (const name of await this.readDir(path)) {
await this.removePathRecursive(`${path}/${name}`)
}
} catch (err) {
- if (!(err instanceof AfcStatusError) || err.status !== AFC_STATUS.ObjectNotFound) {
- // Not a directory is fine; the final RemovePath handles files.
- }
+ // ObjectNotFound just means it doesn't exist (or isn't a directory) —
+ // the final RemovePath below handles files. Anything else is real.
+ if (err instanceof AfcStatusError && err.status !== AFC_STATUS.ObjectNotFound) {
+ throw err
+ } else if (!(err instanceof AfcStatusError)) {
+ throw err
+ }
}
await this.removePath(path)
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async removePathRecursive(path: string): Promise<void> { | |
| try { | |
| for (const name of await this.readDir(path)) { | |
| await this.removePathRecursive(`${path}/${name}`) | |
| } | |
| } catch (err) { | |
| if (!(err instanceof AfcStatusError) || err.status !== AFC_STATUS.ObjectNotFound) { | |
| // Not a directory is fine; the final RemovePath handles files. | |
| } | |
| } | |
| await this.removePath(path) | |
| } | |
| async removePathRecursive(path: string): Promise<void> { | |
| try { | |
| for (const name of await this.readDir(path)) { | |
| await this.removePathRecursive(`${path}/${name}`) | |
| } | |
| } catch (err) { | |
| // ObjectNotFound just means it doesn't exist (or isn't a directory) — | |
| // the final RemovePath below handles files. Anything else is real. | |
| if (err instanceof AfcStatusError && err.status !== AFC_STATUS.ObjectNotFound) { | |
| throw err | |
| } else if (!(err instanceof AfcStatusError)) { | |
| throw err | |
| } | |
| } | |
| await this.removePath(path) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daemon/ios/installer.ts` around lines 194 - 205, The recursive delete in
removePathRecursive is swallowing all errors from readDir, not just the expected
“not a directory” case. Update the catch block so only AfcStatusError with
AFC_STATUS.ObjectNotFound is ignored, and rethrow any other error before calling
removePath(path). Preserve the existing recursive behavior in
daemon/ios/installer.ts by keeping the check near readDir and
removePathRecursive.
| const walk = async (localDir: string): Promise<void> => { | ||
| for (const name of readdirSync(localDir)) { | ||
| const local = join(localDir, name) | ||
| const rel = relative(appPath, local).split("/").filter(Boolean).join("/") | ||
| const remote = `${remoteRoot}/${rel}` | ||
| const st = lstatSync(local) | ||
| if (st.isDirectory()) { | ||
| await afc.makeDir(remote) | ||
| await walk(local) | ||
| } else if (st.isFile()) { | ||
| await afc.writeFile(remote, readFileSync(local)) | ||
| } | ||
| } | ||
| } | ||
| await walk(appPath) |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
Synchronous filesystem I/O blocks the event loop during app upload.
walk() uses readdirSync/lstatSync/readFileSync for every file in the app bundle inside an async function. On a real .app bundle (binaries + resources, possibly tens of MB), this blocks the daemon's single event loop for the whole upload, stalling all other in-flight browser/iOS requests.
♻️ Proposed fix using async fs APIs
-import { lstatSync, readdirSync, readFileSync } from "node:fs"
+import { promises as fs } from "node:fs"
...
- const walk = async (localDir: string): Promise<void> => {
- for (const name of readdirSync(localDir)) {
+ const walk = async (localDir: string): Promise<void> => {
+ for (const name of await fs.readdir(localDir)) {
const local = join(localDir, name)
const rel = relative(appPath, local).split("/").filter(Boolean).join("/")
const remote = `${remoteRoot}/${rel}`
- const st = lstatSync(local)
+ const st = await fs.lstat(local)
if (st.isDirectory()) {
await afc.makeDir(remote)
await walk(local)
} else if (st.isFile()) {
- await afc.writeFile(remote, readFileSync(local))
+ await afc.writeFile(remote, await fs.readFile(local))
}
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const walk = async (localDir: string): Promise<void> => { | |
| for (const name of readdirSync(localDir)) { | |
| const local = join(localDir, name) | |
| const rel = relative(appPath, local).split("/").filter(Boolean).join("/") | |
| const remote = `${remoteRoot}/${rel}` | |
| const st = lstatSync(local) | |
| if (st.isDirectory()) { | |
| await afc.makeDir(remote) | |
| await walk(local) | |
| } else if (st.isFile()) { | |
| await afc.writeFile(remote, readFileSync(local)) | |
| } | |
| } | |
| } | |
| await walk(appPath) | |
| const walk = async (localDir: string): Promise<void> => { | |
| for (const name of await fs.readdir(localDir)) { | |
| const local = join(localDir, name) | |
| const rel = relative(appPath, local).split("/").filter(Boolean).join("/") | |
| const remote = `${remoteRoot}/${rel}` | |
| const st = await fs.lstat(local) | |
| if (st.isDirectory()) { | |
| await afc.makeDir(remote) | |
| await walk(local) | |
| } else if (st.isFile()) { | |
| await afc.writeFile(remote, await fs.readFile(local)) | |
| } | |
| } | |
| } | |
| await walk(appPath) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daemon/ios/installer.ts` around lines 331 - 345, `walk()` in the iOS
installer is doing synchronous filesystem work inside an async upload path,
which blocks the daemon event loop. Replace `readdirSync`, `lstatSync`, and
`readFileSync` with the async `fs.promises` equivalents (or another non-blocking
traversal approach), and await each step within `walk()` so app bundle upload
stays non-blocking while preserving the existing `afc.makeDir` and
`afc.writeFile` behavior.
| function upgradeTls(raw: net.Socket, pair: PairRecord): Promise<TLSSocket> { | ||
| return new Promise((resolve, reject) => { | ||
| raw.removeAllListeners("data") | ||
| const t = tls.connect({ | ||
| socket: raw, | ||
| key: pair.HostPrivateKey, cert: pair.HostCertificate, | ||
| ca: pair.RootCertificate ?? pair.DeviceCertificate, | ||
| rejectUnauthorized: false, // lockdown uses a private CA; the device cert is pinned by pairing | ||
| minVersion: "TLSv1", | ||
| }, () => resolve(t)) | ||
| t.on("error", reject) | ||
| }) | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the relevant function and its call sites.
sed -n '220,320p' daemon/ios/lockdown.ts
printf '\n--- search for tls.connect usage ---\n'
rg -n "tls\.connect|checkServerIdentity|rejectUnauthorized|minVersion" daemon/ios/lockdown.ts
printf '\n--- search for lockdown tls/cert references ---\n'
rg -n "lockdown|DeviceCertificate|HostCertificate|RootCertificate|pairing|servername|SAN|SubjectAltName" -S .Repository: Hacker-Valley-Media/Interceptor
Length of output: 13607
🌐 Web query:
Node.js tls.connect servername checkServerIdentity default behavior when only socket is provided no host
💡 Result:
When using tls.connect in Node.js, if you provide a socket without a host (or servername), the default behavior is that the hostname passed to the checkServerIdentity function will be undefined [1]. Because the default implementation of tls.checkServerIdentity(hostname, cert) attempts to verify the certificate against the provided hostname, calling it with undefined as the hostname will fail validation (typically resulting in an error indicating that the hostname/IP does not match the certificate's subject alternative names, often specifically reporting the host as "undefined") [1]. Consequently, when connecting via a socket without explicitly defining the servername, the built-in identity verification mechanism cannot perform a successful match against the certificate [2][3]. It is standard practice and highly recommended in this scenario to provide a custom checkServerIdentity function in the tls.connect options to handle or bypass the hostname verification as needed, as the default logic is not equipped to handle the absence of a host identifier [2][4][3].
Citations:
- 1: undefined is passed as hostname to tls.checkServerIdentity nodejs/node#1489
- 2: https://nodejs.org/api/tls.html
- 3: https://nodejs.org/docs/latest-v23.x/api/tls.html
- 4: https://nodejs.org/docs/latest-v20.x/api/tls.html
🌐 Web query:
libimobiledevice lockdownd TLS certificate hostname SAN servername pymobiledevice3 lockdownd certificate subjectAltName
💡 Result:
In the context of the iOS lockdownd protocol, there is no requirement for a Subject Alternative Name (SAN) or a specific servername in the client or device certificates generated during pairing [1]. Both libimobiledevice and pymobiledevice3 generate X.509 certificates for pairing that typically use an empty Distinguished Name and are self-signed to establish trust between the specific host and device [1]. These pairing certificates function as a private trust anchor rather than as public-facing web server certificates, meaning they do not need to comply with the SAN requirements that Apple enforces for public TLS server certificates on iOS and macOS [2][1][3]. Technical details regarding these certificates include: 1. Certificate Content: Both implementations generate a chain consisting of a Root CA, a Host certificate, and a Device certificate [1]. These certificates are stored in a pair record (plist) and do not contain SAN extensions or specific hostnames [1]. 2. Pairing vs. TLS/SSL: While lockdownd uses SSL/TLS to secure the communication session after pairing, the SSL context typically ignores hostname verification (check_hostname = False) and certificate verification (verify_mode = ssl.CERT_NONE) because the trust is already established by the prior successful exchange of the pair record credentials [4][5]. 3. Apple's SAN Requirement: The requirement for SAN in TLS certificates cited in official Apple documentation applies to server certificates used for standard HTTPS/TLS connections to prevent impersonation in web contexts, not to the proprietary lockdown pairing process [2][3]. If you are experiencing connection or SSL handshake issues, they are generally not related to missing SAN fields but rather to invalid pair records, clock skew between the device and host, or outdated pairing credentials [6].
Citations:
- 1: https://deepwiki.com/doronz88/pymobiledevice3/2.3-pairing-and-certificate-management
- 2: https://support.apple.com/en-us/103769
- 3: https://kb.filewave.com/books/certificates/page/ssl-server-certificates-ios-13-and-macos-1015
- 4: https://deepwiki.com/libimobiledevice/libimobiledevice/2.2-lockdown-service
- 5: https://github.com/doronz88/pymobiledevice3/blob/1c673a4b/pymobiledevice3/service_connection.py
- 6: pymobiledevice3 lockdown pair is not working doronz88/pymobiledevice3#900
rejectUnauthorized: false leaves the lockdownd session unauthenticated. Lockdown pair certs don’t carry a DNS SAN/servername, so this needs rejectUnauthorized: true plus a checkServerIdentity override; otherwise the ca value is ignored and any TLS peer on the forwarded socket can impersonate the device.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daemon/ios/lockdown.ts` around lines 241 - 253, The upgradeTls handshake
currently disables verification by setting rejectUnauthorized to false, which
leaves the lockdownd connection unauthenticated. Update upgradeTls in
lockdown.ts to use rejectUnauthorized: true and add a checkServerIdentity
override that validates the paired device certificate appropriately, since the
pair certs lack a DNS SAN/servername. Keep the existing socket/cert wiring in
upgradeTls, but ensure the TLS peer is actually verified instead of relying on
the ignored ca setting.
| async function openLockdownSession(udid: string): Promise<{ chan: LockdownChannel; pair: PairRecord; deviceId: number }> { | ||
| const pair = await readPairRecord(udid) | ||
| if (!pair?.HostID || !pair.SystemBUID) { | ||
| throw new Error( | ||
| `ios: '${udid}' is not paired with this Mac yet. Plug it in over USB and tap ` + | ||
| `"Trust This Computer" (then enter the passcode), and re-run.`, | ||
| ) | ||
| } | ||
| const deviceId = await resolveDeviceId(udid) | ||
| if (deviceId === undefined) throw new Error(`ios: device '${udid}' not visible to usbmuxd (plugged in?)`) | ||
|
|
||
| const raw = await connectUsbmuxPort(deviceId, LOCKDOWN_PORT) | ||
| let chan = new LockdownChannel(raw) | ||
| const qt = await chan.request({ Request: "QueryType", Label: CLIENT_LABEL }) | ||
| if (qt.Type !== "com.apple.mobile.lockdown") throw new Error(`lockdown: unexpected QueryType ${String(qt.Type)}`) | ||
| const ss = await chan.request({ Request: "StartSession", Label: CLIENT_LABEL, HostID: pair.HostID, SystemBUID: pair.SystemBUID }) | ||
| if (ss.Error) throw new Error(`lockdown StartSession failed: ${String(ss.Error)}`) | ||
| if (ss.EnableSessionSSL === true) { | ||
| if (!pair.HostCertificate || !pair.HostPrivateKey) throw new Error("lockdown: pair record missing host cert/key for TLS (re-pair the device)") | ||
| chan = new LockdownChannel(await upgradeTls(raw, pair)) | ||
| } | ||
| return { chan, pair, deviceId } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Socket leak when the lockdown handshake fails mid-flight.
openLockdownSession opens raw (and wraps it in chan) before any of the handshake steps that can throw (unexpected QueryType, StartSession error, missing cert/key for a required TLS upgrade). None of these throw paths close raw/chan first, and since callers only receive (and thus only close) chan on success, any of these failures leaks the socket. This runs on every startService/getValue/connectServiceSocket call.
🐛 Proposed fix
async function openLockdownSession(udid: string): Promise<{ chan: LockdownChannel; pair: PairRecord; deviceId: number }> {
const pair = await readPairRecord(udid)
if (!pair?.HostID || !pair.SystemBUID) {
throw new Error(...)
}
const deviceId = await resolveDeviceId(udid)
if (deviceId === undefined) throw new Error(...)
const raw = await connectUsbmuxPort(deviceId, LOCKDOWN_PORT)
- let chan = new LockdownChannel(raw)
- const qt = await chan.request({ Request: "QueryType", Label: CLIENT_LABEL })
- if (qt.Type !== "com.apple.mobile.lockdown") throw new Error(`lockdown: unexpected QueryType ${String(qt.Type)}`)
- const ss = await chan.request({ Request: "StartSession", Label: CLIENT_LABEL, HostID: pair.HostID, SystemBUID: pair.SystemBUID })
- if (ss.Error) throw new Error(`lockdown StartSession failed: ${String(ss.Error)}`)
- if (ss.EnableSessionSSL === true) {
- if (!pair.HostCertificate || !pair.HostPrivateKey) throw new Error("lockdown: pair record missing host cert/key for TLS (re-pair the device)")
- chan = new LockdownChannel(await upgradeTls(raw, pair))
- }
- return { chan, pair, deviceId }
+ let chan = new LockdownChannel(raw)
+ try {
+ const qt = await chan.request({ Request: "QueryType", Label: CLIENT_LABEL })
+ if (qt.Type !== "com.apple.mobile.lockdown") throw new Error(`lockdown: unexpected QueryType ${String(qt.Type)}`)
+ const ss = await chan.request({ Request: "StartSession", Label: CLIENT_LABEL, HostID: pair.HostID, SystemBUID: pair.SystemBUID })
+ if (ss.Error) throw new Error(`lockdown StartSession failed: ${String(ss.Error)}`)
+ if (ss.EnableSessionSSL === true) {
+ if (!pair.HostCertificate || !pair.HostPrivateKey) throw new Error("lockdown: pair record missing host cert/key for TLS (re-pair the device)")
+ chan = new LockdownChannel(await upgradeTls(raw, pair))
+ }
+ return { chan, pair, deviceId }
+ } catch (err) {
+ chan.close()
+ throw err
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function openLockdownSession(udid: string): Promise<{ chan: LockdownChannel; pair: PairRecord; deviceId: number }> { | |
| const pair = await readPairRecord(udid) | |
| if (!pair?.HostID || !pair.SystemBUID) { | |
| throw new Error( | |
| `ios: '${udid}' is not paired with this Mac yet. Plug it in over USB and tap ` + | |
| `"Trust This Computer" (then enter the passcode), and re-run.`, | |
| ) | |
| } | |
| const deviceId = await resolveDeviceId(udid) | |
| if (deviceId === undefined) throw new Error(`ios: device '${udid}' not visible to usbmuxd (plugged in?)`) | |
| const raw = await connectUsbmuxPort(deviceId, LOCKDOWN_PORT) | |
| let chan = new LockdownChannel(raw) | |
| const qt = await chan.request({ Request: "QueryType", Label: CLIENT_LABEL }) | |
| if (qt.Type !== "com.apple.mobile.lockdown") throw new Error(`lockdown: unexpected QueryType ${String(qt.Type)}`) | |
| const ss = await chan.request({ Request: "StartSession", Label: CLIENT_LABEL, HostID: pair.HostID, SystemBUID: pair.SystemBUID }) | |
| if (ss.Error) throw new Error(`lockdown StartSession failed: ${String(ss.Error)}`) | |
| if (ss.EnableSessionSSL === true) { | |
| if (!pair.HostCertificate || !pair.HostPrivateKey) throw new Error("lockdown: pair record missing host cert/key for TLS (re-pair the device)") | |
| chan = new LockdownChannel(await upgradeTls(raw, pair)) | |
| } | |
| return { chan, pair, deviceId } | |
| } | |
| async function openLockdownSession(udid: string): Promise<{ chan: LockdownChannel; pair: PairRecord; deviceId: number }> { | |
| const pair = await readPairRecord(udid) | |
| if (!pair?.HostID || !pair.SystemBUID) { | |
| throw new Error( | |
| `ios: '${udid}' is not paired with this Mac yet. Plug it in over USB and tap ` + | |
| `"Trust This Computer" (then enter the passcode), and re-run.`, | |
| ) | |
| } | |
| const deviceId = await resolveDeviceId(udid) | |
| if (deviceId === undefined) throw new Error(`ios: device '${udid}' not visible to usbmuxd (plugged in?)`) | |
| const raw = await connectUsbmuxPort(deviceId, LOCKDOWN_PORT) | |
| let chan = new LockdownChannel(raw) | |
| try { | |
| const qt = await chan.request({ Request: "QueryType", Label: CLIENT_LABEL }) | |
| if (qt.Type !== "com.apple.mobile.lockdown") throw new Error(`lockdown: unexpected QueryType ${String(qt.Type)}`) | |
| const ss = await chan.request({ Request: "StartSession", Label: CLIENT_LABEL, HostID: pair.HostID, SystemBUID: pair.SystemBUID }) | |
| if (ss.Error) throw new Error(`lockdown StartSession failed: ${String(ss.Error)}`) | |
| if (ss.EnableSessionSSL === true) { | |
| if (!pair.HostCertificate || !pair.HostPrivateKey) throw new Error("lockdown: pair record missing host cert/key for TLS (re-pair the device)") | |
| chan = new LockdownChannel(await upgradeTls(raw, pair)) | |
| } | |
| return { chan, pair, deviceId } | |
| } catch (err) { | |
| chan.close() | |
| throw err | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daemon/ios/lockdown.ts` around lines 271 - 293, openLockdownSession leaks the
underlying lockdown socket when QueryType, StartSession, or the TLS
preconditions fail before the session is returned. Update openLockdownSession to
ensure raw and the current LockdownChannel are closed on every throw path after
connectUsbmuxPort, ideally with a try/finally around the handshake steps and
cleanup of chan/raw on failure. Keep the fix localized to openLockdownSession
and preserve the successful return behavior for
startService/getValue/connectServiceSocket callers.
| /** ws://<host>:<port> the on-device runner dials back into. */ | ||
| private daemonWsUrl(kind: IosDeviceKind): string { | ||
| const override = process.env.INTERCEPTOR_WS_URL | ||
| if (override) return override | ||
| const port = this.deps.wsPort | ||
| if (kind === "simulator") return `ws://127.0.0.1:${port}` | ||
| // Physical device: it reaches the Mac over the LAN, so it needs a routable IPv4. | ||
| for (const addrs of Object.values(networkInterfaces())) { | ||
| for (const ni of addrs ?? []) { | ||
| if (ni.family === "IPv4" && !ni.internal) return `ws://${ni.address}:${port}` | ||
| } | ||
| } | ||
| return `ws://127.0.0.1:${port}` | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
daemonWsUrl can pick an unreachable interface on multi-homed Macs.
Iterating networkInterfaces() and returning the first non-internal IPv4 doesn't guarantee it's on the same LAN as the phone. VPN (utun), Docker bridges, Thunderbolt bridge, or a secondary Ethernet adapter would all match family === "IPv4" && !ni.internal and could be picked ahead of the actual Wi-Fi interface, causing the on-device runner to dial an address the phone can never reach (surfacing as the generic "InterceptorRunner did not register within Xs" timeout).
🌐 Proposed fix to skip common virtual/VPN adapters
// Physical device: it reaches the Mac over the LAN, so it needs a routable IPv4.
- for (const addrs of Object.values(networkInterfaces())) {
- for (const ni of addrs ?? []) {
- if (ni.family === "IPv4" && !ni.internal) return `ws://${ni.address}:${port}`
+ const skipPrefixes = ["utun", "ppp", "awdl", "llw", "bridge", "docker", "veth", "tap"]
+ for (const [name, addrs] of Object.entries(networkInterfaces())) {
+ if (skipPrefixes.some((p) => name.startsWith(p))) continue
+ for (const ni of addrs ?? []) {
+ if (ni.family === "IPv4" && !ni.internal) return `ws://${ni.address}:${port}`
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** ws://<host>:<port> the on-device runner dials back into. */ | |
| private daemonWsUrl(kind: IosDeviceKind): string { | |
| const override = process.env.INTERCEPTOR_WS_URL | |
| if (override) return override | |
| const port = this.deps.wsPort | |
| if (kind === "simulator") return `ws://127.0.0.1:${port}` | |
| // Physical device: it reaches the Mac over the LAN, so it needs a routable IPv4. | |
| for (const addrs of Object.values(networkInterfaces())) { | |
| for (const ni of addrs ?? []) { | |
| if (ni.family === "IPv4" && !ni.internal) return `ws://${ni.address}:${port}` | |
| } | |
| } | |
| return `ws://127.0.0.1:${port}` | |
| } | |
| /** ws://<host>:<port> the on-device runner dials back into. */ | |
| private daemonWsUrl(kind: IosDeviceKind): string { | |
| const override = process.env.INTERCEPTOR_WS_URL | |
| if (override) return override | |
| const port = this.deps.wsPort | |
| if (kind === "simulator") return `ws://127.0.0.1:${port}` | |
| // Physical device: it reaches the Mac over the LAN, so it needs a routable IPv4. | |
| const skipPrefixes = ["utun", "ppp", "awdl", "llw", "bridge", "docker", "veth", "tap"] | |
| for (const [name, addrs] of Object.entries(networkInterfaces())) { | |
| if (skipPrefixes.some((p) => name.startsWith(p))) continue | |
| for (const ni of addrs ?? []) { | |
| if (ni.family === "IPv4" && !ni.internal) return `ws://${ni.address}:${port}` | |
| } | |
| } | |
| return `ws://127.0.0.1:${port}` | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daemon/ios/manager.ts` around lines 190 - 203, daemonWsUrl currently returns
the first non-internal IPv4 from networkInterfaces(), which can select an
unreachable VPN/bridge adapter on multi-homed Macs. Update daemonWsUrl to prefer
a routable LAN-facing interface for physical devices by skipping common
virtual/VPN adapters (for example utun, docker, bridge, thunderbolt/secondary
adapters) and only falling back to 127.0.0.1 if no suitable address is found.
Keep the simulator branch unchanged and make the selection logic explicit within
daemonWsUrl so it reliably chooses an address the phone can reach.
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | ||
| const r = await attemptLaunch() | ||
| if (r.pid != null) { pid = r.pid; launchKeepAlive = r.keepAlive ?? []; break } | ||
| if (r.locked) { | ||
| if (attempt === 1) log("Device is LOCKED; SpringBoard refuses to launch. Unlock the phone; retrying every 3s...") | ||
| await new Promise((res) => setTimeout(res, 3000)); continue | ||
| } | ||
| log(`LAUNCH DENIED - domain=${r.err?.domain} code=${r.err?.code}`) | ||
| const reasons = (r.err?.xml as string).match(/<string>[^<]*(?:launch|denied|reason|failed)[^<]*<\/string>/gi) | ||
| const reasonText = reasons?.map((s) => s.replace(/<\/?string>/g, "")).join("; ") | ||
| if (reasons) reasons.forEach((s) => log(" " + s.replace(/<\/?string>/g, ""))) | ||
| const trustHint = /not been explicitly trusted|Developer App Certificate is not trusted/i.test(r.err?.xml ?? "") | ||
| ? " Trust it on the iPhone: Settings > General > VPN & Device Management > Developer App > Trust." | ||
| : "" | ||
| throw new Error(`runner launch denied by iOS (${r.err?.domain ?? "unknown"} code ${r.err?.code ?? "?"})${reasonText ? `: ${reasonText}` : ""}.${trustHint}`) | ||
| } | ||
| if (pid == null) throw new Error("runner did not launch — device stayed locked") |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Transient errors bypass the retry loop.
attemptLaunch() (lines 912-965) only returns a {locked, err} structure for launch-denied responses; any exception thrown earlier (handshake/stdio timeouts, tunnel drop, etc. at lines 918-936) propagates unhandled out of await attemptLaunch() at line 972, aborting the whole launchRunnerOverUserspaceTunnel call immediately — even though the surrounding loop exists specifically to retry through exactly this class of transient failure ("SpringBoard refuses to launch while the device is locked, so retry until unlocked"). A one-off network hiccup during handshake kills the entire launch instead of being retried like the locked-device case.
♻️ Proposed fix: catch and retry transient attempt failures
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
- const r = await attemptLaunch()
+ let r: { pid: number | null; locked: boolean; err?: any; keepAlive?: unknown[] }
+ try {
+ r = await attemptLaunch()
+ } catch (e) {
+ log(`launch attempt ${attempt} failed transiently: ${(e as Error).message}; retrying...`)
+ await new Promise((res) => setTimeout(res, 1000))
+ continue
+ }
if (r.pid != null) { pid = r.pid; launchKeepAlive = r.keepAlive ?? []; break }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | |
| const r = await attemptLaunch() | |
| if (r.pid != null) { pid = r.pid; launchKeepAlive = r.keepAlive ?? []; break } | |
| if (r.locked) { | |
| if (attempt === 1) log("Device is LOCKED; SpringBoard refuses to launch. Unlock the phone; retrying every 3s...") | |
| await new Promise((res) => setTimeout(res, 3000)); continue | |
| } | |
| log(`LAUNCH DENIED - domain=${r.err?.domain} code=${r.err?.code}`) | |
| const reasons = (r.err?.xml as string).match(/<string>[^<]*(?:launch|denied|reason|failed)[^<]*<\/string>/gi) | |
| const reasonText = reasons?.map((s) => s.replace(/<\/?string>/g, "")).join("; ") | |
| if (reasons) reasons.forEach((s) => log(" " + s.replace(/<\/?string>/g, ""))) | |
| const trustHint = /not been explicitly trusted|Developer App Certificate is not trusted/i.test(r.err?.xml ?? "") | |
| ? " Trust it on the iPhone: Settings > General > VPN & Device Management > Developer App > Trust." | |
| : "" | |
| throw new Error(`runner launch denied by iOS (${r.err?.domain ?? "unknown"} code ${r.err?.code ?? "?"})${reasonText ? `: ${reasonText}` : ""}.${trustHint}`) | |
| } | |
| if (pid == null) throw new Error("runner did not launch — device stayed locked") | |
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | |
| let r: { pid: number | null; locked: boolean; err?: any; keepAlive?: unknown[] } | |
| try { | |
| r = await attemptLaunch() | |
| } catch (e) { | |
| log(`launch attempt ${attempt} failed transiently: ${(e as Error).message}; retrying...`) | |
| await new Promise((res) => setTimeout(res, 1000)) | |
| continue | |
| } | |
| if (r.pid != null) { pid = r.pid; launchKeepAlive = r.keepAlive ?? []; break } | |
| if (r.locked) { | |
| if (attempt === 1) log("Device is LOCKED; SpringBoard refuses to launch. Unlock the phone; retrying every 3s...") | |
| await new Promise((res) => setTimeout(res, 3000)); continue | |
| } | |
| log(`LAUNCH DENIED - domain=${r.err?.domain} code=${r.err?.code}`) | |
| const reasons = (r.err?.xml as string).match(/<string>[^<]*(?:launch|denied|reason|failed)[^<]*<\/string>/gi) | |
| const reasonText = reasons?.map((s) => s.replace(/<\/?string>/g, "")).join("; ") | |
| if (reasons) reasons.forEach((s) => log(" " + s.replace(/<\/?string>/g, ""))) | |
| const trustHint = /not been explicitly trusted|Developer App Certificate is not trusted/i.test(r.err?.xml ?? "") | |
| ? " Trust it on the iPhone: Settings > General > VPN & Device Management > Developer App > Trust." | |
| : "" | |
| throw new Error(`runner launch denied by iOS (${r.err?.domain ?? "unknown"} code ${r.err?.code ?? "?"})${reasonText ? `: ${reasonText}` : ""}.${trustHint}`) | |
| } | |
| if (pid == null) throw new Error("runner did not launch — device stayed locked") |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@daemon/ios/usertunnel.ts` around lines 971 - 987, Transient
handshake/stdio/tunnel failures from attemptLaunch() are escaping the retry loop
in launchRunnerOverUserspaceTunnel, so wrap the await attemptLaunch() call in
the loop with a try/catch and treat those exceptions as retryable rather than
aborting immediately. Use the existing attemptLaunch, MAX_ATTEMPTS, and
locked-device retry flow to distinguish launch-denied responses from transient
exceptions, log the failure details, and continue retrying until the limit or a
successful pid is returned.
| ``` | ||
|
|
||
| Release builds the agent: `release.sh` runs `xcodebuild build-for-testing` of | ||
| `ios/InterceptorRunner` (team `INTERCEPTOR_RUNNER_TEAM`, default Hacker Valley) and | ||
| tars the Products. Override with `INTERCEPTOR_RUNNER_PREBUILT=<Products dir>` to ship | ||
| your own IPA build, or `INTERCEPTOR_SKIP_RUNNER=1` to omit it. Legacy/internal: | ||
| `enable`/`disable`/`status`/`discover` still exist; `--wda-url` is the deprecated WDA hatch. | ||
| ``` | ||
|
|
||
| `interceptor contexts` lists `ios:<udid>` beside browser / `cdp:` / `runtime:` contexts. | ||
|
|
||
| ## Capability boundary | ||
|
|
||
| | Can | Cannot | | ||
| |---|---| | ||
| | Drive any installed app's UI (tap/type/swipe), trusted, **per-element by coordinate** | Pass Face ID / passcode / Apple Pay (Secure Enclave) | | ||
| | Read the **foreground** app's element tree + screenshot | Read other apps' on-disk/sandbox data or object graph | | ||
| | Launch / activate / terminate apps, press hardware buttons | Get past the lock screen / unlock the device | | ||
| | Type into secure fields | Read back secure-field values (AX-redacted) | | ||
| | Keep the screen awake during a session | Run with the device locked or asleep | | ||
|
|
||
| ## Failure modes (clear errors, never hangs) | ||
|
|
||
| - No-Xcode launch uses the daemon's unprivileged CoreDeviceProxy userspace tunnel. The obsolete root `com.interceptor.ios-tunnel` LaunchDaemon is not on the product launch path. | ||
| - Developer App certificate not trusted → iOS denies launch before the runner starts; trust it on-device in Settings → General → VPN & Device Management. | ||
| - iOS 17+ Xcode/operator launch still works via `xcodebuild test-without-building` when `INTERCEPTOR_IOS_USE_XCODE=1` is set. | ||
| - Runner never connects → "InterceptorRunner did not register within Ns" + guidance (check WiFi pairing / Developer Mode, or set `INTERCEPTOR_RUNNER_PROJECT`). | ||
| - Runner socket drops mid-session → context auto-disabled; re-run `enable`. | ||
| - Developer Mode off / not paired → `enable` reports exactly what to fix. | ||
| - Stale ref → "ref `eN` is stale — re-read with `interceptor ios tree`." | ||
| - Secure-Enclave gate / locked device → a specific error, not a hang. | ||
|
|
||
| ## Actuation primitives (InterceptorRunner) | ||
|
|
||
| The runner drives the foreground app via **public XCUITest APIs** (`ios/InterceptorRunner/Sources/InterceptorRunnerUITests.swift`): taps/drags are screen-absolute `XCUICoordinate.tap()` / `press(forDuration:thenDragTo:)`, text is `XCUIApplication.typeText`, screenshots are `XCUIScreen.main.screenshot()`, hardware buttons are `XCUIDevice.press(_:)`, and the `tree` comes from `XCUIElementSnapshot`. `tree`/`find`/`inspect` **auto-target whatever app is on screen** — the runner resolves the foreground app via the private XCTest AX client (`ObjCSupport.m` `ICActiveApplicationBundleID`: `XCUIDevice.accessibilityInterface.activeApplications` → pid → `applicationMonitor.applicationProcessWithPID:` → `bundleID`). `app activate <bundleId>` still pins a specific app if you want. | ||
|
|
||
| ## Getting InterceptorRunner onto a device (iOS 26) | ||
|
|
||
| 1. Install Xcode, open Xcode → Settings → Accounts, and sign in with the Apple ID/team that should own the runner signing. | ||
| 2. Run setup. Pass `--team` when Xcode has more than one team: | ||
| ``` | ||
| interceptor ios setup <UDID-or-alias> --team <TEAM> | ||
| ``` | ||
| The installed PKG includes `/Library/Application Support/Interceptor/ios/InterceptorRunner/InterceptorRunner.xcodeproj`; use `--project <xcodeproj>` only for development overrides. | ||
| 3. Manual diagnostic build (same signing path setup uses internally): | ||
| ``` | ||
| xcrun xcodebuild build-for-testing -project InterceptorRunner.xcodeproj -scheme InterceptorRunner \ | ||
| -destination "id=<UDID>" -allowProvisioningUpdates DEVELOPMENT_TEAM=<TEAM> -derivedDataPath /tmp/runner-dd | ||
| ``` | ||
| By default, Interceptor uses its own userspace CoreDeviceProxy tunnel and testmanagerd handshake. Set `INTERCEPTOR_IOS_USE_XCODE=1` only when you want Xcode/CoreDevice to own the RemoteXPC tunnel, DDI, and `test-without-building` launch. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Print every line containing a triple-backtick fence in docs/ios/app-route.md,
# with line numbers, to confirm the pairing/imbalance described above.
fd 'app-route.md' docs/ios --exec grep -n '^```' {}Repository: Hacker-Valley-Media/Interceptor
Length of output: 206
Close the stray code fence at line 77.
The fence after the Legacy/internal paragraph opens an unclosed block, so the Capability boundary, Failure modes, and Actuation primitives sections render as code until EOF.
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 77-77: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
[warning] 112-112: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
[warning] 118-118: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/ios/app-route.md` around lines 70 - 119, Close the stray markdown code
fence in the app-route documentation after the Legacy/internal paragraph so the
rest of the sections render normally. In the docs content around the legacy
`enable`/`disable`/`status`/`discover` paragraph, remove or balance the
unmatched fence so the following `interceptor contexts`, `Capability boundary`,
`Failure modes`, and `Actuation primitives` sections are treated as regular
markdown instead of being inside a code block.
Source: Linters/SAST tools
| final class WSAgent: NSObject, URLSessionWebSocketDelegate { | ||
| private let url: URL | ||
| private let token: String | ||
| private let udid: String | ||
| private let contextId: String | ||
| /// Set when the socket is gone for good; the test's main run loop watches it. | ||
| private(set) var finished = false | ||
|
|
||
| private var session: URLSession! | ||
| private var task: URLSessionWebSocketTask? | ||
| private var reconnects = 0 | ||
| private let maxReconnects = 5 | ||
|
|
||
| init(url: URL, token: String, udid: String, contextId: String) { | ||
| self.url = url | ||
| self.token = token | ||
| self.udid = udid | ||
| self.contextId = contextId | ||
| super.init() | ||
| self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) | ||
| } | ||
|
|
||
| func start() { connect() } | ||
|
|
||
| private func connect() { | ||
| let t = session.webSocketTask(with: url) | ||
| task = t | ||
| t.resume() | ||
| register() | ||
| receive() | ||
| } | ||
|
|
||
| private func register() { | ||
| send(["type": "ios", "udid": udid, "token": token, "contextId": contextId]) | ||
| } | ||
|
|
||
| private func send(_ obj: [String: Any]) { | ||
| guard | ||
| let data = try? JSONSerialization.data(withJSONObject: sanitize(obj)), | ||
| let str = String(data: data, encoding: .utf8) | ||
| else { return } | ||
| task?.send(.string(str)) { _ in } | ||
| } | ||
|
|
||
| private func receive() { | ||
| task?.receive { [weak self] result in | ||
| guard let self = self else { return } | ||
| switch result { | ||
| case .success(let message): | ||
| self.reconnects = 0 | ||
| let text: String? | ||
| switch message { | ||
| case .string(let s): text = s | ||
| case .data(let d): text = String(data: d, encoding: .utf8) | ||
| @unknown default: text = nil | ||
| } | ||
| if let text = text { | ||
| // Verbs must run on the MAIN (test) thread: XCUITest's snapshot | ||
| // and element queries require the thread-local XCTContext, which | ||
| // exists only there ("Current context must not be nil" otherwise). | ||
| // XCTest spins its own nested run loop while it works, so a verb | ||
| // doesn't freeze the main loop. The receive loop stays on this | ||
| // background delegate queue, so the socket stays responsive. | ||
| DispatchQueue.main.async { self.handle(text) } | ||
| } | ||
| self.receive() // re-arm immediately | ||
| case .failure: | ||
| self.handleDisconnect() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private func handle(_ text: String) { | ||
| guard | ||
| let data = text.data(using: .utf8), | ||
| let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], | ||
| let id = obj["id"] as? String, | ||
| let op = obj["op"] as? String | ||
| else { return } | ||
| let result = Runner.run(op: op, args: obj) | ||
| send(["id": id, "result": result]) | ||
| } | ||
|
|
||
| private func handleDisconnect() { | ||
| reconnects += 1 | ||
| if reconnects <= maxReconnects { | ||
| DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.connect() } | ||
| } else { | ||
| finished = true | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Unsynchronized cross-thread state in WSAgent.
finished, task, and reconnects are mutated from the delegate queue (receive()), a background queue (handleDisconnect()'s reconnect), and read from the main thread (testRunner()'s poll loop, send() called from DispatchQueue.main.async { handle }) with no lock/serial queue coordinating access. This is a data race: a missed/late visibility update to finished could keep testRunner() spinning past a truly-terminal disconnect, and reassigning task from a background reconnect while send() reads it from the main queue is unsafe.
Consider funneling all mutations/reads of this state through a single serial DispatchQueue (or an actor if targeting Swift concurrency), rather than touching the properties directly from multiple queues.
🔒 Sketch: serialize state through a private queue
final class WSAgent: NSObject, URLSessionWebSocketDelegate {
+ private let stateQueue = DispatchQueue(label: "interceptor.ios.wsagent.state")
private let url: URL
private let token: String
private let udid: String
private let contextId: String
- private(set) var finished = false
+ private var _finished = false
+ var finished: Bool { stateQueue.sync { _finished } }Route task/reconnects reads and writes (in connect(), send(), handleDisconnect()) through stateQueue.sync/.async as well.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@ios/InterceptorRunner/Sources/InterceptorRunnerUITests.swift` around lines 82
- 172, WSAgent has unsynchronized cross-thread access to shared state, causing
races across the delegate queue, reconnect path, and main test thread. Fix this
by serializing all reads/writes of finished, task, and reconnects through one
private serial queue or an actor, and update connect(), send(), receive(), and
handleDisconnect() to use that single coordination point. Make sure
testRunner()’s finished polling and any task reassignment/read happen through
the same synchronized path.
| | Drive any app on an owned, unlocked iPhone (tree/tap/type/screenshot/app lifecycle) | iOS | `interceptor ios tree / find / click / type / screenshot / app *` | | ||
|
|
||
| If the task is content **inside** a browser tab, use Browser. If the task is the **shell** the browser runs inside (or any other macOS app), use macOS. | ||
| If the task is content **inside** a browser tab, use Browser. If the task is the **shell** the browser runs inside (or any other macOS app), use macOS. If the task is an app on your **iPhone**, use iOS. | ||
|
|
||
| The deep dives live in the per-surface sections below. Skill packages mirror this split: agent operators load `.agents/skills/interceptor-browser/` for web work and `.agents/skills/interceptor-macos/` for native work. | ||
| The deep dives live in the per-surface sections below. Skill packages mirror this split: agent operators load `.agents/skills/interceptor-browser/` for web work, `.agents/skills/interceptor-macos/` for native work, and `.agents/skills/interceptor-ios/` for iPhone work. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
"Two Surfaces" heading/intro now understates the surface count.
This section adds iOS as a third row/branch, but the unchanged heading (## The Two Surfaces, line 142) and intro ("ships one CLI binary with two product surfaces", line 144) still describe only two surfaces. Update those too so the section is internally consistent with the new iOS content.
📝 Proposed fix (outside the selected range)
-## The Two Surfaces
+## The Three Surfaces
-Interceptor ships one CLI binary with two product surfaces. Pick by what you're driving — a webpage in your browser, or a native app on macOS. Both surfaces share the same daemon, the same wire format, and the same `e1`/`e2`/... ref system.
+Interceptor ships one CLI binary with three product surfaces. Pick by what you're driving — a webpage in your browser, a native app on macOS, or an app on a physical iPhone. All three surfaces share the same daemon, the same wire format, and the same `e1`/`e2`/... ref system.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| | Drive any app on an owned, unlocked iPhone (tree/tap/type/screenshot/app lifecycle) | iOS | `interceptor ios tree / find / click / type / screenshot / app *` | | |
| If the task is content **inside** a browser tab, use Browser. If the task is the **shell** the browser runs inside (or any other macOS app), use macOS. | |
| If the task is content **inside** a browser tab, use Browser. If the task is the **shell** the browser runs inside (or any other macOS app), use macOS. If the task is an app on your **iPhone**, use iOS. | |
| The deep dives live in the per-surface sections below. Skill packages mirror this split: agent operators load `.agents/skills/interceptor-browser/` for web work and `.agents/skills/interceptor-macos/` for native work. | |
| The deep dives live in the per-surface sections below. Skill packages mirror this split: agent operators load `.agents/skills/interceptor-browser/` for web work, `.agents/skills/interceptor-macos/` for native work, and `.agents/skills/interceptor-ios/` for iPhone work. | |
| ## The Three Surfaces | |
| Interceptor ships one CLI binary with three product surfaces. Pick by what you're driving — a webpage in your browser, a native app on macOS, or an app on a physical iPhone. All three surfaces share the same daemon, the same wire format, and the same `e1`/`e2`/... ref system. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@README.md` around lines 161 - 165, Update the section heading and intro
around the surface overview so they match the new iOS addition: change the “The
Two Surfaces” heading and the sentence in that intro that says the CLI ships
with “two” product surfaces to reflect three surfaces. Keep the wording
consistent with the existing Browser, macOS, and iOS references in the same
section so the overview, the table, and the deeper surface sections all agree.
| if [[ "${INTERCEPTOR_SKIP_RUNNER:-0}" != "1" ]]; then | ||
| RUNNER_PRODUCTS="${INTERCEPTOR_RUNNER_PREBUILT:-}" | ||
| if [[ -z "$RUNNER_PRODUCTS" && "${INTERCEPTOR_DRY_RUN:-0}" != "1" ]]; then | ||
| echo "==> Building the iOS agent UNSIGNED (InterceptorRunner; user re-signs at setup)..." | ||
| ( cd "$REPO_ROOT/ios/InterceptorRunner" && xcodegen generate >/dev/null ) | ||
| RUNNER_DD="$(mktemp -d)/dd" | ||
| # Unsigned build-for-testing: no team, no identity, no provisioning. The | ||
| # re-sign at `ios setup` supplies get-task-allow + the user's cert/profile. | ||
| xcrun xcodebuild build-for-testing \ | ||
| -project "$REPO_ROOT/ios/InterceptorRunner/InterceptorRunner.xcodeproj" \ | ||
| -scheme InterceptorRunner -destination 'generic/platform=iOS' \ | ||
| CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" ENTITLEMENTS_REQUIRED=NO \ | ||
| -derivedDataPath "$RUNNER_DD" >/dev/null | ||
| RUNNER_PRODUCTS="$RUNNER_DD/Build/Products" | ||
| fi |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Dry-run flag mismatch: real Xcode build runs even when DRY_RUN=1.
The rest of this script gates side effects on $DRY_RUN via the run() helper (lines 178-184). This block instead checks ${INTERCEPTOR_DRY_RUN:-0} and calls xcodegen generate / xcodebuild build-for-testing directly, bypassing run() entirely. Setting DRY_RUN=1 will not prevent this actual Xcode build from executing, defeating dry-run's purpose and risking CI failures where Xcode/xcodegen aren't available.
🐛 Proposed fix: use the same dry-run variable/wrapper as the rest of the script
RUNNER_PRODUCTS="${INTERCEPTOR_RUNNER_PREBUILT:-}"
- if [[ -z "$RUNNER_PRODUCTS" && "${INTERCEPTOR_DRY_RUN:-0}" != "1" ]]; then
+ if [[ -z "$RUNNER_PRODUCTS" && "$DRY_RUN" != "1" ]]; then
echo "==> Building the iOS agent UNSIGNED (InterceptorRunner; user re-signs at setup)..."
- ( cd "$REPO_ROOT/ios/InterceptorRunner" && xcodegen generate >/dev/null )
+ run bash -c "cd '$REPO_ROOT/ios/InterceptorRunner' && xcodegen generate >/dev/null"
RUNNER_DD="$(mktemp -d)/dd"
- xcrun xcodebuild build-for-testing \
+ run xcrun xcodebuild build-for-testing \
-project "$REPO_ROOT/ios/InterceptorRunner/InterceptorRunner.xcodeproj" \
-scheme InterceptorRunner -destination 'generic/platform=iOS' \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" ENTITLEMENTS_REQUIRED=NO \
-derivedDataPath "$RUNNER_DD" >/dev/null📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if [[ "${INTERCEPTOR_SKIP_RUNNER:-0}" != "1" ]]; then | |
| RUNNER_PRODUCTS="${INTERCEPTOR_RUNNER_PREBUILT:-}" | |
| if [[ -z "$RUNNER_PRODUCTS" && "${INTERCEPTOR_DRY_RUN:-0}" != "1" ]]; then | |
| echo "==> Building the iOS agent UNSIGNED (InterceptorRunner; user re-signs at setup)..." | |
| ( cd "$REPO_ROOT/ios/InterceptorRunner" && xcodegen generate >/dev/null ) | |
| RUNNER_DD="$(mktemp -d)/dd" | |
| # Unsigned build-for-testing: no team, no identity, no provisioning. The | |
| # re-sign at `ios setup` supplies get-task-allow + the user's cert/profile. | |
| xcrun xcodebuild build-for-testing \ | |
| -project "$REPO_ROOT/ios/InterceptorRunner/InterceptorRunner.xcodeproj" \ | |
| -scheme InterceptorRunner -destination 'generic/platform=iOS' \ | |
| CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" ENTITLEMENTS_REQUIRED=NO \ | |
| -derivedDataPath "$RUNNER_DD" >/dev/null | |
| RUNNER_PRODUCTS="$RUNNER_DD/Build/Products" | |
| fi | |
| if [[ "${INTERCEPTOR_SKIP_RUNNER:-0}" != "1" ]]; then | |
| RUNNER_PRODUCTS="${INTERCEPTOR_RUNNER_PREBUILT:-}" | |
| if [[ -z "$RUNNER_PRODUCTS" && "$DRY_RUN" != "1" ]]; then | |
| echo "==> Building the iOS agent UNSIGNED (InterceptorRunner; user re-signs at setup)..." | |
| run bash -c "cd '$REPO_ROOT/ios/InterceptorRunner' && xcodegen generate >/dev/null" | |
| RUNNER_DD="$(mktemp -d)/dd" | |
| # Unsigned build-for-testing: no team, no identity, no provisioning. The | |
| # re-sign at `ios setup` supplies get-task-allow + the user's cert/profile. | |
| run xcrun xcodebuild build-for-testing \ | |
| -project "$REPO_ROOT/ios/InterceptorRunner/InterceptorRunner.xcodeproj" \ | |
| -scheme InterceptorRunner -destination 'generic/platform=iOS' \ | |
| CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" ENTITLEMENTS_REQUIRED=NO \ | |
| -derivedDataPath "$RUNNER_DD" >/dev/null | |
| RUNNER_PRODUCTS="$RUNNER_DD/Build/Products" | |
| fi |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/release.sh` around lines 393 - 407, The release flow in the
interceptor runner build block is bypassing the script’s normal dry-run
handling, so `DRY_RUN=1` still allows real Xcode work to run. Update the `if [[
"${INTERCEPTOR_SKIP_RUNNER:-0}" != "1" ]]` / `RUNNER_PRODUCTS` block to use the
same dry-run mechanism as `run()` instead of calling `xcodegen generate` and
`xcrun xcodebuild` directly, and make the guard consistent with the rest of
`scripts/release.sh` so `INTERCEPTOR_DRY_RUN`/`DRY_RUN` behavior is honored
everywhere.
Summary
Prepares Interceptor
0.20.12and adds a fifth control surface — Interceptor iOS, driving any installed app on an owned, unlocked iPhone. Since the Sparkle feed is still on0.19.1, this release also rolls up the window-mechanics (0.19.2) and context-registration (0.19.3) hardening so updating users get everything.What Changed
Interceptor iOS (new surface)
interceptor ios *surface, addressed as--on <phone>/ios:<udid>.tree,find,inspect,click,type,keys,scroll,drag,press,screenshot,apps,app launch|activate|terminate. Refs carry frames → deterministic coordinate taps.setupor no-Xcodelogin(re-signs with the user's own Apple ID; token in the Keychain, never the password). Background re-sign before certificate expiry.scripts/audit-capability-blind.sh).iOS reliability
interceptor ios appsresolves the device correctly;interceptor ios statusreflects installed-but-idle phones.Rolled up from 0.19.2 — window mechanics resilience: cleanup-safe
chrome.windowstimeouts, tab-independent close/focus/resize,--left/--top/--width/--height/--statewith validation.Rolled up from 0.19.3 — context registration lifecycle: duplicate context rejection, explicit
context_registeredacks, coalesced WebSocket connection attempts, reconnect on re-registration failure.Release prep
0.20.12: package metadata, CLI source version, MV3 manifest, tracked MV2 manifest.interceptor-iosagent-operator skill; document the iOS surface inARCHITECTURE.md/README.md.Validation
bun run typecheck— clean.bun test— 597/0, 10 existing skips.Release Scope
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Bug Fixes
0.20.12.