Core Concepts
Parallel session authority
rn-dev-agent gives every supervised MCP process a fenced session before its worker starts. A successful mutating or verification tool call is bound to that session; diagnostics may enumerate ambient state but are explicitly non-authoritative.
controller + source │ ├── deterministic Metro and Observe port claims ├── exact installed app on one UUID or serial ├── signed initial-bundle Metro marker ├── capability-authenticated native runner └── session-scoped Observe, action, E2E, and proof stateRequirements
Section titled “Requirements”The authority registry uses Node’s built-in node:sqlite DatabaseSync, available by default on every supported Node release. If the API cannot load, tools return AUTHORITY_STORE_UNAVAILABLE. There is no JSON registry fallback and no warning-only authoritative mode.
The registry lives in a private user-local v2 state root. Its SQLite database, WAL/SHM files, session secrets, runner state, and Observe bootstrap state refuse symlinks and use private permissions. After any safe shared-corpus materialization described below, the integration mutation layer rejects a symlinked .rn-agent directory or symlinked path ancestor and fences each retained ancestor against replacement during reads, writes, rollback, and recovery.
Lease timeouts are diagnostic signals, not authority to reclaim resources. A contender must exist and must conservatively prove the prior process dead; an expired lease with unknown process-birth identity returns STALE_LEASE_NOT_RECLAIMABLE.
Session lifecycle
Section titled “Session lifecycle”Use rn_session for staged transitions:
statusreports the redacted session and migration readiness without touching the app or device. If the bound managed Metro lifecycle is no longer live or its proof cannot be authenticated, status atomically clears active Metro and bundle authority, retains the cleanup binding, and reports the terminal reason. An exited launcher or listener also reports a boundedattributionassembled from available launcher diagnostics, authenticated runtime-policy violations, and fixed-vocabulary first-party causes extracted from the Metro log; Metro log free text and unsigned or incorrectly signed violations are excluded. Alongside every legacy field, status additively projects a happy-pathphase(selected→building→running→closing, derived from the internal state and any pending build, and omitted for blocked, handoff, or terminal states), the internal state name indetail, groupedsession,target,runtime, andautomationsub-objects mirroring the same redacted facts, andobserve/proofchild flags. Strict proof additionally projects as an always-present, explicit opt-inproofOverlayoutside those four groups: its soleactivefield is true only while a proof run is in flight betweenbegin_rehearsalandfinalize/discard, false otherwise, and never exposes the run identity.preview_integrationshows the reversiblepackage.jsonand Metro-config edits.apply_integrationapplies them only withconfirmed: true.bind_deviceclaims one exact platform, UUID/serial, and app ID. It refuses a nonexistent or unavailable device (DEVICE_NOT_FOUND), a device claimed by another live session (DEVICE_CLAIM_CONFLICT), and rebinding across an incompatible installed-artifact receipt (DEVICE_RECEIPT_INCOMPATIBLE).DEVICE_BUSYis separate: it reports a live rn-dev-agent device-lock holder for the exact device, stating whether the holder is alive and its heartbeat age bounded to 0–90s. It does not expose the holder PID, project path, or app ID, and it does not mutate the holder. From the holder worktree, rundevice_snapshot action=closeto release it safely. Alternatively, boot a dedicated simulator (or emulator), bind its exact ID withrn_session action=bind_device, run the normal managed build/install there, then select that exact ID withdevice_snapshot action=open ... attachOnly=truewhen the app is already running. Dead holders self-heal on the next open attempt; live holders self-heal once their heartbeat is stale beyond the existing 90s recovery window. A healthy live holder is never stolen or changed by this refusal. A proven-dead exact-device owner returnsSTALE_DEVICE_RELEASE_REQUIREDuntil the caller confirms the bounded cleanup described below; every other claim conflict remains non-stealable, so explicitly hand off live authority or bind a different free simulator and run the normal build/install receipt flow there. Device binding deliberately records no expected Metro port or pre-install origin authority; the bundle handshake owns that decision after the app loads.- Literal
pnpm iosorpnpm androidstarts or validates package-local Metro headlessly on the allocated port, injects the exact device and native-client port, and records the installed artifact receipt. Expo iOS carries the managed port through the build environment and post-install initial URL because Expo CLI rejects--porttogether with--no-bundler; Expo Android instead carries the allocated endpoint through--port,RCT_METRO_PORT, Gradle configuration, the launcher wait target, and the adapter connection. Conflicting launcher flags are rejected as invalid endpoint plumbing; Metro authority remains decided by the signed initial-bundle marker plus Metro instance and origin pin at bundle-handshake bind. Expo Android is a compatibility exception only at the CLI boundary: the adapter uniquely resolves the bound adb serial to Expo 55/56’s physical-model or emulator-AVD display name, verifies the mapping again immediately before spawn, passes that name to--device, and pins adb withANDROID_SERIAL. The serial remains the sole session, receipt, install, runner, and diagnostic authority; missing, unauthorized, duplicate, foreign, or drifted mappings fail asEXPO_DEVICE_IDENTITY_MISMATCHbefore Expo starts. For a physical Android device, the session also establishes and verifies onlyadb -s <serial> reverse tcp:<allocated-port> tcp:<allocated-port>before build or pin launch, persists that exact cleanup authority, and removes only that run-owned forward during Metro/session cleanup; emulators and foreign forwards are never changed. A physical Android Expo build derives its packager proxy loopbackhttp://127.0.0.1:<allocated-port>only from that already-proven run-owned forward; without either a bound exact Dev Client URL or that proven forward it still refuses asDEV_CLIENT_ENDPOINT_NOT_FOUND. After an iOS Expo build installs on the bound simulator, the adapter starts that exact app with the managed Metro URL as the Expo Dev Launcher initial URL; an ambient default-port Metro cannot capture the launch. The generated integration manifest carries the session’s resolved state home, so every package-local CLI call uses the same registry even whenRN_DEV_AGENT_STATE_DIRis non-default; an explicit unknown state home is refused instead of creating an empty registry. If the native command exits for any reason before the build receipt is recorded, the adapter releases the pending build authority through an authenticated abort while preserving the original failure, so the supporteddevice_snapshot action=close→stop_metro→restore_integration→releasecleanup remains reachable. pin_dev_clientlaunches the exact Expo Dev Client URL or app as endpoint data; bare React Native launches the exact app. It does not inspect an Expo manifest, compare a recorded device-origin port, or scan sibling Metros before deciding authority. When no dev-client deep link was bound, an iOS Expo dev client is not bare-launched: the launch target is derived only from the allocated Metro port ashttp://127.0.0.1:<port>— the same derivation the build adapter uses — and the exact app is started with it as the Expo Dev Launcher initial URL. Pinning refuses withDEV_CLIENT_ENDPOINT_NOT_FOUNDwhen that launch data cannot be formed; no port is scanned, guessed, or defaulted. Authority binds only when the app’s actual first bundle exposes this session’s signed marker, the marker names this Metro instance and build generation, and the exact-device connection is pinned to this session’s Metro port. This admits a correct physical-device LAN launch without requiring its public host to equal the supervisor’s loopback host. Missing or stale markers use the existing bundle-handshake refusals, and a connect-stage failure is normalized toBUNDLE_HANDSHAKE_UNAVAILABLEwith its failing stage retained in the message. Pinning has one absolute 120-second wall-clock readiness deadline on both platforms; iOS uses the same bound as Android so a sole exact-device bridgeless target that re-registers slowly after a terminate+relaunch is admitted rather than refused, and the managed reconnect and relaunch recovery paths inherit that same budget. List/filter, exact-device proof, connection, setup, signed-marker verification, and the atomic target-claim/bundle commit must all finish before expiry. Android performs that work on an off-global exact client and publishes it only after final live target/port/generation proof and the authority commit succeed; timeout, stale callbacks, and rollback close only attempt-owned resources and leave the ambient client and durable authority untouched. A stalled target is re-listed only on the same allocated Metro, app, and serial/model association. That exact-port fence persists for the client lifetime and through reconnect, reload, and restart recovery; those paths never probe ambient Metro ports. Modern Bridgeless Hermes targets can omit/json/list’s legacyvm: "Hermes"field, so target presence or that field is never treated as bundle readiness; the signed marker remains mandatory and loader/error targets remain untrusted.prepare_handoff/accept_handofftransfer ownership explicitly, but never adopt a live runner, Observe server, or managed Metro. Acceptance durably records and proves each source-resource shutdown before completing, and binds the durable cleanup plan to the consumed handoff ID plus the exact accepting target session and claim epoch — never a plaintext capability token. Interrupted cleanup remains resumable only by re-presenting that consumed handoff ID and its original capability token to the same accepting session and claim epoch, which the registry revalidates non-mutatingly — together with the current target session reference,handoff_cleanuplifecycle, and currently authorized cleanup worker — before any donor lookup, diagnostics, or cleanup mutation; any mismatch returns only the canonical resumption refusal. Resumption therefore survives an authenticated recovery-worker respawn within the same accepting session and claim epoch, but once a stale adoption transfers the interrupted plan into a different recovery session the original handoff capability no longer authorizesaccept_handoff; continuation flows only throughadopt_stalewith its adoption handle. When package integration is installed, the recipient becomes its restoration owner.- After an integrated build, close the bound native runner with
device_snapshot action=close, then callstop_metro. Ifdevice_snapshot action=openfails after starting a runner or while publishing its binding, it attempts to reap that runner and always releases the local device session and lock; an unconfirmed reap is reported alongside the primary failure. Close remains idempotent when the in-memory device session is already absent: it still finalizes retained runner authority, and it preserves the local session and device lock if finalization fails so the close can be retried. The iOS cold-rebuild recovery credit resets only when an authoritative iOS runner binding is actually released. Metro cleanup refuses while runner authority is active; after the runner closes, it proves the exact managed launcher, listener, port, and evidence endpoint stopped, then clears Metro and bundle authority while retaining the session and integration owner. Retrying an already-complete authenticated stop is idempotent. If cleanup authority was invalidated but the allocated port still listens,stop_metroreturnsMETRO_CLEANUP_PENDINGwith the exact port and observed listener PID rather than signaling an unbound process or reporting success; retry after managed launcher cleanup. If only the session-owned evidence socket remains after every process and listener is proven absent,confirmed: trueauthorizes its removal only when no other session references it; a fully absent unauthenticated binding can likewise be released without signaling only after explicit confirmation. restore_integrationwithconfirmed: truerestores the package scripts and Metro config only after runtime authority is absent. A successfulapply_integrationalso stores the restoration manifest inside the durable session binding, so restoration stays byte-exact even if the on-disk manifest copy is lost across crashes or adoptions. Restoration is authorized only by a SHA-256-verified manifest — durable in the binding or the exact on-disk copy. If a binding has no verified manifest anywhere,restore_integrationrefuses without clearing the binding or touching any file; deterministic inspection ofpackage.json, the selected Metro config, and.rn-agent/integration/appears only as diagnostic markers in the refusal, never as authorization, and the supported recovery is restoring the exact manifest bytes (matching the recorded SHA-256) from your own version control history or backups before retrying.adopt_staleandaccept_handoff— initial acceptance and resumed cleanup alike — first validate the presented capability non-mutatingly, so an invalid or expired handle or token refuses with the canonical capability error before any donor, binding, or file diagnostics, and then require restoration material already durable inside the binding before any reservation, transfer, cleanup, or registry mutation: an exact on-disk manifest copy authorizes only the current owner’srestore_integrationand never an ownership transfer, so a binding carrying durable SHA-256-verified restoration material transfers and reports the restoration duty, a binding without it refuses adoption, acceptance, or resumption outright, andstatusexposes any active binding fence with its owner, manifest availability, and next supported action.releaserefuses while package integration remains installed, then proves any remaining authority-bound runner, Observe server, and managed Metro stopped before releasing claims. Any unproven cleanup leaves the session claimed, so interrupteddevice_snapshot action=close→stop_metro→restore_integration→releasesequences remain retryable.
Status includes the exact opaque session ID so local workflows can remain pinned after handoff without exposing capabilities or device identities.
One canonical app root has one supervisor. Starting another supervisor for the same root is a non-destructive refusal: the contender matches the live owner’s recorded entrypoint against its real command line, exits with the single-instance conflict message, and never rewrites the lock, terminates the owner, allocates a duplicate port, or opens an operational child. A contender that is fenced blocked by the registry also never autostarts Observe. Two same-root supervisors are therefore not cooperative handoff peers; cooperative handoff uses a capability-bound recovery contender and the explicit prepare_handoff / accept_handoff sequence while the source session still owns its claims. After release, that session row is terminal and can never be transitioned again, but the release is not a dead end: the supervisor resolves the session for its next worker instead of inheriting the released row, mints the successor itself, and recycles the worker onto it, so the normal bind_device → apply_integration chain works in-band without registry surgery or a manual restart. The release envelope reports whether that recycle was actually requested; in the unsupervised legacy mode (RN_BRIDGE_SUPERVISOR=0) no supervisor exists to mint a successor, and it names the required transport restart instead of promising an automatic session. Released and proven-stale rows never count as live sessions when matching a worktree either, so they cannot produce a spurious SESSION_AUTHORITY_REQUIRED: multiple live sessions match this worktree.
The source root a session binds is the one its MCP transport was started in, so branch work that lives in a linked git worktree of the same repository is rebound explicitly rather than inferred. rn_session(action="bind_source", projectRoot=…) resolves the declared root by git toplevel (or, for a non-Git app, the declared-root contract below), requires it to share this session’s repository, requires package integration to be restored first, releases dead ownership of the declared root the way a transport restart there would, and then releases this session with a durable successor-source declaration so the supervisor mints the successor on that root instead of the boot cwd; the declaration is withdrawn if the release itself refuses. A declared root that cannot be resolved, or that belongs to a different repository, refuses with SOURCE_ROOT_DIVERGENCE — a foreign tree is never attached. Only an envelope that reports the release is terminal for the fenced operation; a refusal is non-mutating and ends its operation fence normally, so the session stays usable for the next call instead of refusing every later one with OPERATION_ALREADY_IN_PROGRESS. The successor worker is also started in that root, so tools that default projectRoot to the working directory (cdp_run_action, cdp_repair_action, cdp_record_test_save_as_action, cdp_run_e2e_suite) and Metro’s serving-root pin follow the rebound worktree instead of the transport’s startup tree; an inherited RN_PROJECT_ROOT pointing elsewhere is dropped for that worker. The bound root is re-proven by repository identity at every worker spawn, and a root that disappeared or was replaced by another repository refuses with SOURCE_ROOT_UNAVAILABLE / SOURCE_WORKTREE_MISMATCH rather than running the worker in the startup checkout. The same code fences bind_device, preview_integration, and apply_integration when their optional projectRoot is not the session’s exact source root, naming both paths instead of mutating the bound tree; relative values are resolved against the session’s bound app root, never the worker’s working directory. See troubleshooting for the refusal row and its recovery.
Recovery from a dead owner is bounded and explicit, never lease garbage collection. For new sessions (model grouped-v1), a proven-dead same-root predecessor is released by the supervisor itself at startup: a durable cleanup journal is written on the dead session’s row before any side effect, its recorded run-owned physical Android Metro reverse forward is removed and its recorded runner, recorder, Observe, and managed Metro children stop by exact recorded process identity, package integration restores only from the SHA-256-verified durable manifest (a missing or unverifiable manifest refuses and preserves the binding), and the dead session’s claims release only after every obligation is durably complete. Death is positively re-proven at every step; a crash mid-cleanup resumes the same journal on the next start. A live owner — or one whose death cannot be proven — refuses with holder diagnostics (liveness, bounded heartbeat age, how to close it, and the separate-worktree alternative); lease expiry is never authority. New grouped sessions mint no adoption or handoff-recipient handles, and their status uses a typed recovery requirement instead of adopt_stale. When startup cleanup refuses for a proven-dead owner, the already-redacted refusal code, reason, and next action are retained on that owner’s unfinished journal, written to the supervisor’s startup diagnostics instead of being reduced to a bare code, and projected into the blocked contender’s status as an identifier-free startupCleanupBlocked; while that refusal stands, recoveryRequirement reports the measured remedy for it rather than promising that the next transport restart releases the owner automatically. If a persisted METRO_CLEANUP_PENDING obligation has lost the authenticated stop-proof evidence required by every supported cleanup path, status reports unrecoverable-in-band, preserves the unfinished obligation and authority claim, and instructs the operator to report it because neither a transport restart nor packaged repair can reconstruct the proof, and it reports that independently of whether the unauthenticated Metro has already exited: when its recorded processes, port, or evidence socket are still observable the reported next action says the listener may still be running rather than implying it is gone. Recoverable cleanup refusals remain transport-restart with their measured remedy, and a cleanup that has not refused still promises automatic release. Legacy sessions created by earlier plugin versions keep the previous adoption surface for drain: status rotates a recovery handle that is expired or about to expire and keeps the exact prior token valid only through its original expiry, so a handle returned immediately before rotation remains usable without extending or broadening its authority; interrupted handoff_cleanup also exposes the current authenticated adoption-resume handle. If rotation is impossible, status reports the handle as expired with its refresh action instead of offering it. status also names which recovery is actually required — transport-restart when cleanup remains recoverable, unrecoverable-in-band when the managed-Metro stop proof cannot be reconstructed, attach when the prior owner is live or its identity cannot be proven, and (legacy sessions only) adoption when the prior owner is proven dead. Every other gated tool a blocked contender calls shares one refusal: it states that the session does not own this worktree, names rn_session({ action: "status" }) as its only available action, and carries that same measured recoveryRequirement next action instead of a fixed remedy list. A proven-dead device or runner owner discovered after startup — including one from another worktree — is cleared inline by bind_device with confirmed: true: no capability token is minted and nothing expires; the transfer requires the exact platform and device ID, validates the complete exact-device family — claims, bindings, owner, epoch, and each obligation’s full cleanup identity — before any mutation, transfers only that device’s physical Android Metro reverse, runner, and recorder cleanup obligations, re-proves the owner’s death from durable state at execution, records what was released on the dead owner, and never takes over its source, package-integration, Metro, or port authority. Without confirmed: true the refusal names the confirmed retry and mutates nothing. release_stale_device remains a token-less compatibility alias for the same journaled path — the initial transfer takes the exact platform, device ID, and confirmed: true, and a handle from a previously minted release offer is still honored. Once claims transfer, status reports the outstanding journal and an identifier-free release_stale_device resume action; a bare bind_device for the journaled device — or release_stale_device omitting both platform and device ID — resumes the exact journal already owned by the current authenticated worker, session, and claim epoch. The success envelope is published only after that exact scoped journal commit atomically advances the contender’s fenced authority generation; a missing, partial, or differently fenced commit remains a refusal. Losing the fence only after that commit never turns the committed release into a failure: the envelope stays a non-authoritative success and names both facts in meta — authorityLostAfterCommit for a genuine authority loss, failedAfterCommit for any other post-commit error — each carrying the exact released device and a next action to re-read status before the next fenced operation. Acquiring any other device remains refused until that journal finishes, and stale-session adoption transfers the journal with its claims.
Closing a bound runner releases its exclusive claim and clears the runner binding in one atomic registry transaction, so an interrupted close or reacquire cannot leave a store whose runner binding survives without its claim row. A store already carrying that divergence — written by an interrupted unbind under an older plugin — is unsupported legacy state: adopt_stale refuses it with RUNNER_OWNERSHIP_MISMATCH, ownership is never relaxed to accommodate it, and no migration exists. The supported recovery is the latest plugin plus fresh authority state: with no supervisor running for any session in the store, remove the user-local v2 state root and let the next start recreate it.
Proving a bound runner stopped sends SIGTERM, then escalates to SIGKILL after its grace period only once the pid is re-proven to carry that binding’s exact process-birth token, so a reused pid or another session’s runner is never signaled. A process that has terminated but whose parent has not yet reaped it counts as stopped, not as an unreadable identity — that is what lets cdp_restart hardReset=true and the device_snapshot action=close → release sequence complete instead of refusing with RUNNER_ADOPTION_REQUIRED.
Supervisor shutdown follows the same fail-closed resource cleanup. It first reserves the session for closing and cancels active operations, then releases claims only after the run-owned physical Android Metro reverse forward, runner, Observe, and managed Metro teardown succeeds.
Session initialization is transactional: if port allocation, resource claiming, shared-knowledge preparation, or receipt creation fails, partial claims are released before startup reports the failure.
When the integration manifest has no session CLI, the copied package adapter runs the original package script and user arguments unchanged. When a session-capable integration is installed but no live session in the manifest’s authority registry owns the worktree, the adapter exits with code 2 before starting the original script and names the supported repair: rn_session(action="restore_integration", confirmed=true). An explicit session ID is accepted only for the current canonical worktree and app root. When a session exists, unsupported command shapes or conflicting explicit flags fail rather than falling through.
Recovering a wedged source root
Section titled “Recovering a wedged source root”An owner that dies ungracefully leaves its claims in the store, and the OS may then recycle one of its recorded pids into an unrelated process. A recorded pid is only ever written for a process this user could read, so a pid this user can no longer inspect or signal is positively not the recorded owner — that counts as proven dead and startup cleanup releases it (GH #792). An identity that is unreadable for any other reason stays unprovable and keeps refusing: lease expiry, heartbeat age, and elapsed time are never authority, and there is no force-steal.
Two commands drive the same proven-dead cleanup a fresh transport runs. Both work in a headless claude -p session, which cannot run /mcp:
node "${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-${RN_DEV_AGENT_CODEX_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:?set it to the installed rn-dev-agent plugin root, then re-run}}}}/rn-dev-agent-core/dist/session-doctor.js" reportnode "${CLAUDE_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-${RN_DEV_AGENT_CODEX_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:?set it to the installed rn-dev-agent plugin root, then re-run}}}}/rn-dev-agent-core/dist/session-doctor.js" repairreport is read-only. It prints the authority store path, sameRootOwner (absent, live, stale, or unprovable), whether that owner belongs to this exact app root, ownerAppRoot and a truncated ownerSession naming the recorded holder (never its pid), ownerMismatch (app-root or source-identity) when it does not, any retained startupCleanupBlocked refusal, the number of abandoned blocked contenders, and the remedy for what it found. report exits non-zero when it finds the root wedged, so it can gate a script. repair releases a proven-dead same-root owner and discards abandoned blocked-contender rows that never held a claim. It never releases a live or unprovable owner, and exits non-zero when that owner holds this exact root; a live owner of a different app root in this worktree leaves nothing to clean up here, so that run exits zero while report still names the holder. A startupCleanupBlocked.cause of managed-metro-stop-proof-missing is diagnostics-only: preserve the state and follow its reported guidance instead of running repair, which cannot reconstruct the missing proof. The abandoned claim-less contender rows are reaped either way.
A contender that never acquired authority no longer wedges the next attempt: blocked rows hold no claims, so an abandoned one whose supervisor is proven dead is discarded at the next startup instead of accumulating as the next attempt’s unknown prior owner.
Not every refusal on this path is a stale lock. DEVICE_AUTHORITY_MISMATCH from bind_device while status reports deviceBound: false means a runner, proof, or explicitly started Observe binding still holds the device axis — release that axis rather than looking for a wedged owner.
What each source identity proves
Section titled “What each source identity proves”Normal development uses a coarse Git worktree identity, or a declared root plus declared-manifest hash for non-Git apps. That identity remains stable while source files are edited. Strict proof additionally freezes the Git HEAD and dirty-content digest.
A Git worktree is detected from the app root itself and declares nothing. A non-Git application must instead declare its source identity explicitly, through two environment variables the supervisor reads before its worker starts:
| Variable | Required value |
|---|---|
RN_DEV_AGENT_DECLARED_ROOT | The exact existing application root that contains the app root. |
RN_DEV_AGENT_DECLARED_MANIFESTS | A comma-separated list identifying the required existing manifest files, each resolved relative to that root. |
Both are mandatory and neither is ever inferred, defaulted, or generated: the plugin never trusts the working directory as an implicit root and never writes a declaration on the caller’s behalf. Refusals name this exact declaration path rather than sending you back to status — a missing root, a missing manifest list, or a declared manifest that does not exist all fail closed with NON_GIT_MANIFEST_REQUIRED and the two variable names, so the guidance stays actionable after diagnostic redaction. An app root outside the declared root fails with NON_GIT_ROOT_MISMATCH, and a manifest whose canonical path resolves outside it fails with NON_GIT_MANIFEST_OUTSIDE_ROOT; symlinks are resolved before containment is checked and are never broadly accepted. Declared identity is coarse by construction and remains ineligible for strict proof, which requires Git (STRICT_PROOF_GIT_REQUIRED).
The strict dirty digest covers tracked changes, every untracked file, and every ignored file except native build/cache roots such as CocoaPods, Gradle, Expo, and platform build outputs. Local node_modules, Yarn cache, unplugged dependency stores, and signed Metro runtime-policy inputs are streamed into the digest with limits of 50,000 entries, 128 MB per file, 512 MB total, and 128 path levels. This includes symlink targets and external resolver roots. Strict proof additionally requires Metro started by the managed launcher, a serving root contained by the bound worktree, Node.js 24 or newer, one exact terminal integration block, a signed initial-bundle marker, exact device and install bindings, and the source and dirty digest.
Managed Metro permits native .node addons only when their canonical paths remain inside the attested project, app, or dependency roots. Before loading an addon, the launcher records its stable bytes in the signed runtime-evidence chain and publishes a protected acknowledgement that the Metro child can read but cannot modify; after loading, it records completion and verifies that the bytes did not change. An addon outside those roots fails with RN_DEV_AGENT_UNSUPPORTED_NATIVE_ADDON, while missing, unstable, oversized, or unacknowledged evidence fails with METRO_NATIVE_ADDON_EVIDENCE_UNAVAILABLE.
Descendant execution is strict by default from Metro config loading onward. Metro config, resolver, transformer, and serializer callbacks — and every attested descendant process or worker — stay fail-closed: a child there must be the exact Node executable running a file-backed entrypoint inside the attested roots, and anything else fails with RN_DEV_AGENT_UNSUPPORTED_DESCENDANT_EXECUTION. The canonical Expo manifest request handler receives one narrower exception for the canonical expo-updates package CLI with the exact runtimeversion:resolve --platform ios|android [--debug] grammar. Canonical detect-libc module evaluation receives a second, equally narrow exception for the system getconf GNU_LIBC_VERSION and ldd --version probes that package runs at load on Linux; PATH substitutions, extra arguments, shells, exec, and the same binaries spawned from Metro callbacks remain refused. git is never admitted inside the Metro process itself: under the fingerprint runtime-version policy the probes belong to the unattested expo-updates CLI child, which carries no session capability. Those utility children never carry authority: every RN_DEV_AGENT_* capability, the Metro evidence descriptor, the runtime-policy capability, and the authority NODE_OPTIONS preload are stripped from the child environment, extra file descriptors cannot be inherited, and the signed runtime-evidence entry is non-proof-bearing unattested-utility, never an attested descendant launch. Shells, interpreters, osascript, arbitrary project scripts, other node_modules executables, and other operations remain refused. The signed receipt records whether the completed native build used expo run:* or bare React Native, but manifest output remains diagnostic launch data and never grants or denies bundle authority. Strict bundle acceptance requires the signed in-bundle marker and exact Metro origin at the handshake boundary for either build kind.
Authenticated transform descendants receive isolated piped stdin so tools such as Tailwind can remain open for Metro’s message exchange without inheriting the host terminal. If a child exits or does not complete its first authenticated exchange within the bounded watchdog, managed Metro records a signed MANAGED_TRANSFORM_CHANNEL_STALLED violation and reaps a timed-out child. That violation makes strict proof fail closed for the Metro generation instead of allowing a bundle request to hang indefinitely.
On Darwin, managed-sandbox-v1 reports an Apple-signed /usr/bin/sandbox-exec, Seatbelt containment proven by denial canaries, command-chain change detection at launch and receipt time, and executable mapping limited to attested runtime files and canonical .node addons under those same roots. Package-manager shell shims pass preflight through the same resolved command used for live launch, and live admission verifies the attested command executable. Other hosts report unavailable. This sandbox level is a receipt attribute, not a strict-proof precondition, and it does not claim resistance to another process running as the same OS user.
The Darwin sandbox grants writes under the managed runtime directory and the app’s .expo directory, plus one bounded exception for NativeWind’s pre-bind platform stubs: the canonical react-native-css-interop/.cache directory resolved through nativewind/metro, with direct dependency resolution as a fallback. The package must belong to the run’s source or app root; external shared stores, symlinked cache directories, and paths overlapping the runtime or protected roots receive no additional grant. Failed resolution or ownership checks add no cache grant and do not disable enforcement. Other dependency caches and package source files receive no additional write access, and protected roots remain denied. The selected cache path is recorded in the signed runtime manifest and rechecked when verifying the sandbox receipt. Metro’s file watcher receives change notifications through a single mach-lookup allowance for com.apple.FSEvents, so app-scale trees are watched natively instead of exhausting descriptors; no executable, file, or network permission is added and watchman remains denied.
The Darwin profile admits one bounded set of manifest-utility executables so the canonical Expo manifest handler can resolve a runtime version: the canonical expo-updates/bin/cli.js, resolved from the run’s app root the way the manifest handler resolves it and required to sit inside the run’s supported dependency roots, and the Apple developer-directory git that CLI shells out to under the fingerprint runtime-version policy, accepted only as a root-owned, non-group-and-world-writable regular file whose code signature verifies to the Apple root under the com.apple.git identifier. The developer directory is probed with a sanitized environment, so DEVELOPER_DIR cannot redirect the choice, and the developer git is resolved to its canonical path before those checks, so a versioned Xcode bundle reached through a symlink is still admitted. When that git is admitted, Managed Metro places a runtime-owned directory holding only links to it and to the already-admitted Node executable ahead of the inherited PATH; that directory is itself a protected runtime root, so the sandboxed Metro child cannot rewrite the links it resolves. Both names are resolved through PATH inside that CLI child — git by expo-updates, node by @expo/fingerprint when it shells out to the autolinking CLI for the core autolinking sources — and execvp aborts the whole search with EPERM at the first inherited PATH entry the profile denies, so the lookup has to land on the runtime-owned links first. Nothing else is added to the profile: the /usr/bin/git xcrun shim still cannot run, no other developer-directory tool wins a name lookup, and the autolinking CLI itself is never admitted as an executable, only read and passed to the admitted Node as an argument. The profile also grants read access to a linked worktree’s external git directory and its common directory, so the ignore basis a fingerprint is computed from stays the repository’s own. The two grants are withheld independently and neither failure disables enforcement. Projects without expo-updates, and an expo-updates CLI resolved outside the run’s supported dependency roots, receive no manifest-utility grant at all. An unverifiable developer git is narrower: the CLI grant still stands and only the git grant and its read roots are withheld, so the manifest handler runs but its fingerprint probes are denied. That denial is EPERM, and expo-updates only treats ENOENT as “git is missing” and rethrows every other error, so runtime-version resolution fails loudly with the manifest error issue 986 reported rather than serving a runtime version computed from a reduced ignore basis. The concrete outcome is recorded in the signed sandbox receipt and the enforcement diagnostic, distinguishing a withheld git such as developer-git-unsigned from a withheld CLI such as expo-updates-cli-unowned, so which half is missing stays attributable after the fact. No other project or system executable is added.
Managed-Metro launches make a best-effort write of metro-enforcement-diagnostic-<instanceId>.json beside their runtime artifacts, created with mode 0600 and identified by session, Metro instance, build generation and environment digest. The record contains the preparation outcome and, when available, eleven preflight flags, fixed phase timings, exit codes and signals, and predefined categories observed in bounded process output. Categories include permission, address, resource and pipe error codes, plus RN_DEV_AGENT, NODE_RUNTIME and OUT_OF_MEMORY; arbitrary stderr text, runtime versions, error-code suffixes and exception messages are excluded. Missing or unrecognized causes are unknown. A preparation refusal is a complete record with no preflight observation; after successful preparation, missing observations, incomplete flags or phase data, missing exit status and outer timeouts set recordComplete to false. Completeness does not imply enforcement succeeded. These observations never grant enforcement authority or change sandbox permissions, admission, cleanup, timeouts or fallback behavior; diagnostic failures are swallowed.
The HMAC-signed runtime marker proves that the initial bundle came from the claimed Metro instance, session, worktree, app, platform, and build generation. It does not claim Fast Refresh source fidelity. After HMR, the coarse session binding still holds, while sourceFidelity remains not-proven.
If marker signing is unavailable, Metro may emit an unsigned unavailable marker so development builds continue, but authoritative tools return BUNDLE_HANDSHAKE_UNAVAILABLE. The installed-app digest is captured once for each build generation and reused as that generation’s install identity.
A Maestro flow that opens with launchApp: clearState: true uninstalls and reinstalls the app, which rotates that install identity. maestro_run (including through cdp_run_action) and maestro_test_all re-issue the install receipt after such a reinstall, but only after re-hashing the freshly installed bytes and proving they still match the bound receipt’s artifact digest. Any other artifact, or an install that cannot be attested at all, still fails as APP_INSTALL_IDENTITY_CHANGED. maestro_test_all re-issues per flow, right after each reinstalling flow completes — including a flow that fails — so the rest of the corpus runs against the re-proven receipt. cdp_run_action accepts an appFile for the reinstall source and otherwise resolves it from the attested receipt’s exact device and app. That reinstall path stays open to maestro_run and maestro_test_all, and to cdp_run_action on a bare-react-native install: on a session whose receipt attests an expo dev-client build, cdp_run_action — and cdp_login_prologue through it — refuses any flow that clears app state with DEV_CLIENT_CLEARSTATE_REFUSED before invoking the runner, because the clearState relaunch strands the dev client at its picker; run device_reset_state before the replay instead.
Any other byte-identical reinstall — a runner-respawn recovery, an identical dev rebuild — rotates the install generation the same way. When a gated tool’s preflight refuses on install identity, the gate retries once behind that same digest proof: if the installed bytes still hash to the bound receipt’s artifact digest, the receipt is re-issued with the new generation and the call proceeds; a foreign or unattestable artifact still refuses. Strict proof runs are excluded from that opportunistic heal: proof_capture begin_rehearsal attests the exact installed generation as well as the exact bytes, and once a proof run is bound the gate no longer heals a refused install-identity preflight for any tool — not proof_step, not the device and CDP tools driving the run — so an unplanned reinstall anywhere between begin_rehearsal and finalize remains a hard APP_INSTALL_IDENTITY_CHANGED stop. The planned clearState re-issue above is a different path and is unaffected: it belongs to the flow the run is executing, so maestro_run, cdp_run_action, and maestro_test_all still re-issue the receipt for their own reinstall during a proof run, behind the same digest proof.
Restarting managed Metro inside a live session is not a rebuild. When the bound install receipt still matches every session, device, app, and port axis and the installed artifact re-proves byte-identical on-device, the new Metro instance keeps the receipt’s build generation, so pin_dev_client recovers after stop_metro without a ceremonial full rebuild; ensure-metro reports that outcome as buildGeneration, restarted, and receiptPreserved. Any axis mismatch, changed or missing install, or unattestable capture falls back to a freshly bumped generation: Metro still restarts so the app can be rebuilt, and the incoherence surfaces at pin_dev_client as BUILD_RECEIPT_INVALID instead of being relaxed at admission. Preserving a generation never admits a stale or foreign origin, because each instance still mints a fresh signed Metro instance id.
Status surfaces evaluate the bound receipt the same way — rn_session status and cdp_status report installIdentity (verified, reissue-pending, or changed) and never report ready while gated tools would refuse. A changed identity projects install_identity_changed with the rebuild-and-re-attest next action. A reissue-pending identity normally keeps ready, because the gate heals it on the next gated call; while a proof run is bound it instead projects install_identity_reissue_blocked, whose next action is discarding the run with proof_capture action discard — the one gated call that still succeeds, since its profile drops install authority.
Both envelopes also report product. coreVersion is the rn-dev-agent-core package the live session process loaded. When the host plugin manifest version differs, pluginVersion is included beside it. That is live-session evidence, not a separately inspected install on disk.
Tool authority
Section titled “Tool authority”All published tools have an exhaustive authority profile, and worker startup validates the complete registered tool surface before accepting requests. Profile bookkeeping follows the same four ownership groups exposed by session status: Session resolves controller and source authority, Target adds device and install authority, Runtime adds Metro and bundle authority plus the independently verified app-to-device association, and Automation adds runner authority. Individual tools retain narrower resolved profiles where their operation does not need every facet. Session-bound arguments omitted by legacy callers are filled only from the current session; a conflicting platform, device, app, port, or target returns a named authority error.
Raw native runner control and source-fidelity evidence are separate capabilities. device_snapshot, device_screenshot, device_press, device_fill, device_batch, and equivalent direct runner operations require the live controller and exact source identity, the receipted install generation, the exclusive exact-device claim, and the authenticated runner PID, process birth, endpoint capability, app, device, session, claim epoch, protocol, and operational health. They do not require a managed Metro target, so they can inspect or operate an exact Expo Dev Client that is still showing its native picker. Every such result includes meta.originAuthority: "proven" | "not-proven"; unavailable or unstable managed Metro/app-origin authority is reported as not-proven, never silently treated as source evidence.
cdp_dismiss_dev_client_picker is profiled the same way for the same reason: it repairs the stranded-picker state in which Metro and bundle authority are missing, so it runs without them instead of refusing as BUNDLE_HANDSHAKE_UNAVAILABLE, then re-proves the managed origin through the exact-target reconnect once the picker is gone. A retry that finds the picker already dismissed still re-proves rather than reporting success with bundle authority unbound.
When the exact app is already attached to the authority-bound Metro, raw control optionally admits stable Metro plus app-origin authority across preflight and postflight and reports originAuthority: "proven". This is an operation-scoped upgrade, not ambient discovery: an older origin-unproven snapshot remains ineligible for strict evidence even if the app attaches later. A fresh capture under stable proven origin is required. Foreign or stale controller, source, install generation, device/app claim, runner PID or process birth, capability, app/device/session identity, protocol, or health still refuses before dispatch.
Optional origin is not optional once a foreign origin is proven. Unavailable or ambiguous origin evidence — an unreachable Metro, no scanner, an exhausted scan deadline, an undecidable device name — stays not-proven and raw native control proceeds as above. But when the bound device’s app is affirmatively proven to be served by a sibling Metro of this machine (a dev-client fallback or relaunch onto another worktree’s Metro), the device_* tools refuse fail-closed with METRO_ORIGIN_MISMATCH rather than driving the wrong device. That live scan is the only origin refusal on this path: device binding records no expected Metro port and claims no pre-install origin authority, so a wrong endpoint is decided by the bundle handshake once the app has actually loaded, not before it installs. Sibling ports come from the registry’s own port allocations plus the discovery defaults, targets are screened by exact URL port, bundle ID, and device identity, and the whole scan is deadline-bounded; sibling targets are never connected to, so another session’s single debugger seat is never contended.
Strict consumers continue to require managed origin. Cross-platform verdicts, learned-action acceptance and repair, proof rehearsal/steps/finalization, and any artifact or verdict claiming that rendered UI came from this worktree cannot consume origin-unproven native data. Cached native evidence is accepted only when capture-time origin was proven and its platform-specific install, runner, and app-to-Metro origin remain live and authoritative. CDP reads and mutations retain their exact managed Metro and signed bundle-target requirements; native control never substitutes for the bundle handshake.
Runner identity is immutable across an operation: process birth, endpoint capability, instance, session, claim epoch, device, app, and protocol must keep matching the binding. Operational health is checked separately and must report ok: true; transient health fields do not redefine identity. On iOS, attachOnly: true also requires an already-running target and preserves its process identity while activating it, otherwise the runner fails closed.
Maestro-backed flows, including action replay, E2E lock, E2E suite paths, and conditional inline fallbacks, transactionally park the bound native runner immediately before dispatch. Conditional device tools remain ordinary interactions until the fallback actually runs; that dynamic escalation preserves the non-flow teardown-grace invariant while making contenders see BUSY_FLOW_ACTIVE. A launchApp stage is then followed by a managed relaunch of the exact bound app against the session’s allocated Metro port. When that exact device target has not re-registered yet — a mid-flow relaunch whose dev-client only returns to the managed origin once the flow’s own post-launch steps run — the flow is no longer aborted between stages: the failure is carried to flow end, the remaining stages execute under Maestro’s own selectors, and the origin is then re-proven reconnect-only, without a second cold start that would destroy the flow’s end state. A revoked claim still fails fast in place, and if the flow-end re-prove also fails the run fails with the original authority error, so no run reports success without a proven managed origin. Nested replay paths inherit the same authority callbacks, so they cannot fall back to an ambient Metro. Once parked, runner authority is deliberately absent from postflight and receipt axes even when replay is refused, so a stale runner binding cannot survive the attempt.
Inline Maestro runs use one detached process group. Timeout and failure cleanup sends bounded SIGTERM, escalates to SIGKILL, and confirms group absence before returning. If absence cannot be proven, AUTOMATION_CLEANUP_UNPROVEN returns a redacted process-group identity and manual kill -TERM -<pgid> guidance. The same bridge refuses another inline run for that device until group absence is observed; no recovery duty is persisted across bridge restarts.
Every successful authoritative call receives a preflight/postflight authority receipt. Raw native receipts use control axes C/S/I/D/R and add M/A only when optional origin is proven; the receipt and result both carry the explicit origin-authority classification. If a required axis changes during the handler, the outer result becomes AUTHORITY_LOST_DURING_OPERATION; a handler’s apparent PASS is never preserved. The one exception is a side effect the registry itself still proves committed — a scoped stale-device release whose fence is lost after its commit (above) keeps its success envelope and names the loss in meta.
Observe, actions, proof, and feedback
Section titled “Observe, actions, proof, and feedback”Observe is a read-only child of the session: observe start and restart require only the live session, matching autostart’s degraded mode, so a session with no device bound still serves the UI, and Observe always stops with its session. Its E2E run and action panels still pass the full authority gates of cdp_run_e2e_suite and cdp_run_action. Observe uses its allocated port, random capability, and instance ID on every API, event-stream, state, and action request. A page from a crashed session cannot attach to a later listener. Because an Observe binding holds the session’s device axis, the first bind_device stops a session-autostarted Observe to yield that axis — only after every device-side check passes and the device claim is proven available, so a bind refused before the yield commits leaves Observe running — and reports observeYielded with the stopped observePort so you can reopen it. A refusal raised after that commit, with the device claim lost at the final write, can still leave Observe stopped without reporting it, and you start it again. An Observe you bring back yourself, with start or restart, is recorded as caller-started, so it forfeits that automatic yield and the next bind_device refuses until you stop it. An Observe you started explicitly is yours: bind_device refuses with DEVICE_AUTHORITY_MISMATCH before mutating anything, and you stop it yourself.
Mutable action sidecars, E2E runs, bundle state, and proof runtime state live under the session root. An action sidecar is <state-home>/v2/sessions/<sessionId>/runtime/state/<id>.state.json (~/Library/Application Support/rn-dev-agent/v2/sessions/<sessionId>/runtime/state/<id>.state.json by default on macOS), and cdp_run_action returns the exact successful write location as data.writes.runtimeStatePath or meta.writes.runtimeStatePath. The project-local .rn-agent/state/ path is only an unfenced compatibility fallback. With the current storage layout, a new fenced session begins at revision 1 with empty run and repair history and does not read sidecar history or revisions from an earlier session. Canonical lifecycle status remains shared worktree knowledge, so a promotion that updates the tracked action YAML to active is visible to later sessions without exposing the earlier session’s runtime history. Recorder claims live in the session registry, while authenticated recorder sidecars and provisional media use a private user-local runtime directory; finalized media is published only to the requested output path. Canonical action YAML remains worktree knowledge. For a private untracked corpus, consented setup or repository-local post-checkout integration may link only .rn-agent/actions inside a real worktree-local .rn-agent; mutable integration, session state, recordings, generated launchers, and runtime data remain local, while root and unsafe nested symlinks fail closed.
Strict proof receipts use schema v2 and include controller, source/dirty digest, install, Metro, initial-bundle marker, device, runner, proof, and per-event authority hashes. A worker replacement rejects an active proof rather than resuming it.
The feedback collector reports only the current session’s high-level authority, whether its allocated Metro is bound, and a count of other sessions. It never scans ports 8081/8082 as truth and never includes foreign ports, paths, PIDs, device IDs, app IDs, or capabilities.
Migration and threat boundary
Section titled “Migration and threat boundary”rn_session status reports store availability, registry compatibility, legacy-state detection, coarse handshake support, package integration, and the managed-sandbox tier. Legacy files are diagnostics only and are never trusted for mutation or adoption. A database from a newer schema is rejected without downgrade.
This model prevents accidental cross-worktree targeting, stale-owner writes, and ambiguous local selection. It is not a security boundary against a hostile process already running as the same OS user.