diff --git a/src/extension.ts b/src/extension.ts index 5db0996ad657..783e69b7e9b3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -31,6 +31,7 @@ import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" import { API } from "./extension/api" +import { notifyDevContainerStorageSetup } from "./utils/devContainer" import { handleUri, @@ -68,6 +69,22 @@ export async function activate(context: vscode.ExtensionContext) { // Migrate old settings to new await migrateSettings(context, outputChannel) + // Check for Dev Container and notify user about storage setup + const devContainerNotificationDismissed = context.globalState.get( + "devContainerStorageNotificationDismissed", + false, + ) + if (!devContainerNotificationDismissed) { + // Run this asynchronously so it doesn't block activation + setTimeout(() => { + notifyDevContainerStorageSetup(context).catch((error) => { + outputChannel.appendLine( + `[DevContainer] Failed to check Dev Container storage: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + }, 3000) // Delay slightly to let the extension fully activate + } + // Initialize telemetry service. const telemetryService = TelemetryService.createInstance() diff --git a/src/utils/__tests__/devContainer.spec.ts b/src/utils/__tests__/devContainer.spec.ts new file mode 100644 index 000000000000..cca30988987c --- /dev/null +++ b/src/utils/__tests__/devContainer.spec.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as vscode from "vscode" +import * as fs from "fs/promises" + +// Mock vscode module +vi.mock("vscode", () => ({ + env: { + remoteName: undefined, + }, + workspace: { + workspaceFolders: undefined, + getConfiguration: vi.fn(), + }, + window: { + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + ConfigurationTarget: { + Global: 1, + }, +})) + +// Mock fs/promises +vi.mock("fs/promises", () => ({ + default: { + mkdir: vi.fn(), + access: vi.fn(), + constants: { + R_OK: 4, + W_OK: 2, + X_OK: 1, + }, + }, + mkdir: vi.fn(), + access: vi.fn(), + constants: { + R_OK: 4, + W_OK: 2, + X_OK: 1, + }, +})) + +// Mock fs module +vi.mock("fs", () => ({ + accessSync: vi.fn(), + constants: { + F_OK: 0, + }, +})) + +// Import after mocks are set up +import { + isRunningInDevContainer, + getDevContainerPersistentPath, + isEphemeralStoragePath, + notifyDevContainerStorageSetup, +} from "../devContainer" + +describe("devContainer", () => { + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks() + // Reset environment variables + delete process.env.REMOTE_CONTAINERS + delete process.env.CODESPACES + delete process.env.DEVCONTAINER + // Reset vscode.env.remoteName + ;(vscode.env as any).remoteName = undefined + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("isRunningInDevContainer", () => { + it("should return true when REMOTE_CONTAINERS env var is set", () => { + process.env.REMOTE_CONTAINERS = "true" + expect(isRunningInDevContainer()).toBe(true) + }) + + it("should return true when CODESPACES env var is set", () => { + process.env.CODESPACES = "true" + expect(isRunningInDevContainer()).toBe(true) + }) + + it("should return true when DEVCONTAINER env var is set", () => { + process.env.DEVCONTAINER = "true" + expect(isRunningInDevContainer()).toBe(true) + }) + + it("should return true when /.dockerenv file exists", async () => { + const fsModule = vi.mocked(await import("fs")) + fsModule.accessSync.mockImplementation(() => { + // Success - file exists + return undefined + }) + expect(isRunningInDevContainer()).toBe(true) + }) + + it("should return false when /.dockerenv file does not exist", async () => { + const fsModule = vi.mocked(await import("fs")) + fsModule.accessSync.mockImplementation(() => { + throw new Error("File not found") + }) + ;(vscode.env as any).remoteName = undefined + expect(isRunningInDevContainer()).toBe(false) + }) + + it("should return true when VSCode remoteName contains 'container'", async () => { + const fsModule = vi.mocked(await import("fs")) + ;(vscode.env as any).remoteName = "dev-container" + fsModule.accessSync.mockImplementation(() => { + throw new Error("File not found") + }) + expect(isRunningInDevContainer()).toBe(true) + }) + + it("should return false when not in a container", async () => { + const fsModule = vi.mocked(await import("fs")) + fsModule.accessSync.mockImplementation(() => { + throw new Error("File not found") + }) + ;(vscode.env as any).remoteName = "ssh-remote" + expect(isRunningInDevContainer()).toBe(false) + }) + }) + + describe("getDevContainerPersistentPath", () => { + it("should return null when not in a Dev Container", async () => { + // Ensure we're not in a Dev Container + const fsModule = vi.mocked(await import("fs")) + fsModule.accessSync.mockImplementation(() => { + throw new Error("File not found") + }) + const result = await getDevContainerPersistentPath() + expect(result).toBe(null) + }) + + it("should return workspace-relative path when available and writable", async () => { + process.env.DEVCONTAINER = "true" + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + fsPath: "/workspace/myproject", + }, + }, + ] + + const mockMkdir = vi.mocked(fs.mkdir) + const mockAccess = vi.mocked(fs.access) + + mockMkdir.mockResolvedValue(undefined) + mockAccess.mockResolvedValue(undefined) + + const result = await getDevContainerPersistentPath() + expect(result).toBe("/workspace/myproject/.roo-data") + expect(mockMkdir).toHaveBeenCalledWith("/workspace/myproject/.roo-data", { recursive: true }) + }) + + it("should try alternative paths when primary path fails", async () => { + process.env.DEVCONTAINER = "true" + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + fsPath: "/workspace/myproject", + }, + }, + ] + + const mockMkdir = vi.mocked(fs.mkdir) + const mockAccess = vi.mocked(fs.access) + + // First path fails + mockMkdir.mockRejectedValueOnce(new Error("Permission denied")) + // Second path succeeds + mockMkdir.mockResolvedValueOnce(undefined) + mockAccess.mockResolvedValueOnce(undefined) + + const result = await getDevContainerPersistentPath() + expect(result).toBe("/workspaces/.roo-data") + }) + + it("should return null when all paths fail", async () => { + process.env.DEVCONTAINER = "true" + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + fsPath: "/workspace/myproject", + }, + }, + ] + + const mockMkdir = vi.mocked(fs.mkdir) + mockMkdir.mockRejectedValue(new Error("Permission denied")) + + const result = await getDevContainerPersistentPath() + expect(result).toBe(null) + }) + }) + + describe("isEphemeralStoragePath", () => { + it("should return false when not in a Dev Container", async () => { + const fsModule = vi.mocked(await import("fs")) + fsModule.accessSync.mockImplementation(() => { + throw new Error("File not found") + }) + expect(isEphemeralStoragePath("/some/path")).toBe(false) + }) + + it("should return true for /tmp paths in Dev Container", () => { + process.env.DEVCONTAINER = "true" + expect(isEphemeralStoragePath("/tmp/storage")).toBe(true) + }) + + it("should return true for VSCode server paths in Dev Container", () => { + process.env.DEVCONTAINER = "true" + expect(isEphemeralStoragePath("/.vscode-server/data")).toBe(true) + expect(isEphemeralStoragePath("/.vscode-remote/extensions")).toBe(true) + expect(isEphemeralStoragePath("/home/user/.vscode/storage")).toBe(true) + }) + + it("should return true for paths containing temp directories", () => { + process.env.DEVCONTAINER = "true" + expect(isEphemeralStoragePath("/var/tmp/storage")).toBe(true) + expect(isEphemeralStoragePath("/home/user/tmp/data")).toBe(true) + expect(isEphemeralStoragePath("/dev/shm/cache")).toBe(true) + }) + + it("should return false for persistent paths in Dev Container", () => { + process.env.DEVCONTAINER = "true" + expect(isEphemeralStoragePath("/workspace/.roo-data")).toBe(false) + expect(isEphemeralStoragePath("/home/user/.roo-data")).toBe(false) + expect(isEphemeralStoragePath("/data/storage")).toBe(false) + }) + }) + + describe("notifyDevContainerStorageSetup", () => { + it("should not show notification when not in Dev Container", async () => { + const fsModule = vi.mocked(await import("fs")) + fsModule.accessSync.mockImplementation(() => { + throw new Error("File not found") + }) + + const mockContext = { + globalStorageUri: { fsPath: "/normal/storage" }, + globalState: { + update: vi.fn(), + get: vi.fn(), + }, + } + + await notifyDevContainerStorageSetup(mockContext as any) + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + }) + + it("should not show notification when storage path is not ephemeral", async () => { + process.env.DEVCONTAINER = "true" + + const mockContext = { + globalStorageUri: { fsPath: "/workspace/.roo-data" }, + globalState: { + update: vi.fn(), + get: vi.fn(), + }, + } + + await notifyDevContainerStorageSetup(mockContext as any) + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + }) + + it("should show notification when in Dev Container with ephemeral storage", async () => { + process.env.DEVCONTAINER = "true" + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + fsPath: "/workspace/myproject", + }, + }, + ] + + const mockMkdir = vi.mocked(fs.mkdir) + const mockAccess = vi.mocked(fs.access) + mockMkdir.mockResolvedValue(undefined) + mockAccess.mockResolvedValue(undefined) + + const mockShowWarningMessage = vi.mocked(vscode.window.showWarningMessage) + mockShowWarningMessage.mockResolvedValue("Configure Now" as any) + + const mockConfig = { + update: vi.fn().mockResolvedValue(undefined), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + const mockContext = { + globalStorageUri: { fsPath: "/.vscode-server/data/storage" }, + globalState: { + update: vi.fn(), + get: vi.fn().mockReturnValue(false), + }, + } + + await notifyDevContainerStorageSetup(mockContext as any) + + expect(mockShowWarningMessage).toHaveBeenCalledWith( + expect.stringContaining("Dev Container"), + "Configure Now", + "Remind Me Later", + "Don't Show Again", + ) + + expect(mockConfig.update).toHaveBeenCalledWith( + "customStoragePath", + "/workspace/myproject/.roo-data", + vscode.ConfigurationTarget.Global, + ) + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("Storage path set to"), + ) + }) + + it("should update global state when user chooses 'Don't Show Again'", async () => { + process.env.DEVCONTAINER = "true" + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + fsPath: "/workspace/myproject", + }, + }, + ] + + const mockMkdir = vi.mocked(fs.mkdir) + const mockAccess = vi.mocked(fs.access) + mockMkdir.mockResolvedValue(undefined) + mockAccess.mockResolvedValue(undefined) + + const mockShowWarningMessage = vi.mocked(vscode.window.showWarningMessage) + mockShowWarningMessage.mockResolvedValue("Don't Show Again" as any) + + const mockContext = { + globalStorageUri: { fsPath: "/.vscode-server/data/storage" }, + globalState: { + update: vi.fn(), + get: vi.fn().mockReturnValue(false), + }, + } + + await notifyDevContainerStorageSetup(mockContext as any) + + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "devContainerStorageNotificationDismissed", + true, + ) + }) + + it("should not configure when user chooses 'Remind Me Later'", async () => { + process.env.DEVCONTAINER = "true" + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + fsPath: "/workspace/myproject", + }, + }, + ] + + const mockMkdir = vi.mocked(fs.mkdir) + const mockAccess = vi.mocked(fs.access) + mockMkdir.mockResolvedValue(undefined) + mockAccess.mockResolvedValue(undefined) + + const mockShowWarningMessage = vi.mocked(vscode.window.showWarningMessage) + mockShowWarningMessage.mockResolvedValue("Remind Me Later" as any) + + const mockConfig = { + update: vi.fn(), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + const mockContext = { + globalStorageUri: { fsPath: "/.vscode-server/data/storage" }, + globalState: { + update: vi.fn(), + get: vi.fn().mockReturnValue(false), + }, + } + + await notifyDevContainerStorageSetup(mockContext as any) + + expect(mockConfig.update).not.toHaveBeenCalled() + expect(mockContext.globalState.update).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/utils/devContainer.ts b/src/utils/devContainer.ts new file mode 100644 index 000000000000..90b5a2edf8a2 --- /dev/null +++ b/src/utils/devContainer.ts @@ -0,0 +1,143 @@ +import * as vscode from "vscode" +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" + +/** + * Checks if the current environment is running inside a Dev Container + * @returns true if running in a Dev Container, false otherwise + */ +export function isRunningInDevContainer(): boolean { + // Check for common Dev Container environment variables + if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES || process.env.DEVCONTAINER) { + return true + } + + // Check if running in a container by looking for .dockerenv file + try { + const dockerEnvPath = "/.dockerenv" + fsSync.accessSync(dockerEnvPath, fsSync.constants.F_OK) + return true + } catch { + // File doesn't exist, not in Docker container + } + + // Check VSCode's remote name for devcontainer + const remoteName = vscode.env.remoteName + if (remoteName && remoteName.toLowerCase().includes("container")) { + return true + } + + return false +} + +/** + * Gets the recommended persistent storage path for Dev Containers + * This should be a path that persists across container rebuilds + * @returns Recommended storage path or null if not applicable + */ +export async function getDevContainerPersistentPath(): Promise { + if (!isRunningInDevContainer()) { + return null + } + + // Check for workspace folder that might be mounted from host + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + const workspaceRoot = workspaceFolders[0].uri.fsPath + + // Try common persistent mount points in Dev Containers + const possiblePaths = [ + path.join(workspaceRoot, ".roo-data"), // Within workspace (persists if workspace is mounted) + "/workspaces/.roo-data", // Common devcontainer workspace mount + "/home/vscode/.roo-data", // User home in standard devcontainer + "/root/.roo-data", // Root user home + ] + + for (const testPath of possiblePaths) { + try { + // Test if we can create and access this directory + await fs.mkdir(testPath, { recursive: true }) + await fs.access(testPath, fs.constants.R_OK | fs.constants.W_OK) + return testPath + } catch { + // This path is not suitable, try next + continue + } + } + } + + return null +} + +/** + * Checks if the storage path will be lost on container rebuild + * @param storagePath The current storage path + * @returns true if the path is ephemeral (will be lost on rebuild) + */ +export function isEphemeralStoragePath(storagePath: string): boolean { + if (!isRunningInDevContainer()) { + return false + } + + // Common ephemeral paths in containers + const ephemeralPaths = [ + "/tmp", + "/var/tmp", + "/dev/shm", + "/.vscode-server", + "/.vscode-remote", + "/.vscode-server-insiders", + ] + + const normalizedPath = path.normalize(storagePath).toLowerCase() + + return ephemeralPaths.some( + (ephemeral) => + normalizedPath.startsWith(ephemeral.toLowerCase()) || + normalizedPath.includes("/.vscode") || + normalizedPath.includes("/vscode-") || + normalizedPath.includes("/tmp/") || + normalizedPath.includes("/temp/"), + ) +} + +/** + * Shows a notification to the user about Dev Container storage configuration + * @param context The extension context + */ +export async function notifyDevContainerStorageSetup(context: vscode.ExtensionContext): Promise { + const currentStoragePath = context.globalStorageUri.fsPath + + if (!isRunningInDevContainer() || !isEphemeralStoragePath(currentStoragePath)) { + return + } + + const recommendedPath = await getDevContainerPersistentPath() + if (!recommendedPath) { + return + } + + const message = + "You're using Roo Code in a Dev Container. Your task history may be lost when the container is rebuilt. Would you like to configure a persistent storage path?" + + const choice = await vscode.window.showWarningMessage( + message, + "Configure Now", + "Remind Me Later", + "Don't Show Again", + ) + + if (choice === "Configure Now") { + // Set the recommended path + const config = vscode.workspace.getConfiguration("roo-code") + await config.update("customStoragePath", recommendedPath, vscode.ConfigurationTarget.Global) + + vscode.window.showInformationMessage( + `Storage path set to: ${recommendedPath}. Your task history will now persist across container rebuilds.`, + ) + } else if (choice === "Don't Show Again") { + // Store a flag to not show this again + await context.globalState.update("devContainerStorageNotificationDismissed", true) + } +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 5125d6cf4497..e7cbe9418d0f 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -5,10 +5,12 @@ import { constants as fsConstants } from "fs" import { Package } from "../shared/package" import { t } from "../i18n" +import { isRunningInDevContainer, getDevContainerPersistentPath, isEphemeralStoragePath } from "./devContainer" /** * Gets the base storage path for conversations * If a custom path is configured, uses that path + * For Dev Containers with ephemeral storage, suggests a persistent path * Otherwise uses the default VSCode extension global storage path */ export async function getStorageBasePath(defaultPath: string): Promise { @@ -24,8 +26,17 @@ export async function getStorageBasePath(defaultPath: string): Promise { return defaultPath } - // If no custom path is set, use default path + // If no custom path is set, check for Dev Container if (!customStoragePath) { + // Check if we're in a Dev Container with ephemeral storage + if (isRunningInDevContainer() && isEphemeralStoragePath(defaultPath)) { + // Try to get a persistent path for Dev Container + const persistentPath = await getDevContainerPersistentPath() + if (persistentPath) { + console.info(`Dev Container detected: Using persistent storage path ${persistentPath}`) + return persistentPath + } + } return defaultPath }