- Remove WSL-specific check in favor of generic URI handling - Support all vscode-remote:// URI schemes (WSL, SSH, dev-container) - Prefer file:// URIs when available, fall back to remote URIs - Add tests for various remote URI formats - Remove CLAUDE.md per reviewer feedback (use AGENTS.md convention) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
855 lines
28 KiB
TypeScript
855 lines
28 KiB
TypeScript
import * as childProcess from "node:child_process";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from "vitest";
|
|
import { IDE, ToolExtras } from "../..";
|
|
import * as processTerminalStates from "../../util/processTerminalStates";
|
|
import { runTerminalCommandTool } from "../definitions/runTerminalCommand";
|
|
import { runTerminalCommandImpl } from "./runTerminalCommand";
|
|
|
|
// We're using real child processes, so ensure these aren't mocked
|
|
vi.unmock("node:child_process");
|
|
vi.unmock("node:util");
|
|
|
|
describe("runTerminalCommandImpl", () => {
|
|
// Setup mocks and spies
|
|
const mockGetIdeInfo = vi.fn();
|
|
const mockGetWorkspaceDirs = vi.fn();
|
|
const mockOnPartialOutput = vi.fn();
|
|
const mockRunCommand = vi.fn();
|
|
|
|
// Use a simple approach to ensure background processes are terminated
|
|
let testPid: number | null = null;
|
|
|
|
// Create a temp directory for our tests
|
|
const tempDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "terminal-command-test-"),
|
|
);
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
|
|
// Setup backgrounded processes handling - don't mock, just make sure it's empty
|
|
// Clear any processes that might be already tracked
|
|
processTerminalStates.clearAllBackgroundProcesses();
|
|
|
|
// Setup IDE mocks
|
|
mockGetIdeInfo.mockReturnValue(Promise.resolve({ remoteName: "local" }));
|
|
mockGetWorkspaceDirs.mockReturnValue(
|
|
Promise.resolve([`file://${tempDir}`]),
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up any lingering test processes
|
|
if (testPid !== null) {
|
|
try {
|
|
// Platform-independent way to kill a process
|
|
if (process.platform === "win32") {
|
|
await new Promise((resolve) => {
|
|
childProcess.exec(`taskkill /F /PID ${testPid}`, () =>
|
|
resolve(null),
|
|
);
|
|
});
|
|
} else {
|
|
process.kill(testPid);
|
|
}
|
|
} catch (e) {
|
|
// Process might already be gone, ignore
|
|
}
|
|
testPid = null;
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
// Clean up the temp directory after tests
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// Helper function to create a ToolExtras object
|
|
const createMockExtras = (
|
|
overrides: Partial<{
|
|
onPartialOutput?: any;
|
|
remoteName?: string;
|
|
}> = {},
|
|
): ToolExtras => {
|
|
// Update IDE info if remoteName is provided
|
|
if (overrides.remoteName) {
|
|
mockGetIdeInfo.mockReturnValue(
|
|
Promise.resolve({ remoteName: overrides.remoteName }),
|
|
);
|
|
}
|
|
|
|
// Create a minimally compliant IDE mock
|
|
const mockIde = {
|
|
getIdeInfo: mockGetIdeInfo,
|
|
getWorkspaceDirs: mockGetWorkspaceDirs,
|
|
runCommand: mockRunCommand,
|
|
// Add stubs for other required IDE methods
|
|
getIdeSettings: vi.fn(),
|
|
getDiff: vi.fn(),
|
|
getClipboardContent: vi.fn(),
|
|
isTelemetryEnabled: vi.fn(),
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
renameFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
globFiles: vi.fn(),
|
|
ls: vi.fn(),
|
|
};
|
|
|
|
// Create a base ToolExtras object with required properties
|
|
const baseExtras = {
|
|
ide: mockIde as unknown as IDE,
|
|
llm: {} as any,
|
|
fetch: {} as any,
|
|
tool: {} as any,
|
|
toolCallId: "test-tool-call",
|
|
} as ToolExtras;
|
|
|
|
// Apply overrides
|
|
if (overrides.onPartialOutput) {
|
|
baseExtras.onPartialOutput = overrides.onPartialOutput;
|
|
}
|
|
|
|
return baseExtras;
|
|
};
|
|
|
|
it("should execute a command synchronously and capture output", async () => {
|
|
// Use Node.js for cross-platform output generation
|
|
const command = `node -e "console.log('real test output')"`;
|
|
const args = { command, waitForCompletion: true };
|
|
const extras = createMockExtras();
|
|
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
// Verify real command output
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe("Terminal");
|
|
expect(result[0].description).toBe("Terminal command output");
|
|
expect(result[0].content).toContain("real test output");
|
|
// Verify status field indicates successful completion
|
|
expect(result[0].status).toBe("Command completed");
|
|
});
|
|
|
|
it("should stream output when onPartialOutput is provided", async () => {
|
|
// This test uses Node to create a command that outputs data incrementally
|
|
const command = `node -e "
|
|
console.log('first output');
|
|
setTimeout(() => {
|
|
console.log('second output');
|
|
console.error('error output');
|
|
}, 50);
|
|
"`;
|
|
|
|
const args = { command, waitForCompletion: true };
|
|
const mockOutputFn = vi.fn();
|
|
const extras = createMockExtras({ onPartialOutput: mockOutputFn });
|
|
|
|
// Execute the command with streaming
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
// Verify onPartialOutput was called with increasing content
|
|
expect(mockOutputFn).toHaveBeenCalled();
|
|
|
|
// Verify final result has all output
|
|
expect(result[0].content).toContain("first output");
|
|
expect(result[0].content).toContain("second output");
|
|
expect(result[0].content).toContain("error output");
|
|
// Verify status field indicates successful completion
|
|
expect(result[0].status).toBe("Command completed");
|
|
|
|
// Verify that initial streaming updates have empty status for regular commands
|
|
const firstCall = mockOutputFn.mock.calls[0][0];
|
|
if (firstCall && typeof firstCall === "object") {
|
|
const contextItems = (firstCall as any).contextItems;
|
|
if (contextItems && Array.isArray(contextItems) && contextItems[0]) {
|
|
// For commands with waitForCompletion=true, status should be empty while streaming
|
|
expect(contextItems[0].status).toBe("");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("should run commands in background when waitForCompletion is false", async () => {
|
|
// Create a self-identifying background process that writes its PID
|
|
// to a file we can access to kill it later
|
|
const pidFile = path.join(tempDir, "test-pid.txt");
|
|
|
|
// This script creates a long-running process and writes its PID to our file
|
|
// Using a fixed duration timeout so we can be sure to clean it up
|
|
const command = `node -e "
|
|
const fs = require('fs');
|
|
console.log('starting background process with PID: ' + process.pid);
|
|
fs.writeFileSync('${pidFile}', process.pid.toString());
|
|
setTimeout(() => {
|
|
console.log('background process completed');
|
|
}, 500);
|
|
"`;
|
|
|
|
const args = { command, waitForCompletion: false };
|
|
const mockOutputFn = vi.fn();
|
|
const extras = createMockExtras({ onPartialOutput: mockOutputFn });
|
|
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
// Result should indicate background running in either content or status
|
|
expect(result[0].content || result[0].status).toContain(
|
|
"running in the background",
|
|
);
|
|
// Verify status field indicates background running
|
|
expect(result[0].status).toBe("Command is running in the background...");
|
|
|
|
// Initial notification should indicate background running
|
|
expect(mockOutputFn).toHaveBeenCalled();
|
|
// Check the first call contains background running message
|
|
const firstCallData = mockOutputFn.mock.calls[0][0];
|
|
if (firstCallData && typeof firstCallData === "object") {
|
|
const contextItems = (firstCallData as any).contextItems;
|
|
if (contextItems && Array.isArray(contextItems) && contextItems[0]) {
|
|
expect(contextItems[0].content).toContain("");
|
|
expect(contextItems[0].status).toMatch(
|
|
/Command is running in the background.../,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Wait a bit to make sure the PID file is written
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
// Read the PID file to get the process ID so we can kill it later
|
|
if (fs.existsSync(pidFile)) {
|
|
testPid = Number(fs.readFileSync(pidFile, "utf-8"));
|
|
}
|
|
|
|
// Wait a bit longer for the process to complete and onPartialOutput to be called again
|
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
|
|
// Check for background process completion message in mockOutputFn calls
|
|
// Note: This is optional and depends on timing, so we don't want to strictly enforce it
|
|
let foundCompletionMessage = false;
|
|
|
|
for (let i = 0; i < mockOutputFn.mock.calls.length; i++) {
|
|
const call = mockOutputFn.mock.calls[i][0];
|
|
if (call && typeof call === "object") {
|
|
const contextItems = (call as any).contextItems;
|
|
if (contextItems && Array.isArray(contextItems) && contextItems[0]) {
|
|
if (
|
|
contextItems[0].status === "\nBackground command completed" ||
|
|
(contextItems[0].status &&
|
|
contextItems[0].status.includes("Background command"))
|
|
) {
|
|
foundCompletionMessage = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found any completion message in the output, great!
|
|
// But we won't fail the test if we didn't, since it may be timing-dependent
|
|
if (mockOutputFn.mock.calls.length > 1) {
|
|
expect(foundCompletionMessage || true).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it("should handle remote environments", async () => {
|
|
// We'll keep mocking for remote environments as we can't test those directly
|
|
const args = { command: "echo 'test'", waitForCompletion: true };
|
|
const extras = createMockExtras({ remoteName: "ssh" });
|
|
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
// In remote environments, it should use the IDE's runCommand
|
|
expect(mockRunCommand).toHaveBeenCalledWith("echo 'test'");
|
|
// Match the actual output message
|
|
expect(result[0].content).toContain("Terminal output not available");
|
|
expect(result[0].content).toContain("SSH environments");
|
|
// Verify status field indicates command failed in remote environments
|
|
expect(result[0].status).toBe("Command failed");
|
|
});
|
|
|
|
it("should handle errors when executing invalid commands", async () => {
|
|
// Use a command that should fail on any platform
|
|
const args = {
|
|
command: "non-existent-command-xyz",
|
|
waitForCompletion: true,
|
|
};
|
|
const extras = createMockExtras();
|
|
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
expect(result[0].name).toBe("Terminal");
|
|
expect(result[0].description).toBe("Terminal command output");
|
|
|
|
// The exact error message varies by platform, but should contain error information
|
|
const errorContent = result[0].content;
|
|
expect(
|
|
errorContent.includes("Command failed") ||
|
|
errorContent.includes("not found") ||
|
|
errorContent.includes("recognized") ||
|
|
/error/i.test(errorContent),
|
|
).toBe(true);
|
|
|
|
// Verify status field indicates command failure
|
|
expect(result[0].status).toContain("Command failed with:");
|
|
});
|
|
|
|
it("should handle error situations gracefully", async () => {
|
|
// Use Node.js script that writes to stderr for cross-platform testing
|
|
const command = `node -e "process.stderr.write('This is an error message'); process.exit(1)"`;
|
|
const args = { command, waitForCompletion: true };
|
|
const extras = createMockExtras();
|
|
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
// Verify we still get a result with a name and description
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe("Terminal");
|
|
expect(result[0].description).toBe("Terminal command output");
|
|
|
|
// The command should have executed and returned content
|
|
expect(result[0].content).toBeDefined();
|
|
|
|
// We should see the error message we wrote to stderr
|
|
expect(result[0].content).toContain("This is an error message");
|
|
|
|
// Verify status field indicates command failure - the exact message format can vary
|
|
// between different node versions and platforms
|
|
expect(result[0].status).toContain("Command failed");
|
|
});
|
|
|
|
it("should set appropriate status when a command completes successfully", async () => {
|
|
// Use a simple command that will succeed
|
|
const command = `node -e "console.log('success test')"`;
|
|
const args = { command, waitForCompletion: true };
|
|
const extras = createMockExtras();
|
|
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe("Terminal");
|
|
expect(result[0].content).toContain("success test");
|
|
// Verify status field for successful command
|
|
expect(result[0].status).toBe("Command completed");
|
|
});
|
|
|
|
it("should include status info for background commands in non-streaming mode", async () => {
|
|
// Use a simple background command
|
|
const command = `node -e "setTimeout(() => console.log('done'), 300)"`;
|
|
const args = { command, waitForCompletion: false };
|
|
// No streaming in this test (no onPartialOutput)
|
|
const extras = createMockExtras();
|
|
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe("Terminal");
|
|
// Verify both content and status fields for background commands
|
|
expect(result[0].content).toBe("Command is running in the background...");
|
|
expect(result[0].status).toBe("Command is running in the background...");
|
|
});
|
|
|
|
it("should handle missing workspace directory gracefully", async () => {
|
|
// Mock IDE to return empty workspace directories
|
|
const mockEmptyWorkspace = vi.fn().mockReturnValue(Promise.resolve([]));
|
|
|
|
// Create IDE mock with empty workspace
|
|
const mockIde = {
|
|
getIdeInfo: vi
|
|
.fn()
|
|
.mockReturnValue(Promise.resolve({ remoteName: "local" })),
|
|
getWorkspaceDirs: mockEmptyWorkspace,
|
|
runCommand: vi.fn(),
|
|
getIdeSettings: vi.fn(),
|
|
getDiff: vi.fn(),
|
|
getClipboardContent: vi.fn(),
|
|
isTelemetryEnabled: vi.fn(),
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
renameFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
globFiles: vi.fn(),
|
|
ls: vi.fn(),
|
|
};
|
|
|
|
const extras = {
|
|
ide: mockIde as unknown as IDE,
|
|
llm: {} as any,
|
|
fetch: {} as any,
|
|
tool: {} as any,
|
|
toolCallId: "test-tool-call",
|
|
} as ToolExtras;
|
|
|
|
const command = `node -e "console.log('no workspace test')"`;
|
|
const args = { command, waitForCompletion: true };
|
|
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
// Should still work - falling back to HOME or cwd
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe("Terminal");
|
|
expect(result[0].description).toBe("Terminal command output");
|
|
expect(result[0].content).toContain("no workspace test");
|
|
expect(result[0].status).toBe("Command completed");
|
|
|
|
// Verify workspace dirs was called but returned empty
|
|
expect(mockEmptyWorkspace).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle case where cwd fallbacks all fail", async () => {
|
|
// Mock IDE to return empty workspace directories
|
|
const mockEmptyWorkspace = vi.fn().mockReturnValue(Promise.resolve([]));
|
|
|
|
// Save original environment variables and process.cwd
|
|
const originalHome = process.env.HOME;
|
|
const originalUserProfile = process.env.USERPROFILE;
|
|
const originalCwd = process.cwd;
|
|
|
|
try {
|
|
// Mock all fallbacks to fail
|
|
delete process.env.HOME;
|
|
delete process.env.USERPROFILE;
|
|
process.cwd = vi.fn().mockImplementation(() => {
|
|
throw new Error("Current directory unavailable");
|
|
}) as any;
|
|
|
|
// Create IDE mock with empty workspace
|
|
const mockIde = {
|
|
getIdeInfo: vi
|
|
.fn()
|
|
.mockReturnValue(Promise.resolve({ remoteName: "local" })),
|
|
getWorkspaceDirs: mockEmptyWorkspace,
|
|
runCommand: vi.fn(),
|
|
getIdeSettings: vi.fn(),
|
|
getDiff: vi.fn(),
|
|
getClipboardContent: vi.fn(),
|
|
isTelemetryEnabled: vi.fn(),
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
renameFile: vi.fn(),
|
|
deleteFile: vi.fn(),
|
|
globFiles: vi.fn(),
|
|
ls: vi.fn(),
|
|
};
|
|
|
|
const extras = {
|
|
ide: mockIde as unknown as IDE,
|
|
llm: {} as any,
|
|
fetch: {} as any,
|
|
tool: {} as any,
|
|
toolCallId: "test-tool-call",
|
|
} as ToolExtras;
|
|
|
|
const command = `node -e "console.log('fallback test')"`;
|
|
const args = { command, waitForCompletion: true };
|
|
|
|
// This should now handle the case gracefully by falling back to temp directory
|
|
const result = await runTerminalCommandImpl(args, extras);
|
|
|
|
// Should work using the temp directory as fallback
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].name).toBe("Terminal");
|
|
expect(result[0].content).toContain("fallback test");
|
|
expect(result[0].status).toBe("Command completed");
|
|
|
|
console.log(
|
|
"Successfully handled cwd fallback to temp directory:",
|
|
result[0].status,
|
|
);
|
|
} finally {
|
|
// Always restore original values
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
}
|
|
if (originalUserProfile !== undefined) {
|
|
process.env.USERPROFILE = originalUserProfile;
|
|
}
|
|
process.cwd = originalCwd;
|
|
}
|
|
});
|
|
|
|
describe("cwd handling", () => {
|
|
describe("workspace directory handling", () => {
|
|
it("should use file:// URI when available", async () => {
|
|
const fileUri = "file:///home/user/workspace";
|
|
mockGetWorkspaceDirs.mockResolvedValue([fileUri]);
|
|
|
|
// We can't easily test the internal cwd without mocking child_process,
|
|
// but we can verify the function doesn't throw with file URIs
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("should skip non-file URIs and use the first file:// URI", async () => {
|
|
const workspaceDirs = [
|
|
"vscode-vfs://github/user/repo",
|
|
"untitled:/Untitled-1",
|
|
"file:///home/user/workspace",
|
|
"file:///home/user/other-workspace",
|
|
];
|
|
mockGetWorkspaceDirs.mockResolvedValue(workspaceDirs);
|
|
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("should handle workspace with only non-file URIs", async () => {
|
|
const workspaceDirs = [
|
|
"vscode-vfs://github/user/repo",
|
|
"untitled:/Untitled-1",
|
|
];
|
|
mockGetWorkspaceDirs.mockResolvedValue(workspaceDirs);
|
|
|
|
// Should fall back to HOME/USERPROFILE or process.cwd() without throwing
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("should handle empty workspace directories", async () => {
|
|
mockGetWorkspaceDirs.mockResolvedValue([]);
|
|
|
|
// Should fall back to HOME/USERPROFILE or process.cwd() without throwing
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("should properly convert file:// URIs to paths", () => {
|
|
const fileUri = "file:///home/user/workspace";
|
|
const expectedPath = "/home/user/workspace";
|
|
|
|
// Test that fileURLToPath works correctly with file:// URIs
|
|
expect(fileURLToPath(fileUri)).toBe(expectedPath);
|
|
});
|
|
|
|
it("should throw error when trying to convert non-file URI", () => {
|
|
const nonFileUri = "vscode-vfs://github/user/repo";
|
|
|
|
// This demonstrates why the fix is needed - fileURLToPath throws on non-file URIs
|
|
expect(() => fileURLToPath(nonFileUri)).toThrow();
|
|
});
|
|
|
|
it("should handle vscode-remote URIs by extracting pathname", async () => {
|
|
// Various remote URI formats that VS Code uses
|
|
const remoteUris = [
|
|
"vscode-remote://wsl+Ubuntu/home/user/project",
|
|
"vscode-remote://ssh-remote+myserver/home/user/project",
|
|
"vscode-remote://dev-container+abc123/workspace",
|
|
];
|
|
|
|
for (const uri of remoteUris) {
|
|
mockGetWorkspaceDirs.mockResolvedValue([uri]);
|
|
|
|
// Should not throw - the generic URI handler extracts the pathname
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("should decode URI-encoded characters in remote workspace paths", async () => {
|
|
// Path with spaces and special characters
|
|
const encodedUri =
|
|
"vscode-remote://wsl+Ubuntu/home/user/my%20project%20%28test%29";
|
|
mockGetWorkspaceDirs.mockResolvedValue([encodedUri]);
|
|
|
|
// Should handle without throwing - decodeURIComponent is applied
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("should prefer file:// URIs over remote URIs when both present", async () => {
|
|
const workspaceDirs = [
|
|
"vscode-remote://wsl+Ubuntu/home/user/remote-project",
|
|
"file:///home/user/local-project",
|
|
];
|
|
mockGetWorkspaceDirs.mockResolvedValue(workspaceDirs);
|
|
|
|
// Should succeed, preferring the file:// URI
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("remote environment handling", () => {
|
|
it("should use ide.runCommand for non-enabled remote environments", async () => {
|
|
const extras = createMockExtras({
|
|
remoteName: "some-unsupported-remote",
|
|
});
|
|
|
|
const result = await runTerminalCommandImpl(
|
|
{ command: "echo test" },
|
|
extras,
|
|
);
|
|
|
|
expect(mockRunCommand).toHaveBeenCalledWith("echo test");
|
|
expect(result[0].content).toContain("Terminal output not available");
|
|
});
|
|
|
|
it("should handle local environment with file URIs", async () => {
|
|
mockGetWorkspaceDirs.mockResolvedValue(["file:///home/user/workspace"]);
|
|
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras({ remoteName: "local" }),
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("should handle WSL environment", async () => {
|
|
mockGetWorkspaceDirs.mockResolvedValue(["file:///home/user/workspace"]);
|
|
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras({ remoteName: "wsl" }),
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("should handle dev-container environment", async () => {
|
|
mockGetWorkspaceDirs.mockResolvedValue(["file:///workspace"]);
|
|
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras({ remoteName: "dev-container" }),
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("fallback behavior", () => {
|
|
it("should use HOME environment variable as fallback", async () => {
|
|
const originalHome = process.env.HOME;
|
|
process.env.HOME = "/home/testuser";
|
|
|
|
mockGetWorkspaceDirs.mockResolvedValue([
|
|
"vscode-vfs://github/user/repo",
|
|
]);
|
|
|
|
try {
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
} finally {
|
|
process.env.HOME = originalHome;
|
|
}
|
|
});
|
|
|
|
it("should use USERPROFILE on Windows as fallback", async () => {
|
|
const originalHome = process.env.HOME;
|
|
const originalUserProfile = process.env.USERPROFILE;
|
|
|
|
delete process.env.HOME;
|
|
process.env.USERPROFILE = "C:\\Users\\TestUser";
|
|
|
|
mockGetWorkspaceDirs.mockResolvedValue([]);
|
|
|
|
try {
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
} finally {
|
|
process.env.HOME = originalHome;
|
|
process.env.USERPROFILE = originalUserProfile;
|
|
}
|
|
});
|
|
|
|
it("should use os.tmpdir() as final fallback", async () => {
|
|
const originalHome = process.env.HOME;
|
|
const originalUserProfile = process.env.USERPROFILE;
|
|
const originalCwd = process.cwd;
|
|
|
|
delete process.env.HOME;
|
|
delete process.env.USERPROFILE;
|
|
// Mock process.cwd to throw an error
|
|
process.cwd = vi.fn().mockImplementation(() => {
|
|
throw new Error("No cwd available");
|
|
}) as typeof process.cwd;
|
|
|
|
mockGetWorkspaceDirs.mockResolvedValue([]);
|
|
|
|
try {
|
|
// Should fall back to os.tmpdir() without throwing
|
|
await expect(
|
|
runTerminalCommandImpl(
|
|
{ command: "echo test", waitForCompletion: false },
|
|
createMockExtras(),
|
|
),
|
|
).resolves.toBeDefined();
|
|
} finally {
|
|
process.env.HOME = originalHome;
|
|
process.env.USERPROFILE = originalUserProfile;
|
|
process.cwd = originalCwd;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("runTerminalCommandTool.evaluateToolCallPolicy", () => {
|
|
it("should return base policy for safe commands like echo", () => {
|
|
const basePolicy = "allowedWithoutPermission";
|
|
const args = { command: "echo hello world" };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("allowedWithoutPermission");
|
|
});
|
|
|
|
it("should respect disabled policy even for non-echo commands", () => {
|
|
const basePolicy = "disabled";
|
|
const args = { command: "ls -la" };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("disabled");
|
|
});
|
|
|
|
it("should respect disabled policy even for safe commands", () => {
|
|
const basePolicy = "disabled";
|
|
const args = { command: "echo test" };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("disabled");
|
|
});
|
|
|
|
it("should return base policy for non-echo commands", () => {
|
|
const basePolicy = "allowedWithoutPermission";
|
|
const args = { command: "ls -la" };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("allowedWithoutPermission");
|
|
});
|
|
|
|
it("should return base policy for git commands", () => {
|
|
const basePolicy = "allowedWithPermission";
|
|
const args = { command: "git status" };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("allowedWithPermission");
|
|
});
|
|
|
|
it("should handle undefined command gracefully", () => {
|
|
const basePolicy = "allowedWithoutPermission";
|
|
const args = { command: undefined };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("allowedWithoutPermission");
|
|
});
|
|
|
|
it("should handle null command gracefully", () => {
|
|
const basePolicy = "allowedWithoutPermission";
|
|
const args = { command: null };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("allowedWithoutPermission");
|
|
});
|
|
|
|
it("should handle empty command string", () => {
|
|
const basePolicy = "allowedWithoutPermission";
|
|
const args = { command: "" };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("allowedWithoutPermission");
|
|
});
|
|
|
|
it("should disable dangerous commands like rm -rf /", () => {
|
|
const basePolicy = "allowedWithoutPermission";
|
|
const args = { command: "rm -rf /" };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("disabled");
|
|
});
|
|
|
|
it("should require permission for high-risk network commands", () => {
|
|
const basePolicy = "allowedWithoutPermission";
|
|
const args = { command: "curl http://example.com" };
|
|
|
|
const result = runTerminalCommandTool.evaluateToolCallPolicy!(
|
|
basePolicy,
|
|
args,
|
|
);
|
|
|
|
expect(result).toBe("allowedWithPermission");
|
|
});
|
|
});
|