fix: persist task history in Dev Containers across rebuilds

- Add Dev Container detection logic to identify when running in containers
- Implement automatic storage path detection for persistent storage locations
- Add user notification system to warn about ephemeral storage in Dev Containers
- Integrate Dev Container support into existing storage utility
- Add comprehensive test coverage for Dev Container scenarios

Fixes #8681
This commit is contained in:
Roo Code 2025-10-16 12:41:31 +00:00
parent 270dce5050
commit e361447c26
4 changed files with 565 additions and 1 deletions

View file

@ -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<boolean>(
"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()

View file

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

143
src/utils/devContainer.ts Normal file
View file

@ -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<string | null> {
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<void> {
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)
}
}

View file

@ -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<string> {
@ -24,8 +26,17 @@ export async function getStorageBasePath(defaultPath: string): Promise<string> {
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
}