Skip to content

Commit 211100f

Browse files
authored
Merge pull request #156 from Lykhoyda/fix/issue-116-run-action-params-plumbing
fix(gh-116): wire cdp_run_action into /run-action slash command via params plumbing
2 parents f800369 + 5e8604e commit 211100f

10 files changed

Lines changed: 438 additions & 30 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "rn-dev-agent",
1111
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
12-
"version": "0.44.42",
12+
"version": "0.44.43",
1313
"source": "./",
1414
"category": "mobile-development",
1515
"homepage": "https://github.com/Lykhoyda/rn-dev-agent"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent",
3-
"version": "0.44.42",
3+
"version": "0.44.43",
44
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
55
"author": {
66
"name": "Anton Lykhoyda",

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,43 @@ All notable changes to rn-dev-agent will be documented in this file.
44

55
Format follows [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.44.43] — 2026-05-13
8+
9+
### Added (GH #116 — wire cdp_run_action into /run-action slash command)
10+
11+
- **`maestro_run` now accepts `params: Record<string, string>`** that get
12+
forwarded to maestro-runner as `-e KEY=VALUE` argv pairs. Keys must
13+
match `[A-Z_][A-Z0-9_]*` (Maestro env-style convention) — anything
14+
else is refused at the handler boundary so a hostile payload can't
15+
become a shell-injectable flag. Values must be strings. Since the
16+
invocation uses `execFile` (not `exec`), values are passed as
17+
separate argv entries — shell metacharacters are inert by construction.
18+
- **`cdp_run_action` forwards `params`** through to both the first
19+
`maestro_run` call AND the post-repair retry, so a parameterised flow
20+
replays identically after auto-repair.
21+
- **`/rn-dev-agent:run-action` slash command** is rewritten to call
22+
`cdp_run_action` via MCP rather than shelling out to maestro-runner
23+
directly. User invocations of `run-action wizard-create-task -e
24+
TITLE=...` now benefit from auto-repair, structured RunRecords, and
25+
the GH #120 per-phase timing. The slash command still parses args
26+
locally (positional + `-e` + `--platform` + `--dry-run` + new
27+
`--no-auto-repair`) but delegates execution to the MCP tool.
28+
- `--dry-run` keeps the bash-only path since `cdp_run_action` always
29+
executes.
30+
- 6 new handler tests cover: malformed-key refusal (5 shell-injection
31+
shapes), non-string-value refusal, well-formed key acceptance,
32+
cdp_run_action's params forwarding to the first maestro_run call,
33+
end-to-end params threading via a real temp project fixture, and
34+
params persistence into the post-repair retry path.
35+
Suite: 1312 → 1318 passing.
36+
37+
### Note
38+
39+
Step #4 of issue #116 ("Live smoke: replay wizard-create-task with
40+
-e TITLE=foo end-to-end on a booted simulator") is left for a
41+
maintainer-driven verification — it requires a live simulator with the
42+
test app and is outside the scope of this code-only PR.
43+
744
## [0.44.42] — 2026-05-13
845

946
### Hardened (GH #113 — saveAction precondition becomes a runtime soft-assertion)

commands/run-action.md

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
---
22
command: run-action
3-
description: Execute a learned Maestro flow ("action") by name with optional -e KEY=VALUE parameters. Looks the flow up via scripts/learned-actions.mjs (same inventory as /rn-dev-agent:list-learned-actions), then replays it with maestro-runner. Counterpart to /list-learned-actions — list discovers, run executes.
4-
argument-hint: <action-name> [-e KEY=VALUE ...] [--platform ios|android] [--dry-run]
5-
allowed-tools: Bash, Read, Glob
3+
description: Execute a learned Maestro flow ("action") by name with optional -e KEY=VALUE parameters. Looks the flow up via scripts/learned-actions.mjs (same inventory as /rn-dev-agent:list-learned-actions), then replays it via cdp_run_action — auto-repair-aware orchestration with structured RunRecords (GH #116). Counterpart to /list-learned-actions — list discovers, run executes.
4+
argument-hint: <action-name> [-e KEY=VALUE ...] [--platform ios|android] [--no-auto-repair] [--dry-run]
5+
allowed-tools: Bash, Read, Glob, mcp__plugin_rn-dev-agent_cdp__cdp_run_action
66
---
77

88
Execute the learned action: $ARGUMENTS
@@ -23,23 +23,28 @@ without `.yaml`). Substring + case-insensitive — `task-create` will match
2323
The first positional arg is the action name (required). Subsequent args are
2424
passed through to `maestro-runner` verbatim:
2525

26-
- `-e KEY=VALUE` — environment variable for `${KEY}` placeholders in the flow
26+
- `-e KEY=VALUE` — environment variable for `${KEY}` placeholders in the flow. Keys must match `[A-Z_][A-Z0-9_]*` (Maestro convention) — anything else is rejected by `cdp_run_action` / `maestro_run` (GH #116).
2727
- `--platform <ios|android>` — target device (auto-detected from booted device if omitted)
28-
- `--dry-run` — print the resolved replay command without executing it
28+
- `--no-auto-repair` — opt out of `cdp_repair_action` retry on `SELECTOR_NOT_FOUND` (default: auto-repair on)
29+
- `--dry-run` — print the resolved replay command without executing it (bash-only path; bypasses `cdp_run_action`)
2930

3031
Example calls:
3132

3233
```
3334
/rn-dev-agent:run-action wizard-create-task -e TITLE="Buy milk" -e PRIORITY=high -e TAG=feature -e DESC="Test"
3435
/rn-dev-agent:run-action mark-all-done --platform android
3536
/rn-dev-agent:run-action wizard-create-task --dry-run -e TITLE=foo -e PRIORITY=low -e TAG=bug -e DESC=test
37+
/rn-dev-agent:run-action mark-all-done --no-auto-repair # surface the raw failure without patching
3638
```
3739

3840
## Protocol
3941

4042
1. **Parse arguments.** First word of `$ARGUMENTS` is the action name. Detect
41-
`--platform`, `--dry-run`, and collect every `-e KEY=VALUE` pair. Treat
42-
anything else as a passthrough flag.
43+
`--platform`, `--dry-run`, `--no-auto-repair`, and collect every
44+
`-e KEY=VALUE` pair into a `params` object (key must match
45+
`[A-Z_][A-Z0-9_]*`; reject malformed early — `cdp_run_action` will
46+
refuse them anyway, but catching at parse time gives a clearer
47+
error). Treat anything else as a passthrough flag.
4348

4449
2. **Resolve the action via the script** (single source of truth — never glob
4550
`.rn-agent/actions/` directly):
@@ -84,25 +89,78 @@ Example calls:
8489
- If both are booted, stop and ask the user to pass `--platform`.
8590
- If neither, stop and tell the user to boot a device.
8691

87-
5. **Build the replay command**:
88-
```bash
89-
FLOW_PATH=$(echo "$RESULT" | jq -r '.sections.flows.items[0].path')
90-
CMD=(maestro-runner --platform "$PLATFORM" test)
91-
for KV in "${E_FLAGS[@]}"; do CMD+=(-e "$KV"); done
92-
CMD+=("$FLOW_PATH")
92+
5. **Build the call** to `cdp_run_action` from the parsed args. The action
93+
id is the inventory match's `flow` field (filename without `.yaml`).
94+
Convert the `-e KEY=VALUE` array into a `params` object:
95+
```js
96+
{
97+
actionId: "<flow-name>",
98+
platform: "<ios|android>", // omit to auto-detect
99+
params: { TITLE: "Buy milk", PRIORITY: "high", ... },
100+
autoRepair: !noAutoRepair, // default true; --no-auto-repair flips to false
101+
trigger: "agent" // or "human" / "ci" based on context
102+
}
103+
```
104+
If `--dry-run`, do NOT call `cdp_run_action`. Print the resolved call
105+
shape (the JSON args object as above) plus the would-be Maestro CLI
106+
`maestro-runner --platform <PLATFORM> test -e K=V ... <FLOW_PATH>` and
107+
stop. The `cdp_run_action` tool always executes, so a separate
108+
bash-print path is necessary for dry-run.
109+
110+
6. **Execute via MCP**:
111+
```
112+
cdp_run_action({ actionId, platform, params, autoRepair, trigger })
113+
```
114+
Read the returned envelope's `data` field. Shape (matches
115+
`scripts/cdp-bridge/src/tools/run-action.ts`):
116+
```
117+
{
118+
ok: true | false,
119+
data: {
120+
actionId,
121+
passed: boolean, // happy path: true
122+
autoRepair: {
123+
attempted: boolean,
124+
outcome: 'skipped' | 'passed' | 'failed' | 'refused',
125+
refusedReason?: 'USER_DISABLED' | 'NOT_REPAIRABLE_KIND' | 'EDITED_SINCE_LOAD'
126+
| 'BUDGET_EXHAUSTED' | 'NO_CANDIDATE',
127+
phases?: { firstAttemptMs, repairMs?, retryMs? },
128+
diff?: string // patch summary when outcome === 'passed'
129+
},
130+
durationMs,
131+
flowFile,
132+
firstAttemptOutput?: string, // first 500 chars of maestro stdout/stderr
133+
retryOutput?: string, // present iff retriedAfterRepair === true
134+
retriedAfterRepair?: boolean
135+
}
136+
}
93137
```
94-
If `--dry-run`, print `${CMD[*]}` and stop. Otherwise execute it.
95-
96-
6. **Execute and report**:
97-
- Stream the output (don't swallow); capture exit code.
98-
- On success: report `passed/total` commands and `duration_ms` from the
99-
report JSON (`reports/<timestamp>/report.json`), plus the full path so
100-
the user can inspect screenshots and the hierarchy XML.
101-
- On failure: extract the failing command from the report JSON and the
102-
associated `assets/flow-000/cmd-NNN-after.png` screenshot path. Diagnose
103-
in three lines max — point at the most likely cause (stale testID,
104-
iOS keyboard digraph drop per `feedback_maestro_patterns.md` item 9,
105-
auth state lost, etc.) — DO NOT auto-edit the flow.
138+
The persisted RunRecord lands in the sidecar at
139+
`<project>/.rn-agent/state/<actionId>.state.json` — read it via
140+
`cdp_run_action`'s side-effect, not from `data.runRecord` (which is
141+
not present in the response).
142+
143+
Branch on `data.autoRepair.outcome`:
144+
- **`outcome === 'skipped'`** with `attempted: false`: happy path —
145+
report `✅ <flow-name> passed in <durationMs>ms` and stop.
146+
- **`outcome === 'passed'`** with `attempted: true,
147+
retriedAfterRepair: true`: repaired-and-passed — report `🩹
148+
<flow-name> failed, repaired, then passed` and (if `data.autoRepair.diff`
149+
is present) print the one-line patch summary. Suggest the user `git
150+
diff .rn-agent/actions/<id>.yaml` to inspect.
151+
- **`outcome === 'failed'`**: post-repair retry still failed —
152+
`data.retryOutput` carries the trailing maestro output for
153+
diagnosis.
154+
- **`outcome === 'refused'`** with `refusedReason`: auto-repair declined
155+
(user disabled, file edited since load, repair budget exhausted, or
156+
no candidate). Surface the refused reason verbatim — DO NOT edit
157+
the flow yourself; suggest re-running with `--no-auto-repair` to
158+
see the raw failure or running `cdp_repair_action` manually.
159+
160+
In all cases, diagnose in three lines max — point at the most likely
161+
cause (stale testID, iOS keyboard digraph drop per
162+
`feedback_maestro_patterns.md` item 9, auth state lost, etc.) — DO
163+
NOT auto-edit the flow.
106164

107165
## Output
108166

scripts/cdp-bridge/dist/tools/maestro-run.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { resolveBundleId, readExpoSlug } from '../project-config.js';
99
import { chooseMaestroDispatch, shouldWarnFallback } from './maestro-dispatch.js';
1010
import { buildMaestroFlow, parseAndValidateFlow, isValidBundleId, MaestroValidationError, } from '../domain/maestro-validator.js';
1111
const execFile = promisify(execFileCb);
12+
/** GH #116: Maestro env-style key pattern. Refuses anything that could
13+
* syntactically be confused with a flag (`--`, `-e`) or break the
14+
* KEY=VALUE join (`=`, space, control chars). Strict; documented. */
15+
const PARAM_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
1216
function resolvePlatform(override) {
1317
if (override === 'ios' || override === 'android')
1418
return override;
@@ -24,6 +28,21 @@ function resolveAppId(override, platform) {
2428
}
2529
export function createMaestroRunHandler() {
2630
return async (args) => {
31+
// GH #116: validate params shape FIRST so a malformed payload is rejected
32+
// regardless of platform / dispatch-tier availability. CI envs without
33+
// maestro-runner or Maestro CLI would otherwise short-circuit at
34+
// chooseMaestroDispatch before reaching the validator.
35+
if (args.params) {
36+
for (const [key, value] of Object.entries(args.params)) {
37+
if (!PARAM_KEY_RE.test(key)) {
38+
return failResult(`Refusing to run Maestro: invalid param key '${String(key).slice(0, 60)}' ` +
39+
`— must match ${PARAM_KEY_RE.source} (GH #116).`);
40+
}
41+
if (typeof value !== 'string') {
42+
return failResult(`Refusing to run Maestro: param '${key}' has non-string value (GH #116).`);
43+
}
44+
}
45+
}
2746
const platform = resolvePlatform(args.platform);
2847
if (!platform) {
2948
return failResult('Cannot determine platform. Pass platform or open a device session first.');
@@ -83,8 +102,21 @@ export function createMaestroRunHandler() {
83102
throw err;
84103
}
85104
const timeout = args.timeoutMs ?? 120_000;
105+
// GH #116: build the final argv. Start with the dispatch tier's
106+
// base args, then append `-e KEY=VALUE` pairs for any supplied
107+
// params. Validation already ran at the top of the handler so by
108+
// this point every key matches PARAM_KEY_RE and every value is a
109+
// string — no need to re-check.
110+
const baseArgs = dispatch.buildArgs(platform, flowFile);
111+
const paramArgs = [];
112+
if (args.params) {
113+
for (const [key, value] of Object.entries(args.params)) {
114+
paramArgs.push('-e', `${key}=${value}`);
115+
}
116+
}
117+
const finalArgs = [...baseArgs, ...paramArgs];
86118
try {
87-
const { stdout, stderr } = await execFile(dispatch.binPath, dispatch.buildArgs(platform, flowFile), { timeout, encoding: 'utf8' });
119+
const { stdout, stderr } = await execFile(dispatch.binPath, finalArgs, { timeout, encoding: 'utf8' });
88120
const output = (stdout + '\n' + stderr).trim();
89121
const passed = !output.includes('FAILED') && !output.includes('Error:');
90122
const meta = {

scripts/cdp-bridge/dist/tools/run-action.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export function createRunActionHandler(deps = {}) {
145145
flowPath: action.filePath,
146146
platform: args.platform,
147147
timeoutMs,
148+
params: args.params,
148149
});
149150
const firstAttemptMs = Date.now() - tBeforeFirst;
150151
const firstEnv = parseEnvelope(firstResult, 'maestro_run');
@@ -277,6 +278,7 @@ export function createRunActionHandler(deps = {}) {
277278
flowPath: reloadedAction.filePath,
278279
platform: args.platform,
279280
timeoutMs,
281+
params: args.params,
280282
});
281283
const retryMs = Date.now() - tBeforeRetry;
282284
const retryEnv = parseEnvelope(retryResult, 'maestro_run');

scripts/cdp-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent-cdp",
3-
"version": "0.38.37",
3+
"version": "0.38.38",
44
"type": "module",
55
"main": "dist/index.js",
66
"scripts": {

scripts/cdp-bridge/src/tools/maestro-run.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,23 @@ interface MaestroRunArgs {
2323
platform?: 'ios' | 'android';
2424
appId?: string;
2525
timeoutMs?: number;
26+
/**
27+
* GH #116: per-flow parameter bindings forwarded as `-e KEY=VALUE`
28+
* pairs to the maestro-runner subprocess. Keys must match
29+
* /^[A-Z_][A-Z0-9_]*$/ (Maestro's documented env-style convention) —
30+
* any other key shape is refused so a malformed/hostile payload can't
31+
* become a shell-injectable flag. Values are NOT quoted; they're
32+
* passed as separate argv entries so shell metacharacters are inert
33+
* by construction (execFile, not exec).
34+
*/
35+
params?: Record<string, string>;
2636
}
2737

38+
/** GH #116: Maestro env-style key pattern. Refuses anything that could
39+
* syntactically be confused with a flag (`--`, `-e`) or break the
40+
* KEY=VALUE join (`=`, space, control chars). Strict; documented. */
41+
const PARAM_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
42+
2843
function resolvePlatform(override?: string): 'ios' | 'android' | null {
2944
if (override === 'ios' || override === 'android') return override;
3045
const session = getActiveSession();
@@ -39,6 +54,26 @@ function resolveAppId(override?: string, platform?: string): string {
3954

4055
export function createMaestroRunHandler(): (args: MaestroRunArgs) => Promise<ToolResult> {
4156
return async (args) => {
57+
// GH #116: validate params shape FIRST so a malformed payload is rejected
58+
// regardless of platform / dispatch-tier availability. CI envs without
59+
// maestro-runner or Maestro CLI would otherwise short-circuit at
60+
// chooseMaestroDispatch before reaching the validator.
61+
if (args.params) {
62+
for (const [key, value] of Object.entries(args.params)) {
63+
if (!PARAM_KEY_RE.test(key)) {
64+
return failResult(
65+
`Refusing to run Maestro: invalid param key '${String(key).slice(0, 60)}' ` +
66+
`— must match ${PARAM_KEY_RE.source} (GH #116).`,
67+
);
68+
}
69+
if (typeof value !== 'string') {
70+
return failResult(
71+
`Refusing to run Maestro: param '${key}' has non-string value (GH #116).`,
72+
);
73+
}
74+
}
75+
}
76+
4277
const platform = resolvePlatform(args.platform);
4378
if (!platform) {
4479
return failResult(
@@ -102,10 +137,24 @@ export function createMaestroRunHandler(): (args: MaestroRunArgs) => Promise<Too
102137

103138
const timeout = args.timeoutMs ?? 120_000;
104139

140+
// GH #116: build the final argv. Start with the dispatch tier's
141+
// base args, then append `-e KEY=VALUE` pairs for any supplied
142+
// params. Validation already ran at the top of the handler so by
143+
// this point every key matches PARAM_KEY_RE and every value is a
144+
// string — no need to re-check.
145+
const baseArgs = dispatch.buildArgs(platform, flowFile);
146+
const paramArgs: string[] = [];
147+
if (args.params) {
148+
for (const [key, value] of Object.entries(args.params)) {
149+
paramArgs.push('-e', `${key}=${value}`);
150+
}
151+
}
152+
const finalArgs = [...baseArgs, ...paramArgs];
153+
105154
try {
106155
const { stdout, stderr } = await execFile(
107156
dispatch.binPath,
108-
dispatch.buildArgs(platform, flowFile),
157+
finalArgs,
109158
{ timeout, encoding: 'utf8' },
110159
);
111160

scripts/cdp-bridge/src/tools/run-action.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ export interface RunActionArgs {
9595
* 'ci'; human-driven invocations 'human'.
9696
*/
9797
trigger?: 'agent' | 'ci' | 'human';
98+
/**
99+
* GH #116: per-flow parameter bindings forwarded to maestro_run as
100+
* `-e KEY=VALUE` pairs. Keys must match Maestro's env-style convention
101+
* `/^[A-Z_][A-Z0-9_]*$/`; validation enforced in maestro_run itself.
102+
* Pass through unchanged on both first attempt AND post-repair retry
103+
* so a parameterised flow can be replayed identically after repair.
104+
*/
105+
params?: Record<string, string>;
98106
}
99107

100108
interface MaestroEnvelope {
@@ -213,6 +221,7 @@ export function createRunActionHandler(deps: RunActionDeps = {}) {
213221
flowPath: action.filePath,
214222
platform: args.platform,
215223
timeoutMs,
224+
params: args.params,
216225
});
217226
const firstAttemptMs = Date.now() - tBeforeFirst;
218227
const firstEnv = parseEnvelope(firstResult, 'maestro_run');
@@ -367,6 +376,7 @@ export function createRunActionHandler(deps: RunActionDeps = {}) {
367376
flowPath: reloadedAction.filePath,
368377
platform: args.platform,
369378
timeoutMs,
379+
params: args.params,
370380
});
371381
const retryMs = Date.now() - tBeforeRetry;
372382
const retryEnv = parseEnvelope(retryResult, 'maestro_run');

0 commit comments

Comments
 (0)