Skip to content

Commit 694a57d

Browse files
Lykhoydaclaude
andauthored
feat(protocol): version the runner /command wire protocol + move runner state out of /tmp (#383) (#398)
* docs(plan): TDD implementation plan for #383 runner wire-protocol versioning + /tmp state relocation Amendments applied from the multi-LLM plan review (Codex + Claude coordinator; Gemini failed on interactive OAuth): - BLOCKER: legacy /tmp state is now adopted once (lenient, protocolVersion 0) so a live pre-upgrade runner is discovered -> classified legacy -> reaped -> relaunched; /tmp files deleted only after the relaunch persists new state. - BLOCKER: stopFastRunner/stopAndroidRunner + device-interact isFastRunnerAvailable fast-paths are adoption-aware (deviceId-scoped), so a post-respawn close/restart/park never leaks a live runner. - BLOCKER: Android mismatch reap reuses releaseAndroidInteractionSlot() (#237, force-stops BOTH owned packages) via dynamic import. - BLOCKER: fixed self-contradictory sanitize-test assertion (no '/' survives). - IMPORTANT: no android-default state key - persist only under a resolved serial; acceptance criterion holds unconditionally. - IMPORTANT: added missed fixture test/unit/runners/rn-android-runner-client .test.js (catch-all fetch mock must branch /health vs /command). - MINOR: pendingUpgradeNote cleared on the mismatch-reject path. * feat(protocol): RUNNER_PROTOCOL_VERSION constants + tri-file sync test (#383) * refactor(state): extract hardened secure-state-file util from session-file IO (#383) * feat(ios-runner): per-UDID hardened state file replaces /tmp singleton (#383) Relocate iOS rn-fast-runner state from the shared /tmp/rn-fast-runner-state.json singleton to a per-UDID hardened file under <stateDir>/runner-state/ios-<udid>.json (schema v1, protocolVersion stamp). Replace the import-time /tmp load with lazy per-device adoption (adoptPersistedFastRunnerState) so a respawned worker rediscovers a live runner. Adoption-aware teardown: stopFastRunner(deviceId?) adopts before reaping; deviceId threaded through every stop call site. Legacy /tmp state is read once (protocolVersion 0) so a live pre-upgrade runner is adopted not orphaned, and deleted only after a fresh relaunch persists the new per-device file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios-runner): pass deviceId at the two remaining stopFastRunner sites in device-session.ts (#383) stopFastRunner(deviceId?) now adopts persisted per-device state before reaping; a bare call after a bridge-worker respawn no-ops and leaks the runner. Both remaining call sites had a deviceId in scope (session and a function parameter, respectively) but weren't passing it. * chore(lint): fix oxlint/oxfmt drift in gh-383 files (#383) Remove an unused readFileSync import in gh-383-secure-state-file.test.js (oxlint no-unused-vars) and re-run oxfmt on secure-state-file.ts and gh-383-protocol-sync.test.js to clear formatter drift from earlier commits on this branch. dist rebuilt for the util source formatting change. * feat(android-runner): per-serial hardened state file + no-/tmp static invariant (#383) * feat(runners): /health protocolVersion+runnerVersion+capabilities, v stamp on every response (#383) * feat(ios-gate): protocol/version-aware liveness -> stale -> reap; RUNNER_PROTOCOL_MISMATCH after failed reinstall (#383) * feat(android-gate): protocol verify on reuse + post-start; forced reinstall on mismatch (#383) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(surface): deviceSession.runnerProtocol in cdp_status; doctor + docs + changeset (#383) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(surface): thread RUNNER_PROTOCOL_MISMATCH + upgrade note through device-session open paths (#383) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(surface): discard stale Android upgrade note in device-session open catch; type runnerProtocol on StatusResult (#383) * fix(park): thread deviceId through maestro park + android close paths; tighten open-catch wiring test (#383) Fix 1 (critical): runFlowParked's iOS branch called stopFastRunner() bare, so after a bridge-worker respawn wiped in-memory state, parking the L2 runner ahead of a Maestro flow silently no-op'd instead of adopting the persisted per-device state and killing it — a flow-exclusivity regression. Pass opts.deviceId through and widen the FlowParkOpts.stopFastRunner type to accept it. Fix 2 (important): the two Android close paths (device-session.ts's runner-leak-recovery closeSession, and device-session-close.ts's two teardown branches) called stopAndroidRunner() with no deviceId, missing the same adoption-aware teardown the iOS twins already got. Widened CloseDeviceSessionDeps.stopAndroidRunner to (deviceId?: string) => Promise<void> and threaded the in-scope deviceId through all three call sites. Fix 3 (test): the gh-383 open-catch wiring test sliced from '// Ensure runner + launch.', which includes the try body's own consumePendingAndroidUpgradeNote() call on the success path — so indexOf() matched that call instead of the catch-path discard, making the assertion pass even with the catch-path discard removed. Re-anchored the slice to start at 'catch (err)'. Verified locally (then reverted) that removing the catch-path discard now fails the test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ios-runner): pass RN_PLUGIN_VERSION via TEST_RUNNER_ prefix so xcodebuild forwards it (#383) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6873dfc commit 694a57d

57 files changed

Lines changed: 4422 additions & 401 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"rn-dev-agent-cdp": minor
3+
"rn-dev-agent-plugin": minor
4+
---
5+
6+
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.

commands/doctor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ argument-hint:
66

77
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.
88

9-
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.
9+
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.
1010

1111
**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.
1212

docs/superpowers/plans/2026-07-02-383-runner-protocol-versioning.md

Lines changed: 2099 additions & 0 deletions
Large diffs are not rendered by default.

scripts/cdp-bridge/dist/agent-device-wrapper.js

Lines changed: 78 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync, lstatSync } from 'node:fs';
2-
import { join, dirname } from 'node:path';
3-
import { homedir } from 'node:os';
1+
import { unlinkSync } from 'node:fs';
2+
import { join } from 'node:path';
43
import { createHash } from 'node:crypto';
54
import { failResult } from './utils.js';
6-
import { startFastRunner, probeFastRunnerLiveness, reapStaleFastRunner, hasBuiltTestProduct, derivedDataPathForRunner, } from './runners/rn-fast-runner-client.js';
5+
import { startFastRunner, probeFastRunnerLiveness, probeFastRunnerLivenessDetailed, adoptPersistedFastRunnerState, reapStaleFastRunner, hasBuiltTestProduct, derivedDataPathForRunner, } from './runners/rn-fast-runner-client.js';
76
import { resolveBootedIosUdid } from './tools/device-screenshot-raw.js';
87
import { refCenter, getScreenRect, clearRefMap, isRefMapFresh, MAX_REF_MAP_AGE_MS, } from './fast-runner-ref-map.js';
98
import { resolveBundleId } from './project-config.js';
9+
import { getStateDir, readJsonStateFile, writeJsonStateFileAtomic, } from './util/secure-state-file.js';
1010
/**
1111
* CDP-015: derive a per-user, per-project session file path. The previous
1212
* fixed `/tmp/rn-dev-agent-session.json` location bled state across repos,
@@ -21,49 +21,23 @@ import { resolveBundleId } from './project-config.js';
2121
* `<projectHash>` is sha256(cwd).slice(0, 12) so two checkouts of the same
2222
* repo at different paths get different session files.
2323
*/
24-
function getStateDir() {
25-
if (process.env.XDG_STATE_HOME) {
26-
return join(process.env.XDG_STATE_HOME, 'rn-dev-agent');
27-
}
28-
if (process.platform === 'darwin') {
29-
return join(homedir(), 'Library', 'Application Support', 'rn-dev-agent');
30-
}
31-
return join(homedir(), '.rn-dev-agent');
32-
}
3324
function getSessionFilePath() {
3425
const projectId = createHash('sha256').update(process.cwd()).digest('hex').slice(0, 12);
3526
return join(getStateDir(), `session-${projectId}.json`);
3627
}
3728
const SESSION_FILE = getSessionFilePath();
3829
const LEGACY_SESSION_FILE = '/tmp/rn-dev-agent-session.json';
3930
let activeSession = null;
40-
// CDP-015: load session, refusing to follow symlinks (defends against the
41-
// classic /tmp/<predictable-name> -> arbitrary-write attack). On failure
42-
// silently start fresh — the next setActiveSession() call writes the
43-
// canonical per-project location.
44-
function readSessionSafely(path) {
45-
try {
46-
const stat = lstatSync(path);
47-
if (stat.isSymbolicLink())
48-
return null; // refuse to follow
49-
const raw = readFileSync(path, 'utf8');
50-
return JSON.parse(raw);
51-
}
52-
catch {
53-
return null;
54-
}
55-
}
56-
activeSession = readSessionSafely(SESSION_FILE);
31+
activeSession = readJsonStateFile(SESSION_FILE);
5732
if (!activeSession) {
5833
// Migrate from the legacy /tmp location if present — one-time best-effort
5934
// so existing users don't lose their open session on upgrade. We only
6035
// migrate when the new location has nothing — never overwrite.
61-
const legacy = readSessionSafely(LEGACY_SESSION_FILE);
36+
const legacy = readJsonStateFile(LEGACY_SESSION_FILE);
6237
if (legacy) {
6338
activeSession = legacy;
6439
try {
65-
mkdirSync(dirname(SESSION_FILE), { recursive: true });
66-
writeFileSync(SESSION_FILE, JSON.stringify(legacy), { encoding: 'utf8', mode: 0o600 });
40+
writeJsonStateFileAtomic(SESSION_FILE, legacy);
6741
}
6842
catch {
6943
/* migration is best-effort */
@@ -78,10 +52,7 @@ export function setActiveSession(info) {
7852
// CDP-015: atomic write via tmp + rename, restrictive perms (0600 — only
7953
// the user can read).
8054
try {
81-
mkdirSync(dirname(SESSION_FILE), { recursive: true });
82-
const tmpPath = `${SESSION_FILE}.tmp.${process.pid}`;
83-
writeFileSync(tmpPath, JSON.stringify(info), { encoding: 'utf8', mode: 0o600 });
84-
renameSync(tmpPath, SESSION_FILE);
55+
writeJsonStateFileAtomic(SESSION_FILE, info);
8556
}
8657
catch {
8758
/* ignore — in-memory session is still valid */
@@ -518,23 +489,48 @@ export function decideRunnerSpawn(input) {
518489
}
519490
return { action: 'spawn', deviceId: input.deviceId };
520491
}
492+
const PROTOCOL_STALE_REASONS = new Set([
493+
'legacy',
494+
'protocol-older',
495+
'protocol-newer',
496+
'version-skew',
497+
]);
521498
// #210: orchestrate probe → gate → spawn → RE-VERIFY → structured result. ensureFastRunner
522499
// swallows start errors, so the re-probe is what turns a failed spawn into a clean message
523500
// rather than the unstructured postCommand throw downstream (A6, multi-review).
501+
// GH #383: the probe now returns detailed liveness — a protocol/version-stale
502+
// runner is reaped-and-reinstalled transparently (ok + note); a mismatch that
503+
// survives the reinstall surfaces RUNNER_PROTOCOL_MISMATCH (stale prebuilt).
524504
export async function ensureRunnerForCommand(deviceId, bundleId, deps = {}) {
525-
const probe = deps.probe ?? probeFastRunnerLiveness;
505+
const probe = deps.probe ?? probeFastRunnerLivenessDetailed;
526506
const ensure = deps.ensure ?? ensureFastRunner;
527507
const prebuilt = deps.prebuilt ?? (() => hasBuiltTestProduct(derivedDataPathForRunner()));
528-
const liveness = await probe();
529-
const decision = decideRunnerSpawn({ liveness, prebuilt: prebuilt(), deviceId });
508+
const adopt = deps.adopt ?? adoptPersistedFastRunnerState;
509+
adopt(deviceId ?? undefined);
510+
const first = await probe();
511+
const decision = decideRunnerSpawn({ liveness: first.liveness, prebuilt: prebuilt(), deviceId });
530512
if (decision.action === 'proceed')
531513
return { ok: true };
532514
if (decision.action === 'error')
533515
return { ok: false, message: decision.message };
534516
await ensure(decision.deviceId, bundleId);
535517
const after = await probe();
536-
if (after === 'alive')
518+
if (after.liveness === 'alive') {
519+
if (first.staleReason && PROTOCOL_STALE_REASONS.has(first.staleReason)) {
520+
return { ok: true, note: 'runner upgraded (protocol/version mismatch)' };
521+
}
537522
return { ok: true };
523+
}
524+
if (after.staleReason && PROTOCOL_STALE_REASONS.has(after.staleReason)) {
525+
return {
526+
ok: false,
527+
code: 'RUNNER_PROTOCOL_MISMATCH',
528+
message: `rn-fast-runner still speaks an incompatible wire protocol after reinstall ` +
529+
`(runner protocol ${after.runnerProtocolVersion ?? 'none'}, runnerVersion ${after.runnerVersion ?? 'unknown'}). ` +
530+
`The prebuilt XCUITest artifact is stale — rebuild it: delete scripts/rn-fast-runner/build/DerivedData ` +
531+
`and re-open the device session (cold build), or run xcodebuild build-for-testing (see plugin Prerequisites).`,
532+
};
533+
}
538534
return {
539535
ok: false,
540536
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.',
@@ -584,6 +580,27 @@ export function _setRunAgentDeviceForTest(fn) {
584580
}
585581
_runAgentDeviceOverrideForTest = fn;
586582
}
583+
// GH #383: tool results are MCP envelopes (JSON text in content[0]) — attach
584+
// a meta.note by re-encoding, defensively.
585+
export function attachMetaNote(result, note) {
586+
try {
587+
const first = result.content?.[0];
588+
if (!first || first.type !== 'text')
589+
return result;
590+
const envelope = JSON.parse(first.text);
591+
envelope.meta = { ...envelope.meta, note };
592+
return {
593+
...result,
594+
content: [
595+
{ type: 'text', text: JSON.stringify(envelope) },
596+
...result.content.slice(1),
597+
],
598+
};
599+
}
600+
catch {
601+
return result;
602+
}
603+
}
587604
export async function runNative(cliArgs, opts = {}) {
588605
if (_runAgentDeviceOverrideForTest) {
589606
return _runAgentDeviceOverrideForTest(cliArgs, opts);
@@ -612,15 +629,18 @@ export async function runNative(cliArgs, opts = {}) {
612629
const appId = activeSession?.appId ?? resolveBundleId('ios') ?? undefined;
613630
// A2/#210: device_screenshot has its own simctl fallback (device-list.ts) — never block
614631
// it here; the gate is only for verbs that genuinely require the XCUITest runner.
632+
let upgradeNote;
615633
if (cliArgs[0] !== 'screenshot') {
616634
const deviceId = activeSession?.deviceId ?? (await resolveBootedIosUdid());
617635
const ready = await ensureRunnerForCommand(deviceId ?? null, appId ?? '');
618636
if (!ready.ok)
619-
return failResult(ready.message, 'RN_FAST_RUNNER_DOWN');
637+
return failResult(ready.message, ready.code ?? 'RN_FAST_RUNNER_DOWN');
638+
upgradeNote = ready.note;
620639
}
621640
const { runIOS } = await import('./runners/rn-fast-runner-client.js');
622641
const ios = buildRunIOSArgs(cliArgs, appId);
623-
return runIOS(ios);
642+
const result = await runIOS(ios);
643+
return upgradeNote ? attachMetaNote(result, upgradeNote) : result;
624644
}
625645
// `find` is intentionally NOT in this Set — Android, like iOS, treats `device_find`
626646
// as a pure-TS orchestrator (snapshot → match → tap) for cross-platform symmetry.
@@ -660,7 +680,7 @@ export async function runNative(cliArgs, opts = {}) {
660680
// "fetch failed" from runAndroid's internal catch. screenshot has its own adb
661681
// fallback (like iOS simctl) — don't gate it on the runner.
662682
if (cliArgs[0] !== 'screenshot') {
663-
const { resolveAndroidSerial, startAndroidRunner } = await import('./runners/rn-android-runner-client.js');
683+
const { resolveAndroidSerial, startAndroidRunner, consumePendingAndroidUpgradeNote } = await import('./runners/rn-android-runner-client.js');
664684
const serial = activeSession?.deviceId ?? (await resolveAndroidSerial());
665685
if (!serial) {
666686
return failResult('No Android device resolved (none booted, or multiple — pass deviceId / set ANDROID_SERIAL).', 'RN_ANDROID_RUNNER_DOWN');
@@ -669,12 +689,23 @@ export async function runNative(cliArgs, opts = {}) {
669689
await startAndroidRunner(serial, appId);
670690
}
671691
catch (err) {
672-
return failResult(`rn-android-runner did not start: ${err instanceof Error ? err.message : String(err)}`, 'RN_ANDROID_RUNNER_DOWN');
692+
// GH #383: discard any note the failed start left pending — a stale
693+
// note must never attach to a LATER unrelated result.
694+
consumePendingAndroidUpgradeNote();
695+
const msg = err instanceof Error ? err.message : String(err);
696+
// GH #383: a protocol mismatch surviving the reap+reinstall is a distinct,
697+
// actionable failure — surface it rather than the generic runner-down.
698+
if (msg.startsWith('RUNNER_PROTOCOL_MISMATCH')) {
699+
return failResult(msg, 'RUNNER_PROTOCOL_MISMATCH');
700+
}
701+
return failResult(`rn-android-runner did not start: ${msg}`, 'RN_ANDROID_RUNNER_DOWN');
673702
}
674703
}
675-
const { runAndroid } = await import('./runners/rn-android-runner-client.js');
704+
const { runAndroid, consumePendingAndroidUpgradeNote } = await import('./runners/rn-android-runner-client.js');
676705
const android = buildRunAndroidArgs(cliArgs, appId);
677-
return runAndroid({ ...android, deviceId: activeSession?.deviceId });
706+
const result = await runAndroid({ ...android, deviceId: activeSession?.deviceId });
707+
const note = consumePendingAndroidUpgradeNote();
708+
return note ? attachMetaNote(result, note) : result;
678709
}
679710
// No native route for this verb (open/close/devices/find are handled by their
680711
// own native tools; interaction verbs route via the iOS/Android short-circuits

scripts/cdp-bridge/dist/cdp/recover-detached.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ async function recoverDetachedInner(client, deps = {}) {
158158
const reconnect = deps.reconnect ?? (() => client.softReconnect());
159159
const probeAlive = deps.probeAlive ?? (async () => (await probeFreshness(client)).fresh);
160160
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
161-
stopFastRunner();
161+
stopFastRunner(udid);
162162
let relaunchError;
163163
try {
164164
await relaunchApp(udid, appId);

scripts/cdp-bridge/dist/cdp/recover-wedge.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export async function recoverWedge(client, deps = {}) {
5656
const reconnect = deps.reconnect ?? (() => client.softReconnect());
5757
const probeAlive = deps.probeAlive ?? (async () => (await probeFreshness(client)).fresh);
5858
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
59-
stopFastRunner();
59+
stopFastRunner(udid);
6060
try {
6161
await launchApp(udid, appId);
6262
}

scripts/cdp-bridge/dist/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1744,6 +1744,6 @@ main().catch((err) => {
17441744
if (logger.logFilePath) {
17451745
console.error(`CDP bridge log: ${logger.logFilePath}`);
17461746
}
1747-
stopFastRunner();
1747+
stopFastRunner(getActiveSession()?.deviceId);
17481748
process.exit(1);
17491749
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { readFileSync } from 'node:fs';
2+
import { join } from 'node:path';
3+
// GH #383: the native runner /command wire protocol version. Mirrored by
4+
// RunnerProtocol.swift (iOS) and RunnerProtocol.kt (Android); the tri-file
5+
// sync test gh-383-protocol-sync.test.js enforces agreement. Bump when the
6+
// wire shape changes incompatibly; raise MIN_SUPPORTED when old runners can
7+
// no longer be driven.
8+
export const RUNNER_PROTOCOL_VERSION = 1;
9+
export const MIN_SUPPORTED_RUNNER_PROTOCOL = 1;
10+
export function classifyRunnerCompatibility(health, pluginVersion) {
11+
if (health.protocolVersion === undefined)
12+
return { compatible: false, reason: 'legacy' };
13+
if (health.protocolVersion < MIN_SUPPORTED_RUNNER_PROTOCOL) {
14+
return { compatible: false, reason: 'protocol-older' };
15+
}
16+
if (health.protocolVersion > RUNNER_PROTOCOL_VERSION) {
17+
return { compatible: false, reason: 'protocol-newer' };
18+
}
19+
if (pluginVersion !== null &&
20+
health.runnerVersion !== undefined &&
21+
health.runnerVersion !== pluginVersion) {
22+
return { compatible: false, reason: 'version-skew' };
23+
}
24+
return { compatible: true };
25+
}
26+
// Fail-open plugin-version read: null when the manifest can't be read, which
27+
// disables the version-skew check but never blocks a session.
28+
let cachedPluginVersion;
29+
export function getPluginVersion() {
30+
if (cachedPluginVersion !== undefined)
31+
return cachedPluginVersion;
32+
try {
33+
const manifestPath = join(import.meta.dirname, '..', '..', '..', '..', '.claude-plugin', 'plugin.json');
34+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
35+
cachedPluginVersion = typeof parsed.version === 'string' ? parsed.version : null;
36+
}
37+
catch {
38+
cachedPluginVersion = null;
39+
}
40+
return cachedPluginVersion;
41+
}
42+
export function _setPluginVersionForTest(v) {
43+
cachedPluginVersion = v;
44+
}

0 commit comments

Comments
 (0)