Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
51530f9
docs(plan): TDD implementation plan for #383 runner wire-protocol ver…
Lykhoyda Jul 2, 2026
c6a9045
feat(protocol): RUNNER_PROTOCOL_VERSION constants + tri-file sync tes…
Lykhoyda Jul 2, 2026
45b4e9e
refactor(state): extract hardened secure-state-file util from session…
Lykhoyda Jul 2, 2026
d342ac4
feat(ios-runner): per-UDID hardened state file replaces /tmp singleto…
Lykhoyda Jul 2, 2026
47e5651
fix(ios-runner): pass deviceId at the two remaining stopFastRunner si…
Lykhoyda Jul 2, 2026
a0cc9e0
chore(lint): fix oxlint/oxfmt drift in gh-383 files (#383)
Lykhoyda Jul 2, 2026
2ca3838
feat(android-runner): per-serial hardened state file + no-/tmp static…
Lykhoyda Jul 2, 2026
28ff2ec
feat(runners): /health protocolVersion+runnerVersion+capabilities, v …
Lykhoyda Jul 2, 2026
3cde50a
feat(ios-gate): protocol/version-aware liveness -> stale -> reap; RUN…
Lykhoyda Jul 2, 2026
7a40968
feat(android-gate): protocol verify on reuse + post-start; forced rei…
Lykhoyda Jul 2, 2026
fd5f45e
feat(surface): deviceSession.runnerProtocol in cdp_status; doctor + d…
Lykhoyda Jul 2, 2026
e50e2c6
fix(surface): thread RUNNER_PROTOCOL_MISMATCH + upgrade note through …
Lykhoyda Jul 2, 2026
5e92700
fix(surface): discard stale Android upgrade note in device-session op…
Lykhoyda Jul 2, 2026
26a7bd8
fix(park): thread deviceId through maestro park + android close paths…
Lykhoyda Jul 2, 2026
8d86242
fix(ios-runner): pass RN_PLUGIN_VERSION via TEST_RUNNER_ prefix so xc…
Lykhoyda Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/383-runner-protocol-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"rn-dev-agent-cdp": minor
"rn-dev-agent-plugin": minor
---

feat(protocol): version the native runner /command wire protocol + move runner state out of /tmp (#383). Both runners' `GET /health` now reports `{protocolVersion, runnerVersion, capabilities}` and every response carries a `"v"` stamp; the bridge classifies a reachable runner with a missing/older/newer protocol or a skewed `runnerVersion` as stale and transparently reaps + reinstalls it (the first device tool call after upgrading from a pre-protocol plugin pays one runner restart — `meta.note: "runner upgraded (protocol/version mismatch)"`). Only a mismatch that survives reinstall surfaces the new typed error `RUNNER_PROTOCOL_MISMATCH` with exact rebuild commands. Runner state files move from fixed shared `/tmp` paths to per-device hardened files (0600, symlink-refusing, atomic) under the app-support state dir (`runner-state/ios-<udid>.json`, `android-<serial>.json`; Android persists only under a resolved serial) via a shared `util/secure-state-file.ts` also adopted by the session file; a live pre-upgrade runner pointed at by the legacy `/tmp` state is adopted once, reaped, and relaunched before the `/tmp` files are deleted, and a grep-enforced test keeps `/tmp` out of the runner clients. `cdp_status` → `deviceSession.runnerProtocol` surfaces the handshake.
2 changes: 1 addition & 1 deletion commands/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ argument-hint:

Run the environment-diagnostic checklist from the `rn-setup` skill. Walk all 15 prerequisite checks (Node.js version, CDP bridge dependencies, **rn-fast-runner build (iOS)**, **rn-android-runner build/install (Android)**, maestro-runner, iOS simulator, Android emulator, Metro dev server, CDP connection, **injected `__RN_AGENT` helpers**, ffmpeg, physical-device prerequisites, **plugin version freshness**, **Vercel rules sync freshness**, **CDP auto-reconnect mode**) and surface install commands for any missing dependencies.

iOS device automation is owned by the in-tree `rn-fast-runner` XCTest project (D1219, PR #164); Android device automation is owned by the in-tree `rn-android-runner` (UiAutomator instrumentation). These in-tree runners are the sole device backend — there is no external CLI to install. Mark `rn-android-runner` as N/A on iOS-only setups and `rn-fast-runner` as N/A on Android-only / non-macOS setups.
iOS device automation is owned by the in-tree `rn-fast-runner` XCTest project (D1219, PR #164); Android device automation is owned by the in-tree `rn-android-runner` (UiAutomator instrumentation). These in-tree runners are the sole device backend — there is no external CLI to install. Mark `rn-android-runner` as N/A on iOS-only setups and `rn-fast-runner` as N/A on Android-only / non-macOS setups. If a device tool fails with RUNNER_PROTOCOL_MISMATCH, the installed/prebuilt runner artifact predates the plugin's wire protocol: on iOS delete scripts/rn-fast-runner/build/DerivedData and re-open the device session (or re-run xcodebuild build-for-testing); on Android re-run ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest and adb install -r both APKs.

**This command is read-only.** It diagnoses the current environment and recommends fixes. It does NOT modify any files in the user's project, inject documentation, or instrument source code.

Expand Down
2,099 changes: 2,099 additions & 0 deletions docs/superpowers/plans/2026-07-02-383-runner-protocol-versioning.md

Large diffs are not rendered by default.

125 changes: 78 additions & 47 deletions scripts/cdp-bridge/dist/agent-device-wrapper.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync, lstatSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { failResult } from './utils.js';
import { startFastRunner, probeFastRunnerLiveness, reapStaleFastRunner, hasBuiltTestProduct, derivedDataPathForRunner, } from './runners/rn-fast-runner-client.js';
import { startFastRunner, probeFastRunnerLiveness, probeFastRunnerLivenessDetailed, adoptPersistedFastRunnerState, reapStaleFastRunner, hasBuiltTestProduct, derivedDataPathForRunner, } from './runners/rn-fast-runner-client.js';
import { resolveBootedIosUdid } from './tools/device-screenshot-raw.js';
import { refCenter, getScreenRect, clearRefMap, isRefMapFresh, MAX_REF_MAP_AGE_MS, } from './fast-runner-ref-map.js';
import { resolveBundleId } from './project-config.js';
import { getStateDir, readJsonStateFile, writeJsonStateFileAtomic, } from './util/secure-state-file.js';
/**
* CDP-015: derive a per-user, per-project session file path. The previous
* fixed `/tmp/rn-dev-agent-session.json` location bled state across repos,
Expand All @@ -21,49 +21,23 @@ import { resolveBundleId } from './project-config.js';
* `<projectHash>` is sha256(cwd).slice(0, 12) so two checkouts of the same
* repo at different paths get different session files.
*/
function getStateDir() {
if (process.env.XDG_STATE_HOME) {
return join(process.env.XDG_STATE_HOME, 'rn-dev-agent');
}
if (process.platform === 'darwin') {
return join(homedir(), 'Library', 'Application Support', 'rn-dev-agent');
}
return join(homedir(), '.rn-dev-agent');
}
function getSessionFilePath() {
const projectId = createHash('sha256').update(process.cwd()).digest('hex').slice(0, 12);
return join(getStateDir(), `session-${projectId}.json`);
}
const SESSION_FILE = getSessionFilePath();
const LEGACY_SESSION_FILE = '/tmp/rn-dev-agent-session.json';
let activeSession = null;
// CDP-015: load session, refusing to follow symlinks (defends against the
// classic /tmp/<predictable-name> -> arbitrary-write attack). On failure
// silently start fresh — the next setActiveSession() call writes the
// canonical per-project location.
function readSessionSafely(path) {
try {
const stat = lstatSync(path);
if (stat.isSymbolicLink())
return null; // refuse to follow
const raw = readFileSync(path, 'utf8');
return JSON.parse(raw);
}
catch {
return null;
}
}
activeSession = readSessionSafely(SESSION_FILE);
activeSession = readJsonStateFile(SESSION_FILE);
if (!activeSession) {
// Migrate from the legacy /tmp location if present — one-time best-effort
// so existing users don't lose their open session on upgrade. We only
// migrate when the new location has nothing — never overwrite.
const legacy = readSessionSafely(LEGACY_SESSION_FILE);
const legacy = readJsonStateFile(LEGACY_SESSION_FILE);
if (legacy) {
activeSession = legacy;
try {
mkdirSync(dirname(SESSION_FILE), { recursive: true });
writeFileSync(SESSION_FILE, JSON.stringify(legacy), { encoding: 'utf8', mode: 0o600 });
writeJsonStateFileAtomic(SESSION_FILE, legacy);
}
catch {
/* migration is best-effort */
Expand All @@ -78,10 +52,7 @@ export function setActiveSession(info) {
// CDP-015: atomic write via tmp + rename, restrictive perms (0600 — only
// the user can read).
try {
mkdirSync(dirname(SESSION_FILE), { recursive: true });
const tmpPath = `${SESSION_FILE}.tmp.${process.pid}`;
writeFileSync(tmpPath, JSON.stringify(info), { encoding: 'utf8', mode: 0o600 });
renameSync(tmpPath, SESSION_FILE);
writeJsonStateFileAtomic(SESSION_FILE, info);
}
catch {
/* ignore — in-memory session is still valid */
Expand Down Expand Up @@ -518,23 +489,48 @@ export function decideRunnerSpawn(input) {
}
return { action: 'spawn', deviceId: input.deviceId };
}
const PROTOCOL_STALE_REASONS = new Set([
'legacy',
'protocol-older',
'protocol-newer',
'version-skew',
]);
// #210: orchestrate probe → gate → spawn → RE-VERIFY → structured result. ensureFastRunner
// swallows start errors, so the re-probe is what turns a failed spawn into a clean message
// rather than the unstructured postCommand throw downstream (A6, multi-review).
// GH #383: the probe now returns detailed liveness — a protocol/version-stale
// runner is reaped-and-reinstalled transparently (ok + note); a mismatch that
// survives the reinstall surfaces RUNNER_PROTOCOL_MISMATCH (stale prebuilt).
export async function ensureRunnerForCommand(deviceId, bundleId, deps = {}) {
const probe = deps.probe ?? probeFastRunnerLiveness;
const probe = deps.probe ?? probeFastRunnerLivenessDetailed;
const ensure = deps.ensure ?? ensureFastRunner;
const prebuilt = deps.prebuilt ?? (() => hasBuiltTestProduct(derivedDataPathForRunner()));
const liveness = await probe();
const decision = decideRunnerSpawn({ liveness, prebuilt: prebuilt(), deviceId });
const adopt = deps.adopt ?? adoptPersistedFastRunnerState;
adopt(deviceId ?? undefined);
const first = await probe();
const decision = decideRunnerSpawn({ liveness: first.liveness, prebuilt: prebuilt(), deviceId });
if (decision.action === 'proceed')
return { ok: true };
if (decision.action === 'error')
return { ok: false, message: decision.message };
await ensure(decision.deviceId, bundleId);
const after = await probe();
if (after === 'alive')
if (after.liveness === 'alive') {
if (first.staleReason && PROTOCOL_STALE_REASONS.has(first.staleReason)) {
return { ok: true, note: 'runner upgraded (protocol/version mismatch)' };
}
return { ok: true };
}
if (after.staleReason && PROTOCOL_STALE_REASONS.has(after.staleReason)) {
return {
ok: false,
code: 'RUNNER_PROTOCOL_MISMATCH',
message: `rn-fast-runner still speaks an incompatible wire protocol after reinstall ` +
`(runner protocol ${after.runnerProtocolVersion ?? 'none'}, runnerVersion ${after.runnerVersion ?? 'unknown'}). ` +
`The prebuilt XCUITest artifact is stale — rebuild it: delete scripts/rn-fast-runner/build/DerivedData ` +
`and re-open the device session (cold build), or run xcodebuild build-for-testing (see plugin Prerequisites).`,
};
}
return {
ok: false,
message: 'rn-fast-runner did not become ready after auto-spawn. Retry, or run `device_snapshot action=open appId=<your.app.id> platform=ios` to surface the build error.',
Expand Down Expand Up @@ -584,6 +580,27 @@ export function _setRunAgentDeviceForTest(fn) {
}
_runAgentDeviceOverrideForTest = fn;
}
// GH #383: tool results are MCP envelopes (JSON text in content[0]) — attach
// a meta.note by re-encoding, defensively.
export function attachMetaNote(result, note) {
try {
const first = result.content?.[0];
if (!first || first.type !== 'text')
return result;
const envelope = JSON.parse(first.text);
envelope.meta = { ...envelope.meta, note };
return {
...result,
content: [
{ type: 'text', text: JSON.stringify(envelope) },
...result.content.slice(1),
],
};
}
catch {
return result;
}
}
export async function runNative(cliArgs, opts = {}) {
if (_runAgentDeviceOverrideForTest) {
return _runAgentDeviceOverrideForTest(cliArgs, opts);
Expand Down Expand Up @@ -612,15 +629,18 @@ export async function runNative(cliArgs, opts = {}) {
const appId = activeSession?.appId ?? resolveBundleId('ios') ?? undefined;
// A2/#210: device_screenshot has its own simctl fallback (device-list.ts) — never block
// it here; the gate is only for verbs that genuinely require the XCUITest runner.
let upgradeNote;
if (cliArgs[0] !== 'screenshot') {
const deviceId = activeSession?.deviceId ?? (await resolveBootedIosUdid());
const ready = await ensureRunnerForCommand(deviceId ?? null, appId ?? '');
if (!ready.ok)
return failResult(ready.message, 'RN_FAST_RUNNER_DOWN');
return failResult(ready.message, ready.code ?? 'RN_FAST_RUNNER_DOWN');
upgradeNote = ready.note;
}
const { runIOS } = await import('./runners/rn-fast-runner-client.js');
const ios = buildRunIOSArgs(cliArgs, appId);
return runIOS(ios);
const result = await runIOS(ios);
return upgradeNote ? attachMetaNote(result, upgradeNote) : result;
}
// `find` is intentionally NOT in this Set — Android, like iOS, treats `device_find`
// as a pure-TS orchestrator (snapshot → match → tap) for cross-platform symmetry.
Expand Down Expand Up @@ -660,7 +680,7 @@ export async function runNative(cliArgs, opts = {}) {
// "fetch failed" from runAndroid's internal catch. screenshot has its own adb
// fallback (like iOS simctl) — don't gate it on the runner.
if (cliArgs[0] !== 'screenshot') {
const { resolveAndroidSerial, startAndroidRunner } = await import('./runners/rn-android-runner-client.js');
const { resolveAndroidSerial, startAndroidRunner, consumePendingAndroidUpgradeNote } = await import('./runners/rn-android-runner-client.js');
const serial = activeSession?.deviceId ?? (await resolveAndroidSerial());
if (!serial) {
return failResult('No Android device resolved (none booted, or multiple — pass deviceId / set ANDROID_SERIAL).', 'RN_ANDROID_RUNNER_DOWN');
Expand All @@ -669,12 +689,23 @@ export async function runNative(cliArgs, opts = {}) {
await startAndroidRunner(serial, appId);
}
catch (err) {
return failResult(`rn-android-runner did not start: ${err instanceof Error ? err.message : String(err)}`, 'RN_ANDROID_RUNNER_DOWN');
// GH #383: discard any note the failed start left pending — a stale
// note must never attach to a LATER unrelated result.
consumePendingAndroidUpgradeNote();
const msg = err instanceof Error ? err.message : String(err);
// GH #383: a protocol mismatch surviving the reap+reinstall is a distinct,
// actionable failure — surface it rather than the generic runner-down.
if (msg.startsWith('RUNNER_PROTOCOL_MISMATCH')) {
return failResult(msg, 'RUNNER_PROTOCOL_MISMATCH');
}
return failResult(`rn-android-runner did not start: ${msg}`, 'RN_ANDROID_RUNNER_DOWN');
}
}
const { runAndroid } = await import('./runners/rn-android-runner-client.js');
const { runAndroid, consumePendingAndroidUpgradeNote } = await import('./runners/rn-android-runner-client.js');
const android = buildRunAndroidArgs(cliArgs, appId);
return runAndroid({ ...android, deviceId: activeSession?.deviceId });
const result = await runAndroid({ ...android, deviceId: activeSession?.deviceId });
const note = consumePendingAndroidUpgradeNote();
return note ? attachMetaNote(result, note) : result;
}
// No native route for this verb (open/close/devices/find are handled by their
// own native tools; interaction verbs route via the iOS/Android short-circuits
Expand Down
2 changes: 1 addition & 1 deletion scripts/cdp-bridge/dist/cdp/recover-detached.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ async function recoverDetachedInner(client, deps = {}) {
const reconnect = deps.reconnect ?? (() => client.softReconnect());
const probeAlive = deps.probeAlive ?? (async () => (await probeFreshness(client)).fresh);
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
stopFastRunner();
stopFastRunner(udid);
let relaunchError;
try {
await relaunchApp(udid, appId);
Expand Down
2 changes: 1 addition & 1 deletion scripts/cdp-bridge/dist/cdp/recover-wedge.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function recoverWedge(client, deps = {}) {
const reconnect = deps.reconnect ?? (() => client.softReconnect());
const probeAlive = deps.probeAlive ?? (async () => (await probeFreshness(client)).fresh);
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
stopFastRunner();
stopFastRunner(udid);
try {
await launchApp(udid, appId);
}
Expand Down
2 changes: 1 addition & 1 deletion scripts/cdp-bridge/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1744,6 +1744,6 @@ main().catch((err) => {
if (logger.logFilePath) {
console.error(`CDP bridge log: ${logger.logFilePath}`);
}
stopFastRunner();
stopFastRunner(getActiveSession()?.deviceId);
process.exit(1);
});
44 changes: 44 additions & 0 deletions scripts/cdp-bridge/dist/runners/protocol.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
// GH #383: the native runner /command wire protocol version. Mirrored by
// RunnerProtocol.swift (iOS) and RunnerProtocol.kt (Android); the tri-file
// sync test gh-383-protocol-sync.test.js enforces agreement. Bump when the
// wire shape changes incompatibly; raise MIN_SUPPORTED when old runners can
// no longer be driven.
export const RUNNER_PROTOCOL_VERSION = 1;
export const MIN_SUPPORTED_RUNNER_PROTOCOL = 1;
export function classifyRunnerCompatibility(health, pluginVersion) {
if (health.protocolVersion === undefined)
return { compatible: false, reason: 'legacy' };
if (health.protocolVersion < MIN_SUPPORTED_RUNNER_PROTOCOL) {
return { compatible: false, reason: 'protocol-older' };
}
if (health.protocolVersion > RUNNER_PROTOCOL_VERSION) {
return { compatible: false, reason: 'protocol-newer' };
}
if (pluginVersion !== null &&
health.runnerVersion !== undefined &&
health.runnerVersion !== pluginVersion) {
return { compatible: false, reason: 'version-skew' };
}
return { compatible: true };
}
// Fail-open plugin-version read: null when the manifest can't be read, which
// disables the version-skew check but never blocks a session.
let cachedPluginVersion;
export function getPluginVersion() {
if (cachedPluginVersion !== undefined)
return cachedPluginVersion;
try {
const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', '.claude-plugin', 'plugin.json');
const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
cachedPluginVersion = typeof parsed.version === 'string' ? parsed.version : null;
}
catch {
cachedPluginVersion = null;
}
return cachedPluginVersion;
}
export function _setPluginVersionForTest(v) {
cachedPluginVersion = v;
}
Loading
Loading