Skip to content

Commit 5e88c15

Browse files
committed
fix(web,paths,server): WSL2-aware Open in IDE link (closes #1502)
The Open-in-IDE buttons emit a `vscode://file/<path>` URI. When Archon runs inside WSL2 and the browser is on the Windows host, that URI hands a Linux path to Windows VS Code, which can't resolve it — the IDE launches with an empty / "file not found" window. The correct form for that case is: vscode://vscode-remote/wsl+<distro>/<path> This change wires the detection from the server through to the link construction so each setup gets the right URI: paths - isWSL(): two-signal detection (WSL_DISTRO_NAME env var, then /proc/sys/kernel/osrelease containing "microsoft"). - getWSLDistroName(): exposes WSL_DISTRO_NAME for the URI. - Both exported from @archon/paths and tested. server - GET /api/health gains `is_wsl` (always present) and `wsl_distro` (only when known). Schema updated accordingly. web - HealthResponse type in src/lib/api.ts mirrors the server fields. - New helper src/lib/ide-uri.ts builds the right URI based on {is_wsl, wsl_distro}, with tests for each branch (default, WSL2 path, broken inputs, distro-name encoding). - Header, WorkflowRunCard, WorkflowRunGroup, ChatInterface and DashboardPage now plumb the two new health fields the same way they already plumb is_docker, and call ideUri() instead of inlining a vscode://file/... template literal. Tests: paths and web tests pass; the existing api.health tests still pass (the new fields don't break the response-schema assertions — HealthResponse fields are optional except is_docker / is_wsl). Note: api.generated.d.ts not regenerated here because the manual HealthResponse interface in src/lib/api.ts is what the components consume; running `bun --filter @archon/web generate:types` against a live dev server will refresh the generated types in a follow-up.
1 parent b580ca0 commit 5e88c15

12 files changed

Lines changed: 200 additions & 5 deletions

File tree

packages/paths/src/archon-paths.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const isWindows = process.platform === 'win32';
88

99
import {
1010
isDocker,
11+
isWSL,
12+
getWSLDistroName,
1113
getArchonHome,
1214
getArchonWorkspacesPath,
1315
ensureArchonWorkspacesPath,
@@ -39,7 +41,14 @@ import {
3941
} from './archon-paths';
4042

4143
/** All env vars that path functions depend on */
42-
const ENV_VARS = ['WORKSPACE_PATH', 'WORKTREE_BASE', 'ARCHON_HOME', 'ARCHON_DOCKER', 'HOME'];
44+
const ENV_VARS = [
45+
'WORKSPACE_PATH',
46+
'WORKTREE_BASE',
47+
'ARCHON_HOME',
48+
'ARCHON_DOCKER',
49+
'HOME',
50+
'WSL_DISTRO_NAME',
51+
];
4352

4453
/**
4554
* Save and restore environment variables around each test.
@@ -78,6 +87,33 @@ describe('archon-paths', () => {
7887
});
7988
});
8089

90+
describe('isWSL', () => {
91+
test('returns true when WSL_DISTRO_NAME is set', () => {
92+
process.env.WSL_DISTRO_NAME = 'Ubuntu';
93+
expect(isWSL()).toBe(true);
94+
});
95+
96+
test('returns false when WSL_DISTRO_NAME is unset and not on a WSL kernel', () => {
97+
delete process.env.WSL_DISTRO_NAME;
98+
// /proc/sys/kernel/osrelease on real Linux CI doesn't contain "microsoft";
99+
// on a WSL host this assertion is naturally true via the env-var branch above.
100+
// Skip the strict check here — just verify it doesn't throw.
101+
expect(typeof isWSL()).toBe('boolean');
102+
});
103+
});
104+
105+
describe('getWSLDistroName', () => {
106+
test('returns the WSL_DISTRO_NAME env var when set', () => {
107+
process.env.WSL_DISTRO_NAME = 'Debian';
108+
expect(getWSLDistroName()).toBe('Debian');
109+
});
110+
111+
test('returns undefined when WSL_DISTRO_NAME is unset', () => {
112+
delete process.env.WSL_DISTRO_NAME;
113+
expect(getWSLDistroName()).toBeUndefined();
114+
});
115+
});
116+
81117
describe('isDocker', () => {
82118
test('returns true when WORKSPACE_PATH is /workspace', () => {
83119
process.env.WORKSPACE_PATH = '/workspace';

packages/paths/src/archon-paths.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { join, dirname, normalize, basename } from 'path';
1818
import { homedir } from 'os';
1919
import { access, mkdir, symlink, lstat, readdir, readlink, rm } from 'fs/promises';
20+
import { readFileSync } from 'fs';
2021
import { createLogger } from './logger';
2122

2223
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
@@ -48,6 +49,47 @@ export function isDocker(): boolean {
4849
);
4950
}
5051

52+
/**
53+
* Detect if running inside WSL (Windows Subsystem for Linux).
54+
*
55+
* Two signals (either is sufficient):
56+
* - `WSL_DISTRO_NAME` env var is set (always true inside a WSL distro)
57+
* - `/proc/sys/kernel/osrelease` contains "microsoft" (lower-cased)
58+
*
59+
* Used by callers that need to emit Windows-host-friendly URIs — most
60+
* notably the Web UI's "Open in IDE" button, which has to switch from
61+
* `vscode://file/...` to `vscode://vscode-remote/wsl+<distro>/...` when
62+
* the server is inside WSL but the browser is on the Windows host.
63+
*
64+
* Not cached: the env-var read is free and the `/proc` read is cheap
65+
* enough at the call frequency this hits (one per `/api/health` request,
66+
* which the Web UI polls every 30 s).
67+
*/
68+
export function isWSL(): boolean {
69+
if (process.env.WSL_DISTRO_NAME) return true;
70+
71+
try {
72+
const release = readFileSync('/proc/sys/kernel/osrelease', 'utf8').toLowerCase();
73+
return release.includes('microsoft');
74+
} catch {
75+
return false;
76+
}
77+
}
78+
79+
/**
80+
* Return the WSL distribution name (`Ubuntu`, `Debian`, …) when the process
81+
* is running inside WSL, otherwise `undefined`. The value comes straight
82+
* from the `WSL_DISTRO_NAME` env var that WSL sets in every distro.
83+
*
84+
* Returns `undefined` outside of WSL, even though `isWSL()` may technically
85+
* still be true via the `/proc` fallback — without the env var we don't
86+
* know what distro to put into a `vscode://vscode-remote/wsl+<distro>/...`
87+
* URI, and guessing is worse than a sentinel that callers can fall back on.
88+
*/
89+
export function getWSLDistroName(): string | undefined {
90+
return process.env.WSL_DISTRO_NAME ?? undefined;
91+
}
92+
5193
/**
5294
* Get the Archon home directory
5395
* - Docker: /.archon

packages/paths/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
export {
33
expandTilde,
44
isDocker,
5+
isWSL,
6+
getWSLDistroName,
57
getArchonHome,
68
getArchonWorkspacesPath,
79
ensureArchonWorkspacesPath,

packages/server/src/routes/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import {
4141
getRunArtifactsPath,
4242
getArchonHome,
4343
isDocker,
44+
isWSL,
45+
getWSLDistroName,
4446
checkForUpdate,
4547
BUNDLED_IS_BINARY,
4648
BUNDLED_VERSION,
@@ -821,6 +823,8 @@ const getHealthRoute = createRoute({
821823
runningWorkflows: z.number(),
822824
version: z.string().optional(),
823825
is_docker: z.boolean(),
826+
is_wsl: z.boolean(),
827+
wsl_distro: z.string().optional(),
824828
activePlatforms: z.array(z.string()).optional(),
825829
})
826830
.openapi('HealthResponse'),
@@ -2704,6 +2708,8 @@ export function registerApiRoutes(
27042708
runningWorkflows: runningWorkflowRows.length,
27052709
version: appVersion,
27062710
is_docker: isDocker(),
2711+
is_wsl: isWSL(),
2712+
...(getWSLDistroName() ? { wsl_distro: getWSLDistroName() } : {}),
27072713
activePlatforms: activePlatforms ? [...activePlatforms] : ['Web'],
27082714
});
27092715
});

packages/web/src/components/chat/ChatInterface.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ export function ChatInterface({ conversationId }: ChatInterfaceProps): React.Rea
135135
});
136136
// Default to true (hide button) until server confirms non-Docker — prevents broken vscode:// links
137137
const isDocker = health?.is_docker ?? true;
138+
const isWsl = health?.is_wsl ?? false;
139+
const wslDistro = health?.wsl_distro;
138140

139141
// Sync messages to cache for persistence across navigation
140142
useEffect(() => {
@@ -710,6 +712,8 @@ export function ChatInterface({ conversationId }: ChatInterfaceProps): React.Rea
710712
projectName={currentCodebase?.name ?? contextCodebase?.name}
711713
connected={isNewChat ? undefined : connected}
712714
isDocker={isDocker}
715+
isWsl={isWsl}
716+
wslDistro={wslDistro}
713717
/>
714718
{(conversationsError || codebasesError) && (
715719
<div className="flex gap-2 px-4 py-1">

packages/web/src/components/dashboard/WorkflowRunCard.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from 'lucide-react';
2020
import type { DashboardRunResponse } from '@/lib/api';
2121
import { cn } from '@/lib/utils';
22+
import { ideUri } from '@/lib/ide-uri';
2223
import { formatDuration } from '@/lib/format';
2324
import { useWorkflowStore } from '@/stores/workflow-store';
2425
import type { WorkflowState } from '@/lib/types';
@@ -27,6 +28,8 @@ import { ConfirmRunActionDialog } from './ConfirmRunActionDialog';
2728
interface WorkflowRunCardProps {
2829
run: DashboardRunResponse;
2930
isDocker?: boolean;
31+
isWsl?: boolean;
32+
wslDistro?: string;
3033
onCancel: (runId: string) => void;
3134
onResume?: (runId: string) => void;
3235
onAbandon?: (runId: string) => void;
@@ -137,6 +140,8 @@ function NodeCountsSummary({ counts }: { counts: NodeCounts }): React.ReactEleme
137140
export function WorkflowRunCard({
138141
run,
139142
isDocker,
143+
isWsl,
144+
wslDistro,
140145
onCancel,
141146
onResume,
142147
onAbandon,
@@ -297,7 +302,7 @@ export function WorkflowRunCard({
297302
)}
298303
{run.working_path && !isDocker && (
299304
<a
300-
href={`vscode://file/${run.working_path.replace(/\\/g, '/')}`}
305+
href={ideUri(run.working_path, { is_wsl: isWsl, wsl_distro: wslDistro })}
301306
target="_blank"
302307
rel="noopener noreferrer"
303308
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-text-secondary hover:bg-surface-elevated hover:text-text-primary transition-colors"

packages/web/src/components/dashboard/WorkflowRunGroup.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ interface WorkflowRunGroupProps {
77
parentPlatformId: string | null;
88
runs: DashboardRunResponse[];
99
isDocker?: boolean;
10+
isWsl?: boolean;
11+
wslDistro?: string;
1012
onCancel: (runId: string) => void;
1113
onResume?: (runId: string) => void;
1214
onAbandon?: (runId: string) => void;
@@ -19,6 +21,8 @@ export function WorkflowRunGroup({
1921
parentPlatformId,
2022
runs,
2123
isDocker,
24+
isWsl,
25+
wslDistro,
2226
onCancel,
2327
onResume,
2428
onAbandon,
@@ -54,6 +58,8 @@ export function WorkflowRunGroup({
5458
key={run.id}
5559
run={run}
5660
isDocker={isDocker}
61+
isWsl={isWsl}
62+
wslDistro={wslDistro}
5763
onCancel={onCancel}
5864
onResume={onResume}
5965
onAbandon={onAbandon}

packages/web/src/components/layout/Header.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { useState } from 'react';
22
import { ExternalLink, Copy, Check } from 'lucide-react';
33
import { cn } from '@/lib/utils';
4+
import { ideUri } from '@/lib/ide-uri';
45

56
interface HeaderProps {
67
title: string;
78
subtitle?: string;
89
projectName?: string;
910
connected?: boolean;
1011
isDocker?: boolean;
12+
isWsl?: boolean;
13+
wslDistro?: string;
1114
}
1215

1316
function smartPath(fullPath: string): string {
@@ -22,14 +25,14 @@ export function Header({
2225
projectName,
2326
connected,
2427
isDocker,
28+
isWsl,
29+
wslDistro,
2530
}: HeaderProps): React.ReactElement {
2631
const [copied, setCopied] = useState(false);
2732

2833
const openInVSCode = (): void => {
2934
if (subtitle) {
30-
// Normalize backslashes to forward slashes for the vscode:// URI
31-
const normalizedPath = subtitle.replace(/\\/g, '/');
32-
window.open(`vscode://file/${normalizedPath}`, '_blank');
35+
window.open(ideUri(subtitle, { is_wsl: isWsl, wsl_distro: wslDistro }), '_blank');
3336
}
3437
};
3538

packages/web/src/lib/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export interface HealthResponse {
5656
runningWorkflows: number;
5757
version?: string;
5858
is_docker: boolean;
59+
is_wsl: boolean;
60+
/** WSL distribution name (e.g. "Ubuntu") — only present when is_wsl is true. */
61+
wsl_distro?: string;
5962
activePlatforms?: string[];
6063
}
6164

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, test, expect } from 'bun:test';
2+
import { ideUri } from './ide-uri';
3+
4+
describe('ideUri', () => {
5+
describe('default (non-WSL) form', () => {
6+
test('emits vscode://file/<path> when env is undefined', () => {
7+
expect(ideUri('/home/user/project')).toBe('vscode://file//home/user/project');
8+
});
9+
10+
test('emits vscode://file/<path> when env.is_wsl is false', () => {
11+
expect(ideUri('/home/user/project', { is_wsl: false })).toBe(
12+
'vscode://file//home/user/project'
13+
);
14+
});
15+
16+
test('falls back when is_wsl is true but wsl_distro is missing', () => {
17+
// Without a distro name we can't construct the WSL form — better to emit
18+
// the plain URI than to guess a distro that may not exist locally.
19+
expect(ideUri('/home/user/project', { is_wsl: true })).toBe(
20+
'vscode://file//home/user/project'
21+
);
22+
});
23+
24+
test('normalises Windows-style backslashes', () => {
25+
expect(ideUri('C:\\Users\\me\\project')).toBe('vscode://file/C:/Users/me/project');
26+
});
27+
});
28+
29+
describe('WSL2 form', () => {
30+
test('emits vscode://vscode-remote/wsl+<distro>/<path>', () => {
31+
expect(ideUri('/home/user/project', { is_wsl: true, wsl_distro: 'Ubuntu' })).toBe(
32+
'vscode://vscode-remote/wsl+Ubuntu/home/user/project'
33+
);
34+
});
35+
36+
test('preserves leading slash when path already absolute', () => {
37+
const uri = ideUri('/home/user/project', { is_wsl: true, wsl_distro: 'Ubuntu' });
38+
expect(uri).toContain('/wsl+Ubuntu/home/user/project');
39+
expect(uri).not.toContain('/wsl+Ubuntu//home');
40+
});
41+
42+
test('adds leading slash when path is relative-ish (defensive)', () => {
43+
const uri = ideUri('home/user/project', { is_wsl: true, wsl_distro: 'Ubuntu' });
44+
expect(uri).toBe('vscode://vscode-remote/wsl+Ubuntu/home/user/project');
45+
});
46+
47+
test('encodes distro names that contain non-URL-safe characters', () => {
48+
// Hypothetical: WSL distro names with spaces / special chars
49+
const uri = ideUri('/home/user/x', { is_wsl: true, wsl_distro: 'My Distro' });
50+
expect(uri).toBe('vscode://vscode-remote/wsl+My%20Distro/home/user/x');
51+
});
52+
});
53+
});

0 commit comments

Comments
 (0)