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
46 changes: 45 additions & 1 deletion src/lib/harness/shellIntent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,54 @@ describe("formatShellIntent", () => {
});

describe("unwrapShellCommand", () => {
it("removes the shell transport wrapper without changing ordinary commands", () => {
it("unwraps POSIX shells without including trailing shell arguments", () => {
expect(unwrapShellCommand(`/bin/zsh -lc "npm test -- --run app.test.ts"`)).toBe(
"npm test -- --run app.test.ts",
);
expect(unwrapShellCommand(`/bin/zsh -lc "rg -n \\"foo\\" src" ignored`)).toBe(
'rg -n "foo" src',
);
});

it("unwraps PowerShell command remainders", () => {
expect(
unwrapShellCommand(
`"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command 'rg -n foo src'`,
),
).toBe("rg -n foo src");
expect(
unwrapShellCommand(
"powershell.exe -ExecutionPolicy Bypass -Command Get-Content package.json",
),
).toBe("Get-Content package.json");
expect(unwrapShellCommand("pwsh -c Get-Content package.json")).toBe(
"Get-Content package.json",
);
});

it("unwraps cmd command remainders", () => {
expect(unwrapShellCommand(`cmd.exe /d /s /c "npm test"`)).toBe("npm test");
});

it("stops scanning PowerShell launcher options at -File", () => {
expect(unwrapShellCommand(`pwsh -File script.ps1 -Mode -Command build`)).toBe(
`pwsh -File script.ps1 -Mode -Command build`,
);
expect(unwrapShellCommand(`pwsh -f script.ps1 -Mode -c build`)).toBe(
`pwsh -f script.ps1 -Mode -c build`,
);
});

it("leaves ordinary and incomplete commands unchanged", () => {
expect(unwrapShellCommand("git status --short")).toBe("git status --short");
expect(unwrapShellCommand(`pwsh -File '-Command' script.ps1`)).toBe(
`pwsh -File '-Command' script.ps1`,
);
expect(unwrapShellCommand(`pwsh -Command 'npm test`)).toBe(
`pwsh -Command 'npm test`,
);
expect(unwrapShellCommand(`cmd.exe /c "npm test`)).toBe(
`cmd.exe /c "npm test`,
);
});
});
97 changes: 80 additions & 17 deletions src/lib/harness/shellIntent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ export function inferShellIntent(command: string): ShellIntent | undefined {
const stages = splitTopLevel(chain, pipeSep);
let pipeIntent: ShellIntent | undefined;
for (const stage of stages) {
const argv = tokenize(stage);
if (!argv || argv.length === 0) return undefined;
const tokens = tokenize(stage);
if (!tokens || tokens.length === 0) return undefined;
const argv = tokens.map(({ value }) => value);
const classified = classifyArgv(argv);
if (classified === "opaque") return undefined;
if (classified === "noise") continue;
Expand All @@ -54,30 +55,78 @@ export function inferShellIntent(command: string): ShellIntent | undefined {

/**
* Codex may expose a command through the argv used to launch the user's shell,
* for example `/bin/zsh -lc "cat package.json"`. The launcher is transport
* for example `/bin/zsh -lc "cat package.json"` or
* `pwsh.exe -Command "Get-Content package.json"`. The launcher is transport
* noise for this visual-only classifier; inspect the script it was given.
*/
export function unwrapShellCommand(command: string): string {
let current = command.trim();
for (let depth = 0; depth < 2; depth += 1) {
const argv = tokenize(current);
if (!argv || argv.length < 3 || !SHELL_LAUNCHERS.has(binName(argv[0]))) {
break;
}
const scriptIndex = argv.findIndex(
(arg, index) => index > 0 && isCommandFlag(arg),
const tokens = tokenize(current);
if (!tokens || tokens.length < 3) break;
const executable = trimMatchingOuterQuotes(
current.slice(tokens[0].start, tokens[0].end),
);
const wrapper = SHELL_WRAPPERS.find(({ executables }) =>
executables.has(binName(executable)),
);
const script = scriptIndex >= 0 ? argv[scriptIndex + 1]?.trim() : undefined;
if (!wrapper) break;
let flagIndex = -1;
for (let index = 1; index < tokens.length; index += 1) {
const token = tokens[index];
if (token.quoted) continue;
if (
"optionBoundary" in wrapper &&
wrapper.optionBoundary.test(token.value)
) {
break;
}
if (wrapper.commandFlag.test(token.value)) {
flagIndex = index;
break;
}
}
if (flagIndex < 0) break;
const commandToken = tokens[flagIndex + 1];
if (!commandToken) break;
const remainder = current.slice(commandToken.start).trim();
const script = wrapper.consumeRemainder
? trimMatchingOuterQuotes(remainder)
: commandToken.value.trim();
if (!script || script === current) break;
current = script;
}
return current;
}

const SHELL_LAUNCHERS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);

function isCommandFlag(arg: string): boolean {
return arg === "--command" || /^-[A-Za-z]*c[A-Za-z]*$/.test(arg);
const SHELL_WRAPPERS = [
{
executables: new Set(["sh", "bash", "zsh", "dash", "ksh"]),
commandFlag: /^(?:--command|-[a-z]*c[a-z]*)$/i,
consumeRemainder: false,
},
{
executables: new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]),
commandFlag: /^-(?:command|c)$/i,
optionBoundary: /^-(?:file|f)$/i,
consumeRemainder: true,
},
{
executables: new Set(["cmd", "cmd.exe"]),
commandFlag: /^\/c$/i,
consumeRemainder: true,
},
] as const;

function trimMatchingOuterQuotes(value: string): string {
if (
value.length >= 2 &&
((value[0] === '"' && value[value.length - 1] === '"') ||
(value[0] === "'" && value[value.length - 1] === "'"))
) {
return value.slice(1, -1).trim();
}
return value;
}

export function formatShellIntent(
Expand Down Expand Up @@ -555,8 +604,15 @@ function splitTopLevel(
return parts;
}

function tokenize(stage: string): string[] | null {
const tokens: string[] = [];
type ShellToken = {
value: string;
start: number;
end: number;
quoted: boolean;
};

function tokenize(stage: string): ShellToken[] | null {
const tokens: ShellToken[] = [];
let i = 0;
while (i < stage.length) {
// Treat redirects (`2>&1`) as separators so `&` cannot stall the scan.
Expand Down Expand Up @@ -597,7 +653,14 @@ function tokenize(stage: string): string[] | null {
token += c;
i += 1;
}
if (token) tokens.push(token);
if (token) {
tokens.push({
value: token,
start,
end: i,
quoted: stage[start] === "'" || stage[start] === '"',
});
}
if (i <= start) i += 1;
}
return tokens;
Expand Down
Loading