Skip to content

Commit ae65651

Browse files
authored
fix(tui): resolve Windows attachment paths in WSL (#296)
1 parent 35f73f7 commit ae65651

6 files changed

Lines changed: 313 additions & 3 deletions

File tree

‎packages/tui/src/headless/invocation.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
MINIMAX_CODE_MAX_ATTACHMENT_COUNT,
1111
} from '../application/attachment-policy.js';
1212
import { inferTuiNativeVideoMimeType } from '../application/video-mime.js';
13+
import { resolveWslPath } from '../host/wsl-path.js';
1314
import { TuiExecError } from './exit-policy.js';
1415
import type { TuiExecFormat } from './output.js';
1516

@@ -295,7 +296,12 @@ async function resolveHeadlessAttachment(
295296
signal?: AbortSignal,
296297
): Promise<TuiAttachment> {
297298
throwIfAborted(signal);
298-
const requestedPath = expandPath(reference, workspaceDir);
299+
const localReference = await resolveWslPath(reference.trim(), signal).catch((error: unknown) => {
300+
throwIfAborted(signal);
301+
throw invocationError(`Cannot attach ${reference}: ${errorMessage(error)}`);
302+
});
303+
throwIfAborted(signal);
304+
const requestedPath = expandPath(localReference, workspaceDir);
299305
const filePath = await realpath(requestedPath).catch((error: unknown) => {
300306
throw invocationError(`Cannot attach ${reference}: ${errorMessage(error)}`);
301307
});

‎packages/tui/src/host/wsl-path.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { execFile } from "node:child_process";
2+
import { platform, release } from "node:os";
3+
import { posix } from "node:path";
4+
import { promisify } from "node:util";
5+
6+
const executeFile = promisify(execFile);
7+
8+
/** Translate Windows absolute paths before the host's POSIX resolver sees them. */
9+
export async function resolveWslPath(
10+
reference: string,
11+
signal?: AbortSignal,
12+
): Promise<string> {
13+
if (
14+
!/^(?:[a-z]:[\\/]|\\\\)/iu.test(reference) ||
15+
platform() !== "linux" ||
16+
!(
17+
process.env.WSL_DISTRO_NAME ||
18+
process.env.WSL_INTEROP ||
19+
process.env.WSLENV ||
20+
/microsoft/iu.test(release())
21+
)
22+
) {
23+
return reference;
24+
}
25+
26+
// Use the distro's converter so custom automount roots and mounted drives work.
27+
// execFile keeps spaces, backslashes and shell metacharacters in one literal argument.
28+
try {
29+
const { stdout } = await executeFile("wslpath", ["-a", "-u", reference], {
30+
encoding: "utf8",
31+
timeout: 1_000,
32+
maxBuffer: 64 * 1024,
33+
...(signal ? { signal } : {}),
34+
});
35+
const mapped = stdout.replace(/\r?\n$/u, "");
36+
if (!posix.isAbsolute(mapped) || /[\r\n\0]/u.test(mapped)) {
37+
throw new Error("wslpath did not return an absolute Linux path.");
38+
}
39+
return mapped;
40+
} catch (error) {
41+
throw new Error(
42+
"Could not convert the Windows path with wslpath. Use an accessible Linux path instead.",
43+
{ cause: error },
44+
);
45+
}
46+
}

‎packages/tui/src/tui/features/composer/attachments.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
33
import { basename, extname, isAbsolute, resolve } from 'node:path';
44
import type { TuiAttachment } from '../../../application/invocation.js';
55
import { inferTuiNativeVideoMimeType } from '../../../application/video-mime.js';
6+
import { resolveWslPath } from '../../../host/wsl-path.js';
67
import { sanitizeTerminalText } from '../../rendering/terminal-text.js';
78
import type { TranscriptAttachment } from '../../transcript/model.js';
89
import { getTuiTerminalImagePasteFallbackPath } from './terminal-image-paste.js';
@@ -54,12 +55,13 @@ export async function resolveTuiAttachment(
5455
const normalizedReference = stripMatchingQuotes(reference.trim());
5556
if (!normalizedReference) throw new Error('Attachment path is required.');
5657
const userHome = options.homeDir ?? homedir();
57-
const expanded =
58+
const expanded = await resolveWslPath(
5859
normalizedReference === '~'
5960
? userHome
6061
: normalizedReference.startsWith('~/') || normalizedReference.startsWith('~\\')
6162
? resolve(userHome, normalizedReference.slice(2))
62-
: normalizedReference;
63+
: normalizedReference,
64+
);
6365
let filePath = isAbsolute(expanded) ? resolve(expanded) : resolve(options.workspaceDir, expanded);
6466
const info = await stat(filePath)
6567
.catch((error: unknown) => {
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { resolveTuiExecInvocation } from "../../src/headless/invocation.js";
6+
import { resolveWslPath } from "../../src/host/wsl-path.js";
7+
import { resolveTuiAttachment } from "../../src/tui/features/composer/attachments.js";
8+
import { TuiComposerDraft } from "../../src/tui/features/composer/draft.js";
9+
import { isTuiTerminalImagePaste } from "../../src/tui/features/composer/terminal-image-paste.js";
10+
11+
const host = vi.hoisted(() => ({
12+
platform: vi.fn(() => "linux"),
13+
release: vi.fn(() => "6.18.33.2-microsoft-standard-WSL2"),
14+
executeFile: vi.fn(),
15+
}));
16+
17+
vi.mock("node:os", async (importOriginal) => ({
18+
...(await importOriginal<typeof import("node:os")>()),
19+
platform: host.platform,
20+
release: host.release,
21+
}));
22+
vi.mock("node:child_process", async (importOriginal) => ({
23+
...(await importOriginal<typeof import("node:child_process")>()),
24+
execFile: Object.assign(vi.fn(), {
25+
[Symbol.for("nodejs.util.promisify.custom")]: host.executeFile,
26+
}),
27+
}));
28+
29+
let workspaceDir: string;
30+
let imagePath: string;
31+
const windowsPath = String.raw`D:\Users\demo\Documents\Screen shots\截图.png`;
32+
33+
beforeEach(async () => {
34+
host.platform.mockReturnValue("linux");
35+
host.release.mockReturnValue("6.18.33.2-microsoft-standard-WSL2");
36+
host.executeFile.mockReset();
37+
vi.stubEnv("WSL_DISTRO_NAME", "");
38+
vi.stubEnv("WSL_INTEROP", "");
39+
vi.stubEnv("WSLENV", "");
40+
workspaceDir = await mkdtemp(join(tmpdir(), "mcode-wsl-path-"));
41+
imagePath = join(workspaceDir, "截图.png");
42+
await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
43+
host.executeFile.mockResolvedValue({ stdout: `${imagePath}\n`, stderr: "" });
44+
});
45+
46+
afterEach(async () => {
47+
vi.unstubAllEnvs();
48+
await rm(workspaceDir, { recursive: true, force: true });
49+
});
50+
51+
describe("WSL attachment paths", () => {
52+
it.each([
53+
windowsPath,
54+
`"${windowsPath}"`,
55+
`'${windowsPath}'`,
56+
windowsPath.replaceAll("\\", "/"),
57+
])(
58+
"queues a Windows image path as an image placeholder: %s",
59+
async (reference) => {
60+
const placeholders = vi.fn();
61+
const draft = new TuiComposerDraft({
62+
workspaceDir,
63+
resolveAttachment: resolveTuiAttachment,
64+
append: vi.fn(),
65+
onChanged: vi.fn(),
66+
onAttachmentPlaceholdersChanged: placeholders,
67+
});
68+
expect(isTuiTerminalImagePaste(reference)).toBe(true);
69+
await draft.queueAttachment(reference, { source: "terminal-paste" });
70+
expect(draft.snapshot().attachments).toEqual([
71+
{
72+
type: "image",
73+
filePath: imagePath,
74+
fileName: "截图.png",
75+
mimeType: "image/png",
76+
sizeBytes: 4,
77+
},
78+
]);
79+
expect(placeholders).toHaveBeenLastCalledWith([
80+
{ id: imagePath, label: "[Image #1]" },
81+
]);
82+
expect(host.executeFile).toHaveBeenCalledWith(
83+
"wslpath",
84+
["-a", "-u", reference.replace(/^['"]|['"]$/gu, "")],
85+
expect.objectContaining({
86+
encoding: "utf8",
87+
timeout: 1_000,
88+
maxBuffer: 64 * 1024,
89+
}),
90+
);
91+
},
92+
);
93+
94+
it("resolves headless --file through the same conversion before realpath", async () => {
95+
const invocation = await resolveTuiExecInvocation(
96+
"describe",
97+
{ cwd: workspaceDir, file: [windowsPath] },
98+
async () => "",
99+
);
100+
expect(invocation.attachments).toEqual([
101+
{
102+
type: "image",
103+
filePath: await realpath(imagePath),
104+
fileName: "截图.png",
105+
mimeType: "image/png",
106+
sizeBytes: 4,
107+
},
108+
]);
109+
expect(host.executeFile).toHaveBeenCalledOnce();
110+
});
111+
112+
it.each(["WSL_DISTRO_NAME", "WSL_INTEROP", "WSLENV"])(
113+
"detects WSL via %s even without a Microsoft kernel name",
114+
async (name) => {
115+
host.release.mockReturnValue("custom-kernel");
116+
vi.stubEnv(name, "synthetic-wsl");
117+
await expect(resolveWslPath(windowsPath)).resolves.toBe(imagePath);
118+
},
119+
);
120+
121+
it.each(["darwin", "win32", "linux"])(
122+
"preserves Windows path syntax on a non-WSL %s host",
123+
async (platform) => {
124+
host.platform.mockReturnValue(platform);
125+
host.release.mockReturnValue("generic-kernel");
126+
await expect(resolveWslPath(windowsPath)).resolves.toBe(windowsPath);
127+
expect(host.executeFile).not.toHaveBeenCalled();
128+
},
129+
);
130+
131+
it.each([
132+
"/mnt/d/Screen shots/截图.png",
133+
"/tmp/literal\\name.png",
134+
"//tmp/image.png",
135+
"image.png",
136+
"~/image.png",
137+
"D:relative.png",
138+
])(
139+
"leaves native and relative paths unchanged in WSL: %s",
140+
async (reference) => {
141+
await expect(resolveWslPath(reference)).resolves.toBe(reference);
142+
expect(host.executeFile).not.toHaveBeenCalled();
143+
},
144+
);
145+
146+
it("keeps native attachment resolution working in WSL without a subprocess", async () => {
147+
await expect(
148+
resolveTuiAttachment("截图.png", { workspaceDir }),
149+
).resolves.toMatchObject({ filePath: imagePath });
150+
await expect(
151+
resolveTuiAttachment("~/截图.png", {
152+
workspaceDir,
153+
homeDir: workspaceDir,
154+
}),
155+
).resolves.toMatchObject({ filePath: imagePath });
156+
expect(host.executeFile).not.toHaveBeenCalled();
157+
});
158+
159+
it.each([
160+
String.raw`d:\Screen shots\$(touch marker);'截图'.png`,
161+
String.raw`\\server\share\截图.png`,
162+
])("passes Windows paths as a literal argument: %s", async (reference) => {
163+
await expect(resolveWslPath(reference)).resolves.toBe(imagePath);
164+
expect(host.executeFile.mock.calls[0]?.slice(0, 2)).toEqual([
165+
"wslpath",
166+
["-a", "-u", reference],
167+
]);
168+
expect(host.executeFile.mock.calls[0]?.[2]).not.toHaveProperty("shell");
169+
});
170+
171+
it("honors the converter output for custom mount roots and preserves trailing spaces", async () => {
172+
host.executeFile.mockResolvedValue({
173+
stdout: "/custom/windows/d/Screen shots/image.png \n",
174+
});
175+
await expect(resolveWslPath(windowsPath)).resolves.toBe(
176+
"/custom/windows/d/Screen shots/image.png ",
177+
);
178+
});
179+
180+
it.each([
181+
"",
182+
"\n",
183+
"relative/image.png\n",
184+
"D:\\image.png\n",
185+
"/tmp/image.png\nextra\n",
186+
"/tmp/image\0.png\n",
187+
])(
188+
"rejects invalid converter output instead of treating it as a local path: %j",
189+
async (stdout) => {
190+
host.executeFile.mockResolvedValue({ stdout });
191+
await expect(
192+
resolveTuiAttachment(windowsPath, { workspaceDir }),
193+
).rejects.toThrow("Could not convert the Windows path with wslpath");
194+
},
195+
);
196+
197+
it.each(["ENOENT", "EACCES", "ETIMEDOUT"])(
198+
"reports converter failure %s without falling back to a workspace filename",
199+
async (code) => {
200+
if (process.platform !== "win32")
201+
await writeFile(join(workspaceDir, windowsPath), "wrong image");
202+
host.executeFile.mockRejectedValue(
203+
Object.assign(new Error("converter failed"), { code }),
204+
);
205+
await expect(
206+
resolveTuiAttachment(windowsPath, {
207+
workspaceDir,
208+
source: "terminal-paste",
209+
}),
210+
).rejects.toThrow("Use an accessible Linux path instead");
211+
await expect(
212+
resolveTuiExecInvocation(
213+
"describe",
214+
{ cwd: workspaceDir, file: [windowsPath] },
215+
async () => "",
216+
),
217+
).rejects.toMatchObject({
218+
kind: "invocation",
219+
message: expect.stringContaining("wslpath"),
220+
});
221+
},
222+
);
223+
224+
it("still rejects missing files and directories after conversion", async () => {
225+
host.executeFile.mockResolvedValue({
226+
stdout: `${join(workspaceDir, "missing.png")}\n`,
227+
});
228+
await expect(
229+
resolveTuiAttachment(windowsPath, { workspaceDir }),
230+
).rejects.toThrow("ENOENT");
231+
host.executeFile.mockResolvedValue({ stdout: `${workspaceDir}\n` });
232+
await expect(
233+
resolveTuiAttachment(windowsPath, { workspaceDir }),
234+
).rejects.toThrow("not a file");
235+
});
236+
237+
it("preserves headless cancellation while converting a path", async () => {
238+
const controller = new AbortController();
239+
host.executeFile.mockImplementation(async (_file, _args, options) => {
240+
expect(options.signal).toBe(controller.signal);
241+
controller.abort();
242+
throw new Error("aborted");
243+
});
244+
await expect(
245+
resolveTuiExecInvocation(
246+
"describe",
247+
{ cwd: workspaceDir, file: [windowsPath] },
248+
async () => "",
249+
controller.signal,
250+
),
251+
).rejects.toMatchObject({ kind: "cancelled" });
252+
});
253+
});

‎release/public-source.json‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/vitest-suites.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"packages/tui/test/unit/runtime-data-dir.test.ts",
6969
"packages/tui/test/unit/headless-contract.test.ts",
7070
"packages/tui/test/unit/headless-invocation.test.ts",
71+
"packages/tui/test/unit/wsl-attachment-paths.test.ts",
7172
"packages/tui/test/unit/headless-model-selection.test.ts",
7273
"packages/tui/test/unit/headless-preparation-cancellation.test.ts",
7374
"packages/tui/test/unit/headless-runner.test.ts",

0 commit comments

Comments
 (0)