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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,54 @@ Restart OpenFox after installing or updating a plugin.
- To use free OpenRouter models, you can install the [`openfox-openrouter-free`](https://github.com/JamesDAdams/openfox-openrouter-free) plugin.
- To use free OpenCode models, you can install the [`openfox-opencode-free`](https://github.com/JamesDAdams/openfox-opencode-free) plugin.

### Plugin Settings

Plugins can expose custom configuration settings in the OpenFox UI by calling `registry.registerSettings` during plugin initialization or specifying `"hasSettings": true` under the `openfox` key in `package.json`:

```typescript
import type { ProviderPluginRegistry } from 'openfox'

export function register(registry: ProviderPluginRegistry) {
registry.registerSettings({
title: 'My Plugin Settings',
description: 'Configure API options',
fields: [
{
key: 'apiKey',
label: 'API Key',
type: 'password', // 'text' | 'password' | 'number' | 'boolean' | 'select' | 'textarea'
required: true,
},
],
})
}
```

In `package.json`:

```json
{
"name": "my-openfox-plugin",
"version": "1.0.0",
"openfox": {
"apiVersion": 1,
"plugin": "dist/index.js",
"hasSettings": true
}
}
```

Or for plugins rendering a custom UI:

```typescript
export function register(registry: ProviderPluginRegistry) {
registry.registerSettings({
title: 'Custom Plugin Settings',
customUiUrl: 'http://localhost:3000/plugin-settings-ui',
})
}
```

## Screenshots

_Homepage — Project overview and session history_
Expand Down
35 changes: 35 additions & 0 deletions src/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,37 @@ export interface ProviderPluginRuntime {
readonly configDirectory: string
}

// ============================================================================
// Plugin Settings Types
// ============================================================================

export type PluginSettingFieldType = 'text' | 'password' | 'number' | 'boolean' | 'select' | 'textarea'

export interface PluginSettingOption {
label: string
value: string
}

export interface PluginSettingField {
key: string
label: string
type: PluginSettingFieldType
description?: string
defaultValue?: string | number | boolean
options?: PluginSettingOption[]
placeholder?: string
required?: boolean
}

export interface PluginSettingsSpec {
title?: string
description?: string
fields?: PluginSettingField[]
customUiUrl?: string
getSettings?: () => Promise<Record<string, unknown>> | Record<string, unknown>
saveSettings?: (values: Record<string, unknown>) => Promise<void> | void
}

// ============================================================================
// Plugin Registry (passed to plugins during registration)
// ============================================================================
Expand All @@ -105,6 +136,8 @@ export interface ProviderPluginRegistry {
registerAuth(adapter: ProviderAuthAdapter): void
registerTransport(adapter: ProviderTransportAdapter): void
registerPreset(preset: ProviderPreset): void
registerSettings(spec: PluginSettingsSpec): void
registerSettingsForPlugin(packageName: string, spec: PluginSettingsSpec): void
readonly runtime: ProviderPluginRuntime
}

Expand Down Expand Up @@ -138,6 +171,8 @@ export interface ProviderPluginManifest {
name: string
/** Semver version string. */
version: string
/** Whether the plugin provides a settings page / configuration. */
hasSettings?: boolean
/** Auth adapters this plugin provides. */
authAdapters: PluginAuthDescriptor[]
/** Transport adapters this plugin provides. */
Expand Down
9 changes: 9 additions & 0 deletions src/server/llm/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,18 @@ vi.mock('undici', () => {
}
})

const { mockNativeFetch } = vi.hoisted(() => {
const fn = vi.fn().mockImplementation(() => Promise.resolve(new Response('native-ok')))
globalThis.fetch = fn
return { mockNativeFetch: fn }
})

import { __resetProxyCache } from './proxy.js'

describe('global fetch override', () => {
beforeEach(() => {
vi.clearAllMocks()
mockNativeFetch.mockImplementation(() => Promise.resolve(new Response('native-ok')))
mockProxyAgentInstances.length = 0
__resetProxyCache()
})
Expand All @@ -51,6 +58,7 @@ describe('global fetch override', () => {
const result = await fetch('http://example.com')

expect(result).toBeInstanceOf(Response)
expect(mockNativeFetch).toHaveBeenCalledWith('http://example.com', undefined)
expect(mockUndiciFetch).not.toHaveBeenCalled()
})

Expand All @@ -60,6 +68,7 @@ describe('global fetch override', () => {
const result = await fetch('http://example.com')

expect(result).toBeInstanceOf(Response)
expect(mockNativeFetch).toHaveBeenCalledWith('http://example.com', undefined)
expect(mockUndiciFetch).not.toHaveBeenCalled()
})

Expand Down
8 changes: 8 additions & 0 deletions src/server/providers/plugins/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface ProviderPluginDiagnostic {
version?: string
source: string
loaded: boolean
hasSettings?: boolean
authAdapters: string[]
transportAdapters: string[]
presets: string[]
Expand Down Expand Up @@ -106,6 +107,13 @@ export async function loadProviderPlugins(options: {
options.registry.registerPreset(preset)
diagnostic.presets.push(preset.id)
},
registerSettings(spec) {
diagnostic.hasSettings = true
options.registry.registerSettingsForPlugin(packageName, spec)
},
registerSettingsForPlugin(packageName, spec) {
options.registry.registerSettingsForPlugin(packageName, spec)
},
}
try {
const module = (await import(pathToFileURL(join(packageDir, plugin)).href)) as {
Expand Down
15 changes: 15 additions & 0 deletions src/server/providers/plugins/registry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Provider } from '../../../shared/types.js'
import type {
PluginSettingsSpec,
ProviderAuthAdapter,
ProviderPluginRegistry,
ProviderPluginRuntime,
Expand All @@ -11,6 +12,7 @@ export class ProviderRegistry implements ProviderPluginRegistry {
private readonly authAdapters = new Map<string, ProviderAuthAdapter>()
private readonly transportAdapters = new Map<string, ProviderTransportAdapter>()
private readonly presets = new Map<string, ProviderPreset>()
private readonly pluginSettingsSpecs = new Map<string, PluginSettingsSpec>()

constructor(readonly runtime: ProviderPluginRuntime) {}

Expand All @@ -26,6 +28,19 @@ export class ProviderRegistry implements ProviderPluginRegistry {
this.register(this.presets, preset.id, preset, 'preset')
}

registerSettings(spec: PluginSettingsSpec): void {
// Note: registerSettings can be bound to a specific plugin when registered via trackingRegistry
this.registerSettingsForPlugin('_global', spec)
}

registerSettingsForPlugin(packageName: string, spec: PluginSettingsSpec): void {
this.pluginSettingsSpecs.set(packageName, spec)
}

getPluginSettingsSpec(packageName: string): PluginSettingsSpec | undefined {
return this.pluginSettingsSpecs.get(packageName)
}

getAuth(id?: string): ProviderAuthAdapter | undefined {
return id ? this.authAdapters.get(id) : undefined
}
Expand Down
102 changes: 102 additions & 0 deletions src/server/routes/plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import { tmpdir } from 'node:os'
import { createPluginRoutes } from './plugins.js'
import { ProviderRegistry } from '../providers/plugins/registry.js'
import type { ProviderPluginDiagnostic } from '../providers/plugins/index.js'
import { closeDatabase, initDatabase } from '../db/index.js'
import { loadConfig } from '../config.js'
import { SETTINGS_KEYS, setSetting } from '../db/settings.js'

let mockConfigDir = ''
vi.mock('../../cli/paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../cli/paths.js')>()
return {
...actual,
getGlobalConfigDir: () => mockConfigDir || actual.getGlobalConfigDir('test'),
}
})

function createApp(options?: Partial<Parameters<typeof createPluginRoutes>[0]>) {
const app = express()
Expand Down Expand Up @@ -37,7 +49,12 @@ describe('plugin routes', () => {
let baseUrl: string

beforeEach(async () => {
closeDatabase()
const cfg = loadConfig()
cfg.database.path = ':memory:'
initDatabase(cfg)
rootDir = await mkdtemp(join(tmpdir(), 'openfox-plugins-'))
mockConfigDir = rootDir
const { app } = createApp({
config: { mode: 'test', providers: [] } as any,
})
Expand Down Expand Up @@ -121,6 +138,91 @@ describe('plugin routes', () => {
})
})

describe('GET /:name/settings and POST /:name/settings', () => {
it('returns settings spec and saved values', async () => {
const appWithSpec = createApp({
config: { mode: 'test', providers: [] } as any,
})
appWithSpec.providerAdapters.registerSettingsForPlugin('test-plugin', {
title: 'Test Plugin Settings',
description: 'Configure test plugin options',
fields: [
{ key: 'apiKey', label: 'API Key', type: 'password', required: true },
{ key: 'enableFeature', label: 'Enable Feature', type: 'boolean', defaultValue: true },
],
})
const lServer = appWithSpec.app.listen(0)
const lUrl = `http://localhost:${(lServer.address() as { port: number }).port}`

try {
const resGet1 = await fetch(`${lUrl}/api/plugins/test-plugin/settings`)
expect(resGet1.status).toBe(200)
const bodyGet1 = (await resGet1.json()) as {
name: string
hasSpec: boolean
spec: any
values: Record<string, unknown>
}
expect(bodyGet1.hasSpec).toBe(true)
expect(bodyGet1.spec.title).toBe('Test Plugin Settings')
expect(bodyGet1.values['enableFeature']).toBe(true)

const resPost = await fetch(`${lUrl}/api/plugins/test-plugin/settings`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ values: { apiKey: 'secret123', enableFeature: false } }),
})
expect(resPost.status).toBe(200)
const bodyPost = (await resPost.json()) as { success: boolean; values: Record<string, unknown> }
expect(bodyPost.success).toBe(true)
expect(bodyPost.values).toEqual({ apiKey: 'secret123', enableFeature: false })

const resGet2 = await fetch(`${lUrl}/api/plugins/test-plugin/settings`)
const bodyGet2 = (await resGet2.json()) as { values: Record<string, unknown>; configuredKeys: string[] }
// password field must not be echoed back on GET
expect(bodyGet2.values).toEqual({ enableFeature: false })
expect(bodyGet2.configuredKeys).toEqual(['apiKey'])

// Updating other settings without re-sending password should succeed
const resPost2 = await fetch(`${lUrl}/api/plugins/test-plugin/settings`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ values: { enableFeature: true } }),
})
expect(resPost2.status).toBe(200)
} finally {
lServer.close()
}
})

it('returns translated error message when required field is missing in French locale', async () => {
setSetting(SETTINGS_KEYS.DISPLAY_LOCALE, 'fr')
const appWithSpec = createApp({
config: { mode: 'test', providers: [] } as any,
})
appWithSpec.providerAdapters.registerSettingsForPlugin('test-fr-plugin', {
title: 'Settings FR',
fields: [{ key: 'apiKey', label: 'Clé API', type: 'password', required: true }],
})
const lServer = appWithSpec.app.listen(0)
const lUrl = `http://localhost:${(lServer.address() as { port: number }).port}`

try {
const resPost = await fetch(`${lUrl}/api/plugins/test-fr-plugin/settings`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ values: {} }),
})
expect(resPost.status).toBe(400)
const body = (await resPost.json()) as { error: string }
expect(body.error).toBe('Clé API est requis')
} finally {
setSetting(SETTINGS_KEYS.DISPLAY_LOCALE, 'en')
lServer.close()
}
})
})

describe('DELETE /:name', () => {
it('rejects plugin name with dots', async () => {
const res = await fetch(`${baseUrl}/api/plugins/my.plugin`, {
Expand Down
Loading
Loading