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
40 changes: 40 additions & 0 deletions docs/agent-runtime-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,43 @@ pointing at a mirror that bun's newer versions can't parse. Use
`thirdparty/opencode/node_modules/.bun/node_modules/@lydell/node-pty-*`
is missing. Either step 3 didn't finish or you're running a packaged
build (not yet supported, see the TODO).


## Web research in the agent

With a fresh agent configuration, Lumina connects to
[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp)
for web search and page fetching. Search uses Fast mode, with no account or
API key required. Ask the AI panel to research a current topic or read a URL;
the tools are `lumina_parallel_web_search` and `lumina_parallel_web_fetch`.
Lumina asks for permission before using these tools unless you enabled automatic
tool approval. The background wiki agent cannot use them.

The default is added by Lumina's plugin after opencode merges its configuration.
An existing `mcp` map, explicit `websearch`/`webfetch` settings or permission
rules, `OPENCODE_ENABLE_EXA=1` (or `true`), `EXA_API_KEY`, or
`PARALLEL_API_KEY` preserves your existing search setup. The experimental Exa
flags (`OPENCODE_EXPERIMENTAL` and `OPENCODE_EXPERIMENTAL_EXA`) do too. Lumina does not switch
to Parallel when a configured service fails. Model provider selections and
credentials remain managed by **Settings -> AI**.

To disable the default, add this to the embedded runtime's `opencode.json`
under `<Lumina user data>/opencode-runtime/config/opencode/` and restart Lumina:

```json
{
"mcp": {
"lumina_parallel": { "enabled": false }
}
}
```

This is Lumina's isolated configuration directory, separate from the opencode
CLI and vault project configuration. Existing custom MCP configurations are
used as written, including their URLs, credentials, timeouts and enabled flags.
Agent-specific web tool denials also apply to the default MCP tools; scoped URL
or query permission rules conservatively deny the corresponding MCP tool.

The anonymous service has usage limits and can return quota or service errors.
Requests time out after 30 seconds. Lumina reports failures through the agent's
normal tool-result flow so the assistant can explain an unavailable search.
5 changes: 5 additions & 0 deletions electron/main/agent-v2/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import fs from 'node:fs/promises'

import { configureSearchMcp } from './search.js'

import {
getLuminaPluginContext,
type PluginImageProviderId,
Expand Down Expand Up @@ -73,6 +75,9 @@ export function clampLuminaChatMaxOutputTokens(

const pluginFn: Plugin = async () => {
return {
config: async (config) => {
configureSearchMcp(config)
},
'chat.params': async (input, output) => {
clampLuminaChatMaxOutputTokens(input, output)
},
Expand Down
94 changes: 94 additions & 0 deletions electron/main/agent-v2/plugin/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import plugin from './index.js'
import type { SearchConfig } from './search.js'

async function configure(config: SearchConfig) {
const hooks = await plugin.server({})
await hooks.config!(config)
return config
}

describe('default Parallel Search MCP', () => {
afterEach(() => vi.unstubAllEnvs())

it('registers anonymous HTTP search and fetch through the installed plugin hook', async () => {
const config = await configure({})
expect(config.mcp).toEqual({
lumina_parallel: {
type: 'remote', url: 'https://search.parallel.ai/mcp', oauth: false,
timeout: 30_000, headers: { 'User-Agent': expect.stringMatching(/^Lumina-Note\//) },
},
})
expect(config.permission).toEqual({ 'lumina_parallel_*': 'ask' })
})

it.each([{}, { other: { type: 'remote', url: 'https://example.com/mcp' } },
{ lumina_parallel: { enabled: false } },
{ lumina_parallel: { type: 'remote', url: 'https://example.com/mcp', headers: { Authorization: 'Bearer saved' } } },
])('preserves an explicit MCP configuration unchanged', async (mcp) => {
const config = { mcp }
const saved = structuredClone(config)
await configure(config)
expect(config).toEqual(saved)
})

it.each(['OPENCODE_ENABLE_EXA', 'OPENCODE_EXPERIMENTAL', 'OPENCODE_EXPERIMENTAL_EXA', 'EXA_API_KEY', 'PARALLEL_API_KEY'])('preserves explicit %s configuration', async (name) => {
vi.stubEnv(name, '1')
expect((await configure({})).mcp).toBeUndefined()
})

it.each<SearchConfig>([
{ permission: 'deny' }, { permission: { 'web*': 'deny' } }, { permission: { 'websearch *': 'deny' } }, { permission: { '*': 'deny' } },
{ permission: { websearch: 'deny' } }, { permission: { webfetch: { '*': 'deny', 'https://allowed.test/*': 'allow' } } },
{ tools: { websearch: false } }, { tools: { webfetch: true } },
])('preserves explicit network tool policies', async (config) => {
const saved = structuredClone(config)
await configure(config)
expect(config).toEqual(saved)
})

it('keeps auto-approve and unrelated permissions', async () => {
expect((await configure({ permission: 'allow' })).permission).toBe('allow')
expect((await configure({ permission: { edit: 'ask' } })).permission).toEqual({
'lumina_parallel_*': 'ask', edit: 'ask',
})
})

it.each([
{ tools: { websearch: true }, permission: { websearch: 'deny' } },
{ permission: { websearch: 'deny', 'web*': 'allow' } },
{ permission: { 'web*': 'allow', websearch: 'deny' } },
{ permission: { websearch: 'deny', '*web*': 'allow' } },
{ permission: { 'websearch *': 'deny' } },
])('keeps exact network denials above deprecated tools and wildcard allows', async (agent) => {
const config = await configure({ agent: { restricted: agent } })
expect(config.agent!.restricted.permission).toMatchObject({ lumina_parallel_web_search: 'deny' })
})

it('preserves an agent’s explicit MCP permissions', async () => {
const config = await configure({ agent: { custom: { permission: {
websearch: 'deny', 'lumina_parallel_*': 'ask',
} } } })
expect(config.agent!.custom.permission).toEqual({
websearch: 'deny', 'lumina_parallel_*': 'ask',
})
})

it('carries background wiki restrictions over to both MCP tools', async () => {
const config = await configure({ agent: {
'wiki-sync': { permission: { read: 'allow', websearch: 'deny', webfetch: 'deny' } },
wildcard: { permission: { 'web*': 'deny' } },
limited: { tools: { websearch: false }, permission: { webfetch: { 'https://allowed.test/*': 'allow' } } },
} })
expect(config.agent!['wiki-sync'].permission).toEqual({
read: 'allow', websearch: 'deny', webfetch: 'deny',
lumina_parallel_web_search: 'deny', lumina_parallel_web_fetch: 'deny',
})
expect(config.agent!.wildcard.permission).toMatchObject({
lumina_parallel_web_search: 'deny', lumina_parallel_web_fetch: 'deny',
})
expect(config.agent!.limited.permission).toMatchObject({
lumina_parallel_web_search: 'deny', lumina_parallel_web_fetch: 'deny',
})
})
})
94 changes: 94 additions & 0 deletions electron/main/agent-v2/plugin/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { version } from '../../../../package.json'

/** The subset of opencode configuration used by Lumina's search defaults. */
export interface SearchConfig {
mcp?: Record<string, unknown>
tools?: Record<string, boolean>
permission?: string | Record<string, unknown>
agent?: Record<string, {
tools?: Record<string, boolean>
permission?: string | Record<string, unknown>
}>
}

// Keep permission-name matching aligned with the release-pinned opencode
// util/wildcard.ts, including its optional trailing " *" convention.
function matches(pattern: string, name: string): boolean {
name = name.replace(/\\/g, '/')
pattern = pattern.replace(/\\/g, '/')
let escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*').replace(/\?/g, '.')
if (escaped.endsWith(' .*')) escaped = escaped.slice(0, -3) + '( .*)?'
const flags = process.platform === 'win32' ? 'si' : 's'
return new RegExp(`^${escaped}$`, flags).test(name)
}

/**
* Called with opencode's merged configuration, before MCP initialization.
* An existing MCP map or network-tool policy is an explicit user choice.
*/
export function configureSearchMcp(config: SearchConfig): void {
const permission = config.permission
if (
config.mcp !== undefined ||
['OPENCODE_ENABLE_EXA', 'OPENCODE_EXPERIMENTAL', 'OPENCODE_EXPERIMENTAL_EXA']
.some((name) => ['1', 'true'].includes(process.env[name] ?? '')) ||
process.env.EXA_API_KEY ||
process.env.PARALLEL_API_KEY ||
config.tools?.websearch !== undefined ||
config.tools?.webfetch !== undefined ||
permission === 'deny' ||
(typeof permission === 'object' && (
permission['*'] === 'deny' ||
Object.keys(permission).some((key) => key !== '*' &&
(matches(key, 'websearch') || matches(key, 'webfetch')))
))
) return

// Use the normal approval flow for the new tools. Explicit global allow
// (including Lumina's auto-approve setting) remains authoritative.
if (permission === undefined) config.permission = { 'lumina_parallel_*': 'ask' }
else if (typeof permission === 'object') {
config.permission = { 'lumina_parallel_*': 'ask', ...permission }
}

config.mcp = {
lumina_parallel: {
type: 'remote',
url: 'https://search.parallel.ai/mcp',
oauth: false,
timeout: 30_000,
headers: { 'User-Agent': `Lumina-Note/${version}` },
},
}

// MCP tools have their own permission names. Carry over agent-specific
// network restrictions so background agents cannot bypass them.
for (const agent of Object.values(config.agent ?? {})) {
if (typeof agent.permission === 'string') continue
// Match ConfigAgent.normalize: deprecated tools are defaults; explicit
// permissions win. The hook normally receives this already normalized.
const original: Record<string, unknown> = {
...Object.fromEntries(Object.entries(agent.tools ?? {})
.map(([name, enabled]) => [name, enabled ? 'allow' : 'deny'])),
...agent.permission,
}
const policy = { ...agent.permission }
// Match Permission.fromConfig: wildcard tool rules precede exact rules.
const entries = Object.entries(original).sort(([a], [b]) =>
Number(b.includes('*')) - Number(a.includes('*')))
for (const [builtin, mcp] of [
['websearch', 'lumina_parallel_web_search'],
['webfetch', 'lumina_parallel_web_fetch'],
]) {
if (Object.keys(original).some((key) => key !== '*' && matches(key, mcp) && !matches(key, builtin))) continue
for (const [pattern, action] of entries) {
if (pattern === '*' || !matches(pattern, builtin)) continue
// URL/query-scoped rules cannot be applied to MCP's '*' resource.
// Keep those restrictions by denying the corresponding default tool.
policy[mcp] = typeof action === 'string' ? action : 'deny'
}
}
agent.permission = policy
}
}
2 changes: 2 additions & 0 deletions electron/main/agent-v2/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import { z } from 'zod'
import type { SearchConfig } from './search.js'

export interface AskInput {
permission: string
Expand Down Expand Up @@ -65,6 +66,7 @@ export const tool: ToolHelper = Object.assign(
) as ToolHelper

export interface Hooks {
config?: (config: SearchConfig) => Promise<void>
tool?: Record<string, ToolDefinition>
"chat.params"?: (
input: {
Expand Down