fix(maestro): scope assignment-skipping to wrappers that accept it

`command FOO=1 claude` and `nohup FOO=1 claude` are invalid shells (they
exec `FOO=1` as the program, exit 127), but the resolver skipped the
assignment unconditionally after any wrapper. Assignment prefixes are
only real at the start of a simple command or after a wrapper that itself
interprets them as env vars (env, sudo) or a keyword that restarts the
assignment grammar (time) — not after command, nohup, or a package
manager's subcommand, which just exec their next argument as the program.

Attach an accepts_assignment flag to each wrapper transition in both the
Rust and TS resolvers and gate the assignment-skip loop on the flag from
the most recently consumed wrapper. Extends the shared fixture with the
two reported cases plus every other wrapper transition and several nested
combinations (sudo+time valid, sudo+command / command+nohup / time+command
invalid, nohup+env valid) to lock the per-wrapper policy in for both
languages.
This commit is contained in:
Miguel Palhas
2026-09-05 14:25:57 +01:00
parent 758bacf3fc
commit 4c8217385a
3 changed files with 136 additions and 44 deletions
+64 -20
View File
@@ -105,24 +105,33 @@ function isEnvAssignment(word: Word): boolean {
}
/** Walk `words` past leading `FOO=bar` assignments and known wrapper forms
* (interleaved in any order/count) to the index of the actual program. */
* (interleaved in any order/count) to the index of the actual program. An
* assignment run is only consumed at the very start of the line or right
* after a wrapper whose own semantics accept one there ({@link Wrapper.acceptsAssignment}) —
* `command FOO=1 claude` and `nohup FOO=1 claude` stop at `FOO=1` rather than
* skipping to `claude`, since `command`/`nohup` just exec their next argument
* as the program, they don't setenv from an assignment-shaped one (#199). */
function programIndex(words: Word[]): number {
const texts = words.map((w) => w.text);
let i = 0;
while (i < words.length) {
if (isEnvAssignment(words[i])) {
i += 1;
continue;
let assignmentAllowed = true;
for (;;) {
if (assignmentAllowed) {
while (i < words.length && isEnvAssignment(words[i])) {
i += 1;
}
}
if (i >= words.length) break;
const wrapper = basename(texts[i]).toLowerCase();
const resolver = WRAPPER_RESOLVERS[wrapper];
if (!resolver) break;
const next = resolver(texts, i);
const w = WRAPPER_RESOLVERS[wrapper];
if (!w) break;
const next = w.resolve(texts, i);
// Not a form of the wrapper we recognise (an unknown option, or — for
// `pnpm`/`yarn`/`bun` — no `dlx`/`x` subcommand behind it): the wrapper
// word itself is the program, not a launcher.
if (next === null || next <= i) break;
i = next;
assignmentAllowed = w.acceptsAssignment;
}
return i;
}
@@ -433,18 +442,53 @@ function resolveBun(words: string[], i: number): number | null {
* isn't a form we resolve through. */
type WrapperResolver = (words: string[], i: number) => number | null;
const WRAPPER_RESOLVERS: Record<string, WrapperResolver> = {
env: (words, i) => consumeOptions(ENV_OPTS, words, i + 1),
sudo: (words, i) => consumeOptions(SUDO_OPTS, words, i + 1),
command: (words, i) => consumeOptions(COMMAND_BUILTIN_OPTS, words, i + 1),
nohup: (words, i) => consumeOptions(NO_OPTIONS, words, i + 1),
time: resolveTime,
npx: (words, i) => consumeOptions(NPX_OPTS, words, i + 1),
pnpm: (words, i) => resolveDlx(PNPM_OPTS, PNPM_DLX_OPTS, words, i),
yarn: (words, i) => resolveDlx(YARN_OPTS, YARN_DLX_OPTS, words, i),
bun: resolveBun,
bunx: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
uvx: (_words, i) => i + 1,
/** One recognised wrapper form: how to walk past it, and whether an
* assignment word is a valid follow-on once it's been walked past. */
interface Wrapper {
resolve: WrapperResolver;
/** `true` only for wrappers that themselves interpret a following
* `FOO=bar` as an environment assignment for the command they launch
* (`env`, `sudo`), or a shell keyword that restarts the assignment-prefix
* grammar for a brand new simple command (`time`). Every other wrapper here
* is a plain executable that execs its next argument as the program
* outright — an assignment-shaped one names a program literally called
* `FOO=1`, not an env var (#199). */
acceptsAssignment: boolean;
}
const WRAPPER_RESOLVERS: Record<string, Wrapper> = {
env: { resolve: (words, i) => consumeOptions(ENV_OPTS, words, i + 1), acceptsAssignment: true },
sudo: {
resolve: (words, i) => consumeOptions(SUDO_OPTS, words, i + 1),
acceptsAssignment: true,
},
command: {
resolve: (words, i) => consumeOptions(COMMAND_BUILTIN_OPTS, words, i + 1),
acceptsAssignment: false,
},
nohup: {
resolve: (words, i) => consumeOptions(NO_OPTIONS, words, i + 1),
acceptsAssignment: false,
},
time: { resolve: resolveTime, acceptsAssignment: true },
npx: {
resolve: (words, i) => consumeOptions(NPX_OPTS, words, i + 1),
acceptsAssignment: false,
},
pnpm: {
resolve: (words, i) => resolveDlx(PNPM_OPTS, PNPM_DLX_OPTS, words, i),
acceptsAssignment: false,
},
yarn: {
resolve: (words, i) => resolveDlx(YARN_OPTS, YARN_DLX_OPTS, words, i),
acceptsAssignment: false,
},
bun: { resolve: resolveBun, acceptsAssignment: false },
bunx: {
resolve: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
acceptsAssignment: false,
},
uvx: { resolve: (_words, i) => i + 1, acceptsAssignment: false },
};
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */