Skip to content
Open
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
9 changes: 9 additions & 0 deletions packages/types/src/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type HookDefinition,
} from "../hooks.js"
import { clineMessageSchema, clineSaySchema } from "../message.js"
import { GLOBAL_SETTINGS_KEYS, globalSettingsSchema } from "../global-settings.js"

const encoder = new TextEncoder()

Expand All @@ -29,6 +30,14 @@ const sessionHook: HookDefinition = {
argv: ["node", "setup.js"],
}

describe("hook global settings", () => {
it("is optional, known, and accepts an explicit empty array", () => {
expect(globalSettingsSchema.parse({}).hookDefinitions).toBeUndefined()
expect(globalSettingsSchema.parse({ hookDefinitions: [] }).hookDefinitions).toEqual([])
expect(GLOBAL_SETTINGS_KEYS).toContain("hookDefinitions")
})
})

const preToolHook: HookDefinition = {
id: "pre-read",
name: "Check reads",
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod"
import { codebaseIndexConfigSchema, codebaseIndexModelsSchema } from "./codebase-index.js"
import { experimentsSchema } from "./experiment.js"
import { historyItemSchema } from "./history.js"
import { hookDefinitionsSchema } from "./hooks.js"
import { customModePromptsSchema, customSupportPromptsSchema, modeConfigSchema } from "./mode.js"
import {
type ProviderSettings,
Expand Down Expand Up @@ -273,6 +274,7 @@ export const globalSettingsSchema = z.object({
* Tools in this list will be excluded from prompt generation and rejected at execution time.
*/
disabledTools: z.array(toolNamesSchema).optional(),
hookDefinitions: hookDefinitionsSchema.optional(),
})

export type GlobalSettings = z.infer<typeof globalSettingsSchema>
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ export type ExtensionState = Pick<
| "requestDelaySeconds"
| "showWorktreesInHomeScreen"
| "disabledTools"
| "hookDefinitions"
> & {
lockApiConfigAcrossModes?: boolean
version: string
Expand Down
1 change: 1 addition & 0 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({
taskHistory: true,
listApiConfigMeta: true,
currentApiConfigName: true,
hookDefinitions: true,
})

export class ContextProxy {
Expand Down
21 changes: 21 additions & 0 deletions src/core/config/__tests__/ContextProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,27 @@ describe("ContextProxy", () => {
})
})

describe("hook definitions", () => {
it("initializes, stores, and excludes hook definitions from export", async () => {
const definitions = [
{
id: "session-hook",
name: "Session hook",
enabled: false,
phase: "sessionStart" as const,
executable: "node",
argv: ["script.js"],
},
]

expect(mockGlobalState.get).toHaveBeenCalledWith("hookDefinitions")
await proxy.setValue("hookDefinitions", definitions)
expect(proxy.getValue("hookDefinitions")).toEqual(definitions)
expect(mockGlobalState.update).toHaveBeenCalledWith("hookDefinitions", definitions)
expect(await proxy.export()).not.toHaveProperty("hookDefinitions")
})
})

describe("updateGlobalState", () => {
it("should update state directly in original context", async () => {
await proxy.updateGlobalState("apiProvider", "deepseek")
Expand Down
36 changes: 36 additions & 0 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,42 @@ describe("importExport", () => {
])
})

it("does not install hook definitions from imported settings", async () => {
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
;(fs.readFile as Mock).mockResolvedValue(
JSON.stringify({
providerProfiles: {
currentApiConfigName: "test",
apiConfigs: { test: { apiProvider: "openai", id: "test-id" } },
},
globalSettings: {
mode: "code",
hookDefinitions: [
{
id: "imported",
name: "Imported",
enabled: true,
phase: "sessionStart",
executable: "node",
argv: [],
},
],
},
}),
)
mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "test", apiConfigs: {} })
mockProviderSettingsManager.listConfig.mockResolvedValue([])

const result = await importSettings({
providerSettingsManager: mockProviderSettingsManager,
contextProxy: mockContextProxy,
customModesManager: mockCustomModesManager,
})

expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" })
expect(result).toMatchObject({ success: true, warnings: [expect.stringContaining("cannot be imported")] })
})

it("should return success: false when file content is invalid", async () => {
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])

Expand Down
4 changes: 4 additions & 0 deletions src/core/config/importExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ function sanitizeGlobalSettings(rawGlobalSettings: unknown): {

for (const [key, rawValue] of Object.entries(rawGlobalSettings)) {
const path = `globalSettings.${key}`
if (key === "hookDefinitions") {
warnings.push(`Setting "${path}" was skipped: Hook definitions cannot be imported.`)
continue
}
const schema = globalSettingsShape[key as keyof GlobalSettings]

if (!schema) {
Expand Down
4 changes: 4 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES,
DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED,
DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES,
DEFAULT_HOOK_DEFINITIONS,
ORGANIZATION_ALLOW_ALL,
DEFAULT_MODES,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
Expand Down Expand Up @@ -2389,6 +2390,7 @@ export class ClineProvider
autoCloseZooOpenedFiles,
autoCloseZooOpenedFilesAfterUserEdited,
autoCloseZooOpenedNewFiles,
hookDefinitions,
} = await this.getState()

let cloudOrganizations: CloudOrganizationMembership[] = []
Expand Down Expand Up @@ -2573,6 +2575,7 @@ export class ClineProvider
autoCloseZooOpenedFilesAfterUserEdited:
autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED,
autoCloseZooOpenedNewFiles: autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES,
hookDefinitions: hookDefinitions ?? [...DEFAULT_HOOK_DEFINITIONS],
openAiCodexIsAuthenticated: await (async () => {
try {
const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth")
Expand Down Expand Up @@ -2795,6 +2798,7 @@ export class ClineProvider
autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles,
autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited,
autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles,
hookDefinitions: stateValues.hookDefinitions ?? [...DEFAULT_HOOK_DEFINITIONS],
}
}

Expand Down
20 changes: 20 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,26 @@ describe("ClineProvider", () => {
expect((await provider.getState()).showRooIgnoredFiles).toBe(false)
})

test("returns persisted hook definitions and the shared empty default in both state stages", async () => {
expect((await provider.getState()).hookDefinitions).toEqual([])
expect((await provider.getStateToPostToWebview()).hookDefinitions).toEqual([])

const definitions = [
{
id: "session-hook",
name: "Session hook",
enabled: false,
phase: "sessionStart" as const,
executable: "node",
argv: ["script.js"],
},
]
await provider.contextProxy.setValue("hookDefinitions", definitions)

expect((await provider.getState()).hookDefinitions).toEqual(definitions)
expect((await provider.getStateToPostToWebview()).hookDefinitions).toEqual(definitions)
})

test("handles updatePrompt message correctly", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
Expand Down
70 changes: 69 additions & 1 deletion src/core/webview/__tests__/webviewMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ vi.mock("../rulesMessageHandler", () => ({
handleOpenRulesDirectory: vi.fn(),
}))

import type { ModelRecord } from "@roo-code/types"
import type { ModelRecord, WebviewMessage } from "@roo-code/types"

import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
Expand Down Expand Up @@ -1173,6 +1173,74 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => {
})
})

describe("webviewMessageHandler - hookDefinitions", () => {
const validHook = {
id: "session-hook",
name: "Session hook",
enabled: false,
phase: "sessionStart" as const,
executable: "node",
argv: ["script.js", " argument with spaces "],
}

beforeEach(() => vi.clearAllMocks())

it("persists the complete hook array without normalizing command fields", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { hookDefinitions: [validHook] },
})

expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("hookDefinitions", [validHook])
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledOnce()
})

it("persists an explicit empty array to clear hooks", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { hookDefinitions: [] },
})

expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("hookDefinitions", [])
})

it("rejects duplicate definitions atomically before persisting other settings", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { language: "en", hookDefinitions: [validHook, validHook] },
})

expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled()
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining("must be unique"))
})

it("rejects malformed phases and command values", async () => {
// This test deliberately crosses the typed webview boundary with a malformed runtime payload.
const message = {
type: "updateSettings",
updatedSettings: {
hookDefinitions: [{ ...validHook, phase: "postToolUse", executable: " node " }],
},
} as unknown as WebviewMessage

await webviewMessageHandler(mockClineProvider, message)

expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled()
expect(vscode.window.showErrorMessage).toHaveBeenCalled()
})

it("rejects command fields that schema parsing would otherwise normalize", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { hookDefinitions: [{ ...validHook, executable: " node " }] },
})

expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining("valid as entered"))
})
})

describe("webviewMessageHandler - terminalProfile", () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
44 changes: 43 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
checkoutRestorePayloadSchema,
getCompletionCheckpoint,
providerIdentifiers,
hookDefinitionsSchema,
} from "@roo-code/types"
import { customToolRegistry } from "@roo-code/core"
import { CloudService } from "@roo-code/cloud"
Expand Down Expand Up @@ -699,8 +700,49 @@ export const webviewMessageHandler = async (
}
}

let parsedHookDefinitions
if (Object.prototype.hasOwnProperty.call(message.updatedSettings, "hookDefinitions")) {
const result = hookDefinitionsSchema.safeParse(message.updatedSettings.hookDefinitions)
if (!result.success) {
const details = result.error.issues
.map((issue) => `${issue.path.join(".") || "hookDefinitions"}: ${issue.message}`)
.join(", ")
await vscode.window.showErrorMessage(`Hook settings were not saved: ${details}`)
break
}
const hasNormalizedOrUnsupportedFields = result.data.some((definition, index) => {
const raw = (message.updatedSettings?.hookDefinitions as unknown[])[index] as Record<
string,
unknown
>
const allowedKeys = new Set([
"id",
"name",
"enabled",
"phase",
"executable",
"argv",
...(definition.phase === "preToolUse" ? ["toolMatcher"] : []),
])
return (
Object.keys(raw).some((key) => !allowedKeys.has(key)) ||
raw.name !== definition.name ||
raw.executable !== definition.executable ||
JSON.stringify(raw.argv) !== JSON.stringify(definition.argv) ||
JSON.stringify(raw.toolMatcher) !== JSON.stringify(definition.toolMatcher)
)
})
if (hasNormalizedOrUnsupportedFields) {
await vscode.window.showErrorMessage(
"Hook settings were not saved: command fields must be valid as entered and unsupported fields are not allowed.",
)
break
}
parsedHookDefinitions = result.data
}

for (const [key, value] of Object.entries(message.updatedSettings)) {
let newValue = value
let newValue = key === "hookDefinitions" ? parsedHookDefinitions : value

if (key === "language") {
newValue = value ?? "en"
Expand Down
Loading
Loading