From e319f8b025ff0370ccf5d2e0b8ceb2783c44358d Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 16 Sep 2025 16:26:07 +0000 Subject: [PATCH] feat: persist chat history in Remote SSH sessions - Add remote environment detection utility to identify SSH, WSL, Dev Containers, etc. - Modify storage paths to include remote-specific subdirectories - Add configuration option "persistChatInRemote" to control the feature - Ensure chat history is saved per remote workspace and restored on reconnection - Add comprehensive tests for remote storage functionality Fixes #8028 --- src/package.json | 5 + src/package.nls.json | 1 + src/utils/__tests__/remoteEnvironment.spec.ts | 234 ++++++++++++++++++ src/utils/__tests__/storage.spec.ts | 18 +- src/utils/remoteEnvironment.ts | 82 ++++++ src/utils/storage.ts | 60 +++-- 6 files changed, 380 insertions(+), 20 deletions(-) create mode 100644 src/utils/__tests__/remoteEnvironment.spec.ts create mode 100644 src/utils/remoteEnvironment.ts diff --git a/src/package.json b/src/package.json index 1d121f32a3..cd06fb3d1a 100644 --- a/src/package.json +++ b/src/package.json @@ -384,6 +384,11 @@ "default": "", "description": "%settings.customStoragePath.description%" }, + "roo-cline.persistChatInRemote": { + "type": "boolean", + "default": true, + "description": "%settings.persistChatInRemote.description%" + }, "roo-cline.enableCodeActions": { "type": "boolean", "default": true, diff --git a/src/package.nls.json b/src/package.nls.json index b0b7f401f8..3bad2503d8 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -36,6 +36,7 @@ "settings.vsCodeLmModelSelector.vendor.description": "The vendor of the language model (e.g. copilot)", "settings.vsCodeLmModelSelector.family.description": "The family of the language model (e.g. gpt-4)", "settings.customStoragePath.description": "Custom storage path. Leave empty to use the default location. Supports absolute paths (e.g. 'D:\\RooCodeStorage')", + "settings.persistChatInRemote.description": "Persist chat history in Remote SSH sessions. When enabled, chat history will be saved per remote workspace and restored on reconnection.", "settings.enableCodeActions.description": "Enable Roo Code quick fixes", "settings.autoImportSettingsPath.description": "Path to a RooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import.", "settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)", diff --git a/src/utils/__tests__/remoteEnvironment.spec.ts b/src/utils/__tests__/remoteEnvironment.spec.ts new file mode 100644 index 0000000000..8a688292a6 --- /dev/null +++ b/src/utils/__tests__/remoteEnvironment.spec.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" +import { + isRemoteEnvironment, + getRemoteType, + getRemoteWorkspaceId, + getEnvironmentStoragePath, +} from "../remoteEnvironment" + +// Mock vscode module +vi.mock("vscode", () => ({ + env: { + remoteName: undefined, + }, + workspace: { + workspaceFolders: undefined, + }, +})) + +describe("remoteEnvironment", () => { + let mockVscode: any + + beforeEach(() => { + mockVscode = vi.mocked(vscode) + }) + + afterEach(() => { + // Reset mocks after each test + mockVscode.env.remoteName = undefined + mockVscode.workspace.workspaceFolders = undefined + }) + + describe("isRemoteEnvironment", () => { + it("should return false when not in remote environment", () => { + mockVscode.env.remoteName = undefined + expect(isRemoteEnvironment()).toBe(false) + }) + + it("should return true when in SSH remote environment", () => { + mockVscode.env.remoteName = "ssh-remote" + expect(isRemoteEnvironment()).toBe(true) + }) + + it("should return true when in WSL environment", () => { + mockVscode.env.remoteName = "wsl" + expect(isRemoteEnvironment()).toBe(true) + }) + + it("should return true when in dev container environment", () => { + mockVscode.env.remoteName = "dev-container" + expect(isRemoteEnvironment()).toBe(true) + }) + + it("should return true when in codespaces environment", () => { + mockVscode.env.remoteName = "codespaces" + expect(isRemoteEnvironment()).toBe(true) + }) + }) + + describe("getRemoteType", () => { + it("should return undefined when not in remote environment", () => { + mockVscode.env.remoteName = undefined + expect(getRemoteType()).toBeUndefined() + }) + + it("should return 'ssh-remote' for SSH remote", () => { + mockVscode.env.remoteName = "ssh-remote" + expect(getRemoteType()).toBe("ssh-remote") + }) + + it("should return 'wsl' for WSL", () => { + mockVscode.env.remoteName = "wsl" + expect(getRemoteType()).toBe("wsl") + }) + }) + + describe("getRemoteWorkspaceId", () => { + it("should return undefined when not in remote environment", () => { + mockVscode.env.remoteName = undefined + expect(getRemoteWorkspaceId()).toBeUndefined() + }) + + it("should return remote name when no workspace folders", () => { + mockVscode.env.remoteName = "ssh-remote" + mockVscode.workspace.workspaceFolders = undefined + expect(getRemoteWorkspaceId()).toBe("ssh-remote") + }) + + it("should return remote name when workspace folders is empty", () => { + mockVscode.env.remoteName = "ssh-remote" + mockVscode.workspace.workspaceFolders = [] + expect(getRemoteWorkspaceId()).toBe("ssh-remote") + }) + + it("should include workspace folder name and hash in ID", () => { + mockVscode.env.remoteName = "ssh-remote" + mockVscode.workspace.workspaceFolders = [ + { + name: "my-project", + uri: { + toString: () => "file:///home/user/projects/my-project", + }, + }, + ] + const id = getRemoteWorkspaceId() + expect(id).toMatch(/^ssh-remote-my-project-[a-z0-9]+$/) + }) + + it("should create consistent hash for same workspace", () => { + mockVscode.env.remoteName = "ssh-remote" + const workspaceUri = "file:///home/user/projects/my-project" + mockVscode.workspace.workspaceFolders = [ + { + name: "my-project", + uri: { + toString: () => workspaceUri, + }, + }, + ] + const id1 = getRemoteWorkspaceId() + const id2 = getRemoteWorkspaceId() + expect(id1).toBe(id2) + }) + + it("should create different hashes for different workspaces", () => { + mockVscode.env.remoteName = "ssh-remote" + + // First workspace + mockVscode.workspace.workspaceFolders = [ + { + name: "project1", + uri: { + toString: () => "file:///home/user/projects/project1", + }, + }, + ] + const id1 = getRemoteWorkspaceId() + + // Second workspace + mockVscode.workspace.workspaceFolders = [ + { + name: "project2", + uri: { + toString: () => "file:///home/user/projects/project2", + }, + }, + ] + const id2 = getRemoteWorkspaceId() + + expect(id1).not.toBe(id2) + }) + }) + + describe("getEnvironmentStoragePath", () => { + const basePath = "/home/user/.vscode/extensions/storage" + + it("should return base path unchanged when not in remote environment", () => { + mockVscode.env.remoteName = undefined + expect(getEnvironmentStoragePath(basePath)).toBe(basePath) + }) + + it("should add remote subdirectory for SSH remote", () => { + mockVscode.env.remoteName = "ssh-remote" + mockVscode.workspace.workspaceFolders = [ + { + name: "my-project", + uri: { + toString: () => "file:///home/user/projects/my-project", + }, + }, + ] + const result = getEnvironmentStoragePath(basePath) + expect(result).toMatch( + /^\/home\/user\/.vscode\/extensions\/storage\/remote\/ssh-remote-my-project-[a-z0-9]+$/, + ) + }) + + it("should add remote subdirectory for WSL", () => { + mockVscode.env.remoteName = "wsl" + mockVscode.workspace.workspaceFolders = [ + { + name: "linux-project", + uri: { + toString: () => "file:///home/user/linux-project", + }, + }, + ] + const result = getEnvironmentStoragePath(basePath) + expect(result).toMatch(/^\/home\/user\/.vscode\/extensions\/storage\/remote\/wsl-linux-project-[a-z0-9]+$/) + }) + + it("should add remote subdirectory for dev containers", () => { + mockVscode.env.remoteName = "dev-container" + mockVscode.workspace.workspaceFolders = [ + { + name: "container-app", + uri: { + toString: () => "file:///workspace/container-app", + }, + }, + ] + const result = getEnvironmentStoragePath(basePath) + expect(result).toMatch( + /^\/home\/user\/.vscode\/extensions\/storage\/remote\/dev-container-container-app-[a-z0-9]+$/, + ) + }) + + it("should handle Windows paths correctly", () => { + const windowsBasePath = "C:\\Users\\user\\AppData\\Roaming\\Code\\User\\globalStorage\\roo-cline" + mockVscode.env.remoteName = "ssh-remote" + mockVscode.workspace.workspaceFolders = [ + { + name: "remote-project", + uri: { + toString: () => "file:///home/remote/project", + }, + }, + ] + const result = getEnvironmentStoragePath(windowsBasePath) + // The path.join will use forward slashes even on Windows in Node.js context + expect(result).toMatch( + /^C:\\Users\\user\\AppData\\Roaming\\Code\\User\\globalStorage\\roo-cline\/remote\/ssh-remote-remote-project-[a-z0-9]+$/, + ) + }) + + it("should fallback to base path when remote ID cannot be determined", () => { + mockVscode.env.remoteName = "unknown-remote" + mockVscode.workspace.workspaceFolders = undefined + // In this case, getRemoteWorkspaceId returns just "unknown-remote" + const result = getEnvironmentStoragePath(basePath) + expect(result).toBe(`${basePath}/remote/unknown-remote`) + }) + }) +}) diff --git a/src/utils/__tests__/storage.spec.ts b/src/utils/__tests__/storage.spec.ts index e5e1586dc6..5e6b27ce20 100644 --- a/src/utils/__tests__/storage.spec.ts +++ b/src/utils/__tests__/storage.spec.ts @@ -5,6 +5,12 @@ vi.mock("fs/promises", async () => { return (mod as any).default ?? mod }) +// Mock the remoteEnvironment module +vi.mock("../remoteEnvironment", () => ({ + isRemoteEnvironment: vi.fn(() => false), + getEnvironmentStoragePath: vi.fn((path: string) => path), +})) + describe("getStorageBasePath - customStoragePath", () => { const defaultPath = "/test/global-storage" @@ -63,9 +69,13 @@ describe("getStorageBasePath - customStoragePath", () => { const firstArg = showErrorSpy.mock.calls[0][0] expect(typeof firstArg).toBe("string") }) - it("returns the default path when customStoragePath is an empty string and does not touch fs", async () => { + it("returns the default path when customStoragePath is an empty string", async () => { vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({ - get: vi.fn().mockReturnValue(""), + get: vi.fn((key: string) => { + if (key === "customStoragePath") return "" + if (key === "persistChatInRemote") return true + return undefined + }), } as any) const fsPromises = await import("fs/promises") @@ -74,7 +84,9 @@ describe("getStorageBasePath - customStoragePath", () => { const result = await getStorageBasePath(defaultPath) expect(result).toBe(defaultPath) - expect((fsPromises as any).mkdir).not.toHaveBeenCalled() + // Now it will create the directory for the final path + expect((fsPromises as any).mkdir).toHaveBeenCalledWith(defaultPath, { recursive: true }) + // Access check is not called since we're using the default path expect((fsPromises as any).access).not.toHaveBeenCalled() }) diff --git a/src/utils/remoteEnvironment.ts b/src/utils/remoteEnvironment.ts new file mode 100644 index 0000000000..edd2c03be5 --- /dev/null +++ b/src/utils/remoteEnvironment.ts @@ -0,0 +1,82 @@ +import * as vscode from "vscode" + +/** + * Detects if the extension is running in a remote environment + * (Remote SSH, WSL, Dev Containers, Codespaces, etc.) + */ +export function isRemoteEnvironment(): boolean { + // Check if we're in a remote extension host + // vscode.env.remoteName will be defined in remote contexts + // It will be 'ssh-remote' for SSH, 'wsl' for WSL, 'dev-container' for containers, etc. + return typeof vscode.env.remoteName !== "undefined" +} + +/** + * Gets the type of remote environment if in one + * @returns The remote name (e.g., 'ssh-remote', 'wsl', 'dev-container') or undefined if local + */ +export function getRemoteType(): string | undefined { + return vscode.env.remoteName +} + +/** + * Gets a unique identifier for the remote workspace + * This combines the remote type with workspace information to create + * a stable identifier for the remote session + */ +export function getRemoteWorkspaceId(): string | undefined { + if (!isRemoteEnvironment()) { + return undefined + } + + const remoteName = vscode.env.remoteName || "remote" + const workspaceFolders = vscode.workspace.workspaceFolders + + if (workspaceFolders && workspaceFolders.length > 0) { + // Use the first workspace folder's name and URI as part of the identifier + const firstFolder = workspaceFolders[0] + const folderName = firstFolder.name + // Create a stable hash from the URI to avoid issues with special characters + const uriHash = hashString(firstFolder.uri.toString()) + return `${remoteName}-${folderName}-${uriHash}` + } + + // Fallback to just remote name if no workspace + return remoteName +} + +/** + * Simple string hash function for creating stable identifiers + */ +function hashString(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32-bit integer + } + return Math.abs(hash).toString(36) +} + +/** + * Gets the appropriate storage base path for the current environment + * In remote environments, this will include a remote-specific subdirectory + * to keep remote and local chat histories separate + */ +export function getEnvironmentStoragePath(basePath: string): string { + if (!isRemoteEnvironment()) { + // Local environment - use the base path as-is + return basePath + } + + // Remote environment - add a remote-specific subdirectory + const remoteId = getRemoteWorkspaceId() + if (remoteId) { + // Use path.join for proper path construction + const path = require("path") + return path.join(basePath, "remote", remoteId) + } + + // Fallback to base path if we can't determine remote ID + return basePath +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 5125d6cf44..0bba85c14d 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -5,6 +5,7 @@ import { constants as fsConstants } from "fs" import { Package } from "../shared/package" import { t } from "../i18n" +import { getEnvironmentStoragePath, isRemoteEnvironment } from "./remoteEnvironment" /** * Gets the base storage path for conversations @@ -12,39 +13,64 @@ import { t } from "../i18n" * Otherwise uses the default VSCode extension global storage path */ export async function getStorageBasePath(defaultPath: string): Promise { - // Get user-configured custom storage path + // Get user-configured custom storage path and remote persistence setting let customStoragePath = "" + let persistChatInRemote = true try { // This is the line causing the error in tests const config = vscode.workspace.getConfiguration(Package.name) customStoragePath = config.get("customStoragePath", "") + persistChatInRemote = config.get("persistChatInRemote", true) } catch (error) { console.warn("Could not access VSCode configuration - using default path") - return defaultPath + // Apply remote environment path adjustment even for default path if enabled + return persistChatInRemote ? getEnvironmentStoragePath(defaultPath) : defaultPath } - // If no custom path is set, use default path + // Determine the base path (custom or default) + let basePath: string + if (!customStoragePath) { - return defaultPath - } + basePath = defaultPath + } else { + try { + // Ensure custom path exists + await fs.mkdir(customStoragePath, { recursive: true }) - try { - // Ensure custom path exists - await fs.mkdir(customStoragePath, { recursive: true }) + // Check directory write permission without creating temp files + await fs.access(customStoragePath, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK) + + basePath = customStoragePath + } catch (error) { + // If path is unusable, report error and fall back to default path + console.error(`Custom storage path is unusable: ${error instanceof Error ? error.message : String(error)}`) + if (vscode.window) { + vscode.window.showErrorMessage( + t("common:errors.custom_storage_path_unusable", { path: customStoragePath }), + ) + } + basePath = defaultPath + } + } - // Check directory write permission without creating temp files - await fs.access(customStoragePath, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK) + // Apply remote environment path adjustment if enabled + // This will add a remote-specific subdirectory if in a remote environment + const finalPath = persistChatInRemote ? getEnvironmentStoragePath(basePath) : basePath - return customStoragePath + // Ensure the final path exists + try { + await fs.mkdir(finalPath, { recursive: true }) } catch (error) { - // If path is unusable, report error and fall back to default path - console.error(`Custom storage path is unusable: ${error instanceof Error ? error.message : String(error)}`) - if (vscode.window) { - vscode.window.showErrorMessage(t("common:errors.custom_storage_path_unusable", { path: customStoragePath })) - } - return defaultPath + console.warn(`Could not create storage path: ${error}`) } + + // Log the storage path being used for debugging + if (isRemoteEnvironment() && persistChatInRemote) { + console.log(`Using remote-specific storage path: ${finalPath}`) + } + + return finalPath } /**