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
26 changes: 0 additions & 26 deletions .husky/pre-commit

This file was deleted.

42 changes: 0 additions & 42 deletions .husky/pre-push

This file was deleted.

43 changes: 43 additions & 0 deletions packages/types/src/__tests__/global-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,49 @@ describe("alwaysDenyUnapprovedCommands global setting", () => {
})
})

describe("viewStates global setting", () => {
it("parses a valid per-view record", () => {
const parsed = globalSettingsSchema.parse({
viewStates: {
"stable-view-1": { mode: "code", currentApiConfigName: "profile-a", updatedAt: 1730000000000 },
"stable-view-2": {},
},
})
expect(parsed.viewStates?.["stable-view-1"]).toEqual({
mode: "code",
currentApiConfigName: "profile-a",
updatedAt: 1730000000000,
})
expect(parsed.viewStates?.["stable-view-2"]).toEqual({})
expect(GLOBAL_SETTINGS_KEYS).toContain("viewStates")
})

it("parses an entry that carries the host-minted claim token", () => {
// The claim binds the entry to the webview that wrote it; a legacy entry without one
// still parses, because refusing it is setViewStateId's job, not the schema's.
const parsed = globalSettingsSchema.parse({
viewStates: {
"stable-view-1": { mode: "code", updatedAt: 1730000000000, claimToken: "7f9c-host-minted" },
"stable-view-2": { mode: "code" },
},
})
expect(parsed.viewStates?.["stable-view-1"]?.claimToken).toBe("7f9c-host-minted")
expect(parsed.viewStates?.["stable-view-2"]).toEqual({ mode: "code" })
})

it("rejects invalid field values inside a view entry", () => {
expect(() => globalSettingsSchema.parse({ viewStates: { v: { mode: 42 } } })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: { currentApiConfigName: 42 } } })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: { updatedAt: "soon" } } })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: { claimToken: 42 } } })).toThrow()
})

it("rejects non-object viewStates values", () => {
expect(() => globalSettingsSchema.parse({ viewStates: "not-a-record" })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: "not-an-entry" } })).toThrow()
})
})

describe("collapseCommandOutputByDefault global setting", () => {
it.each([true, false, undefined])("round-trips %s through the export/import schema", (value) => {
const settings = globalSettingsSchema.parse({ collapseCommandOutputByDefault: value })
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import { GLOBAL_STATE_KEYS } from "../index.js"

describe("GLOBAL_STATE_KEYS", () => {
it("should contain registered durable per-view state", () => {
expect(GLOBAL_STATE_KEYS).toContain("viewStates")
})

it("should contain provider settings keys", () => {
expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled")
})
Expand All @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => {

it("should not contain secret state keys", () => {
expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey")
expect(GLOBAL_STATE_KEYS).not.toContain("apiKey")
})

it("should contain OpenAI Compatible base URL setting", () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60
*/
export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15

/**
* Persisted non-secret selections for a stable webview instance.
*/
export const viewStateSchema = z.object({
mode: z.string().optional(),
currentApiConfigName: z.string().optional(),
updatedAt: z.number().optional(),
/**
* Host-minted capability token binding this entry to the webview that wrote it.
* Minted by the extension host (`crypto.randomUUID()`), delivered to that webview and
* never accepted from webview input. An entry is only re-adopted for a webview that
* presents this token, so a view cannot read another (e.g. closed) view's selections.
*/
claimToken: z.string().optional(),
})

/**
* GlobalSettings
*/
Expand All @@ -122,6 +138,7 @@ export const globalSettingsSchema = z.object({
currentApiConfigName: z.string().optional(),
listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(),
pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(),
viewStates: z.record(z.string(), viewStateSchema).optional(),

lastShownAnnouncementId: z.string().optional(),
customInstructions: z.string().optional(),
Expand Down
15 changes: 15 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export interface ExtensionMessage {
| "originalContent"
| "rooHistoryImportProgress"
| "themeFixtureProbeRequest"
| "viewStateClaim"
text?: string
/** For fileContent: { path, content, error? } */
fileContent?: { path: string; content: string | null; error?: string }
Expand Down Expand Up @@ -169,6 +170,13 @@ export interface ExtensionMessage {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
values?: Record<string, any>
requestId?: string
/**
* For `viewStateClaim`: the durable `viewStates` key the delivered claim governs. The
* message is only ever sent to the webview that key belongs to.
*/
viewStateId?: string
/** For `viewStateClaim`: the host-minted token the webview stores and echoes on launch. */
viewStateClaim?: string
themeFixture?: WebviewThemeFixture
promptText?: string
results?:
Expand Down Expand Up @@ -669,6 +677,13 @@ export interface WebviewMessage {
| "openRulesDirectory"
| "themeFixtureProbeResponse"
text?: string
viewStateId?: string
/**
* For `webviewDidLaunch`: the host-minted claim previously delivered for `viewStateId`.
* The webview only echoes what it was given; the host rejects an id whose stored claim
* does not match, so a view cannot adopt another view's persisted selections.
*/
viewStateClaim?: string
taskId?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
Expand Down
3 changes: 3 additions & 0 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({
taskHistory: true,
listApiConfigMeta: true,
currentApiConfigName: true,
// Per-view selection state is machine-local: it keeps flowing through the
// normal runtime and pruning paths but must not transfer between settings.
viewStates: true,
})

export class ContextProxy {
Expand Down
20 changes: 19 additions & 1 deletion src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ export const providerProfilesSchema = z.object({

export type ProviderProfiles = z.infer<typeof providerProfilesSchema>

/**
* Signals that a profile's configuration no longer exists. Callers that treat an
* already-deleted profile as an idempotent no-op branch on this type instead of
* matching error message text, which a profile name containing the phrase could
* otherwise spoof.
*/
export class ProviderSettingsNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = "ProviderSettingsNotFoundError"
}
}

export class ProviderSettingsManager {
private static readonly SCOPE_PREFIX = "roo_cline_config_"
private readonly defaultConfigId = this.generateId()
Expand Down Expand Up @@ -511,7 +524,7 @@ export class ProviderSettingsManager {
const providerProfiles = await this.load()

if (!providerProfiles.apiConfigs[name]) {
throw new Error(`Config '${name}' not found`)
throw new ProviderSettingsNotFoundError(`Config '${name}' not found`)
}

if (Object.keys(providerProfiles.apiConfigs).length === 1) {
Expand All @@ -522,6 +535,11 @@ export class ProviderSettingsManager {
await this.store(providerProfiles)
})
} catch (error) {
// A missing config is a caller-meaningful signal, not a failure: rethrow it
// unwrapped so callers can branch on the type instead of message text.
if (error instanceof ProviderSettingsNotFoundError) {
throw error
}
throw new Error(`Failed to delete config: ${error}`)
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/core/config/__tests__/ContextProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,4 +740,20 @@ Output only the summary of the conversation so far, without any additional comme
expect(customSupportPromptsUpdateCalls.length).toBe(0)
})
})

describe("export", () => {
it("should exclude viewStates from the exported settings", async () => {
await proxy.setValue("viewStates", {
"stable-sidebar-view": { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 1 },
})
await proxy.setValue("customInstructions", "global instructions")

const exported = await proxy.export()

// Per-view selection state is machine-local and must never transfer
// between settings, while ordinary global settings keep round-tripping.
expect(exported).not.toHaveProperty("viewStates")
expect(exported?.customInstructions).toBe("global instructions")
})
})
})
7 changes: 6 additions & 1 deletion src/core/config/__tests__/ProviderSettingsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import { clearAllMocks } from "../../../test-utils/reset"
import { makeExtensionContext } from "../../../test-utils/vscode"

import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager"
import { ProviderSettingsManager, ProviderSettingsNotFoundError, ProviderProfiles } from "../ProviderSettingsManager"

// `export()` builds an API handler per profile to read model capabilities. Mock
// buildApiHandler with the real @roo-code/types model definitions so the token-field
Expand Down Expand Up @@ -973,6 +973,11 @@ describe("ProviderSettingsManager", () => {
}),
)

// The typed not-found signal is the contract callers branch on: a profile
// name containing "not found" must not be matchable via message text.
await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toBeInstanceOf(
ProviderSettingsNotFoundError,
)
await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow(
"Config 'nonexistent' not found",
)
Expand Down
45 changes: 45 additions & 0 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,51 @@ describe("importExport", () => {
])
})

it("should not apply imported viewStates to the context proxy", async () => {
const fileContent = JSON.stringify({
providerProfiles: {
currentApiConfigName: "test",
apiConfigs: {
test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" },
},
},
globalSettings: {
mode: "code",
viewStates: {
"stable-sidebar-view": {
mode: "architect",
currentApiConfigName: "profile-a",
updatedAt: 1,
},
},
},
})

;(fs.readFile as Mock).mockResolvedValue(fileContent)

mockProviderSettingsManager.export.mockResolvedValue({
currentApiConfigName: "default",
apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } },
})

mockProviderSettingsManager.listConfig.mockResolvedValue([
{ name: "test", id: "test-id", apiProvider: providerIdentifiers.openai },
{ name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic },
])

const result = await importSettingsFromPath("/mock/path/settings.json", {
providerSettingsManager: mockProviderSettingsManager,
contextProxy: mockContextProxy,
customModesManager: mockCustomModesManager,
})

expect(result.success).toBe(true)
// Per-view selection state is machine-local: importing settings must not
// apply another machine's view pins, while other settings round-trip.
expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" })
expect(result).not.toHaveProperty("globalSettings.viewStates")
})

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

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

for (const [key, rawValue] of Object.entries(rawGlobalSettings)) {
const path = `globalSettings.${key}`

// Per-view selection state is machine-local: it round-trips through the
// normal runtime and pruning paths, but importing it would pin selections
// from another machine's views on this one.
if (key === "viewStates") {
continue
}

const schema = globalSettingsShape[key as keyof GlobalSettings]

if (!schema) {
Expand Down
Loading
Loading