Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4ffdc21
fix(webview): render expanded task header text as markdown
easonliang28 Aug 16, 2026
dce70f2
fix(webview): use VS Code-style scrollbar for expanded task prompt box
easonliang28 Aug 16, 2026
dcf2738
fix(webview): keep task header expanded when clicking rendered markdo…
easonliang28 Aug 16, 2026
86f5b2c
test(webview): drop as-any cast from empty-prompt TaskHeader fixture
easonliang28 Aug 16, 2026
b84fc76
fix(chat): preserve mentions in expanded task markdown
easonliang28 Aug 17, 2026
37648c4
fix(webview): keep expanded task panel open after mention click
easonliang28 Aug 19, 2026
dd76171
fix(webview): keep mentions literal in code and keyboard accessible
easonliang28 Aug 19, 2026
005ea49
test(webview): cover remaining mention-splitter branches
easonliang28 Aug 19, 2026
d227f3e
fix(webview): gate clickable mentions to user-authored task text
easonliang28 Aug 20, 2026
0498c09
fix(webview): keep newlines and full mention paths in expanded task h…
easonliang28 Aug 22, 2026
f75fa94
fix(webview): respect mention boundaries and prevent Space scroll on …
easonliang28 Aug 22, 2026
9fa1a40
fix(webview): mask reference link definitions before mention rewriting
easonliang28 Aug 23, 2026
0b30b0c
fix(webview): mask reference links and image references before mentio…
easonliang28 Aug 23, 2026
62e7e93
test(webview): add Story Gallery visual case for expanded TaskHeader …
easonliang28 Aug 30, 2026
8afec01
Merge branch 'main' into fix/task-header-markdown
easonLiangWorldedtech Sep 1, 2026
47de09f
Merge branch 'main' into fix/task-header-markdown
edelauna Sep 3, 2026
8acd892
Merge branch 'main' into fix/task-header-markdown
edelauna Sep 5, 2026
1a4312c
Merge branch 'main' into fix/task-header-markdown
easonLiangWorldedtech Sep 5, 2026
f451cee
fix(webview): validate markdown links and contain openFile to the wor…
easonliang28 Sep 5, 2026
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
26 changes: 26 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

192 changes: 192 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.openFile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// npx vitest core/webview/__tests__/webviewMessageHandler.openFile.spec.ts

import { describe, it, expect, vi, beforeEach } from "vitest"
import * as nodePath from "path"
import * as vscode from "vscode"
import { openFile } from "../../../integrations/misc/open-file"
import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
import type { Task } from "../../task/Task"

vi.mock("../../../api/providers/fetchers/modelCache")

vi.mock("vscode", () => ({
window: {
showInformationMessage: vi.fn(),
showErrorMessage: vi.fn(),
showTextDocument: vi.fn(),
},
workspace: {
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
openTextDocument: vi.fn().mockResolvedValue({}),
},
commands: {
executeCommand: vi.fn(),
},
}))

vi.mock("../../../i18n", () => ({
// Echo the key with params serialized so tests can assert the full
// message arguments without loading a real i18n catalogue.
t: vi.fn((key: string, params?: Record<string, unknown>) => (params ? `${key}:${JSON.stringify(params)}` : key)),
}))

vi.mock("../../../utils/fs")
vi.mock("../../../utils/path")
vi.mock("../../../utils/globalContext")

// Hand-rolled containment check mirroring isPathOutsideWorkspace, but resolving
// the workspace root too so the mock works on both POSIX and Windows test runs.
vi.mock("../../../utils/pathUtils", () => ({
isPathOutsideWorkspace: vi.fn((filePath: string) => {
const nodePath = require("path")
const normalized = nodePath.resolve(filePath)
const workspaceRoot = nodePath.resolve("/mock/workspace")
if (normalized === workspaceRoot) return false
if (normalized.startsWith(workspaceRoot + nodePath.sep)) return false
return true
}),
}))

vi.mock("../../mentions/resolveImageMentions", () => ({
resolveImageMentions: vi.fn(async ({ text, images }: { text: string; images?: string[] }) => ({
text,
images: [...(images ?? [])],
})),
}))

// Mock the openFile module so the test observes the handler's resolved path and
// proves markdown-sourced requests never reach out-of-workspace targets.
vi.mock("../../../integrations/misc/open-file", () => ({
openFile: vi.fn().mockResolvedValue(undefined),
}))

const MOCK_CWD = "/mock/workspace/project"

const mockProvider = {
getState: vi.fn(),
postMessageToWebview: vi.fn(),
customModesManager: {
getCustomModes: vi.fn(),
deleteCustomMode: vi.fn(),
},
context: {
extensionPath: "/mock/extension/path",
globalStorageUri: { fsPath: "/mock/global/storage" },
},
contextProxy: {
context: {
extensionPath: "/mock/extension/path",
globalStorageUri: { fsPath: "/mock/global/storage" },
},
setValue: vi.fn(),
getValue: vi.fn(),
},
log: vi.fn(),
postStateToWebview: vi.fn(),
getCurrentTask: vi.fn().mockReturnValue({ cwd: MOCK_CWD }),
getTaskWithId: vi.fn(),
createTaskWithHistoryItem: vi.fn(),
cwd: MOCK_CWD,
} as unknown as ClineProvider

describe("webviewMessageHandler - openFile markdown workspace containment", () => {
beforeEach(() => {
vi.clearAllMocks()
// The containment logic only reads `cwd`; a full Task would be noise. The single
// assertion is safe because Task is structurally compatible with the stub.
vi.mocked(mockProvider.getCurrentTask).mockReturnValue({ cwd: MOCK_CWD } as Task)
;(mockProvider as { cwd?: string }).cwd = MOCK_CWD
})

// MarkdownBlock tags its openFile posts with fromMarkdown, flagging the
// request as sourced from untrusted task markdown.
it("opens a markdown file within the workspace using a relative path", async () => {
await webviewMessageHandler(mockProvider, {
type: "openFile",
text: "src/index.ts",
values: { line: 3, fromMarkdown: true },
})

expect(openFile).toHaveBeenCalledWith(nodePath.resolve(MOCK_CWD, "src/index.ts"), {
line: 3,
fromMarkdown: true,
})
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
})

it("rejects a markdown relative path that traverses outside the workspace", async () => {
await webviewMessageHandler(mockProvider, {
type: "openFile",
text: "../../.env",
values: { fromMarkdown: true },
})

expect(openFile).not.toHaveBeenCalled()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
'common:errors.cannot_access_path:{"path":"../../.env","error":"common:errors.path_outside_workspace"}',
)
})

it("rejects a markdown absolute path outside the workspace", async () => {
await webviewMessageHandler(mockProvider, {
type: "openFile",
text: "/etc/passwd",
values: { fromMarkdown: true },
})

expect(openFile).not.toHaveBeenCalled()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
'common:errors.cannot_access_path:{"path":"/etc/passwd","error":"common:errors.path_outside_workspace"}',
)
})

it("opens a markdown file using an absolute path within the workspace", async () => {
await webviewMessageHandler(mockProvider, {
type: "openFile",
text: `${MOCK_CWD}/src/index.ts`,
values: { fromMarkdown: true },
})

expect(openFile).toHaveBeenCalledWith(`${MOCK_CWD}/src/index.ts`, { fromMarkdown: true })
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
})

// First-party callers (slash-command settings, modes, MCP) are not flagged
// and keep the previous behavior, including global config files that live
// outside the workspace.
it("keeps legacy behavior for untagged callers opening paths outside the workspace", async () => {
await webviewMessageHandler(mockProvider, {
type: "openFile",
text: "/global/roo/commands/my-command.md",
})

expect(openFile).toHaveBeenCalledWith("/global/roo/commands/my-command.md", undefined)
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
})

it("does nothing when no path is provided", async () => {
await webviewMessageHandler(mockProvider, {
type: "openFile",
})

expect(openFile).not.toHaveBeenCalled()
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
})

it("shows an error when no workspace cwd is available", async () => {
vi.mocked(mockProvider.getCurrentTask).mockReturnValue(undefined)
;(mockProvider as { cwd?: string }).cwd = undefined

await webviewMessageHandler(mockProvider, {
type: "openFile",
text: "src/index.ts",
values: { fromMarkdown: true },
})

expect(openFile).not.toHaveBeenCalled()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
'common:errors.could_not_open_file:{"errorMessage":"common:errors.no_workspace"}',
)
})
})
41 changes: 37 additions & 4 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,13 +1515,46 @@ export const webviewMessageHandler = async (
}
}
break
case "openFile":
let filePath: string = message.text!
case "openFile": {
const rawPath = message.text || ""
if (!rawPath) {
break
}
// Task markdown links are untrusted, so markdown-sourced openFile
// requests (flagged by the webview with fromMarkdown) must resolve
// inside the current workspace. First-party callers (modes, MCP,
// slash-command settings) may legitimately open global config files
// outside the workspace, so they keep the previous behavior.
const fromMarkdown = message.values?.fromMarkdown === true
let filePath = rawPath
if (!path.isAbsolute(filePath)) {
filePath = path.join(getCurrentCwd(), filePath)
const cwd = getCurrentCwd()
if (!cwd) {
void vscode.window.showErrorMessage(
t("common:errors.could_not_open_file", { errorMessage: t("common:errors.no_workspace") }),
)
break
}
filePath = path.resolve(cwd, filePath)
}
// Workspace-boundary validation (defense in depth): the webview already
// rejects traversal in markdown anchors, but refuse any markdown path
// that still resolves outside the workspace.
if (fromMarkdown && isPathOutsideWorkspace(filePath)) {
void vscode.window.showErrorMessage(
t("common:errors.cannot_access_path", {
path: rawPath,
error: t("common:errors.path_outside_workspace"),
}),
)
break
}
await openFile(filePath, message.values as { create?: boolean; content?: string; line?: number })
await openFile(
filePath,
message.values as { create?: boolean; content?: string; line?: number; fromMarkdown?: boolean },
)
break
}
case "readFileContent": {
const relPath = message.text || ""
if (!relPath) {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ca/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/de/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"failed_remove_directory": "Failed to remove task directory: {{error}}",
"custom_storage_path_unusable": "Custom storage path \"{{path}}\" is unusable, will use default path",
"cannot_access_path": "Cannot access path {{path}}: {{error}}",
"path_outside_workspace": "Path is outside the workspace",
"settings_import_failed": "Settings import failed: {{error}}.",
"mistake_limit_guidance": "This may indicate a failure in the model's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. \"Try breaking down the task into smaller steps\").",
"violated_organization_allowlist": "Failed to run task: the current profile isn't compatible with your organization settings",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/fr/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/hi/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/id/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/it/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/i18n/locales/ja/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading