Skip to content
Merged
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
244 changes: 147 additions & 97 deletions scripts/wsl-emulator.js
Original file line number Diff line number Diff line change
@@ -1,122 +1,172 @@
#!/usr/bin/env node

// WSL Emulator for cross-platform testing
// Mimics basic wsl.exe behavior for development and testing
// WSL emulator for cross-platform tests.
// Supports:
// - `-l -v` / `--list --verbose`
// - `-e <command> [args...]`

import { spawnSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';

const args = process.argv.slice(2);

// Mock file system for 'ls' command emulation
const mockFileSystem = {
'/tmp': [
// Mimicking 'ls -la /tmp' output structure for simplicity, even if not all details are used by tests
'total 0',
'drwxrwxrwt 2 root root 40 Jan 1 00:00 .',
'drwxr-xr-x 20 root root 480 Jan 1 00:00 ..'
// Add more mock files/dirs for /tmp if needed by other tests, e.g., 'somefile.txt'
],
// Example: Add other mock paths as needed by tests
// '/mnt/c/Users/testuser/docs': ['document1.txt', 'report.pdf'],
};


// Handle WSL list distributions command
if ((args.includes('-l') || args.includes('--list')) &&
(args.includes('-v') || args.includes('--verbose'))) {
function normalizePosixPath(inputPath) {
const normalized = path.posix.normalize(inputPath);
if (normalized === '/') {
return normalized;
}
return normalized.replace(/\/+$/, '');
}

function isPathInAllowedPaths(testPath, allowedPaths) {
const normalizedTestPath = normalizePosixPath(testPath);

return allowedPaths.some((allowedPath) => {
const normalizedAllowedPath = normalizePosixPath(allowedPath);
const relativePath = path.posix.relative(normalizedAllowedPath, normalizedTestPath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.posix.isAbsolute(relativePath));
});
}

function parseAllowedPaths(rawValue) {
if (!rawValue) {
return [];
}

const trimmed = rawValue.trim();
if (!trimmed) {
return [];
}

try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.filter((value) => typeof value === 'string' && value.startsWith('/'));
}
} catch {
// Fall through to delimiter parsing
}

return trimmed
.split(/[;,]/)
.map((value) => value.trim())
.filter((value) => value.startsWith('/'));
}

function validateWorkingDirFromEnv() {
const allowedPathsRaw = process.env.WSL_ALLOWED_PATHS || process.env.ALLOWED_PATHS || '';
const allowedPaths = parseAllowedPaths(allowedPathsRaw);

if (allowedPaths.length === 0) {
return;
}

const workingDir = process.env.WSL_ORIGINAL_PATH || process.cwd();
if (!workingDir.startsWith('/') || !isPathInAllowedPaths(workingDir, allowedPaths)) {
console.error(`WSL working directory is not allowed: ${workingDir}`);
process.exit(1);
}
}

if ((args.includes('-l') || args.includes('--list')) && (args.includes('-v') || args.includes('--verbose'))) {
console.log('NAME STATE VERSION');
console.log('* Ubuntu-Test Running 2');
process.exit(0);
}

// Handle command execution with -e flag
if (args[0] === '-e') {
if (args.length < 2) {
console.error('Error: No command provided after -e flag.');
console.error('Usage: wsl-emulator -e <command>');
process.exit(1);
if (args[0] !== '-e' || args.length < 2) {
console.error('Error: Invalid arguments. Expected -e <command> [args...] OR --list --verbose');
process.exit(1);
}

validateWorkingDirFromEnv();

const command = args[1];
const commandArgs = args.slice(2);
const emulatedWorkingDir = process.env.WSL_ORIGINAL_PATH || process.cwd();

switch (command) {
case 'pwd':
console.log(emulatedWorkingDir);
process.exit(0);
break;
case 'echo':
console.log(commandArgs.join(' '));
process.exit(0);
break;
case 'exit': {
const exitCode = commandArgs.length === 1 ? Number.parseInt(commandArgs[0], 10) : 0;
process.exit(Number.isNaN(exitCode) ? 0 : exitCode);
break;
}
case 'uname':
if (commandArgs.length > 0 && commandArgs[0] === '-a') {
console.log('Linux Ubuntu-Test 5.15.0-0-generic x86_64 GNU/Linux');
} else {
console.log('Linux');
}
process.exit(0);
break;
case 'ls': {
const resolvedArgs = commandArgs.length > 0 ? commandArgs : [emulatedWorkingDir];
const hasAllFlag = resolvedArgs.some((arg) => arg.startsWith('-') && arg.includes('a'));
const explicitTmp = resolvedArgs.includes('/tmp');

// Get the command and its arguments
const command = args[1];
const commandArgs = args.slice(2);

// Special handling for common test commands
switch (command) {
case 'pwd':
// Use original WSL path if available (when called from server)
if (process.env.WSL_ORIGINAL_PATH) {
console.log(process.env.WSL_ORIGINAL_PATH);
} else {
// When called directly (like in standalone tests), return actual directory
console.log(process.cwd());
}
if (hasAllFlag && explicitTmp) {
console.log('total 8');
console.log('drwxrwxrwt 10 root root 4096 Jan 1 00:00 .');
console.log('drwxr-xr-x 23 root root 4096 Jan 1 00:00 ..');
process.exit(0);
break;
case 'exit':
if (commandArgs.length === 1) {
const exitCode = parseInt(commandArgs[0], 10);
if (!isNaN(exitCode)) {
process.exit(exitCode);
}
}

const lsResult = spawnSync('ls', resolvedArgs, {
cwd: process.cwd(),
env: process.env,
encoding: 'utf8'
});

if (!lsResult.error) {
if (lsResult.stdout) {
process.stdout.write(lsResult.stdout);
}
process.exit(0);
break;
case 'ls':
const pathArg = commandArgs.find(arg => arg.startsWith('/'));

if (pathArg) {
// Path argument provided, use mockFileSystem
if (mockFileSystem.hasOwnProperty(pathArg)) {
mockFileSystem[pathArg].forEach(item => console.log(item));
process.exit(0);
} else {
console.error(`ls: cannot access '${pathArg}': No such file or directory`);
process.exit(2);
}
} else {
// No path argument, list contents of process.cwd()
try {
const files = fs.readdirSync(process.cwd());
files.forEach(file => {
console.log(file); // Test 5.1.1 expects to find 'src'
});
process.exit(0);
} catch (e) {
console.error(`ls: cannot read directory '.': ${e.message}`);
process.exit(2);
}
if (lsResult.stderr) {
process.stderr.write(lsResult.stderr);
}
process.exit(typeof lsResult.status === 'number' ? lsResult.status : 0);
break;
case 'echo':
console.log(commandArgs.join(' '));
}

const targetPath = commandArgs.find((arg) => !arg.startsWith('-')) || emulatedWorkingDir;
try {
const entries = fs.readdirSync(targetPath);
entries.forEach((entry) => console.log(entry));
process.exit(0);
break;
case 'uname':
if (commandArgs.includes('-a')) {
console.log('Linux Ubuntu-Test 5.10.0-0-generic #0-Ubuntu SMP x86_64 GNU/Linux');
process.exit(0);
}
break;
} catch {
console.error(`ls: cannot access '${targetPath}': No such file or directory`);
process.exit(2);
}
break;
}

// For other commands, try to execute them
try {
default: {
const result = spawnSync(command, commandArgs, {
stdio: 'inherit',
shell: true,
env: { ...process.env, WSL_DISTRO_NAME: 'Ubuntu-Test' }
cwd: process.cwd(),
env: process.env,
encoding: 'utf8'
});
process.exit(result.status || 0);
} catch (error) {
console.error(`Command execution failed: ${error.message}`);
process.exit(127);

if (result.error) {
console.error(result.error.message);
process.exit(typeof result.status === 'number' ? result.status : 1);
}

if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
process.exit(typeof result.status === 'number' ? result.status : 0);
}
}

// If no recognized command, show error
console.error('Error: Invalid arguments. Expected -e <command> OR --list --verbose');
console.error('Received:', args.join(' '));
process.exit(1);
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ class CLIServer {
let shellProcess: ReturnType<typeof spawn>;
let spawnArgs: string[];

if (shellConfig.type === 'wsl' || shellConfig.type === 'bash') {
if (shellConfig.type === 'wsl') {
const parsedCommand = parseCommand(command);
spawnArgs = [...shellConfig.executable.args, parsedCommand.command, ...parsedCommand.args];
} else {
Expand All @@ -396,7 +396,7 @@ class CLIServer {
// For WSL, convert WSL paths back to Windows paths for spawn cwd
let spawnCwd = workingDir;
let envVars = { ...process.env };
if (shellConfig.type === 'wsl' || shellConfig.type === 'bash') {
if (shellConfig.type === 'wsl') {
if (workingDir.startsWith('/mnt/')) {
// Convert /mnt/c/path to C:\path
const match = workingDir.match(/^\/mnt\/([a-z])\/(.*)$/i);
Expand Down
2 changes: 1 addition & 1 deletion src/utils/validationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function createValidationContext(
): ValidationContext {
const isWindowsShell = shellConfig.type === 'cmd' || shellConfig.type === 'powershell';
const isUnixShell = shellConfig.type === 'gitbash' || shellConfig.type === 'wsl' || shellConfig.type === 'bash';
const isWslShell = shellConfig.type === 'wsl' || shellConfig.type === 'bash';
const isWslShell = shellConfig.type === 'wsl';

return {
shellName,
Expand Down
4 changes: 3 additions & 1 deletion tests/bash/bashShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { CLIServer } from '../../src/index.js';
import { DEFAULT_CONFIG } from '../../src/utils/config.js';
import type { ServerConfig } from '../../src/types/config.js';

const describeWithBash = process.platform === 'win32' ? describe.skip : describe;

let server: CLIServer;
let config: ServerConfig;

Expand Down Expand Up @@ -30,7 +32,7 @@ beforeEach(() => {
server = new CLIServer(config);
});

describe('Bash shell basic execution', () => {
describeWithBash('Bash shell basic execution', () => {
test('echo command', async () => {
const result = await server._executeTool({
name: 'execute_command',
Expand Down
48 changes: 43 additions & 5 deletions tests/helpers/TestCLIServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,48 @@ export class TestCLIServer {
(baseConfig.global.restrictions.blockedArguments || []).filter(a => a !== '-e');
}

// Merge shell overrides deeply so partial test overrides don't drop required defaults.
const mergedShells: ServerConfig['shells'] = { ...(baseConfig.shells || {}) };
for (const [shellName, shellOverride] of Object.entries(overrides.shells || {})) {
const key = shellName as keyof ServerConfig['shells'];
const baseShell = mergedShells[key] as any;
const overrideShell = shellOverride as any;

if (!baseShell) {
(mergedShells as any)[key] = overrideShell;
continue;
}

(mergedShells as any)[key] = {
...baseShell,
...overrideShell,
executable: {
...(baseShell.executable || {}),
...(overrideShell.executable || {})
},
overrides: {
...(baseShell.overrides || {}),
...(overrideShell.overrides || {}),
security: {
...(baseShell.overrides?.security || {}),
...(overrideShell.overrides?.security || {})
},
paths: {
...(baseShell.overrides?.paths || {}),
...(overrideShell.overrides?.paths || {})
},
restrictions: {
...(baseShell.overrides?.restrictions || {}),
...(overrideShell.overrides?.restrictions || {})
}
},
wslConfig: {
...(baseShell.wslConfig || {}),
...(overrideShell.wslConfig || {})
}
};
}

// Merge overrides deeply
const config: ServerConfig = {
...baseConfig,
Expand All @@ -82,11 +124,7 @@ export class TestCLIServer {
...(overrides.global?.restrictions || {})
}
},
shells: {
...baseConfig.shells,
...(overrides.shells || {}),
wsl: overrides.shells?.wsl ? { ...wslShell, ...overrides.shells.wsl } : wslShell
}
shells: mergedShells
} as ServerConfig;

this.server = new CLIServer(config);
Expand Down
Loading