Files
callstack__agent-device/scripts/help-conformance-plan-validator.mjs
Michał Pierzchała 9c25bc66f4 docs(cli): advertise open --foreground and snapshot --actions in the workflow card (#1682)
* docs(cli): advertise open --foreground and snapshot --actions in the workflow card

open --foreground (#1670/#1671) and snapshot -i --actions (#1665) shipped
with no mention in the compact `help workflow` card, so a planning model
never discovers either. Add one terse line each: the foreground fast-path
in Bootstrap, and the merged-element custom-action guidance in Validation
and evidence. Stays under the 9,000-byte compact-card budget (8493 -> 8908
bytes).

Adds two help-conformance bench cases per the repo's changed-guidance rule:
foreground-attach-single-sim (correct plan starts with `open --foreground`
in an unambiguous single-sim scenario, fail-closed alternative forbidden)
and merged-card-actions-not-directly-invokable (a merged Bluesky-style feed
card's actions list is evidence, not a selector). Both use a real pinned
sample rebuilt through the production snapshot renderer.

* fix(scripts): accept flag order in the foreground-attach conformance matcher

Flag order after `open` isn't semantically meaningful (`open --platform ios
--foreground` is exactly as correct as `open --foreground --platform ios`),
but startsWithForegroundOpen required --foreground to be the literal next
token after `open`. Rescoring the completed repeat=3 bench report shows this
docked codex:gpt-5.4-mini on all 3 trials even though its plan was
config-order noise, not a real deviation -- the no-positional/no-device
guarantee already comes from the forbidden checks. Loosened to require
--foreground anywhere on the open line; foreground-attach-single-sim now
scores 54/54 across both runners.

* fix: close workflow help conformance gaps
2026-08-08 10:35:12 +02:00

303 lines
10 KiB
JavaScript

import { execFile } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const COMMAND_VALIDATOR = join(ROOT, 'scripts', 'help-conformance-command-validator.ts');
const ALLOWED_PNPM_SCRIPTS = new Set(['build', 'build:android', 'build:xcuitest', 'clean:daemon']);
export async function validatePlanCommands(commands, options = {}) {
const parsedCommands = commands.flatMap((command) => parseCommandLine(command));
const agentCommands = parsedCommands.filter(
({ tokens, issues }) => issues.length === 0 && tokens[0] === 'agent-device',
);
const agentResults = await validateAgentCommands(
agentCommands.map(({ tokens }) => tokens.slice(1)),
);
const agentResultState = { results: agentResults, index: 0 };
const allowedExternalCommands = options.allowedExternalCommands ?? [];
return parsedCommands.map((parsed) =>
applyCommandPolicy(parsed, agentResultState, allowedExternalCommands),
);
}
function applyCommandPolicy(parsed, agentResultState, allowedExternalCommands) {
if (parsed.issues.length > 0) return parsed;
if (parsed.tokens[0] !== 'agent-device') {
return validateExternalCommand(parsed, allowedExternalCommands);
}
const result = agentResultState.results[agentResultState.index++];
return result?.valid
? attachAgentCommand(parsed, result.agentCommand)
: withIssue(
parsed,
result?.kind ?? 'agent-device-grammar',
result?.error ?? 'Command validation returned no result.',
);
}
function attachAgentCommand(parsed, agentCommand) {
return agentCommand ? { ...parsed, agentCommand } : parsed;
}
// The compact workflow card teaches chaining confident consecutive steps with
// an unquoted `&&` (`press ... --settle && fill ... --settle`). Split on it
// before tokenizing a line so each chained segment is validated as its own
// full agent-device invocation, rather than the whole line failing as one
// shell-projection violation. A `&&` inside a quoted selector value (for
// example label="A && B") is not a chain boundary and must not split.
//
// A real shell rejects `&&` with an empty operand on either side (leading
// `&& foo`, trailing `foo &&`, or doubled `foo && && bar`): each is a syntax
// error, not two commands. The splitter below produces an empty segment for
// exactly those shapes, so parseChainSegment turns an empty (post-trim)
// segment into a validation issue instead of silently dropping it — a plan
// with one of these shapes must not be blessed by validPlanCommands when it
// would fail at execution.
function parseCommandLine(command) {
return splitOnUnquotedAnd(command).map((segment) => parseChainSegment(segment));
}
function parseChainSegment(segment) {
if (segment.trim().length > 0) return parsePlanCommand(segment.trim());
return {
command: segment,
tokens: [],
issues: [
{
kind: 'empty-chain-operand',
error:
'A && chain must have a non-empty command on both sides (no leading, trailing, or doubled &&).',
},
],
};
}
function splitOnUnquotedAnd(command) {
const state = { segments: [], current: '', quote: undefined };
for (let index = 0; index < command.length; index += 1) {
index = consumeSplitCharacter(command, index, state);
}
state.segments.push(state.current);
return state.segments;
}
function consumeSplitCharacter(command, index, state) {
const character = command[index];
if (state.quote) return consumeQuotedSplitCharacter(command, index, character, state);
if (character === "'" || character === '"') {
state.quote = character;
state.current += character;
return index;
}
if (character === '&' && command[index + 1] === '&') {
state.segments.push(state.current);
state.current = '';
return index + 1;
}
state.current += character;
return index;
}
function consumeQuotedSplitCharacter(command, index, character, state) {
if (character === '\\' && state.quote === '"' && index + 1 < command.length) {
state.current += character + command[index + 1];
return index + 1;
}
state.current += character;
if (character === state.quote) state.quote = undefined;
return index;
}
function parsePlanCommand(command) {
const tokenized = tokenize(command);
const issues = [];
if (/@<[^>]+>/.test(command)) {
issues.push({
kind: 'pseudo-ref',
error: 'Placeholder refs such as @<ref> are not runnable observed refs.',
});
}
if (tokenized.issue) issues.push(tokenized.issue);
return { command, tokens: tokenized.tokens, issues };
}
function tokenize(command) {
const state = { tokens: [], current: '', quote: undefined, started: false };
for (let index = 0; index < command.length; index += 1) {
const consumed = consumeCharacter(command, index, state);
if (consumed.issue) return { tokens: state.tokens, issue: consumed.issue };
index = consumed.nextIndex;
}
return finalizeTokens(state);
}
function consumeCharacter(command, index, state) {
const character = command[index];
if (state.quote) return consumeQuotedCharacter(command, index, character, state);
return consumeUnquotedCharacter(command, index, character, state);
}
function consumeUnquotedCharacter(command, index, character, state) {
const boundary = consumeUnquotedBoundary(character, index, state);
if (boundary) return boundary;
if (character === '#' && !state.started) return { nextIndex: command.length };
const escape = consumeUnquotedEscape(command, index, character, state);
if (escape) return escape;
const operator = shellOperatorAt(command, index);
if (operator) {
return shellProjectionIssue(
index,
`Unquoted shell operator "${operator}" is not allowed in a command plan.`,
);
}
appendCharacter(state, character);
return { nextIndex: index };
}
function consumeUnquotedBoundary(character, index, state) {
if (character === "'" || character === '"') {
state.quote = character;
state.started = true;
return { nextIndex: index };
}
if (character === '\r' || character === '\n') {
return shellProjectionIssue(index, 'Unquoted line breaks are not allowed in a command plan.');
}
if (/\s/.test(character)) {
flushToken(state);
return { nextIndex: index };
}
return undefined;
}
function consumeUnquotedEscape(command, index, character, state) {
if (character !== '\\' || index + 1 >= command.length) return undefined;
const escaped = command[index + 1];
if (escaped === '\r' || escaped === '\n') {
return shellProjectionIssue(index, 'Unquoted line breaks are not allowed in a command plan.');
}
appendCharacter(state, escaped);
return { nextIndex: index + 1 };
}
function shellProjectionIssue(nextIndex, error) {
return {
nextIndex,
issue: {
kind: 'shell-projection',
error,
},
};
}
function consumeQuotedCharacter(command, index, character, state) {
if (character === state.quote) {
state.quote = undefined;
state.started = true;
return { nextIndex: index };
}
if (character === '\\' && state.quote === '"' && index + 1 < command.length) {
appendCharacter(state, command[index + 1]);
return { nextIndex: index + 1 };
}
appendCharacter(state, character);
return { nextIndex: index };
}
function appendCharacter(state, character) {
state.current += character;
state.started = true;
}
function flushToken(state) {
if (!state.started) return;
state.tokens.push(state.current);
state.current = '';
state.started = false;
}
function finalizeTokens(state) {
if (state.quote) {
return {
tokens: state.tokens,
issue: { kind: 'shell-projection', error: `Unclosed ${state.quote} quote.` },
};
}
flushToken(state);
return state.tokens.length > 0
? { tokens: state.tokens }
: {
tokens: state.tokens,
issue: { kind: 'command-shape', error: 'Command line is empty.' },
};
}
function shellOperatorAt(command, index) {
const character = command[index];
if (character === '`' || '|;<>'.includes(character)) return character;
if (character === '&') return command[index + 1] === '&' ? '&&' : '&';
if (character === '$' && command[index + 1] === '(') return '$(';
return undefined;
}
function validateExternalCommand(parsed, allowedExternalCommands) {
const executable = parsed.tokens[0];
if (!allowedExternalCommands.includes(executable)) {
return withIssue(
parsed,
'executable-policy',
`Executable "${executable}" is not permitted for this case.`,
);
}
if (executable === 'mkdir') {
return parsed.tokens.length >= 3 && parsed.tokens[1] === '-p'
? parsed
: withIssue(parsed, 'external-command-grammar', 'Only mkdir -p <path> is permitted.');
}
if (executable === 'pnpm') {
return validatePnpmCommand(parsed);
}
return withIssue(
parsed,
'external-command-grammar',
`No command grammar is registered for "${executable}".`,
);
}
function validatePnpmCommand(parsed) {
const args = parsed.tokens[1] === 'run' ? parsed.tokens.slice(2) : parsed.tokens.slice(1);
const [script, ...scriptArgs] = args;
if (!ALLOWED_PNPM_SCRIPTS.has(script)) {
return withIssue(
parsed,
'external-command-grammar',
`Permitted pnpm scripts: ${[...ALLOWED_PNPM_SCRIPTS].join(', ')}.`,
);
}
const validArgs =
scriptArgs.length === 0 ||
(script === 'clean:daemon' && scriptArgs.length === 1 && scriptArgs[0] === '--prune-dev');
return validArgs
? parsed
: withIssue(parsed, 'external-command-grammar', `Unsupported arguments for pnpm ${script}.`);
}
async function validateAgentCommands(argvs) {
if (argvs.length === 0) return [];
const { stdout } = await execFileAsync(
process.execPath,
['--experimental-strip-types', COMMAND_VALIDATOR, JSON.stringify(argvs)],
{ maxBuffer: 1024 * 1024, timeout: 10_000 },
);
const parsed = JSON.parse(stdout);
if (!Array.isArray(parsed)) throw new Error('Command validator returned a non-array result.');
return parsed;
}
function withIssue(parsed, kind, error) {
return { ...parsed, issues: [...parsed.issues, { kind, error }] };
}