Skip to content

Commit de47989

Browse files
authored
Recognize PowerShell -ec inline commands [AI] (#80893)
* fix: recognize PowerShell -ec inline command flag * fix: recognize PowerShell -ec inline command flag * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing codex review * fix: recognize PowerShell encoded command prefixes * addressing review-skill * addressing review-skill * addressing codex review * addressing codex review * docs: add changelog entry for PR merge
1 parent 6e498a1 commit de47989

8 files changed

Lines changed: 340 additions & 13 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Fixes
88

9+
- Recognize PowerShell -ec inline commands [AI]. (#80893) Thanks @pgondhi987.
910
- fix(qqbot): authorize approval button callbacks [AI]. (#80892) Thanks @pgondhi987.
1011
- Scrub streamable MCP redirect headers [AI]. (#80906) Thanks @pgondhi987.
1112
- fix(memory-wiki): require admin scope for ingest [AI]. (#80897) Thanks @pgondhi987.

src/infra/exec-wrapper-resolution.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,70 @@ describe("extractShellWrapperCommand", () => {
483483
expectedInline: "Get-Date",
484484
expectedCommand: { isWrapper: true, command: "Get-Date" },
485485
},
486+
{
487+
argv: ["pwsh", "-ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
488+
expectedInline: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
489+
expectedCommand: {
490+
isWrapper: true,
491+
command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
492+
},
493+
},
494+
{
495+
argv: ["pwsh", "-en", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
496+
expectedInline: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
497+
expectedCommand: {
498+
isWrapper: true,
499+
command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
500+
},
501+
},
502+
{
503+
argv: ["pwsh", "/NoProfile", "/ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
504+
expectedInline: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
505+
expectedCommand: {
506+
isWrapper: true,
507+
command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
508+
},
509+
},
510+
{
511+
argv: [
512+
"pwsh",
513+
"-WorkingDir",
514+
"/tmp/project",
515+
"/ec",
516+
"VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
517+
],
518+
expectedInline: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
519+
expectedCommand: {
520+
isWrapper: true,
521+
command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
522+
},
523+
},
524+
{
525+
argv: ["pwsh", "-if", "XML", "-EncodedCommand", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
526+
expectedInline: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
527+
expectedCommand: {
528+
isWrapper: true,
529+
command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
530+
},
531+
},
532+
{
533+
argv: ["pwsh", "-config", "SomeConfig", "-ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
534+
expectedInline: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
535+
expectedCommand: {
536+
isWrapper: true,
537+
command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
538+
},
539+
},
540+
{
541+
argv: ["pwsh", "-cwa", "Write-Output", "hi"],
542+
expectedInline: "Write-Output hi",
543+
expectedCommand: { isWrapper: true, command: "Write-Output hi" },
544+
},
545+
{
546+
argv: ["pwsh", "script.ps1", "-en", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
547+
expectedInline: null,
548+
expectedCommand: { isWrapper: false, command: null },
549+
},
486550
{
487551
argv: ["bash", "script.sh"],
488552
expectedInline: null,

src/infra/shell-inline-command.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
POSIX_INLINE_COMMAND_FLAGS,
44
POWERSHELL_INLINE_COMMAND_FLAGS,
55
resolveInlineCommandMatch,
6+
resolvePowerShellInlineCommandMatch,
67
} from "./shell-inline-command.js";
78

89
describe("resolveInlineCommandMatch", () => {
@@ -31,6 +32,30 @@ describe("resolveInlineCommandMatch", () => {
3132
flags: POWERSHELL_INLINE_COMMAND_FLAGS,
3233
expected: { command: "script.ps1", valueTokenIndex: 2 },
3334
},
35+
{
36+
name: "extracts the next token for PowerShell -ec",
37+
argv: ["pwsh", "-ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
38+
flags: POWERSHELL_INLINE_COMMAND_FLAGS,
39+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 2 },
40+
},
41+
{
42+
name: "extracts the next token for PowerShell -EC",
43+
argv: ["pwsh", "-EC", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
44+
flags: POWERSHELL_INLINE_COMMAND_FLAGS,
45+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 2 },
46+
},
47+
{
48+
name: "extracts the next token for PowerShell encoded-command prefixes",
49+
argv: ["pwsh", "-en", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
50+
flags: POWERSHELL_INLINE_COMMAND_FLAGS,
51+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 2 },
52+
},
53+
{
54+
name: "extracts the next token for PowerShell slash switch forms",
55+
argv: ["pwsh", "/ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
56+
flags: POWERSHELL_INLINE_COMMAND_FLAGS,
57+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 2 },
58+
},
3459
{
3560
name: "supports combined -c forms when enabled",
3661
argv: ["sh", "-cecho hi"],
@@ -83,4 +108,81 @@ describe("resolveInlineCommandMatch", () => {
83108
valueTokenIndex: null,
84109
});
85110
});
111+
112+
it.each([
113+
{
114+
name: "stops at an omitted PowerShell script file before script args",
115+
argv: ["pwsh", "script.ps1", "-en", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
116+
expected: { command: null, valueTokenIndex: null },
117+
},
118+
{
119+
name: "skips PowerShell option values before encoded-command prefixes",
120+
argv: [
121+
"pwsh",
122+
"-WorkingDirectory",
123+
"/tmp/project",
124+
"-en",
125+
"VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
126+
],
127+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 4 },
128+
},
129+
{
130+
name: "skips abbreviated PowerShell window style values before slash encoded command",
131+
argv: ["pwsh", "-win", "hidden", "/ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
132+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 4 },
133+
},
134+
{
135+
name: "skips abbreviated PowerShell working directory values before slash encoded command",
136+
argv: [
137+
"pwsh",
138+
"-WorkingDir",
139+
"/tmp/project",
140+
"/ec",
141+
"VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA",
142+
],
143+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 4 },
144+
},
145+
{
146+
name: "skips PowerShell input format alias values before encoded-command prefixes",
147+
argv: ["pwsh", "-if", "XML", "-EncodedCommand", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
148+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 4 },
149+
},
150+
{
151+
name: "skips slash PowerShell input format alias values before slash encoded command",
152+
argv: ["pwsh", "/if", "XML", "/ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
153+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 4 },
154+
},
155+
{
156+
name: "skips PowerShell configuration name values before encoded-command prefixes",
157+
argv: ["pwsh", "-config", "SomeConfig", "-ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
158+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 4 },
159+
},
160+
{
161+
name: "skips PowerShell custom pipe name values before encoded-command prefixes",
162+
argv: ["pwsh", "-cus", "pipe-name", "-ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
163+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 4 },
164+
},
165+
{
166+
name: "extracts the command tail for PowerShell command-with-args",
167+
argv: ["pwsh", "-cwa", "Write-Output", "hi"],
168+
expected: { command: "Write-Output hi", valueTokenIndex: 2 },
169+
},
170+
{
171+
name: "extracts PowerShell slash switch forms before script file binding",
172+
argv: ["pwsh", "/ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
173+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 2 },
174+
},
175+
{
176+
name: "keeps scanning after PowerShell slash switches",
177+
argv: ["pwsh", "/NoProfile", "/ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
178+
expected: { command: "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA", valueTokenIndex: 3 },
179+
},
180+
{
181+
name: "stops at slash paths before PowerShell script args",
182+
argv: ["/usr/bin/pwsh", "/tmp/script.ps1", "/ec", "VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABoAGkA"],
183+
expected: { command: null, valueTokenIndex: null },
184+
},
185+
])("$name", ({ argv, expected }) => {
186+
expect(resolvePowerShellInlineCommandMatch(argv)).toEqual(expected);
187+
});
86188
});

src/infra/shell-inline-command.ts

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,50 @@
11
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
22

33
export const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]);
4+
5+
function expandPowerShellSwitchPrefixForms(match: string, smallestMatch: string): string[] {
6+
const forms: string[] = [];
7+
for (let length = smallestMatch.length; length <= match.length; length += 1) {
8+
const prefix = match.slice(0, length);
9+
forms.push(`-${prefix}`, `--${prefix}`, `/${prefix}`);
10+
}
11+
return forms;
12+
}
13+
414
export const POWERSHELL_INLINE_COMMAND_FLAGS = new Set([
5-
"-c",
6-
"-command",
7-
"--command",
8-
"-f",
9-
"-file",
10-
"-encodedcommand",
11-
"-enc",
12-
"-e",
15+
...expandPowerShellSwitchPrefixForms("command", "c"),
16+
...expandPowerShellSwitchPrefixForms("commandwithargs", "cwa"),
17+
...expandPowerShellSwitchForms(["cwa"]),
18+
...expandPowerShellSwitchPrefixForms("file", "f"),
19+
...expandPowerShellSwitchPrefixForms("encodedcommand", "e"),
20+
...expandPowerShellSwitchPrefixForms("ec", "e"),
21+
]);
22+
23+
const POWERSHELL_INLINE_REST_COMMAND_FLAGS = new Set([
24+
...expandPowerShellSwitchPrefixForms("commandwithargs", "cwa"),
25+
...expandPowerShellSwitchForms(["cwa"]),
26+
]);
27+
28+
function expandPowerShellSwitchForms(names: readonly string[]): string[] {
29+
return names.flatMap((name) => [`-${name}`, `--${name}`, `/${name}`]);
30+
}
31+
32+
const POWERSHELL_OPTIONS_WITH_SEPARATE_VALUES = new Set([
33+
...expandPowerShellSwitchPrefixForms("configurationfile", "conf"),
34+
...expandPowerShellSwitchPrefixForms("configurationname", "config"),
35+
...expandPowerShellSwitchPrefixForms("custompipename", "cus"),
36+
...expandPowerShellSwitchPrefixForms("encodedarguments", "encodeda"),
37+
...expandPowerShellSwitchPrefixForms("executionpolicy", "ex"),
38+
...expandPowerShellSwitchPrefixForms("inputformat", "inp"),
39+
...expandPowerShellSwitchPrefixForms("outputformat", "o"),
40+
...expandPowerShellSwitchPrefixForms("psconsolefile", "pscf"),
41+
...expandPowerShellSwitchPrefixForms("settingsfile", "settings"),
42+
...expandPowerShellSwitchPrefixForms("token", "to"),
43+
...expandPowerShellSwitchPrefixForms("utctimestamp", "utc"),
44+
...expandPowerShellSwitchPrefixForms("version", "v"),
45+
...expandPowerShellSwitchPrefixForms("windowstyle", "w"),
46+
...expandPowerShellSwitchPrefixForms("workingdirectory", "w"),
47+
...expandPowerShellSwitchForms(["ea", "ep", "if", "of", "wd"]),
1348
]);
1449

1550
const POSIX_SHELL_OPTIONS_WITH_SEPARATE_VALUES = new Set([
@@ -105,10 +140,20 @@ function advancePosixInlineOptionScan(token: string): number {
105140
return 1;
106141
}
107142

143+
function isPowerShellOptionToken(token: string): boolean {
144+
return token.startsWith("-") || /^\/[A-Za-z][A-Za-z0-9]*$/.test(token);
145+
}
146+
108147
export function resolveInlineCommandMatch(
109148
argv: string[],
110149
flags: ReadonlySet<string>,
111-
options: { allowCombinedC?: boolean } = {},
150+
options: {
151+
allowCombinedC?: boolean;
152+
isOptionToken?: (token: string) => boolean;
153+
restValueFlags?: ReadonlySet<string>;
154+
stopAtFirstNonOption?: boolean;
155+
valueOptions?: ReadonlySet<string>;
156+
} = {},
112157
): { command: string | null; valueTokenIndex: number | null } {
113158
for (let i = 1; i < argv.length; ) {
114159
const token = argv[i]?.trim();
@@ -123,6 +168,14 @@ export function resolveInlineCommandMatch(
123168
const comparableToken = options.allowCombinedC ? token : lower;
124169
if (flags.has(comparableToken)) {
125170
const valueTokenIndex = i + 1 < argv.length ? i + 1 : null;
171+
if (options.restValueFlags?.has(comparableToken)) {
172+
const command = argv
173+
.slice(i + 1)
174+
.map((arg) => arg.trim())
175+
.join(" ")
176+
.trim();
177+
return { command: command ? command : null, valueTokenIndex };
178+
}
126179
const command = argv[i + 1]?.trim();
127180
return { command: command ? command : null, valueTokenIndex };
128181
}
@@ -135,6 +188,15 @@ export function resolveInlineCommandMatch(
135188
const command = argv[valueTokenIndex]?.trim();
136189
return { command: command ? command : null, valueTokenIndex };
137190
}
191+
if (options.valueOptions?.has(lower)) {
192+
i += 2;
193+
continue;
194+
}
195+
const isOptionToken =
196+
options.isOptionToken?.(token) ?? (token.startsWith("-") || token.startsWith("+"));
197+
if (options.stopAtFirstNonOption && !isOptionToken) {
198+
break;
199+
}
138200
if (options.allowCombinedC && !token.startsWith("-") && !token.startsWith("+")) {
139201
break;
140202
}
@@ -143,6 +205,22 @@ export function resolveInlineCommandMatch(
143205
return { command: null, valueTokenIndex: null };
144206
}
145207

208+
export function resolvePowerShellInlineCommandMatch(argv: string[]): {
209+
command: string | null;
210+
valueTokenIndex: number | null;
211+
} {
212+
return resolveInlineCommandMatch(argv, POWERSHELL_INLINE_COMMAND_FLAGS, {
213+
isOptionToken: isPowerShellOptionToken,
214+
restValueFlags: POWERSHELL_INLINE_REST_COMMAND_FLAGS,
215+
stopAtFirstNonOption: true,
216+
valueOptions: POWERSHELL_OPTIONS_WITH_SEPARATE_VALUES,
217+
});
218+
}
219+
220+
export function isPowerShellInlineRestCommandFlag(token: string): boolean {
221+
return POWERSHELL_INLINE_REST_COMMAND_FLAGS.has(normalizeLowercaseStringOrEmpty(token));
222+
}
223+
146224
export function hasPosixInteractiveStartupBeforeInlineCommand(
147225
argv: string[],
148226
flags: ReadonlySet<string>,

src/infra/shell-wrapper-resolution.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {
1111
hasPosixInteractiveStartupBeforeInlineCommand,
1212
hasPosixLoginStartupBeforeInlineCommand,
1313
POSIX_INLINE_COMMAND_FLAGS,
14-
POWERSHELL_INLINE_COMMAND_FLAGS,
1514
resolveInlineCommandMatch,
15+
resolvePowerShellInlineCommandMatch,
1616
} from "./shell-inline-command.js";
1717

1818
const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const;
@@ -217,7 +217,7 @@ function extractCmdInlineCommand(argv: string[]): string | null {
217217
}
218218

219219
function extractPowerShellInlineCommand(argv: string[]): string | null {
220-
return extractInlineCommandByFlags(argv, POWERSHELL_INLINE_COMMAND_FLAGS);
220+
return resolvePowerShellInlineCommandMatch(argv).command;
221221
}
222222

223223
function extractInlineCommandByFlags(

0 commit comments

Comments
 (0)