From b461dc19acf0497430cd46641ae0013eb8c12259 Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:38:15 +0530 Subject: [PATCH 1/3] feat(maestro): support evalScript inline JavaScript expressions Add evalScript as a supported Maestro command: a single-line JavaScript expression evaluated against flow env and prior output leaves, with the assigned output object folded back into the flat string-key variable model so ${output.x} and ${output.list.length} resolve in later steps. The command is handled host-side by the compute engine (node:vm) and never dispatched to the device port. Update the support matrix, ADR 0015, the conformance expected-divergence (upstream/053_repeat_times is now identical), and add parser, engine, and eval-module unit tests. --- docs/adr/0015-direct-maestro-engine.md | 12 +- .../__tests__/engine-eval-script.test.ts | 44 +++++++ .../src/internal/__tests__/engine.test.ts | 62 ++++++++++ .../__tests__/program-ir-parser.test.ts | 15 +++ .../src/internal/conformance-normalize.ts | 5 + .../src/internal/engine-eval-script.ts | 113 ++++++++++++++++++ .../src/internal/program-ir-command-parser.ts | 14 +++ packages/maestro/src/internal/program-ir.ts | 7 ++ .../src/internal/replay-plan-resume.ts | 4 +- .../internal/replay-plan-step-execution.ts | 14 +++ .../src/internal/runtime-port-commands.ts | 11 ++ .../maestro/src/internal/support-matrix.ts | 6 +- .../test/conformance/expected-divergence.ts | 6 - 13 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 packages/maestro/src/internal/__tests__/engine-eval-script.test.ts create mode 100644 packages/maestro/src/internal/engine-eval-script.ts diff --git a/docs/adr/0015-direct-maestro-engine.md b/docs/adr/0015-direct-maestro-engine.md index 7ba9bca43..1cf5c1439 100644 --- a/docs/adr/0015-direct-maestro-engine.md +++ b/docs/adr/0015-direct-maestro-engine.md @@ -191,10 +191,14 @@ two-pointer plans, executor selection, and app-observable effects must remain un - agent-device supports selected Maestro Flow syntax and behavior. Unsupported features return explicit errors, and intentional differences are declared in the conformance fixtures. - `${...}` interpolation stays variable-lookup-only, by decision (#1292): upstream's GraalJS - expression evaluation is not reimplemented, so unresolved or expression-shaped payloads fail - loud with source context; the escape hatch is `runScript` (compute) → `${output.x}` (consume). - `assertTrue` (#1295) is scoped to that same lookup-only subset — literal values and bare - `${VAR}` lookups evaluated with a pinned string-truthiness table — for the same reason. + expression evaluation is not reimplemented outside `evalScript`, so unresolved or + expression-shaped payloads fail loud with source context; the escape hatches are `evalScript` + (inline expression → `output.*` leaves) and `runScript` (file compute) → `${output.x}` (consume). + `evalScript` is the one command whose payload is evaluated as JavaScript: flow env and prior + `output` leaves are bound as strings in a `node:vm` context and the assigned `output` object + folds back into the flat string-key model, so `${output.uppercaseName}` and + `${output.list.length}` resolve. `assertTrue` (#1295) is scoped to that same lookup-only subset — + literal values and bare `${VAR}` lookups evaluated with a pinned string-truthiness table. - Shipping two production engines or a runtime fallback between them is rejected because it doubles semantic and performance ownership. - Source provenance and runtime values stay typed through execution. diff --git a/packages/maestro/src/internal/__tests__/engine-eval-script.test.ts b/packages/maestro/src/internal/__tests__/engine-eval-script.test.ts new file mode 100644 index 000000000..9bdc1ad09 --- /dev/null +++ b/packages/maestro/src/internal/__tests__/engine-eval-script.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'vitest'; +import { evaluateMaestroEvalScript } from '../engine-eval-script.ts'; + +describe('evaluateMaestroEvalScript', () => { + test('evaluates a ${...} expression with env and output leaves bound', () => { + assert.deepEqual( + evaluateMaestroEvalScript('${output.upper = MY_NAME.toUpperCase()}', { MY_NAME: 'John' }), + { 'output.upper': 'JOHN' }, + ); + }); + + test('flattens arrays into index and length leaves', () => { + assert.deepEqual(evaluateMaestroEvalScript('${output.list = [1, 2, 3]}', {}), { + 'output.list.0': '1', + 'output.list.1': '2', + 'output.list.2': '3', + 'output.list.length': '3', + }); + }); + + test('seeds output from prior leaves and reads them in a later expression', () => { + assert.deepEqual( + evaluateMaestroEvalScript('${output.total = Number(output.sum) + 10}', { + 'output.sum': '3', + }), + { 'output.sum': '3', 'output.total': '13' }, + ); + }); + + test('drops unsafe output segments and survives self-references', () => { + assert.deepEqual( + evaluateMaestroEvalScript('${output.__proto__ = 1; output.a = output; output.b = 2}', {}), + { 'output.b': '2' }, + ); + }); + + test('rejects a failing expression with a wrapped error', () => { + assert.throws( + () => evaluateMaestroEvalScript('${exploded.leaf()}', {}), + /Maestro evalScript failed/, + ); + }); +}); \ No newline at end of file diff --git a/packages/maestro/src/internal/__tests__/engine.test.ts b/packages/maestro/src/internal/__tests__/engine.test.ts index b1394f755..276b5b2c8 100644 --- a/packages/maestro/src/internal/__tests__/engine.test.ts +++ b/packages/maestro/src/internal/__tests__/engine.test.ts @@ -596,6 +596,68 @@ describe('executeMaestroProgram', () => { expect(texts).toEqual(['ready']); }); + test('evalScript computes output leaves consumed by later steps', async () => { + const texts: string[] = []; + const port = makePort({ + execute: vi.fn(async (request) => { + if (request.command.kind === 'inputText') texts.push(request.command.text); + request.invalidateObservation(); + return {}; + }), + }); + const program = parseMaestroProgram( + [ + 'env:', + ' BASE: "10"', + '---', + '- evalScript: ${output.sum = 1 + 2}', + '- inputText: ${output.sum}', + '- evalScript: ${output.total = Number(BASE) + Number(output.sum)}', + '- inputText: ${output.total}', + ].join('\n'), + ); + + await executeMaestroProgram(program, port); + + expect(texts).toEqual(['3', '13']); + }); + + test('evalScript array output resolves .length for a later repeat', async () => { + const port = makePort({ + execute: vi.fn(async (request) => { + if (request.command.kind !== 'takeScreenshot') request.invalidateObservation(); + return {}; + }), + }); + const program = parseMaestroProgram( + [ + '---', + '- evalScript: ${output.list = [1, 2, 3]}', + '- repeat:', + ' times: ${output.list.length}', + ' commands:', + ' - tapOn: Item', + ].join('\n'), + ); + + await executeMaestroProgram(program, port); + + expect(port.execute.mock.calls.filter(([request]) => request.command.kind === 'tapOn')).toHaveLength( + 3, + ); + }); + + test('evalScript reports a failing expression with step source', async () => { + const program = parseMaestroProgram( + ['---', '- evalScript: ${exploded.leaf()}'].join('\n'), + { sourcePath: '/flows/eval.yaml' }, + ); + + await expect(executeMaestroProgram(program, makePort())).rejects.toThrow( + /evalScript failed/i, + ); + }); + test('rejects recursive file includes before loading the child', async () => { const loadProgram = vi.fn(); const program = parseMaestroProgram('---\n- runFlow: ./main.yaml\n', { diff --git a/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts b/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts index 9596e2792..1532857dd 100644 --- a/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts +++ b/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts @@ -409,6 +409,21 @@ describe('parseMaestroProgram', () => { }); }); + test('parses evalScript as a scalar script string', () => { + const program = parseMaestroProgram( + ['---', '- evalScript: ${output.sum = 1 + 2}'].join('\n'), + ); + assert.deepEqual(program.commands[0], { + kind: 'evalScript', + source: { line: 2 }, + script: '${output.sum = 1 + 2}', + }); + assert.throws( + () => parseMaestroProgram(['---', '- evalScript: [1, 2]'].join('\n')), + /evalScript expects a scalar value/i, + ); + }); + test('reports source lines for unsupported and invalid command shapes', () => { assert.throws( () => diff --git a/packages/maestro/src/internal/conformance-normalize.ts b/packages/maestro/src/internal/conformance-normalize.ts index a7d127bf2..3d811c53a 100644 --- a/packages/maestro/src/internal/conformance-normalize.ts +++ b/packages/maestro/src/internal/conformance-normalize.ts @@ -81,6 +81,7 @@ export type CanonicalCommand = | { kind: 'retry'; maxRetries?: string | number } | { kind: 'runFlow'; label?: string; source: 'file' | 'commands' } | { kind: 'runScript' } + | { kind: 'evalScript' } | { kind: 'unsupported'; command: string }; // --------------------------------------------------------------------------- @@ -213,6 +214,8 @@ function canonicalizeUpstreamCommand(command: UpstreamCommand): CanonicalCommand }); case 'RunScriptCommand': return { kind: 'runScript' }; + case 'EvalScriptCommand': + return { kind: 'evalScript' }; default: return { kind: 'unsupported', command: unsupportedName(command.type) }; } @@ -425,6 +428,8 @@ function canonicalizeAgentCommand( }); case 'runScript': return { kind: 'runScript' }; + case 'evalScript': + return { kind: 'evalScript' }; default: { const exhaustive: never = command; throw new Error(`Unhandled agent command: ${JSON.stringify(exhaustive)}`); diff --git a/packages/maestro/src/internal/engine-eval-script.ts b/packages/maestro/src/internal/engine-eval-script.ts new file mode 100644 index 000000000..13d44ea8d --- /dev/null +++ b/packages/maestro/src/internal/engine-eval-script.ts @@ -0,0 +1,113 @@ +import vm from 'node:vm'; +import { AppError, errorMessage } from '@agent-device/kernel/errors'; + +const MAESTRO_EVAL_SCRIPT_TIMEOUT_MS = 10_000; +const UNSAFE_OUTPUT_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']); + +// Maestro evaluates evalScript inside its flow JS context, where env values stay +// real JS values. This engine keeps every variable as a flat string, so it +// evaluates the expression in `node:vm` (trusted flow code, like runScript) and +// folds the assigned `output` object back into string leaves that the flat-key +// interpolator can read — `${output.uppercaseName}` and `${output.list.length}` +// both resolve, which is the same surface the corpus flow exercises. +export function evaluateMaestroEvalScript( + script: string, + values: Readonly>, +): Record { + const output = seedMaestroOutput(values); + const expression = unwrapMaestroEvalScriptExpression(script); + try { + vm.runInNewContext(expression, { ...values, output }, { + filename: 'evalScript', + timeout: MAESTRO_EVAL_SCRIPT_TIMEOUT_MS, + }); + } catch (error) { + // A vm context throws its own realm's errors, which are not host `Error` + // instances; read the message directly rather than through normalizeError. + throw new AppError( + 'COMMAND_FAILED', + `Maestro evalScript failed: ${errorMessage(error)}`, + undefined, + error instanceof Error ? error : undefined, + ); + } + return flattenMaestroOutput(output); +} + +function unwrapMaestroEvalScriptExpression(script: string): string { + const trimmed = script.trim(); + return trimmed.startsWith('${') && trimmed.endsWith('}') ? trimmed.slice(2, -1) : trimmed; +} + +// Prior `output.*` leaves become an `output` sandbox object so a chained +// evalScript can read them. Leaves are strings (runScript parity); numeric +// re-interpolation across steps loses JS typing, noted at the flatten gate. +function seedMaestroOutput(values: Readonly>): Record { + const output: Record = Object.create(null) as Record; + for (const [key, value] of Object.entries(values)) { + if (!key.startsWith('output.') || key === 'output') continue; + writeNestedOutput(output, key.slice('output.'.length), value); + } + return output; +} + +function writeNestedOutput( + root: Record, + path: string, + value: string, +): void { + const segments = path.split('.').filter(isSafeOutputSegment); + if (segments.length === 0) return; + let node = root; + for (const segment of segments.slice(0, -1)) { + const child = node[segment]; + if (child === null || typeof child !== 'object') { + node[segment] = Object.create(null) as Record; + } + node = node[segment] as Record; + } + node[segments[segments.length - 1]!] = value; +} + +function flattenMaestroOutput(output: Record): Record { + const flat: Record = {}; + const visited = new Set(); + writeOutputLeaves(output, [], flat, visited); + return flat; +} + +function writeOutputLeaves( + value: unknown, + segments: readonly string[], + flat: Record, + visited: Set, +): void { + if (value === undefined) return; + if (value === null || typeof value !== 'object') { + flat[`output${segments.map((segment) => `.${segment}`).join('')}`] = + stringifyOutputValue(value); + return; + } + if (visited.has(value)) return; + visited.add(value); + const children = Array.isArray(value) + ? [...value.keys(), 'length'] + : Object.keys(value).filter(isSafeOutputSegment); + for (const segment of children) { + const child = + Array.isArray(value) && segment === 'length' + ? value.length + : (value as Record)[segment]; + writeOutputLeaves(child, [...segments, segment], flat, visited); + } +} + +function isSafeOutputSegment(segment: string): boolean { + return !UNSAFE_OUTPUT_SEGMENTS.has(segment); +} + +function stringifyOutputValue(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return JSON.stringify(value); +} \ No newline at end of file diff --git a/packages/maestro/src/internal/program-ir-command-parser.ts b/packages/maestro/src/internal/program-ir-command-parser.ts index 9dced618f..0b073aca0 100644 --- a/packages/maestro/src/internal/program-ir-command-parser.ts +++ b/packages/maestro/src/internal/program-ir-command-parser.ts @@ -5,6 +5,7 @@ import type { MaestroBackCommand, MaestroCommand, MaestroEraseTextCommand, + MaestroEvalScriptCommand, MaestroExtendedWaitUntilCommand, MaestroHideKeyboardCommand, MaestroInputTextCommand, @@ -123,6 +124,7 @@ const COMMAND_VALUE_PARSERS: Readonly> = { waitForAnimationToEnd: parseWaitForAnimationToEnd, stopApp: parseStopApp, runScript: parseMaestroRunScriptCommand, + evalScript: parseEvalScript, runFlow: (value, node, context) => parseMaestroRunFlowCommand(value, node, context, parseMaestroCommandList), repeat: (value, node, context) => @@ -447,6 +449,18 @@ function parseStopApp( return { kind: 'stopApp', source, appId: readRequiredString(value, 'stopApp', context) }; } +function parseEvalScript( + value: Node | null, + commandNode: Node, + context: MaestroProgramParseContext, +): MaestroEvalScriptCommand { + return { + kind: 'evalScript', + source: sourceAt(commandNode, context), + script: readRequiredString(value, 'evalScript', context), + }; +} + function parseLaunchArguments( node: Node | null | undefined, name: string, diff --git a/packages/maestro/src/internal/program-ir.ts b/packages/maestro/src/internal/program-ir.ts index d32e7844d..b9f870465 100644 --- a/packages/maestro/src/internal/program-ir.ts +++ b/packages/maestro/src/internal/program-ir.ts @@ -214,6 +214,12 @@ export type MaestroRunScriptCommand = { env?: Record; }; +export type MaestroEvalScriptCommand = { + kind: 'evalScript'; + source: MaestroSourceLocation; + script: string; +}; + export type MaestroRunFlowCondition = { platform?: MaestroPlatform; visible?: MaestroSelector; @@ -266,6 +272,7 @@ export type MaestroCommand = | MaestroWaitForAnimationToEndCommand | MaestroStopAppCommand | MaestroRunScriptCommand + | MaestroEvalScriptCommand | MaestroRunFlowCommand | MaestroRepeatCommand | MaestroRetryCommand; diff --git a/packages/maestro/src/internal/replay-plan-resume.ts b/packages/maestro/src/internal/replay-plan-resume.ts index 3afe0671d..c5fa7035f 100644 --- a/packages/maestro/src/internal/replay-plan-resume.ts +++ b/packages/maestro/src/internal/replay-plan-resume.ts @@ -38,10 +38,10 @@ export function evaluateMaestroReplayResume( reason: `step ${index + 1} is opaque runtime control flow (${step.command.kind}) and cannot be skipped safely.`, }; } - if (step.command.kind === 'runScript') { + if (step.command.kind === 'runScript' || step.command.kind === 'evalScript') { return { allowed: false, - reason: `step ${index + 1} (runScript) can produce outputEnv values and cannot be skipped safely.`, + reason: `step ${index + 1} (${step.command.kind}) can produce outputEnv values and cannot be skipped safely.`, }; } } diff --git a/packages/maestro/src/internal/replay-plan-step-execution.ts b/packages/maestro/src/internal/replay-plan-step-execution.ts index 7d61b3b7c..5eee2445f 100644 --- a/packages/maestro/src/internal/replay-plan-step-execution.ts +++ b/packages/maestro/src/internal/replay-plan-step-execution.ts @@ -7,6 +7,7 @@ import { type MaestroCompatibilityTimingPolicy, } from './compatibility-policy.ts'; import type { MaestroExecutionContext } from './engine-context.ts'; +import { evaluateMaestroEvalScript } from './engine-eval-script.ts'; import { checkpointMaestroCancellation, observationConditions, @@ -80,6 +81,9 @@ async function executeOptionalCommand( appId: string | undefined, state: MaestroReplayPlanExecutionState, ): Promise { + if (rawCommand.kind === 'evalScript') { + return await executeEvalScript(rawCommand, state); + } const command = resolveCommand(rawCommand, state.context); try { return await executeResolvedCommand(command, appId, state); @@ -104,6 +108,16 @@ function isOptionalCommand(command: MaestroRuntimeCommand): boolean { return 'optional' in command && command.optional === true; } +async function executeEvalScript( + command: Extract, + state: MaestroReplayPlanExecutionState, +): Promise { + const outputEnv = evaluateMaestroEvalScript(command.script, state.context.values); + state.context.merge(outputEnv); + state.executed += 1; + return undefined; +} + async function executeResolvedCommand( command: MaestroRuntimeCommand, appId: string | undefined, diff --git a/packages/maestro/src/internal/runtime-port-commands.ts b/packages/maestro/src/internal/runtime-port-commands.ts index 577ec1384..4217cc551 100644 --- a/packages/maestro/src/internal/runtime-port-commands.ts +++ b/packages/maestro/src/internal/runtime-port-commands.ts @@ -68,6 +68,7 @@ const MAESTRO_RUNTIME_COMMAND_HANDLERS = { waitForAnimationToEnd: executeNavigationCommand, takeScreenshot: executeSupportCommand, runScript: executeSupportCommand, + evalScript: executeEvaluationCommand, assertVisible: executeObservationCommand, assertNotVisible: executeObservationCommand, assertTrue: executeObservationCommand, @@ -92,6 +93,7 @@ const MAESTRO_COMMAND_REQUIRES_SETTLED_PREDECESSOR = { waitForAnimationToEnd: true, takeScreenshot: false, runScript: false, + evalScript: false, assertVisible: false, assertNotVisible: false, assertTrue: false, @@ -394,6 +396,15 @@ async function executeObservationCommand(command: MaestroObservationCommand): Pr ); } +function executeEvaluationCommand( + command: MaestroCommandOf<'evalScript'>, +): Promise { + throw new AppError( + 'COMMAND_FAILED', + `Maestro evalScript must be executed by the compute engine at ${command.source.path ?? ''}line ${command.source.line}.`, + ); +} + async function invokeOperation( operation: ( input: TInput, diff --git a/packages/maestro/src/internal/support-matrix.ts b/packages/maestro/src/internal/support-matrix.ts index 160519b51..276167f31 100644 --- a/packages/maestro/src/internal/support-matrix.ts +++ b/packages/maestro/src/internal/support-matrix.ts @@ -2,15 +2,15 @@ export const MAESTRO_COMPAT_SUPPORTED_CAPABILITIES = [ 'Flows: launchApp; runFlow file/inline with platform, visibility, and limited boolean conditions; onFlowStart/onFlowComplete; repeat.times and retry.', 'Interactions: tapOn, doubleTapOn, longPressOn, inputText on the focused element, eraseText, openLink, hideKeyboard, basic pressKey, and back; selector targets poll until available and support recursive index, childOf, above, below, leftOf, rightOf, containsChild, containsDescendants, points, and optional; outer command labels are metadata, not target selectors.', 'Assertions and navigation: assertVisible, assertNotVisible, assertTrue (literal values and ${VAR} lookups only; "", "false", "0", "null", and "undefined" are falsy, everything else is truthy), extendedWaitUntil, scroll, scrollUntilVisible, absolute/percentage/target swipe, takeScreenshot, waitForAnimationToEnd, and stopApp.', - 'Scripts: ordered runScript file/env scripts with http.post, json, and output variables.', + 'Scripts: ordered runScript file/env scripts with http.post, json, and output variables; evalScript inline expressions run flow-scoped JavaScript and write output.* leaves for later steps.', ] as const; export const MAESTRO_COMPAT_LIMITATIONS = [ 'Runtime: iOS and Android only; launchApp.clearState supports Android and iOS simulators, launch arguments are Apple-only, and standalone device utility/state commands are unsupported.', - 'Expressions: when.true supports boolean literals and maestro.platform comparisons; assertTrue supports literal values and ${VAR} lookups only; repeat.while, evalScript, and broader JavaScript expressions are unsupported.', + 'Expressions: evalScript is the only command whose payload is evaluated as JavaScript (flow env and prior output leaves are string-typed); with that exception, fields stay literal or ${VAR} lookup-only — assertTrue supports literals and bare lookups, repeat.while is unsupported, and other expression-shaped payloads fail loud.', 'Environment: flow env is the default, AD_VAR_* overrides it, and CLI -e KEY=VALUE wins over both.', 'Failure diagnostics: resolved targets and runFlow paths are rendered, while inputText payloads remain hidden; do not place secrets in diagnostic identifiers.', - 'Trust: runScript executes trusted scripts, may make http.post network requests, and is not a security sandbox; output keys cannot contain a dot.', + 'Trust: runScript and evalScript execute trusted flow scripts in-process and are not a security sandbox; runScript may make http.post network requests and its output keys cannot contain a dot.', 'Errors and tracking: unsupported commands and fields fail with source context when available; open a focused issue only when implementation work is planned.', ] as const; diff --git a/packages/maestro/test/conformance/expected-divergence.ts b/packages/maestro/test/conformance/expected-divergence.ts index f17e41305..221a728ac 100644 --- a/packages/maestro/test/conformance/expected-divergence.ts +++ b/packages/maestro/test/conformance/expected-divergence.ts @@ -58,12 +58,6 @@ export const FLOW_DIVERGENCES: Record = { reason: 'Standalone setPermissions is outside the supported subset.', unsupported: ['setPermissions'], }, - 'upstream/053_repeat_times': { - classification: 'we-reject', - reason: - 'repeat is supported, but the flow also uses evalScript and a ${output.list.length} times expression.', - unsupported: ['evalScript'], - }, // --- Deliberately stricter than upstream --- 'invalid/duplicate-keys': { classification: 'we-reject', From 582eb29549999b819e856dc8a5c6fc927e6c1cb5 Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:50:21 +0530 Subject: [PATCH 2/3] fix(maestro): refuse remote evalScript, pin resume and manifest Reject evalScript when trustedScripts is false (remote HTTP surface via publicNetworkOnly), since node:vm is not a security sandbox. Add engine adversarial refusal matrix and handler wiring test, explicit runScript/evalScript resume regression, and fix stale upstream 053 manifest note. Also fix lint/typecheck in touched files. --- docs/adr/0015-direct-maestro-engine.md | 6 ++ .../__tests__/engine-eval-script.test.ts | 16 ++++- .../src/internal/__tests__/engine.test.ts | 44 ++++++++++--- .../__tests__/program-ir-parser.test.ts | 4 +- .../internal/__tests__/replay-plan.test.ts | 30 +++++++++ .../src/internal/engine-eval-script.ts | 29 +++++---- packages/maestro/src/internal/engine-types.ts | 8 +++ .../maestro/src/internal/facade-execution.ts | 3 + .../internal/replay-plan-step-execution.ts | 7 ++ .../maestro/src/internal/support-matrix.ts | 2 +- .../maestro-conformance/build-manifest.mjs | 2 +- .../maestro-conformance/corpus/manifest.json | 2 +- ...n-replay-maestro-remote-evalscript.test.ts | 64 +++++++++++++++++++ .../session-replay-maestro-runtime.ts | 3 + 14 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 src/daemon/replay/internal/__tests__/session-replay-maestro-remote-evalscript.test.ts diff --git a/docs/adr/0015-direct-maestro-engine.md b/docs/adr/0015-direct-maestro-engine.md index 1cf5c1439..ac124de17 100644 --- a/docs/adr/0015-direct-maestro-engine.md +++ b/docs/adr/0015-direct-maestro-engine.md @@ -199,6 +199,12 @@ two-pointer plans, executor selection, and app-observable effects must remain un folds back into the flat string-key model, so `${output.uppercaseName}` and `${output.list.length}` resolve. `assertTrue` (#1295) is scoped to that same lookup-only subset — literal values and bare `${VAR}` lookups evaluated with a pinned string-truthiness table. + `node:vm` is explicitly not a security sandbox — a bound context's prototype chain still + resolves to host `Object`/`Function`, so an untrusted expression can escape it. `runScript` + already carried this trust assumption for daemon-local flows; `evalScript` inherits the same + assumption but is reachable from a flow accepted over the daemon's remote HTTP surface, so + `MaestroEngineOptions.trustedScripts` gates it — set from `publicNetworkOnly` at the HTTP + boundary (`restrictRemoteHttpRequest`) — and `evalScript` is refused outright when false. - Shipping two production engines or a runtime fallback between them is rejected because it doubles semantic and performance ownership. - Source provenance and runtime values stay typed through execution. diff --git a/packages/maestro/src/internal/__tests__/engine-eval-script.test.ts b/packages/maestro/src/internal/__tests__/engine-eval-script.test.ts index 9bdc1ad09..7b9b41409 100644 --- a/packages/maestro/src/internal/__tests__/engine-eval-script.test.ts +++ b/packages/maestro/src/internal/__tests__/engine-eval-script.test.ts @@ -28,6 +28,20 @@ describe('evaluateMaestroEvalScript', () => { ); }); + test('resolves a host escape into output when trusted (why remote runs are refused)', () => { + // `this` in the vm script is the sandbox global, whose prototype chain still reaches host + // `Object`/`Function`, so it can construct a host-scoped `Function` and read `process`. + // This pins the leak the gate exists for: local-trusted evalScript reaches the host realm, + // so the remote HTTP surface must refuse evalScript wholesale before this runs. + assert.deepEqual( + evaluateMaestroEvalScript( + '${output.pwned = this.constructor.constructor("return process.versions.node")()}', + {}, + ), + { 'output.pwned': process.versions.node }, + ); + }); + test('drops unsafe output segments and survives self-references', () => { assert.deepEqual( evaluateMaestroEvalScript('${output.__proto__ = 1; output.a = output; output.b = 2}', {}), @@ -41,4 +55,4 @@ describe('evaluateMaestroEvalScript', () => { /Maestro evalScript failed/, ); }); -}); \ No newline at end of file +}); diff --git a/packages/maestro/src/internal/__tests__/engine.test.ts b/packages/maestro/src/internal/__tests__/engine.test.ts index 276b5b2c8..a70574d62 100644 --- a/packages/maestro/src/internal/__tests__/engine.test.ts +++ b/packages/maestro/src/internal/__tests__/engine.test.ts @@ -642,20 +642,44 @@ describe('executeMaestroProgram', () => { await executeMaestroProgram(program, port); - expect(port.execute.mock.calls.filter(([request]) => request.command.kind === 'tapOn')).toHaveLength( - 3, - ); + expect( + vi.mocked(port.execute).mock.calls.filter(([request]) => request.command.kind === 'tapOn'), + ).toHaveLength(3); }); test('evalScript reports a failing expression with step source', async () => { - const program = parseMaestroProgram( - ['---', '- evalScript: ${exploded.leaf()}'].join('\n'), - { sourcePath: '/flows/eval.yaml' }, - ); + const program = parseMaestroProgram(['---', '- evalScript: ${exploded.leaf()}'].join('\n'), { + sourcePath: '/flows/eval.yaml', + }); - await expect(executeMaestroProgram(program, makePort())).rejects.toThrow( - /evalScript failed/i, - ); + await expect(executeMaestroProgram(program, makePort())).rejects.toThrow(/evalScript failed/i); + }); + + test('evalScript refuses to run when trustedScripts is false', async () => { + const program = parseMaestroProgram(['---', '- evalScript: ${output.sum = 1 + 2}'].join('\n'), { + sourcePath: '/flows/eval.yaml', + }); + + await expect( + executeMaestroProgram(program, makePort(), { trustedScripts: false }), + ).rejects.toThrow(/not permitted for flows received over the remote daemon surface/); + }); + + test.each([ + '${output.pwned = this.constructor.constructor("return process.versions.node")()}', + '${output.pwned = this.constructor.constructor("return process.env")()}', + '${output.pwned = this.constructor.constructor("return process")().getBuiltinModule("fs").readFileSync("/etc/passwd", "utf8")}', + '${output.pwned = this.constructor.constructor("return process")().getBuiltinModule("child_process").execSync("id").toString()}', + '${output.pwned = fetch("http://169.254.169.254/latest/meta-data/").toString()}', + '${while (true) {}}', + ])('remote evalScript %s is refused before vm evaluation', async (script) => { + const program = parseMaestroProgram(['---', `- evalScript: ${script}`].join('\n'), { + sourcePath: '/flows/eval.yaml', + }); + + await expect( + executeMaestroProgram(program, makePort(), { trustedScripts: false }), + ).rejects.toThrow(/not permitted for flows received over the remote daemon surface/); }); test('rejects recursive file includes before loading the child', async () => { diff --git a/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts b/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts index 1532857dd..d93889c8c 100644 --- a/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts +++ b/packages/maestro/src/internal/__tests__/program-ir-parser.test.ts @@ -410,9 +410,7 @@ describe('parseMaestroProgram', () => { }); test('parses evalScript as a scalar script string', () => { - const program = parseMaestroProgram( - ['---', '- evalScript: ${output.sum = 1 + 2}'].join('\n'), - ); + const program = parseMaestroProgram(['---', '- evalScript: ${output.sum = 1 + 2}'].join('\n')); assert.deepEqual(program.commands[0], { kind: 'evalScript', source: { line: 2 }, diff --git a/packages/maestro/src/internal/__tests__/replay-plan.test.ts b/packages/maestro/src/internal/__tests__/replay-plan.test.ts index 5d8428a91..1ed52bf12 100644 --- a/packages/maestro/src/internal/__tests__/replay-plan.test.ts +++ b/packages/maestro/src/internal/__tests__/replay-plan.test.ts @@ -110,6 +110,36 @@ describe('typed Maestro replay plan', () => { }); }); + test('refuses --from past a runScript or evalScript step, which can produce outputEnv values', async () => { + // Each fixture puts its output-producing step first so the resume scan hits + // that step's own no-skip branch rather than an unrelated opaque step first — + // the shared `replay-plan.test.ts` fixture above only ever reaches its opaque + // `repeat`/`retry` branch, leaving the runScript/evalScript branch untested. + const runScriptPlan = await compileMaestroReplayPlan( + parseMaestroProgram(['---', '- runScript: setup.js', '- inputText: after'].join('\n')), + { platform: 'android', target: 'simulator' }, + ); + expect( + evaluateMaestroReplayResume(runScriptPlan, { from: 2, planDigest: runScriptPlan.digest }), + ).toMatchObject({ + allowed: false, + reason: 'step 1 (runScript) can produce outputEnv values and cannot be skipped safely.', + }); + + const evalScriptPlan = await compileMaestroReplayPlan( + parseMaestroProgram( + ['---', '- evalScript: ${output.x = 1}', '- inputText: after'].join('\n'), + ), + { platform: 'android', target: 'simulator' }, + ); + expect( + evaluateMaestroReplayResume(evalScriptPlan, { from: 2, planDigest: evalScriptPlan.digest }), + ).toMatchObject({ + allowed: false, + reason: 'step 1 (evalScript) can produce outputEnv values and cannot be skipped safely.', + }); + }); + test('executes from a stable plan index and reports plan ordinals', async () => { const program = parseMaestroProgram('---\n- inputText: first\n- inputText: second\n'); const execute = vi.fn(async (request) => { diff --git a/packages/maestro/src/internal/engine-eval-script.ts b/packages/maestro/src/internal/engine-eval-script.ts index 13d44ea8d..acb40e60d 100644 --- a/packages/maestro/src/internal/engine-eval-script.ts +++ b/packages/maestro/src/internal/engine-eval-script.ts @@ -17,10 +17,14 @@ export function evaluateMaestroEvalScript( const output = seedMaestroOutput(values); const expression = unwrapMaestroEvalScriptExpression(script); try { - vm.runInNewContext(expression, { ...values, output }, { - filename: 'evalScript', - timeout: MAESTRO_EVAL_SCRIPT_TIMEOUT_MS, - }); + vm.runInNewContext( + expression, + { ...values, output }, + { + filename: 'evalScript', + timeout: MAESTRO_EVAL_SCRIPT_TIMEOUT_MS, + }, + ); } catch (error) { // A vm context throws its own realm's errors, which are not host `Error` // instances; read the message directly rather than through normalizeError. @@ -51,11 +55,7 @@ function seedMaestroOutput(values: Readonly>): Record, - path: string, - value: string, -): void { +function writeNestedOutput(root: Record, path: string, value: string): void { const segments = path.split('.').filter(isSafeOutputSegment); if (segments.length === 0) return; let node = root; @@ -66,7 +66,7 @@ function writeNestedOutput( } node = node[segment] as Record; } - node[segments[segments.length - 1]!] = value; + node[segments.at(-1)!] = value; } function flattenMaestroOutput(output: Record): Record { @@ -94,11 +94,12 @@ function writeOutputLeaves( ? [...value.keys(), 'length'] : Object.keys(value).filter(isSafeOutputSegment); for (const segment of children) { + const key = String(segment); const child = - Array.isArray(value) && segment === 'length' + Array.isArray(value) && key === 'length' ? value.length - : (value as Record)[segment]; - writeOutputLeaves(child, [...segments, segment], flat, visited); + : (value as Record)[key]; + writeOutputLeaves(child, [...segments, key], flat, visited); } } @@ -110,4 +111,4 @@ function stringifyOutputValue(value: unknown): string { if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); return JSON.stringify(value); -} \ No newline at end of file +} diff --git a/packages/maestro/src/internal/engine-types.ts b/packages/maestro/src/internal/engine-types.ts index 2e703bde5..fa7878884 100644 --- a/packages/maestro/src/internal/engine-types.ts +++ b/packages/maestro/src/internal/engine-types.ts @@ -162,6 +162,14 @@ export type MaestroEngineOptions = { signal?: AbortSignal; observer?: MaestroEngineObserver; now?: () => number; + /** + * False for a flow accepted over the daemon's remote HTTP surface. `evalScript` + * runs the caller's expression through `node:vm`, which Node documents as not a + * security boundary — the bound context's prototype chain still resolves to host + * `Object`/`Function`, so an untrusted expression can escape. Undefined/true is + * trusted: CLI-invoked and daemon-local flows never carry this as false. + */ + trustedScripts?: boolean; }; export type MaestroEngineResult = { diff --git a/packages/maestro/src/internal/facade-execution.ts b/packages/maestro/src/internal/facade-execution.ts index cb5757f73..49255cd7c 100644 --- a/packages/maestro/src/internal/facade-execution.ts +++ b/packages/maestro/src/internal/facade-execution.ts @@ -89,6 +89,8 @@ export type MaestroExecutionOptions = { readonly planDigest?: string; readonly signal?: AbortSignal; readonly observer?: MaestroExecutionObserver; + /** Forwarded to `MaestroEngineOptions.trustedScripts` — see its doc there. */ + readonly trustedScripts?: boolean; /** * #1802: how a `runFlow` include's text is obtained. Required — the engine * owns no filesystem, so a run against a remote daemon reads the caller's @@ -151,6 +153,7 @@ export async function executeMaestroFlow( loadProgram: loader, signal: options.signal, startIndex, + trustedScripts: options.trustedScripts, observer: createObserver(plan, options.observer, (event) => { failed = event; }), diff --git a/packages/maestro/src/internal/replay-plan-step-execution.ts b/packages/maestro/src/internal/replay-plan-step-execution.ts index 5eee2445f..5d2de5b28 100644 --- a/packages/maestro/src/internal/replay-plan-step-execution.ts +++ b/packages/maestro/src/internal/replay-plan-step-execution.ts @@ -112,6 +112,13 @@ async function executeEvalScript( command: Extract, state: MaestroReplayPlanExecutionState, ): Promise { + if (state.options.trustedScripts === false) { + throw new AppError( + 'UNAUTHORIZED', + 'Maestro evalScript is not permitted for flows received over the remote daemon surface: ' + + 'node:vm is not a security sandbox, so an untrusted expression can escape to the host.', + ); + } const outputEnv = evaluateMaestroEvalScript(command.script, state.context.values); state.context.merge(outputEnv); state.executed += 1; diff --git a/packages/maestro/src/internal/support-matrix.ts b/packages/maestro/src/internal/support-matrix.ts index 276167f31..2a1abc146 100644 --- a/packages/maestro/src/internal/support-matrix.ts +++ b/packages/maestro/src/internal/support-matrix.ts @@ -10,7 +10,7 @@ export const MAESTRO_COMPAT_LIMITATIONS = [ 'Expressions: evalScript is the only command whose payload is evaluated as JavaScript (flow env and prior output leaves are string-typed); with that exception, fields stay literal or ${VAR} lookup-only — assertTrue supports literals and bare lookups, repeat.while is unsupported, and other expression-shaped payloads fail loud.', 'Environment: flow env is the default, AD_VAR_* overrides it, and CLI -e KEY=VALUE wins over both.', 'Failure diagnostics: resolved targets and runFlow paths are rendered, while inputText payloads remain hidden; do not place secrets in diagnostic identifiers.', - 'Trust: runScript and evalScript execute trusted flow scripts in-process and are not a security sandbox; runScript may make http.post network requests and its output keys cannot contain a dot.', + 'Trust: runScript and evalScript execute flow scripts in-process via node:vm, which is not a security sandbox; runScript may make http.post network requests and its output keys cannot contain a dot. evalScript is refused outright for a flow accepted over the daemon’s remote HTTP surface, since that context can escape to the host.', 'Errors and tracking: unsupported commands and fields fail with source context when available; open a focused issue only when implementation work is planned.', ] as const; diff --git a/scripts/maestro-conformance/build-manifest.mjs b/scripts/maestro-conformance/build-manifest.mjs index f970ec1c3..6658683f6 100644 --- a/scripts/maestro-conformance/build-manifest.mjs +++ b/scripts/maestro-conformance/build-manifest.mjs @@ -35,7 +35,7 @@ const NOTES = { 'authored/extended-wait': 'Coverage: extendedWaitUntil (upstream 042 interpolates ${TIMEOUT} from a flow env block).', 'authored/repeat': - 'Coverage: repeat.times (upstream 053 also uses the unsupported evalScript command).', + 'Coverage: repeat.times with ${output.list.length} (upstream 053 exercises the same evalScript-to-repeat shape).', 'authored/presskey': 'Coverage: pressKey supported keys (upstream 034 exercises many unsupported keycodes).', 'authored/numeric-variable-tap': diff --git a/scripts/maestro-conformance/corpus/manifest.json b/scripts/maestro-conformance/corpus/manifest.json index 21b4fc793..acc798051 100644 --- a/scripts/maestro-conformance/corpus/manifest.json +++ b/scripts/maestro-conformance/corpus/manifest.json @@ -557,7 +557,7 @@ "file": "authored/repeat.yaml", "origin": { "kind": "authored", - "note": "Coverage: repeat.times (upstream 053 also uses the unsupported evalScript command)." + "note": "Coverage: repeat.times with ${output.list.length} (upstream 053 exercises the same evalScript-to-repeat shape)." } }, { diff --git a/src/daemon/replay/internal/__tests__/session-replay-maestro-remote-evalscript.test.ts b/src/daemon/replay/internal/__tests__/session-replay-maestro-remote-evalscript.test.ts new file mode 100644 index 000000000..ab5b89925 --- /dev/null +++ b/src/daemon/replay/internal/__tests__/session-replay-maestro-remote-evalscript.test.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, test, vi } from 'vitest'; +import { runTypedMaestroReplay } from '../session-replay-maestro-runtime.ts'; +import { SessionStore } from '../../../session-store.ts'; +import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; +import { maestroScriptSourceBundleFor } from '../../../../__tests__/test-utils/replay-script-source.ts'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; +import type { DaemonRequest } from '../../../daemon-request.ts'; +import { createReplaySession } from '../../../handlers/session-replay-command.ts'; +import * as maestro from '@agent-device/maestro'; + +const spy = vi.spyOn(maestro, 'executeMaestroFlow'); + +async function runWithNetworkFlag(publicNetworkOnly: boolean | undefined) { + const root = mkdtempForTestSync('agent-device-maestro-remote-wire-'); + const flowPath = path.join(root, 'flow.yaml'); + fs.writeFileSync( + flowPath, + ['appId: com.example.app', '---', '- evalScript: ${output.sum = 1 + 2}', ''].join('\n'), + ); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + sessionStore.set('default', makeIosSession('default')); + + const req = { + token: 'test-token', + session: 'default', + command: 'replay', + positionals: [flowPath], + flags: { + platform: 'ios', + replayScriptSource: await maestroScriptSourceBundleFor(flowPath), + }, + ...(publicNetworkOnly === true ? { internal: { publicNetworkOnly: true } } : {}), + meta: { requestId: `req-maestro-wire-${publicNetworkOnly}` }, + } as unknown as DaemonRequest; + + spy.mockResolvedValueOnce({ ok: true, replayed: 1, planDigest: 'test', startIndex: 0 } as never); + + const response = await runTypedMaestroReplay({ + request: req, + session: createReplaySession('default', path.join(root, 'daemon.log'), sessionStore), + invoke: async () => ({ ok: true, data: {} }) as never, + }); + + expect(response.ok).toBe(true); + return spy.mock.calls.at(-1)?.[2] as { trustedScripts?: boolean } | undefined; +} + +describe('remote Maestro evalScript trust wiring', () => { + // Proves the remote HTTP surface reaches the engine as trustedScripts:false. + // The engine side (process/fs/child-process/network/budget matrix in + // packages/maestro/src/internal/__tests__/engine.test.ts) proves false refuses + // before vm evaluation — together they prove remote evalScript never runs. + test('remote HTTP (publicNetworkOnly) forwards trustedScripts:false so the engine refuses before vm', async () => { + const options = await runWithNetworkFlag(true); + expect(options?.trustedScripts).toBe(false); + }); + + test('local flow does not forward trustedScripts:false', async () => { + const options = await runWithNetworkFlag(undefined); + expect(options?.trustedScripts).not.toBe(false); + }); +}); diff --git a/src/daemon/replay/internal/session-replay-maestro-runtime.ts b/src/daemon/replay/internal/session-replay-maestro-runtime.ts index a086b502f..9ce42e540 100644 --- a/src/daemon/replay/internal/session-replay-maestro-runtime.ts +++ b/src/daemon/replay/internal/session-replay-maestro-runtime.ts @@ -127,6 +127,9 @@ async function executeTypedMaestroReplay( signal: context.signal, from: req.flags?.replayFrom, planDigest: req.flags?.replayPlanDigest, + // evalScript runs via node:vm, which is not a security sandbox; only trust it + // for flows that did not arrive over the daemon's remote HTTP surface. + trustedScripts: req.internal?.publicNetworkOnly !== true, // #1802: `runFlow` includes resolve out of the caller's bundle, so a local // and a remote run compile the same flow closure. readSource: (includePath) => readReplayScriptSourceFile(bundle, includePath), From 2edf6306d483e66f2369cc608350b42fa4101a2c Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:57:00 +0530 Subject: [PATCH 3/3] fix(maestro): replace output namespace on evalScript commit evalScript flattened the new output object correctly, but merged it over the old variables, so shrunken arrays and deleted leaves survived (e.g. output.list=[1,2,3] then [4] still resolved output.list.2=3). Commit via replaceOutput that clears output/output.* before writing. --- .../src/internal/__tests__/engine.test.ts | 71 +++++++++++++++++++ .../maestro/src/internal/engine-context.ts | 10 +++ .../internal/replay-plan-step-execution.ts | 2 +- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/maestro/src/internal/__tests__/engine.test.ts b/packages/maestro/src/internal/__tests__/engine.test.ts index a70574d62..133457709 100644 --- a/packages/maestro/src/internal/__tests__/engine.test.ts +++ b/packages/maestro/src/internal/__tests__/engine.test.ts @@ -647,6 +647,77 @@ describe('executeMaestroProgram', () => { ).toHaveLength(3); }); + test('evalScript replaces output namespace so shrunken arrays drop stale leaves', async () => { + const texts: string[] = []; + const port = makePort({ + execute: vi.fn(async (request) => { + if (request.command.kind === 'inputText') texts.push(request.command.text); + request.invalidateObservation(); + return {}; + }), + }); + const program = parseMaestroProgram( + [ + '---', + '- evalScript: ${output.list = [1, 2, 3]}', + '- evalScript: ${output.list = [4]}', + '- inputText: ${output.list.0}', + '- inputText: ${output.list.length}', + ].join('\n'), + ); + + await executeMaestroProgram(program, port); + + expect(texts).toEqual(['4', '1']); + const staleProgram = parseMaestroProgram( + [ + '---', + '- evalScript: ${output.list = [1, 2, 3]}', + '- evalScript: ${output.list = [4]}', + '- inputText: ${output.list.2}', + ].join('\n'), + ); + + await expect(executeMaestroProgram(staleProgram, makePort())).rejects.toThrow( + /output\.list\.2.*not defined/i, + ); + }); + + test('evalScript drops deleted output leaves while keeping siblings', async () => { + const texts: string[] = []; + const port = makePort({ + execute: vi.fn(async (request) => { + if (request.command.kind === 'inputText') texts.push(request.command.text); + request.invalidateObservation(); + return {}; + }), + }); + const program = parseMaestroProgram( + [ + '---', + '- evalScript: ${output.keep = 1; output.drop = 2}', + '- evalScript: ${delete output.drop}', + '- inputText: ${output.keep}', + ].join('\n'), + ); + + await executeMaestroProgram(program, port); + + expect(texts).toEqual(['1']); + const staleProgram = parseMaestroProgram( + [ + '---', + '- evalScript: ${output.keep = 1; output.drop = 2}', + '- evalScript: ${delete output.drop}', + '- inputText: ${output.drop}', + ].join('\n'), + ); + + await expect(executeMaestroProgram(staleProgram, makePort())).rejects.toThrow( + /output\.drop.*not defined/i, + ); + }); + test('evalScript reports a failing expression with step source', async () => { const program = parseMaestroProgram(['---', '- evalScript: ${exploded.leaf()}'].join('\n'), { sourcePath: '/flows/eval.yaml', diff --git a/packages/maestro/src/internal/engine-context.ts b/packages/maestro/src/internal/engine-context.ts index 4c5782841..2cd000efe 100644 --- a/packages/maestro/src/internal/engine-context.ts +++ b/packages/maestro/src/internal/engine-context.ts @@ -44,6 +44,16 @@ export function createMaestroExecutionContext( persistentValues = { ...persistentValues, ...output }; cachedValues = undefined; }, + replaceOutput(output: Record): void { + const next: Record = {}; + for (const [key, value] of Object.entries(persistentValues)) { + if (key === 'output' || key.startsWith('output.')) continue; + next[key] = value; + } + Object.assign(next, output); + persistentValues = next; + cachedValues = undefined; + }, recordObservation(next: MaestroObservation): void { if (next.generation !== generation) { throw new AppError( diff --git a/packages/maestro/src/internal/replay-plan-step-execution.ts b/packages/maestro/src/internal/replay-plan-step-execution.ts index 5d2de5b28..2861cc974 100644 --- a/packages/maestro/src/internal/replay-plan-step-execution.ts +++ b/packages/maestro/src/internal/replay-plan-step-execution.ts @@ -120,7 +120,7 @@ async function executeEvalScript( ); } const outputEnv = evaluateMaestroEvalScript(command.script, state.context.values); - state.context.merge(outputEnv); + state.context.replaceOutput(outputEnv); state.executed += 1; return undefined; }