Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions scripts/cdp-bridge/dist/cdp/recovery.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
const FRESHNESS_PROBE_MS = 2000;
const STALE_RETRY_DELAY_MS = 500;
const STALE_RETRY_PROBE_MS = 3000;
// GH #186: cross-tool "the CDP target may be stale" signal. A device-session
// runner-leak recovery can re-foreground/relaunch the app out from under CDP,
// after which the next cdp_* call would hit a ~47s STALE_TARGET timeout in the
// handler. The recovery sets this flag; withConnection consumes it before the
// next handler and proactively re-pins via recoverFromStaleTarget, turning that
// 47s catch-path recovery into a fast pre-handler one. Process-scoped boolean —
// a single MCP serves one device at a time, so no per-client keying is needed.
let cdpStale = false;
export function markCdpStale() { cdpStale = true; }
/** Read-and-clear: returns whether the stale flag was set, resetting it. */
export function consumeCdpStale() {
const was = cdpStale;
cdpStale = false;
return was;
}
export async function probeFreshness(client, timeoutMs = FRESHNESS_PROBE_MS) {
if (!client.isConnected) {
return { fresh: false, version: null, probed: false };
Expand Down
172 changes: 169 additions & 3 deletions scripts/cdp-bridge/dist/domain/maestro-validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
* default-deny rationale.
*/
import yaml from 'yaml';
import { join, dirname, isAbsolute, sep } from 'node:path';
import { readFileSync, realpathSync } from 'node:fs';
// ── Errors ──────────────────────────────────────────────────────────
export class MaestroValidationError extends Error {
constructor(message) {
Expand Down Expand Up @@ -105,6 +107,12 @@ const ALLOWED_COMMANDS = new Set([
'killApp',
'stopApp',
'tap',
// GH #186: runFlow (conditional dialog handling — deep-link "Open in", Expo
// dev-client picker). Validated specially (validateRunFlowValue) so nested
// `commands` get full command-level allowlist checks, and {file} refs are
// securely resolved + expanded inline (expandRunFlows) — they are NOT passed
// through generic validateValue, which would miss nested denied commands.
'runFlow',
]);
const DENIED_COMMANDS = new Set([
'runScript',
Expand Down Expand Up @@ -153,8 +161,52 @@ function validateCommand(cmd) {
if (!ALLOWED_COMMANDS.has(key)) {
throw new MaestroValidationError(`Command not in allowlist: ${key}`);
}
if (key === 'runFlow') {
validateRunFlowValue(cmd[key]);
return;
}
validateValue(cmd[key]);
}
/**
* GH #186: validate a runFlow value. The string/`{file}` form is a sub-flow
* file ref (a safe scalar here; secure resolution happens in expandRunFlows).
* Inline `commands` are validated as COMMANDS (recursive allowlist check) — the
* critical difference from generic validateValue, which would let a nested
* `runScript` slip through as a plain scalar key/value.
*/
function validateRunFlowValue(v) {
if (typeof v === 'string') {
if (!isSafeMaestroScalar(v)) {
throw new MaestroValidationError(`Unsafe runFlow file ref: ${JSON.stringify(v).slice(0, 80)}`);
}
return;
}
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
throw new MaestroValidationError(`runFlow value must be a file string or an object, got ${Array.isArray(v) ? 'array' : typeof v}`);
}
const obj = v;
if ('file' in obj && (typeof obj.file !== 'string' || !isSafeMaestroScalar(obj.file))) {
throw new MaestroValidationError(`runFlow.file must be a safe scalar string`);
}
if ('when' in obj)
validateValue(obj.when);
if ('commands' in obj) {
if (!Array.isArray(obj.commands)) {
throw new MaestroValidationError(`runFlow.commands must be an array`);
}
for (const c of obj.commands)
validateCommand(c);
}
// Any other keys (env/label/config) are validated as generic safe values.
for (const [k, val] of Object.entries(obj)) {
if (k === 'file' || k === 'when' || k === 'commands')
continue;
if (!isSafeMaestroScalar(k)) {
throw new MaestroValidationError(`Unsafe runFlow key: ${JSON.stringify(k).slice(0, 80)}`);
}
validateValue(val);
}
}
function validateValue(v) {
if (v === null || v === undefined)
return;
Expand Down Expand Up @@ -182,6 +234,117 @@ function validateValue(v) {
}
throw new MaestroValidationError(`Unsupported value type: ${typeof v}`);
}
// GH #186: recognize a single-key `runFlow` command and extract its shape.
// Returns null for non-runFlow OR malformed runFlow (which validateCommand then
// rejects), so expandRunFlows passes those through untouched.
function asRunFlow(cmd) {
if (!cmd || typeof cmd !== 'object' || Array.isArray(cmd))
return null;
const keys = Object.keys(cmd);
if (keys.length !== 1 || keys[0] !== 'runFlow')
return null;
const v = cmd.runFlow;
if (typeof v === 'string')
return { file: v };
if (v && typeof v === 'object' && !Array.isArray(v)) {
const o = v;
return {
file: typeof o.file === 'string' ? o.file : undefined,
when: o.when,
commands: Array.isArray(o.commands) ? o.commands : undefined,
};
}
return null;
}
// GH #186: resolve a runFlow file ref to a canonical path, enforcing: relative
// only, no `..`, .yaml/.yml only, and containment within flowRoot after realpath
// (defeats symlink escape). Throws on any violation or missing root context.
function resolveRunFlowTarget(file, opts) {
if (!opts.flowDir || !opts.flowRoot) {
throw new MaestroValidationError(`runFlow file ref "${file}" requires a flow root context (flowDir + flowRoot)`);
}
if (isAbsolute(file)) {
throw new MaestroValidationError(`runFlow file ref must be relative, got absolute: ${file}`);
}
if (file.split(/[\\/]/).includes('..')) {
throw new MaestroValidationError(`runFlow file ref must not contain '..': ${file}`);
}
if (!/\.ya?ml$/i.test(file)) {
throw new MaestroValidationError(`runFlow file ref must be a .yaml/.yml file: ${file}`);
}
const realpath = opts.realpathFn ?? realpathSync;
let resolved;
let rootReal;
try {
resolved = realpath(join(opts.flowDir, file));
rootReal = realpath(opts.flowRoot);
}
catch (err) {
throw new MaestroValidationError(`runFlow file ref "${file}" could not be resolved: ${err.message}`);
}
if (resolved !== rootReal && !resolved.startsWith(rootReal + sep)) {
throw new MaestroValidationError(`runFlow file ref "${file}" escapes the flow root`);
}
return resolved;
}
// GH #186: expand runFlow file refs inline so the serialized flow (written to
// /tmp) has no remaining file references. A `{file}` with a `when` becomes an
// inline conditional `{when, commands}` (semantics preserved); without `when`
// the sub-flow's commands are spliced flat. Inline runFlow is recursed into.
export function expandRunFlows(commands, opts) {
const out = [];
for (const cmd of commands) {
const rf = asRunFlow(cmd);
if (!rf) {
out.push(cmd);
continue;
}
if (rf.file !== undefined) {
const depth = opts._depth ?? 0;
const max = opts.maxRunFlowDepth ?? 5;
if (depth >= max) {
throw new MaestroValidationError(`runFlow nesting exceeded max depth ${max}`);
}
const resolved = resolveRunFlowTarget(rf.file, opts);
const visited = opts._visited ?? new Set();
if (visited.has(resolved)) {
throw new MaestroValidationError(`runFlow cycle detected at "${rf.file}"`);
}
const readFile = opts.readFileFn ?? ((p) => readFileSync(p, 'utf8'));
let subText;
try {
subText = readFile(resolved);
}
catch (err) {
throw new MaestroValidationError(`runFlow file "${rf.file}" could not be read: ${err.message}`);
}
const sub = parseAndValidateFlow(subText, {
...opts,
rejectHeader: true,
flowDir: dirname(resolved),
_depth: depth + 1,
_visited: new Set([...visited, resolved]),
});
if (rf.when !== undefined) {
out.push({ runFlow: { when: rf.when, commands: sub.commands } });
}
else {
out.push(...sub.commands);
}
}
else {
// Inline runFlow (no file) — recurse into nested commands, keep the wrapper.
const inner = rf.commands
? expandRunFlows(rf.commands, { ...opts, _depth: (opts._depth ?? 0) + 1 })
: [];
const wrapped = { commands: inner };
if (rf.when !== undefined)
wrapped.when = rf.when;
out.push({ runFlow: wrapped });
}
}
return out;
}
export function parseAndValidateFlow(yamlText, opts = {}) {
let docs;
try {
Expand Down Expand Up @@ -216,9 +379,12 @@ export function parseAndValidateFlow(yamlText, opts = {}) {
if (!Array.isArray(body)) {
throw new MaestroValidationError(`Flow body must be an array, got ${typeof body}`);
}
for (const cmd of body) {
// GH #186: resolve + inline runFlow file refs FIRST, then validate the
// expanded (file-ref-free) body so what we serialize is self-contained.
const expanded = expandRunFlows(body, opts);
for (const cmd of expanded) {
validateCommand(cmd);
}
const raw = buildMaestroFlow(appId !== undefined ? { appId } : {}, body);
return { appId, commands: body, raw };
const raw = buildMaestroFlow(appId !== undefined ? { appId } : {}, expanded);
return { appId, commands: expanded, raw };
}
7 changes: 7 additions & 0 deletions scripts/cdp-bridge/dist/domain/reusable-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ export function parseM7Header(yamlText, fallbackId) {
else if (key === 'produces') {
meta.produces = parseProducesMap(raw);
}
else if (key === 'expectedRouteSequence') {
meta.expectedRouteSequence = raw.replace(/^\[|\]$/g, '').split(',').map((t) => t.trim()).filter(Boolean);
}
else if (key === 'id' || key === 'intent' || key === 'status' || key === 'appId' || key === 'createdAt' || key === 'author') {
meta[key] = raw;
}
Expand Down Expand Up @@ -193,6 +196,7 @@ export function parseM7Header(yamlText, fallbackId) {
createdAt: meta.createdAt,
author: meta.author,
produces: meta.produces,
expectedRouteSequence: meta.expectedRouteSequence,
};
}
/**
Expand Down Expand Up @@ -262,5 +266,8 @@ export function serializeM7Header(metadata) {
});
lines.push(`# produces: { ${pairs.join(', ')} }`);
}
if (metadata.expectedRouteSequence && metadata.expectedRouteSequence.length) {
lines.push(`# expectedRouteSequence: [${metadata.expectedRouteSequence.map(stripNewlines).join(', ')}]`);
}
return lines.join('\n');
}
32 changes: 32 additions & 0 deletions scripts/cdp-bridge/dist/nav-graph/route-sequence.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export function validateRouteSequenceAgainstGraph(graph, expected) {
if (!expected || expected.length === 0)
return { ok: true };
const known = new Set(graph?.all_screens ?? []);
// Empty/unknown graph → can't make a confident judgement; don't false-positive.
if (known.size === 0)
return { ok: true };
const missing = expected.filter((s) => !known.has(s));
if (missing.length > 0) {
return {
ok: false,
reason: `action expects screen(s) the nav graph no longer has: ${missing.join(', ')}`,
missing,
};
}
return { ok: true };
}
export function classifyRouteDriftAfterFailure(input) {
const { expectedSequence, liveRoute } = input;
if (!expectedSequence || expectedSequence.length === 0)
return { isDrift: false, liveRoute };
if (!liveRoute)
return { isDrift: false, liveRoute };
if (!expectedSequence.includes(liveRoute)) {
return {
isDrift: true,
liveRoute,
reason: `live route "${liveRoute}" is not in the action's expected sequence [${expectedSequence.join(' → ')}] — an unexpected screen appeared (structural drift, not a stale selector)`,
};
}
return { isDrift: false, liveRoute };
}
41 changes: 41 additions & 0 deletions scripts/cdp-bridge/dist/tools/device-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { runAgentDevice, setActiveSession, clearActiveSession, getActiveSession, ensureFastRunner, cacheSnapshot, getAdbSerial, } from '../agent-device-wrapper.js';
import { stopFastRunner } from '../runners/rn-fast-runner-client.js';
import { markCdpStale } from '../cdp/recovery.js';
import { detectLegacyAgentDevice, detectAndroidExternalRunner, } from '../runners/external-runner-detect.js';
import { okResult, failResult, warnResult } from '../utils.js';
import { resolveBundleId } from '../project-config.js';
Expand Down Expand Up @@ -200,9 +201,22 @@ export function createDeviceSnapshotHandler() {
openSession: ({ appId, platform, attachOnly }) => reopenSessionForRecovery(appId, platform, attachOnly),
resnapshot: () => rawSnapshot(),
parseNodes: parseSnapshotNodes,
// GH #186: non-destructive reacquire tried before the destructive
// close/relaunch tiers. Only when we have the full iOS context
// (appId + deviceId) needed to re-foreground the app and restart the
// fast-runner; otherwise omitted so recovery falls back to the
// existing tiers.
reacquire: (session?.platform === 'ios' && session?.appId && session?.deviceId)
? () => reacquireIosTargetApp(session.appId, session.deviceId)
: undefined,
});
if (recovery.recovered) {
cacheSnapshotIfPossible(recovery.result);
// GH #186: the recovery re-foregrounded/relaunched the app, which can
// leave the CDP target pinned to a now-stale context. Flag it so the
// next cdp_* call re-pins proactively (fast) instead of hitting the
// ~47s STALE_TARGET timeout that prompted this issue.
markCdpStale();
return wrapWithMeta(recovery.result, {
recovered: 'agent-device-runner-leak',
recoveryTier: recovery.tier,
Expand Down Expand Up @@ -230,6 +244,33 @@ export function runnerLeakFailureHint(reason, session) {
}
return 'Manually close + reopen the session with action=open appId=<your.bundle.id> platform=ios (full launch, not attachOnly). Upstream: Callstack/agent-device, see B119/GH#35.';
}
/**
* GH #186: non-destructive reacquire of the iOS target app after a runner-leak
* sentinel. Both the daemon-leak and a maestro-eviction (a foreign XCUITest
* session stealing focus) surface as the same sentinel, so rather than closing
* the session + relaunching (~44s, drops JS/CDP state) we: stop the
* (possibly evicted) fast-runner so it can't compete for focus, re-foreground
* the TARGET app via simctl (displacing the foreign session), then restart the
* fast-runner bound to the app. The caller (recoverFromRunnerLeak) re-snapshots
* and only falls through to the destructive tiers if the sentinel persists.
* Mirrors repair-action.ts:bringTargetAppToForeground, kept local here to keep
* the dependency surface tight (same rationale as that copy).
*/
async function reacquireIosTargetApp(appId, deviceId) {
try {
stopFastRunner();
}
catch { /* best-effort — may already be dead */ }
try {
await execFile('xcrun', ['simctl', 'launch', 'booted', appId], { timeout: 5000, encoding: 'utf8' });
}
catch { /* best-effort — the sentinel re-check covers a failed foreground */ }
try {
await ensureFastRunner(deviceId, appId);
}
catch { /* non-fatal — re-snapshot will surface a still-broken runner */ }
return okResult({ reacquired: true, appId });
}
async function rawSnapshot() {
return runAgentDevice(['snapshot', '-i']);
}
Expand Down
10 changes: 8 additions & 2 deletions scripts/cdp-bridge/dist/tools/maestro-run.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, dirname } from 'node:path';
import { okResult, failResult, warnResult } from '../utils.js';
import { getActiveSession } from '../agent-device-wrapper.js';
import { resolveBundleId, readExpoSlug } from '../project-config.js';
Expand Down Expand Up @@ -81,7 +81,13 @@ export function createMaestroRunHandler() {
return failResult('Provide either flowPath or inlineYaml.');
}
try {
const parsed = parseAndValidateFlow(rawYaml);
// GH #186: when running a saved flow FILE, resolve+inline any runFlow file
// refs relative to that file's directory, contained within it. Inline YAML
// has no on-disk root, so runFlow file refs stay rejected there.
const runFlowOpts = args.flowPath
? { flowDir: dirname(args.flowPath), flowRoot: dirname(args.flowPath) }
: {};
const parsed = parseAndValidateFlow(rawYaml, runFlowOpts);
const rawAppId = resolveAppId(args.appId, platform);
const headerAppId = parsed.appId ?? (rawAppId && isValidBundleId(rawAppId) ? rawAppId : undefined);
if (rawAppId && !parsed.appId && !isValidBundleId(rawAppId)) {
Expand Down
Loading
Loading