mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
a158434a9c
* feat(cli): compact workflow help card + version header Shrinks the per-task agent protocol tax of the help/skill surface. `agent-device help workflow` drops from 41025 to 8466 bytes (-79%) by moving depth into new `help scripting` (save-script, secret-safe fills, batch JSON, replay divergence/repair) and `help gestures` (multi-touch shapes/quirks) topics, and folding a few paragraphs into topics that already owned the subject (help debugging, help physical-device, help validate). Content is moved, not deleted. Every `help <topic>` first line is now `agent-device <version> — <topic>`, so the skill router reads the CLI version off the mandatory first help read instead of a separate `agent-device --version` call. SKILL.md is updated to do that and stays a thin router otherwise. The compact card also gains two terse behavioral rules: chain confident consecutive steps with `&&` (falling back to one command at a time when uncertain), and confirm the requested end state is actually visible on screen before declaring a task done. help-conformance-bench (22 cases x 2 runners) improves after the change: 29/44 -> 32/44 passing checks. * fix(cli): review follow-ups on the compact workflow card (#1663) Three fixes from PR review: - Extend the help-conformance plan validator to split a command line on unquoted && and validate each chained segment independently, so a plan that follows the workflow card's "chain confident consecutive steps with &&" guidance is accepted instead of rejected as one shell-projection violation. && inside a quoted selector value (e.g. label="A && B") is not a chain boundary and does not split. Adds unit tests for the splitter and a chains-confident-consecutive- settle-steps conformance case. batch stays out of this: it is deliberately stop-only. - Replace the literal @ref placeholder the compact card used in its own "snapshot -s @ref" example with a concrete ref (snapshot -s @e12 (the current concrete ref)), matching the same card's rule against placeholder targets. Reverts the test to demand the concrete shape. - Give help scripting and help gestures real conformance cases instead of waivers: a secret-safe recorded-fill + publish case, and an Android transform-then-verify case whose exact verification text only appears in the gestures topic. Removes both waivers. help-conformance-bench (25 cases x 2 runners, repeat=1) after these fixes: two full runs landed at 32/50 and 33/50. That is on par with the pre-change baseline (29/44) once the topic-untouched cases' run-to-run swings are accounted for (confirmed noise: one case with zero exposure to any change here flipped 10/10 -> 1/10 on a runner API error, and another swung across all three post-fix runs). The new scripting case now passes 8/8 for both runners; the new chaining case correctly reports the model's choice not to chain as a soft signal, not a validator failure. * fix(cli): update session.test.ts help pointer for moved script-authoring content * fix(cli): reject empty && chain operands in the plan validator (#1663) splitOnUnquotedAnd() previously trimmed and filtered out empty segments, so a plan with a leading (`&& press ...`), trailing (`press ... &&`), or doubled (`a && && b`) operator passed validPlanCommands even though a real shell rejects all three as a syntax error. The validator would bless a plan that fails at execution. Empty segments are now surfaced as an `empty-chain-operand` issue instead of being silently dropped. The quoted-&& non-split behavior (label="A && B") is unchanged, and a normal single command with no chain still parses identically to before. Adds regression tests for all three empty-operand shapes plus the quoted-&& case.
299 lines
9.9 KiB
JavaScript
299 lines
9.9 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
|
|
? parsed
|
|
: withIssue(
|
|
parsed,
|
|
result?.kind ?? 'agent-device-grammar',
|
|
result?.error ?? 'Command validation returned no result.',
|
|
);
|
|
}
|
|
|
|
// 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 }] };
|
|
}
|