Skip to content

Commit 6449c60

Browse files
committed
fix(cmd): update quoting behavior for cmd /c to preserve percent-encoded characters
1 parent f5fedad commit 6449c60

5 files changed

Lines changed: 38 additions & 12 deletions

File tree

‎README.zh.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
3636

3737
<p align="center">
3838
<a href="https://cloud.video.taobao.com/vod/dS2F4huqbw5Nfe5L3wwb3grz2q2DNYD3retq8dU-iHo.mp4">
39-
<img src="https://img.alicdn.com/imgextra/i1/O1CN01Q5052k232Hd36NodG_!!6000000007197-0-tps-2940-1656.jpg" alt="点击播放演示视频" width="720" />
39+
<img src="https://img.alicdn.com/imgextra/i4/O1CN01YRqEiQMN7tJ2b87U_!!6000000004549-0-videocover-1280-744.jpg" alt="点击播放演示视频" width="720" />
4040
</a>
4141
</p>
4242

‎packages/bailian-kb-dsh/src/console-login.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,14 @@ async function extractCredentials(
161161
}
162162

163163
/**
164-
* Quote a value for `cmd.exe`. `%` is doubled because cmd expands variables even
165-
* inside quotes; embedded `"` becomes `""`.
164+
* Quote a value for `cmd /c`. An embedded `"` is written as `""`.
165+
* Do not double `%`: that substitution only happens inside batch files, and
166+
* `cmd /c` would otherwise open a target that still contains `%%`.
166167
* Keep in sync with `buildOpenInvocation` in
167168
* `packages/commands/src/commands/shared/local-server.ts`.
168169
*/
169170
function quoteForCmd(value: string): string {
170-
return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`;
171+
return `"${value.replaceAll('"', '""')}"`;
171172
}
172173

173174
/**

‎packages/bailian-kb-dsh/tests/console-login.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,11 @@ describe("buildOpenInvocation", () => {
7979
expect(invocation.windowsVerbatimArguments).toBe(true);
8080
expect(command.slice(1, -1)).toBe(`start "" "${url}"`);
8181
});
82+
83+
it("leaves percent-encoded characters unchanged for cmd /c", () => {
84+
const url = "https://example.com/a%20b";
85+
const command = buildOpenInvocation(url, "win32").args.at(-1) ?? "";
86+
expect(command).toContain("%20");
87+
expect(command).not.toContain("%%");
88+
});
8289
});

‎packages/commands/src/commands/shared/local-server.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,15 @@ export interface OpenInvocation {
3030
}
3131

3232
/**
33-
* Quote a value for `cmd.exe`. `%` is doubled because cmd expands variables even
34-
* inside quotes; embedded `"` becomes `""`.
33+
* Quote a value for `cmd /c`. An embedded `"` is written as `""`, which cmd
34+
* reads as one literal quote.
35+
*
36+
* Do not double `%`. `%%` → `%` is a batch-file rule and does not apply when
37+
* Node starts `cmd /c` directly. Doubling would make `start` open a different
38+
* target (`%20` would stay `%20` with two percent signs).
3539
*/
3640
function quoteForCmd(value: string): string {
37-
return `"${value.replaceAll("%", "%%").replaceAll('"', '""')}"`;
41+
return `"${value.replaceAll('"', '""')}"`;
3842
}
3943

4044
/**

‎packages/commands/tests/local-server-open.test.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ function commandExecutedByCmd(args: string[]): string {
1414
return remainder.slice(1, -1);
1515
}
1616

17+
/**
18+
* Target `start` receives after cmd quote parsing: one outer quote pair is
19+
* removed, and each `""` inside it is one literal `"`. `%` is not rewritten.
20+
*/
21+
function targetReceivedByStart(executed: string): string {
22+
const prefix = 'start "" ';
23+
if (!executed.startsWith(prefix)) throw new Error("not a start command");
24+
const quoted = executed.slice(prefix.length);
25+
if (!quoted.startsWith('"') || !quoted.endsWith('"')) throw new Error("target not quoted");
26+
return quoted.slice(1, -1).replaceAll('""', '"');
27+
}
28+
1729
/** Split on `&` that is not inside double quotes, the way `cmd.exe` does. */
1830
function splitUnquotedAmpersand(command: string): string[] {
1931
const parts: string[] = [];
@@ -45,13 +57,15 @@ test("win32 打开带 & 的登录 URL 时,needapikey 留在同一条 start 命
4557
const executed = commandExecutedByCmd(invocation.args);
4658
expect(executed).toBe(`start "" "${LOGIN_URL}"`);
4759
expect(splitUnquotedAmpersand(executed)).toEqual([executed]);
48-
expect(executed).toContain("&needapikey=true");
60+
expect(targetReceivedByStart(executed)).toBe(LOGIN_URL);
4961
});
5062

51-
test("win32 转义 cmd 会展开的 % 和引号", () => {
52-
const invocation = buildOpenInvocation('https://example.com/a%20b?"q"', "win32");
53-
const executed = commandExecutedByCmd(invocation.args);
54-
expect(executed).toBe('start "" "https://example.com/a%%20b?""q"""');
63+
test("win32 传给 start 的目标保持原样,% 不会被写成 %%", () => {
64+
const target = 'https://example.com/a%20b?"q"&x=1';
65+
const executed = commandExecutedByCmd(buildOpenInvocation(target, "win32").args);
66+
expect(executed).not.toContain("%%");
67+
expect(targetReceivedByStart(executed)).toBe(target);
68+
expect(splitUnquotedAmpersand(executed)).toEqual([executed]);
5569
});
5670

5771
test("darwin / linux 直接把目标交给系统打开命令", () => {

0 commit comments

Comments
 (0)