Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
38 changes: 37 additions & 1 deletion packages/paths/src/archon-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const isWindows = process.platform === 'win32';

import {
isDocker,
isWSL,
getWSLDistroName,
getArchonHome,
getArchonWorkspacesPath,
ensureArchonWorkspacesPath,
Expand Down Expand Up @@ -39,7 +41,14 @@ import {
} from './archon-paths';

/** All env vars that path functions depend on */
const ENV_VARS = ['WORKSPACE_PATH', 'WORKTREE_BASE', 'ARCHON_HOME', 'ARCHON_DOCKER', 'HOME'];
const ENV_VARS = [
'WORKSPACE_PATH',
'WORKTREE_BASE',
'ARCHON_HOME',
'ARCHON_DOCKER',
'HOME',
'WSL_DISTRO_NAME',
];

/**
* Save and restore environment variables around each test.
Expand Down Expand Up @@ -78,6 +87,33 @@ describe('archon-paths', () => {
});
});

describe('isWSL', () => {
test('returns true when WSL_DISTRO_NAME is set', () => {
process.env.WSL_DISTRO_NAME = 'Ubuntu';
expect(isWSL()).toBe(true);
});

test('returns false when WSL_DISTRO_NAME is unset and not on a WSL kernel', () => {
delete process.env.WSL_DISTRO_NAME;
// /proc/sys/kernel/osrelease on real Linux CI doesn't contain "microsoft";
// on a WSL host this assertion is naturally true via the env-var branch above.
// Skip the strict check here — just verify it doesn't throw.
expect(typeof isWSL()).toBe('boolean');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

describe('getWSLDistroName', () => {
test('returns the WSL_DISTRO_NAME env var when set', () => {
process.env.WSL_DISTRO_NAME = 'Debian';
expect(getWSLDistroName()).toBe('Debian');
});

test('returns undefined when WSL_DISTRO_NAME is unset', () => {
delete process.env.WSL_DISTRO_NAME;
expect(getWSLDistroName()).toBeUndefined();
});
});

describe('isDocker', () => {
test('returns true when WORKSPACE_PATH is /workspace', () => {
process.env.WORKSPACE_PATH = '/workspace';
Expand Down
42 changes: 42 additions & 0 deletions packages/paths/src/archon-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { join, dirname, normalize, basename } from 'path';
import { homedir } from 'os';
import { access, mkdir, symlink, lstat, readdir, readlink, rm } from 'fs/promises';
import { readFileSync } from 'fs';
import { createLogger } from './logger';

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

/**
* Detect if running inside WSL (Windows Subsystem for Linux).
*
* Two signals (either is sufficient):
* - `WSL_DISTRO_NAME` env var is set (always true inside a WSL distro)
* - `/proc/sys/kernel/osrelease` contains "microsoft" (lower-cased)
*
* Used by callers that need to emit Windows-host-friendly URIs — most
* notably the Web UI's "Open in IDE" button, which has to switch from
* `vscode://file/...` to `vscode://vscode-remote/wsl+<distro>/...` when
* the server is inside WSL but the browser is on the Windows host.
*
* Not cached: the env-var read is free and the `/proc` read is cheap
* enough at the call frequency this hits (one per `/api/health` request,
* which the Web UI polls every 30 s).
*/
export function isWSL(): boolean {
if (process.env.WSL_DISTRO_NAME) return true;

try {
const release = readFileSync('/proc/sys/kernel/osrelease', 'utf8').toLowerCase();
return release.includes('microsoft');
} catch {
return false;
}
}

/**
* Return the WSL distribution name (`Ubuntu`, `Debian`, …) when the process
* is running inside WSL, otherwise `undefined`. The value comes straight
* from the `WSL_DISTRO_NAME` env var that WSL sets in every distro.
*
* Returns `undefined` outside of WSL, even though `isWSL()` may technically
* still be true via the `/proc` fallback — without the env var we don't
* know what distro to put into a `vscode://vscode-remote/wsl+<distro>/...`
* URI, and guessing is worse than a sentinel that callers can fall back on.
*/
export function getWSLDistroName(): string | undefined {
return process.env.WSL_DISTRO_NAME ?? undefined;
}

/**
* Get the Archon home directory
* - Docker: /.archon
Expand Down
2 changes: 2 additions & 0 deletions packages/paths/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
export {
expandTilde,
isDocker,
isWSL,
getWSLDistroName,
getArchonHome,
getArchonWorkspacesPath,
ensureArchonWorkspacesPath,
Expand Down
6 changes: 6 additions & 0 deletions packages/server/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
getRunArtifactsPath,
getArchonHome,
isDocker,
isWSL,
getWSLDistroName,
checkForUpdate,
BUNDLED_IS_BINARY,
BUNDLED_VERSION,
Expand Down Expand Up @@ -824,6 +826,8 @@ const getHealthRoute = createRoute({
runningWorkflows: z.number(),
version: z.string().optional(),
is_docker: z.boolean(),
is_wsl: z.boolean(),
wsl_distro: z.string().optional(),
activePlatforms: z.array(z.string()).optional(),
})
.openapi('HealthResponse'),
Expand Down Expand Up @@ -2753,6 +2757,8 @@ export function registerApiRoutes(
runningWorkflows: runningWorkflowRows.length,
version: appVersion,
is_docker: isDocker(),
is_wsl: isWSL(),
...(getWSLDistroName() ? { wsl_distro: getWSLDistroName() } : {}),
activePlatforms: activePlatforms ? [...activePlatforms] : ['Web'],
});
});
Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/components/chat/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export function ChatInterface({ conversationId }: ChatInterfaceProps): React.Rea
});
// Default to true (hide button) until server confirms non-Docker — prevents broken vscode:// links
const isDocker = health?.is_docker ?? true;
const isWsl = health?.is_wsl ?? false;
const wslDistro = health?.wsl_distro;

// Sync messages to cache for persistence across navigation
useEffect(() => {
Expand Down Expand Up @@ -710,6 +712,8 @@ export function ChatInterface({ conversationId }: ChatInterfaceProps): React.Rea
projectName={currentCodebase?.name ?? contextCodebase?.name}
connected={isNewChat ? undefined : connected}
isDocker={isDocker}
isWsl={isWsl}
wslDistro={wslDistro}
/>
{(conversationsError || codebasesError) && (
<div className="flex gap-2 px-4 py-1">
Expand Down
7 changes: 6 additions & 1 deletion packages/web/src/components/dashboard/WorkflowRunCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from 'lucide-react';
import type { DashboardRunResponse } from '@/lib/api';
import { cn } from '@/lib/utils';
import { ideUri } from '@/lib/ide-uri';
import { formatDuration } from '@/lib/format';
import { useWorkflowStore } from '@/stores/workflow-store';
import type { WorkflowState } from '@/lib/types';
Expand All @@ -27,6 +28,8 @@ import { ConfirmRunActionDialog } from './ConfirmRunActionDialog';
interface WorkflowRunCardProps {
run: DashboardRunResponse;
isDocker?: boolean;
isWsl?: boolean;
wslDistro?: string;
onCancel: (runId: string) => void;
onResume?: (runId: string) => void;
onAbandon?: (runId: string) => void;
Expand Down Expand Up @@ -137,6 +140,8 @@ function NodeCountsSummary({ counts }: { counts: NodeCounts }): React.ReactEleme
export function WorkflowRunCard({
run,
isDocker,
isWsl,
wslDistro,
onCancel,
onResume,
onAbandon,
Expand Down Expand Up @@ -297,7 +302,7 @@ export function WorkflowRunCard({
)}
{run.working_path && !isDocker && (
<a
href={`vscode://file/${run.working_path.replace(/\\/g, '/')}`}
href={ideUri(run.working_path, { is_wsl: isWsl, wsl_distro: wslDistro })}
target="_blank"
rel="noopener noreferrer"
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"
Expand Down
6 changes: 6 additions & 0 deletions packages/web/src/components/dashboard/WorkflowRunGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ interface WorkflowRunGroupProps {
parentPlatformId: string | null;
runs: DashboardRunResponse[];
isDocker?: boolean;
isWsl?: boolean;
wslDistro?: string;
onCancel: (runId: string) => void;
onResume?: (runId: string) => void;
onAbandon?: (runId: string) => void;
Expand All @@ -19,6 +21,8 @@ export function WorkflowRunGroup({
parentPlatformId,
runs,
isDocker,
isWsl,
wslDistro,
onCancel,
onResume,
onAbandon,
Expand Down Expand Up @@ -54,6 +58,8 @@ export function WorkflowRunGroup({
key={run.id}
run={run}
isDocker={isDocker}
isWsl={isWsl}
wslDistro={wslDistro}
onCancel={onCancel}
onResume={onResume}
onAbandon={onAbandon}
Expand Down
9 changes: 6 additions & 3 deletions packages/web/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { useState } from 'react';
import { ExternalLink, Copy, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ideUri } from '@/lib/ide-uri';

interface HeaderProps {
title: string;
subtitle?: string;
projectName?: string;
connected?: boolean;
isDocker?: boolean;
isWsl?: boolean;
wslDistro?: string;
}

function smartPath(fullPath: string): string {
Expand All @@ -22,14 +25,14 @@ export function Header({
projectName,
connected,
isDocker,
isWsl,
wslDistro,
}: HeaderProps): React.ReactElement {
const [copied, setCopied] = useState(false);

const openInVSCode = (): void => {
if (subtitle) {
// Normalize backslashes to forward slashes for the vscode:// URI
const normalizedPath = subtitle.replace(/\\/g, '/');
window.open(`vscode://file/${normalizedPath}`, '_blank');
window.open(ideUri(subtitle, { is_wsl: isWsl, wsl_distro: wslDistro }), '_blank');
}
};

Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export interface HealthResponse {
runningWorkflows: number;
version?: string;
is_docker: boolean;
is_wsl: boolean;
/** WSL distribution name (e.g. "Ubuntu") — only present when is_wsl is true. */
wsl_distro?: string;
activePlatforms?: string[];
}

Expand Down
53 changes: 53 additions & 0 deletions packages/web/src/lib/ide-uri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, test, expect } from 'bun:test';
import { ideUri } from './ide-uri';

describe('ideUri', () => {
describe('default (non-WSL) form', () => {
test('emits vscode://file/<path> when env is undefined', () => {
expect(ideUri('/home/user/project')).toBe('vscode://file//home/user/project');
});

test('emits vscode://file/<path> when env.is_wsl is false', () => {
expect(ideUri('/home/user/project', { is_wsl: false })).toBe(
'vscode://file//home/user/project'
);
});

test('falls back when is_wsl is true but wsl_distro is missing', () => {
// Without a distro name we can't construct the WSL form — better to emit
// the plain URI than to guess a distro that may not exist locally.
expect(ideUri('/home/user/project', { is_wsl: true })).toBe(
'vscode://file//home/user/project'
);
});

test('normalises Windows-style backslashes', () => {
expect(ideUri('C:\\Users\\me\\project')).toBe('vscode://file/C:/Users/me/project');
});
});

describe('WSL2 form', () => {
test('emits vscode://vscode-remote/wsl+<distro>/<path>', () => {
expect(ideUri('/home/user/project', { is_wsl: true, wsl_distro: 'Ubuntu' })).toBe(
'vscode://vscode-remote/wsl+Ubuntu/home/user/project'
);
});

test('preserves leading slash when path already absolute', () => {
const uri = ideUri('/home/user/project', { is_wsl: true, wsl_distro: 'Ubuntu' });
expect(uri).toContain('/wsl+Ubuntu/home/user/project');
expect(uri).not.toContain('/wsl+Ubuntu//home');
});

test('adds leading slash when path is relative-ish (defensive)', () => {
const uri = ideUri('home/user/project', { is_wsl: true, wsl_distro: 'Ubuntu' });
expect(uri).toBe('vscode://vscode-remote/wsl+Ubuntu/home/user/project');
});

test('encodes distro names that contain non-URL-safe characters', () => {
// Hypothetical: WSL distro names with spaces / special chars
const uri = ideUri('/home/user/x', { is_wsl: true, wsl_distro: 'My Distro' });
expect(uri).toBe('vscode://vscode-remote/wsl+My%20Distro/home/user/x');
});
});
});
31 changes: 31 additions & 0 deletions packages/web/src/lib/ide-uri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Build a `vscode://...` URI for opening a server-side absolute path in the
* user's locally-installed VS Code.
*
* Two flavours, picked based on where the Archon server is running:
*
* - **default**: `vscode://file/<path>` — works when the path is reachable
* from the same OS as the browser (typical local dev or remote SSH proxied
* to localhost).
* - **WSL2**: `vscode://vscode-remote/wsl+<distro>/<path>` — required when
* Archon runs inside a WSL2 distro and the browser is on the Windows host.
* Without this prefix Windows VS Code receives the Linux path verbatim and
* tries to resolve it on the Windows filesystem, which silently fails.
*
* @param path Absolute path on the server, as reported by the API. Backslashes
* are normalised to forward slashes.
* @param env Server-environment hints from `/api/health` — `is_wsl` plus
* `wsl_distro` when known. Omit (or pass `is_wsl: false`) for
* the plain `vscode://file/...` form.
*/
export function ideUri(path: string, env?: { is_wsl?: boolean; wsl_distro?: string }): string {
const normalised = path.replace(/\\/g, '/');

if (env?.is_wsl && env.wsl_distro) {
// vscode-remote URIs need a leading slash before the absolute Linux path
const withLeadingSlash = normalised.startsWith('/') ? normalised : `/${normalised}`;
return `vscode://vscode-remote/wsl+${encodeURIComponent(env.wsl_distro)}${withLeadingSlash}`;
}

return `vscode://file/${normalised}`;
}
4 changes: 4 additions & 0 deletions packages/web/src/routes/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@ export function DashboardPage(): React.ReactElement {
key={run.id}
run={run}
isDocker={health?.is_docker}
isWsl={health?.is_wsl}
wslDistro={health?.wsl_distro}
onCancel={handleCancel}
onResume={handleResume}
onAbandon={handleAbandon}
Expand All @@ -392,6 +394,8 @@ export function DashboardPage(): React.ReactElement {
parentPlatformId={group.parentPlatformId}
runs={group.runs}
isDocker={health?.is_docker}
isWsl={health?.is_wsl}
wslDistro={health?.wsl_distro}
onCancel={handleCancel}
onResume={handleResume}
onAbandon={handleAbandon}
Expand Down