Skip to content
Closed
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
5 changes: 5 additions & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/)",
Expand Down
234 changes: 234 additions & 0 deletions src/utils/__tests__/remoteEnvironment.spec.ts
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the mixed path separator intentional here? The comment mentions forward slashes but the regex expects a mix of forward and backward slashes. This could cause test failures on Windows. Consider normalizing the path or adjusting the regex to handle both separators consistently.

/^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`)
})
})
})
18 changes: 15 additions & 3 deletions src/utils/__tests__/storage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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")
Expand All @@ -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()
})

Expand Down
82 changes: 82 additions & 0 deletions src/utils/remoteEnvironment.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we improve the hash function to reduce collision risk? The current implementation might produce the same hash for different URIs, potentially mixing chat histories from different workspaces. Consider using a more robust hashing approach or including more entropy:

Suggested change
function hashString(str: string): string {
function hashString(str: string): string {
let hash = 5381
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) + hash) + char // hash * 33 + char
}
return Math.abs(hash).toString(36).padStart(8, '0')
}

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()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add error handling here? If getRemoteWorkspaceId() throws an error or returns something unexpected, we're silently falling back to the base path. Consider adding a try-catch with logging to help debug issues:

Suggested change
const remoteId = getRemoteWorkspaceId()
const remoteId = getRemoteWorkspaceId()
if (remoteId) {
try {
// Use path.join for proper path construction
const path = require("path")
return path.join(basePath, "remote", remoteId)
} catch (error) {
console.warn()
}
}

if (remoteId) {
// Use path.join for proper path construction
const path = require("path")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentional? Using require("path") dynamically inside a function could cause bundling issues with esbuild or webpack. Consider importing path at the top of the file instead:

Suggested change
const path = require("path")
import * as path from "path"

Then you can remove line 76 and the dynamic require.

return path.join(basePath, "remote", remoteId)
}

// Fallback to base path if we can't determine remote ID
return basePath
}
Loading
Loading