Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e258a7b
perf(sync): read the backup database asynchronously
Sep 14, 2026
b302678
fix(sync): pin backup snapshot with WAL read lock
Sep 14, 2026
ba23455
fix(sync): include WAL sidecar in fallback backup
Sep 15, 2026
fddf957
fix(sync): keep BEGIN inside backup lock try block
Sep 15, 2026
5fd89d2
fix(sync): pin WAL snapshot during backup copy
Sep 15, 2026
10b539c
perf(sync): avoid copying zip chunks into buffers
Sep 15, 2026
e1b4a1d
perf(sync): read backup support files asynchronously
Sep 15, 2026
626a484
fix(sync): harden backup error paths and fs races
Sep 15, 2026
485782e
Merge branch 'ThinkInAIXYZ:dev' into dev
xiao-text Sep 16, 2026
a673536
fix(auth): harden OAuth credential storage
Sep 16, 2026
0428443
test(auth): emulate fs rename and ENOENT in OAuth auth tests
Sep 16, 2026
f0f66a0
fix(auth): prefer credential load error in status
Sep 16, 2026
590de99
fix(auth): address credential store review findings
Sep 16, 2026
075d82a
fix(auth): address credential store review nits
Sep 16, 2026
fd15074
fix(auth): clear randomized credential temp files
Sep 16, 2026
eb1d2d3
Merge remote-tracking branch 'origin/dev' into sync/pr-2304
zhangmo8 Sep 16, 2026
2ba5b1a
Merge branch 'ThinkInAIXYZ:dev' into dev
xiao-text Sep 17, 2026
78f3105
Merge branch 'ThinkInAIXYZ:dev' into dev
xiao-text Sep 18, 2026
2d08712
Merge branch 'ThinkInAIXYZ:dev' into dev
xiao-text Sep 19, 2026
74801f4
fix(plugin): bind settings window to own plugin
Sep 19, 2026
e53affd
test(renderer): fix plugin preload boundary test
Sep 19, 2026
e66faa4
fix(plugin): harden plugin settings window
Sep 19, 2026
a33e1af
fix(auth): deny popups in oauth window
Sep 19, 2026
d2cde3e
test(desktop): constructible BrowserWindow mock
Sep 19, 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
2 changes: 1 addition & 1 deletion src/main/app/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2796,7 +2796,7 @@ export async function createMainProcessControl(dependencies: {
recordSettingsActivity: (input) => settingsDatabase.recordSettingsActivity(input)
})
const toolRoutes = createToolRoutes(toolService)
const pluginRoutes = createPluginRoutes(pluginService)
const pluginRoutes = createPluginRoutes(pluginService, pluginSettingsWindow)
const skillRoutes = createSkillRoutes({
skillService,
skillSyncService,
Expand Down
23 changes: 21 additions & 2 deletions src/main/desktop/pluginSettingsWindow.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { BrowserWindow } from 'electron'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import type { PluginSettingsWindowPort } from '@/plugin'

export class PluginSettingsWindow implements PluginSettingsWindowPort {
private readonly windows = new Map<string, BrowserWindow>()
private readonly pluginIdByWebContentsId = new Map<number, string>()

async open(input: { pluginId: string; title: string; entry: string }): Promise<void> {
const existing = this.windows.get(input.pluginId)
Expand All @@ -23,19 +25,32 @@ export class PluginSettingsWindow implements PluginSettingsWindowPort {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, '../preload/pluginSettings.mjs'),
sandbox: false
sandbox: false,
additionalArguments: [`--deepchat-plugin-id=${encodeURIComponent(input.pluginId)}`]
}
})

const webContentsId = settingsWindow.webContents.id
const entryPath = pathToFileURL(input.entry).pathname
this.windows.set(input.pluginId, settingsWindow)
this.pluginIdByWebContentsId.set(webContentsId, input.pluginId)
settingsWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
settingsWindow.webContents.on('will-navigate', (event, url) => {
const target = new URL(url)
if (target.protocol !== 'file:' || target.pathname !== entryPath) {
event.preventDefault()
}
})
settingsWindow.on('ready-to-show', () => {
if (!settingsWindow.isDestroyed()) {
settingsWindow.show()
}
})
settingsWindow.on('closed', () => {
this.windows.delete(input.pluginId)
this.pluginIdByWebContentsId.delete(webContentsId)
if (this.windows.get(input.pluginId) === settingsWindow) {
this.windows.delete(input.pluginId)
}
})

await settingsWindow.loadFile(input.entry, {
Expand All @@ -45,6 +60,10 @@ export class PluginSettingsWindow implements PluginSettingsWindowPort {
})
}

getPluginIdForWebContents(webContentsId: number): string | null {
return this.pluginIdByWebContentsId.get(webContentsId) ?? null
}

close(pluginId: string): void {
const settingsWindow = this.windows.get(pluginId)
if (settingsWindow && !settingsWindow.isDestroyed()) {
Expand Down
1 change: 1 addition & 0 deletions src/main/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface PluginSettingsWindowPort {
open(input: { pluginId: string; title: string; entry: string }): Promise<void>
close(pluginId: string): void
closeAll(): void
getPluginIdForWebContents(webContentsId: number): string | null
}

type PluginServiceDeps = {
Expand Down
33 changes: 26 additions & 7 deletions src/main/plugin/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,25 @@ import {
pluginsInvokeActionRoute,
pluginsListRoute
} from '@shared/contracts/routes'
import { createRouteMap, type DeepchatRouteMap } from '@/routes/routeRegistry'
import type { PluginServicePort } from './index'
import { createRouteMap, type DeepchatRouteMap, type RouteContext } from '@/routes/routeRegistry'
import type { PluginServicePort, PluginSettingsWindowPort } from './index'

export function createPluginRoutes(
pluginService: PluginServicePort,
settingsWindow: PluginSettingsWindowPort
): DeepchatRouteMap {
const assertPluginSettingsCallerOwns = (context: RouteContext, pluginId: string): void => {
if (context.caller.kind !== 'renderer') {
return
}
const ownerPluginId = settingsWindow.getPluginIdForWebContents(context.caller.webContentsId)
if (ownerPluginId != null && ownerPluginId !== pluginId) {
throw new Error(
`Plugin settings window for "${ownerPluginId}" cannot control plugin "${pluginId}"`
)
}
}

export function createPluginRoutes(pluginService: PluginServicePort): DeepchatRouteMap {
return createRouteMap([
[
pluginsInspectSourceRoute.name,
Expand Down Expand Up @@ -78,35 +93,39 @@ export function createPluginRoutes(pluginService: PluginServicePort): DeepchatRo
],
[
pluginsGetRoute.name,
async (rawInput) => {
async (rawInput, context) => {
const input = pluginsGetRoute.input.parse(rawInput)
assertPluginSettingsCallerOwns(context, input.pluginId)
return pluginsGetRoute.output.parse({
plugin: await pluginService.getPlugin(input.pluginId)
})
}
],
[
pluginsEnableRoute.name,
async (rawInput) => {
async (rawInput, context) => {
const input = pluginsEnableRoute.input.parse(rawInput)
assertPluginSettingsCallerOwns(context, input.pluginId)
return pluginsEnableRoute.output.parse({
result: await pluginService.enablePlugin(input.pluginId)
})
}
],
[
pluginsDisableRoute.name,
async (rawInput) => {
async (rawInput, context) => {
const input = pluginsDisableRoute.input.parse(rawInput)
assertPluginSettingsCallerOwns(context, input.pluginId)
return pluginsDisableRoute.output.parse({
result: await pluginService.disablePlugin(input.pluginId)
})
}
],
[
pluginsInvokeActionRoute.name,
async (rawInput) => {
async (rawInput, context) => {
const input = pluginsInvokeActionRoute.input.parse(rawInput)
assertPluginSettingsCallerOwns(context, input.pluginId)
return pluginsInvokeActionRoute.output.parse({
result: await pluginService.invokeAction(input.pluginId, input.actionId, input.payload)
})
Expand Down
2 changes: 2 additions & 0 deletions src/main/provider/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ export class OAuthService implements OAuthServicePort {
const authUrl = this.buildAuthUrl(config)
logger.info('Opening OAuth URL:', authUrl)

this.authWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))

// Load authorization page
this.authWindow.loadURL(authUrl)
this.authWindow.show()
Expand Down
7 changes: 6 additions & 1 deletion src/preload/plugin-settings-preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ import {
} from '@shared/contracts/routes'
import type { PluginSettingsApiStatus } from '@shared/types/plugin'

const PLUGIN_ID_ARG_PREFIX = '--deepchat-plugin-id='

function readPluginId(): string {
const pluginId = new URL(window.location.href).searchParams.get('pluginId')?.trim()
const arg = process.argv.find((value) => value.startsWith(PLUGIN_ID_ARG_PREFIX))
const pluginId = arg
? decodeURIComponent(arg.slice(PLUGIN_ID_ARG_PREFIX.length)).trim()
: undefined
if (!pluginId) {
throw new Error('Plugin settings renderer is missing pluginId')
}
Expand Down
87 changes: 87 additions & 0 deletions test/main/desktop/pluginSettingsWindow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import { BrowserWindow } from 'electron'
import { PluginSettingsWindow } from '@/desktop/pluginSettingsWindow'

type Handler = (...args: any[]) => void

type FakeWindow = {
webContents: {
id: number
on: ReturnType<typeof vi.fn>
setWindowOpenHandler: ReturnType<typeof vi.fn>
}
isDestroyed: ReturnType<typeof vi.fn>
on: ReturnType<typeof vi.fn>
show: ReturnType<typeof vi.fn>
focus: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
loadFile: ReturnType<typeof vi.fn>
handlers: Map<string, Handler>
webContentsHandlers: Map<string, Handler>
}

function installBrowserWindowMock(): FakeWindow[] {
const created: FakeWindow[] = []
vi.mocked(BrowserWindow).mockImplementation(function () {
const handlers = new Map<string, Handler>()
const webContentsHandlers = new Map<string, Handler>()
const win: FakeWindow = {
webContents: {
id: 100 + created.length,
on: vi.fn((event: string, cb: Handler) => webContentsHandlers.set(event, cb)),
setWindowOpenHandler: vi.fn()
},
isDestroyed: vi.fn(() => false),
on: vi.fn((event: string, cb: Handler) => handlers.set(event, cb)),
show: vi.fn(),
focus: vi.fn(),
close: vi.fn(),
loadFile: vi.fn().mockResolvedValue(undefined),
handlers,
webContentsHandlers
}
created.push(win)
return win as any
})
return created
}

const input = { pluginId: 'p1', title: 'P1', entry: '/plugins/p1/settings.html' }

describe('PluginSettingsWindow', () => {
it('keeps the reopened window record when a stale closed event fires late', async () => {
const created = installBrowserWindowMock()
const settingsWindow = new PluginSettingsWindow()

await settingsWindow.open(input)
settingsWindow.close(input.pluginId)
await settingsWindow.open(input)

created[0].handlers.get('closed')?.()

settingsWindow.close(input.pluginId)
expect(created[1].close).toHaveBeenCalled()
expect(settingsWindow.getPluginIdForWebContents(created[1].webContents.id)).toBe('p1')
})

it('restricts navigation to the file entry', async () => {
const created = installBrowserWindowMock()
const settingsWindow = new PluginSettingsWindow()

await settingsWindow.open(input)

const onWillNavigate = created[0].webContentsHandlers.get('will-navigate')
expect(onWillNavigate).toBeDefined()

const isAllowed = (url: string): boolean => {
const event = { preventDefault: vi.fn() }
onWillNavigate?.(event, url)
return event.preventDefault.mock.calls.length === 0
}

expect(isAllowed('file:///plugins/p1/settings.html?pluginId=p1')).toBe(true)
expect(isAllowed('file:///plugins/p1/settings.html#section')).toBe(true)
expect(isAllowed('file:///plugins/other/settings.html')).toBe(false)
expect(isAllowed('https://example.com/')).toBe(false)
})
})
101 changes: 101 additions & 0 deletions test/main/plugin/pluginRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest'
import {
pluginsDisableRoute,
pluginsEnableRoute,
pluginsGetRoute,
pluginsInvokeActionRoute
} from '@shared/contracts/routes'
import type { PluginServicePort, PluginSettingsWindowPort } from '@/plugin'
import { createPluginRoutes } from '@/plugin/routes'
import { createRendererRouteContext, type RouteContext } from '@/routes/routeRegistry'

const actionResult = { ok: true }

function setup(ownerPluginId: string | null) {
const pluginService = {
enablePlugin: vi.fn().mockResolvedValue(actionResult),
disablePlugin: vi.fn().mockResolvedValue(actionResult),
invokeAction: vi.fn().mockResolvedValue(actionResult),
getPlugin: vi.fn().mockResolvedValue({ id: 'plugin-a' })
}
const settingsWindow: PluginSettingsWindowPort = {
open: async () => {},
close: () => {},
closeAll: () => {},
getPluginIdForWebContents: () => ownerPluginId
}
const routes = createPluginRoutes(pluginService as unknown as PluginServicePort, settingsWindow)
return { pluginService, routes }
}

const pluginWindowContext = (): RouteContext => createRendererRouteContext(42, 7)

describe('createPluginRoutes settings-window ownership', () => {
it('rejects disable for another plugin from a plugin settings window', async () => {
const { pluginService, routes } = setup('plugin-a')
const handler = routes.get(pluginsDisableRoute.name)

await expect(handler?.({ pluginId: 'plugin-b' }, pluginWindowContext())).rejects.toThrow(
/cannot control plugin/
)
expect(pluginService.disablePlugin).not.toHaveBeenCalled()
})

it('rejects enable for another plugin from a plugin settings window', async () => {
const { pluginService, routes } = setup('plugin-a')
const handler = routes.get(pluginsEnableRoute.name)

await expect(handler?.({ pluginId: 'plugin-b' }, pluginWindowContext())).rejects.toThrow(
/cannot control plugin/
)
expect(pluginService.enablePlugin).not.toHaveBeenCalled()
})

it('rejects invokeAction for another plugin from a plugin settings window', async () => {
const { pluginService, routes } = setup('plugin-a')
const handler = routes.get(pluginsInvokeActionRoute.name)

await expect(
handler?.({ pluginId: 'plugin-b', actionId: 'act' }, pluginWindowContext())
).rejects.toThrow(/cannot control plugin/)
expect(pluginService.invokeAction).not.toHaveBeenCalled()
})

it('rejects get for another plugin from a plugin settings window', async () => {
const { pluginService, routes } = setup('plugin-a')
const handler = routes.get(pluginsGetRoute.name)

await expect(handler?.({ pluginId: 'plugin-b' }, pluginWindowContext())).rejects.toThrow(
/cannot control plugin/
)
expect(pluginService.getPlugin).not.toHaveBeenCalled()
})

it('allows a plugin settings window to control its own plugin', async () => {
const { pluginService, routes } = setup('plugin-a')
const handler = routes.get(pluginsDisableRoute.name)

await handler?.({ pluginId: 'plugin-a' }, pluginWindowContext())

expect(pluginService.disablePlugin).toHaveBeenCalledWith('plugin-a')
})

it('allows renderer callers that are not plugin settings windows', async () => {
const { pluginService, routes } = setup(null)
const handler = routes.get(pluginsDisableRoute.name)

await handler?.({ pluginId: 'plugin-b' }, pluginWindowContext())

expect(pluginService.disablePlugin).toHaveBeenCalledWith('plugin-b')
})

it('allows non-renderer callers', async () => {
const { pluginService, routes } = setup('plugin-a')
const handler = routes.get(pluginsEnableRoute.name)
const context: RouteContext = { caller: { kind: 'internal', component: 'scheduler' } }

await handler?.({ pluginId: 'plugin-b' }, context)

expect(pluginService.enablePlugin).toHaveBeenCalledWith('plugin-b')
})
})
7 changes: 6 additions & 1 deletion test/main/routes/dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1616,7 +1616,12 @@ function createRuntime() {
recordSettingsActivity: (input) => sqlitePresenter.recordSettingsActivity(input)
})
const toolRoutes = createToolRoutes(toolService)
const pluginRoutes = createPluginRoutes(pluginService)
const pluginRoutes = createPluginRoutes(pluginService, {
open: async () => {},
close: () => {},
closeAll: () => {},
getPluginIdForWebContents: () => null
})
const assertSessionActiveSkillsMutable = vi.fn().mockResolvedValue(undefined)
const skillRoutes = createSkillRoutes({
skillService,
Expand Down
Loading