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
This commit is contained in:
Roo Code 2025-09-16 16:26:07 +00:00
parent 3c2fd1d3d3
commit e319f8b025
6 changed files with 381 additions and 21 deletions

View file

@ -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,

View file

@ -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/)",

View file

@ -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`)
})
})
})

View file

@ -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()
})

View file

@ -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
}

View file

@ -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<string> {
// 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<string>("customStoragePath", "")
persistChatInRemote = config.get<boolean>("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)
// Check directory write permission without creating temp files
await fs.access(customStoragePath, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK)
return 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 = 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
}
return defaultPath
}
// 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
// Ensure the final path exists
try {
await fs.mkdir(finalPath, { recursive: true })
} catch (error) {
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
}
/**