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
37 changes: 36 additions & 1 deletion apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens VS Code Remote - WSL URLs", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const urls = [
"vscode://vscode-remote/wsl+Ubuntu/home/user/My%20Project",
"vscode-insiders://vscode-remote/wsl+Debian/home/user/project",
];
const results = yield* Effect.forEach(urls, (url) => electronShell.openExternal(url));

assert.deepEqual(results, [true, true]);
assert.deepEqual(
openExternalMock.mock.calls,
urls.map((url) => [url]),
);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens the JetBrains Toolbox ssh deep link", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand Down Expand Up @@ -138,12 +157,28 @@ describe("ElectronShell", () => {
const results = yield* Effect.all([
electronShell.openExternal("zed://extension/attacker"),
electronShell.openExternal("vscode://ssh/example.com/home/user/project"),
electronShell.openExternal("vscode://vscode-remote/wsl+bad!distro/home/user/project"),
electronShell.openExternal("cursor://vscode-remote/wsl+Ubuntu/home/user/project"),
electronShell.openExternal("vscode://vscode-remote/wsl+Ubuntu"),
electronShell.openExternal("vscode://vscode-remote/wsl+Ubuntu/home/%2Fetc"),
electronShell.openExternal("vscode://vscode-remote/wsl+Ubuntu/home/user/project?x=1"),
electronShell.openExternal("jetbrains://idea/navigate/reference?project=x&path=y"),
electronShell.openExternal("jetbrains://gateway/ssh/environment?launchIde=true"),
electronShell.openExternal("jetbrains://gateway/com.example.provider/connect?h=x"),
]);

assert.deepEqual(results, [false, false, false, false, false]);
assert.deepEqual(results, [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
]);
assert.equal(openExternalMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronShell.layer)),
);
Expand Down
22 changes: 20 additions & 2 deletions apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ const APP_LAUNCH_URLS = new Set(["grokbot://app"]);
// Zed embeds [user@]host in the path; reject passwords and ports there.
const ZED_SSH_PATHNAME = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/;

function isValidWslRemotePath(pathname: string): boolean {
const match = /^\/wsl\+[A-Za-z0-9._-]+\/(.*)$/.exec(pathname);
if (!match || pathname.includes("\\") || /%(?:2f|5c)/i.test(pathname)) {
return false;
}
try {
return match[1]!.split("/").every((segment) => {
const decoded = decodeURIComponent(segment);
return decoded !== "." && decoded !== ".." && !/[\u0000-\u001f\u007f\\]/.test(decoded);
});
} catch {
return false;
}
}

const isRemoteEditorUrl = (url: URL) =>
REMOTE_EDITOR_PROTOCOLS.has(url.protocol) &&
url.username.length === 0 &&
Expand All @@ -39,8 +54,11 @@ const isRemoteEditorUrl = (url: URL) =>
url.pathname === "/ssh/environment" &&
(url.searchParams.get("h") ?? "").length > 0
: url.host === "vscode-remote" &&
url.pathname.startsWith("/ssh-remote+") &&
url.pathname.length > "/ssh-remote+".length);
((url.pathname.startsWith("/ssh-remote+") && url.pathname.length > "/ssh-remote+".length) ||
((url.protocol === "vscode:" || url.protocol === "vscode-insiders:") &&
url.search.length === 0 &&
url.hash.length === 0 &&
isValidWslRemotePath(url.pathname))));

export function parseSafeExternalUrl(rawUrl: unknown): Option.Option<string> {
if (typeof rawUrl !== "string") {
Expand Down
37 changes: 36 additions & 1 deletion apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import * as Option from "effect/Option";
import { vi } from "vite-plus/test";
import type * as Electron from "electron";

const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({
const { focusedWebContents, ownerWindow, protocolHandler } = vi.hoisted(() => ({
focusedWebContents: vi.fn(),
protocolHandler: vi.fn<(url: string) => Promise<{ path: string }>>(),
ownerWindow: vi.fn(),
}));
vi.mock("electron", () => ({
app: { getApplicationInfoForProtocol: protocolHandler },
webContents: { getFocusedWebContents: focusedWebContents },
BrowserWindow: { fromWebContents: ownerWindow },
}));
Expand Down Expand Up @@ -474,3 +476,36 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
assert.include(editors, "webstorm");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("detects Windows VS Code URL handlers independently of PATH", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-wsl-editors-" });
const executable = path.join(root, "Code - Insiders.exe");
yield* fs.writeFileString(executable, "");
protocolHandler.mockReset().mockImplementation(async (url) => ({
path: url === "vscode-insiders://" ? executable : path.join(root, "missing.exe"),
}));
const editors = yield* probeRemoteEditors
.handler(undefined)
.pipe(
Effect.provideService(HostProcess.Environment, { PATH: "" }),
Effect.provideService(HostProcess.Platform, "win32"),
);
assert.notInclude(editors, "vscode");
assert.include(editors, "vscode-insiders");
protocolHandler.mockImplementation(async (url) => {
if (url === "vscode-insiders://") throw new Error("No handler");
return { path: executable };
});
const stableOnly = yield* probeRemoteEditors
.handler(undefined)
.pipe(
Effect.provideService(HostProcess.Environment, { PATH: "" }),
Effect.provideService(HostProcess.Platform, "win32"),
);
assert.include(stableOnly, "vscode");
assert.notInclude(stableOnly, "vscode-insiders");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
22 changes: 22 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
PickFolderOptionsSchema,
PRIMARY_LOCAL_ENVIRONMENT_ID,
REMOTE_CAPABLE_EDITOR_IDS,
WSL_REMOTE_EDITOR_IDS,
remoteSchemeForEditor,
SystemSettingsPaneSchema,
type DesktopEnvironmentBootstrap,
type PickedThemeFile,
Expand Down Expand Up @@ -397,7 +399,27 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () {
const available: Array<EditorId> = [];
const env = yield* HostProcess.Environment;
const platform = yield* HostProcess.Platform;
const fs = yield* FileSystem.FileSystem;
for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) {
if (platform === "win32" && WSL_REMOTE_EDITOR_IDS.some((id) => id === editorId)) {
const scheme = remoteSchemeForEditor(editorId);
const handler =
scheme === undefined
? Option.none<string>()
: yield* Effect.tryPromise({
try: async () =>
(await Electron.app.getApplicationInfoForProtocol(`${scheme}://`)).path,
catch: () => undefined,
}).pipe(Effect.option);
if (
Option.isSome(handler) &&
(yield* fs.exists(handler.value).pipe(Effect.orElseSucceed(() => false)))
) {
available.push(editorId);
}
continue;
}
const editor = EDITORS.find((editor) => editor.id === editorId);
if (editor && Option.isSome(yield* resolveEditorCommand(editor, env))) {
available.push(editorId);
Expand Down
21 changes: 20 additions & 1 deletion apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
null,
null,
]);
const unresolved = `${cwd}${path.sep}link${path.sep}..${path.sep}custom-name`;
const realpath = vi.mocked(NodeFSP.realpath).mockResolvedValueOnce(file);
expect(
(yield* service.getMetadata({ paths: [unresolved], resolveRealPath: true })).entries,
).toEqual([{ kind: "file", byteLength: 5, realPath: file }]);
expect(realpath).toHaveBeenCalledWith(unresolved);
}),
);

Expand Down Expand Up @@ -172,7 +178,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
"//attacker.test/s/a.ts",
"/\\attacker.test\\s",
];
expect((yield* service.getMetadata({ paths })).entries).toEqual([null, null, null]);
const realpath = vi.mocked(NodeFSP.realpath).mockClear();
expect((yield* service.getMetadata({ paths, resolveRealPath: true })).entries).toEqual([
null,
null,
null,
]);
expect(realpath).not.toHaveBeenCalled();
expect(statted).toEqual([]);
}),
);
Expand Down Expand Up @@ -381,6 +393,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
});

expect(result.contents).toBe("beside the target\n");
const metadata = yield* workspaceFileSystem.getMetadata({
paths: [`${root}/link/../parent.md`],
resolveRealPath: true,
});
expect(metadata.entries[0]?.realPath).toBe(
yield* fs.realPath(path.join(root, "target", "parent.md")),
);
}),
);

Expand Down
27 changes: 22 additions & 5 deletions apps/server/src/workspace/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

Expand Down Expand Up @@ -142,15 +143,26 @@ export const make = Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const isServerInternalPath = yield* makeIsServerInternalPath;

const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) {
const metadataForPath = Effect.fnUntraced(function* (
requestedPath: string,
resolveRealPath: boolean,
) {
const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory);
// Chips ask as soon as an agent's message renders, and touching a network
// path makes Windows sign in to its host with the user's NTLM credentials.
if (!path.isAbsolute(expandedPath) || isNetworkPath(expandedPath)) return null;
const stat = yield* fileSystem.stat(expandedPath).pipe(Effect.option);
const resolved = resolveRealPath
? yield* Effect.tryPromise({
try: () => NodeFSP.realpath(expandedPath),
catch: () => undefined,
}).pipe(Effect.option)
: Option.some(expandedPath);
if (Option.isNone(resolved)) return null;
const stat = yield* fileSystem.stat(resolved.value).pipe(Effect.option);
if (stat._tag === "None") return null;
if (stat.value.type === "Directory") return { kind: "directory" } as const;
if (stat.value.type !== "File") return { kind: "other" } as const;
const realPath = resolveRealPath ? { realPath: resolved.value } : {};
if (stat.value.type === "Directory") return { kind: "directory", ...realPath } as const;
if (stat.value.type !== "File") return { kind: "other", ...realPath } as const;
let mimeType: string | undefined;
if (path.extname(expandedPath) === "" && stat.value.size > 0) {
mimeType = yield* Effect.tryPromise({
Expand All @@ -173,6 +185,7 @@ export const make = Effect.gen(function* () {
}
return {
kind: "file",
...realPath,
byteLength: Number(stat.value.size),
...(mimeType === undefined ? {} : { mimeType }),
} satisfies FilesystemEntryMetadata;
Expand All @@ -182,7 +195,11 @@ export const make = Effect.gen(function* () {
"WorkspaceFileSystem.getMetadata",
)(function* (input) {
const paths = [...new Set(input.paths)];
const entries = yield* Effect.forEach(paths, metadataForPath, { concurrency: 8 });
const entries = yield* Effect.forEach(
paths,
(entry) => metadataForPath(entry, input.resolveRealPath === true),
{ concurrency: 8 },
);
const byPath = new Map(paths.map((value, index) => [value, entries[index] ?? null]));
return { entries: input.paths.map((value) => byPath.get(value) ?? null) };
});
Expand Down
Loading
Loading