Skip to content
Open
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
18 changes: 14 additions & 4 deletions docs/adr/0015-direct-maestro-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,20 @@ 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.
`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.
Expand Down
58 changes: 58 additions & 0 deletions packages/maestro/src/internal/__tests__/engine-eval-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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('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}', {}),
{ 'output.b': '2' },
);
});

test('rejects a failing expression with a wrapped error', () => {
assert.throws(
() => evaluateMaestroEvalScript('${exploded.leaf()}', {}),
/Maestro evalScript failed/,
);
});
});
157 changes: 157 additions & 0 deletions packages/maestro/src/internal/__tests__/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,163 @@ 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(
vi.mocked(port.execute).mock.calls.filter(([request]) => request.command.kind === 'tapOn'),
).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',
});

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 () => {
const loadProgram = vi.fn();
const program = parseMaestroProgram('---\n- runFlow: ./main.yaml\n', {
Expand Down
13 changes: 13 additions & 0 deletions packages/maestro/src/internal/__tests__/program-ir-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,19 @@ 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(
() =>
Expand Down
30 changes: 30 additions & 0 deletions packages/maestro/src/internal/__tests__/replay-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/maestro/src/internal/conformance-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) };
}
Expand Down Expand Up @@ -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)}`);
Expand Down
10 changes: 10 additions & 0 deletions packages/maestro/src/internal/engine-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ export function createMaestroExecutionContext(
persistentValues = { ...persistentValues, ...output };
cachedValues = undefined;
},
replaceOutput(output: Record<string, string>): void {
const next: Record<string, string> = {};
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(
Expand Down
Loading
Loading