@swarmclawai/swarmclaw 1.5.46 → 1.5.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -396,6 +396,16 @@ Operational docs: https://swarmclaw.ai/docs/observability
396
396
 
397
397
  ## Releases
398
398
 
399
+ ### v1.5.47 Highlights
400
+
401
+ - **MCP injection for GitHub Copilot CLI and OpenAI Codex CLI agents**: agents using the `copilot-cli` or `codex-cli` providers now run with their assigned MCP servers attached at runtime. Copilot CLI receives the servers via `--additional-mcp-config @<tempfile>`; Codex CLI gets per-session `[mcp_servers.*]` TOML sections appended to a scoped `config.toml`. Stdio transports (command, args, env, cwd) and SSE / streamable-http transports (url, headers) are both supported. Skills assigned to the agent continue to be injected via the system prompt.
402
+ - **Skills and MCP panel visible for copilot-cli and codex-cli in the agent editor**: the Advanced Settings section now opens for these two providers so you can attach skills and MCP servers from the UI. Routing, memory, and voice panels stay hidden since these providers are worker-only.
403
+ - **Codex CLI approval policy change**: Codex CLI sessions now launch with `--dangerously-bypass-approvals-and-sandbox` instead of `--full-auto`. The old flag silently cancels MCP tool calls via Codex's approval gate, which is why MCP tool results were not landing. SwarmClaw itself runs in its own sandbox, so Codex's additional sandbox was not load-bearing, but be aware of the change if you were relying on it for a specific agent.
404
+ - **Under the hood**: `~/.codex-sessions/<session.id>/` replaces `/tmp/swarmclaw-codex-*` as the per-session Codex config directory because Codex refuses to create helper binaries under `/tmp`. The Playwright MCP proxy now passes an explicit `cwd: process.cwd()` when spawning, so it no longer crashes with `uv_cwd ENOENT` when the server is restarted after a directory move.
405
+ - **Exa as a new web search provider**: Settings > Web Search gains an Exa option alongside Tavily, Brave, SearXNG, DuckDuckGo, Google, and Bing. Exa uses neural search with AI-generated summaries and falls back to highlights, then raw text when summaries are unavailable. Configure the key via the UI, the `EXA_API_KEY` environment variable, or the secrets store. Requests carry an `x-exa-integration: swarmclaw` tracking header so usage attributed to SwarmClaw is visible to Exa.
406
+
407
+ Thanks to [@borislavnnikolov](https://github.com/borislavnnikolov) and [@tgonzalezc5](https://github.com/tgonzalezc5) for the contributions.
408
+
399
409
  ### v1.5.46 Highlights
400
410
 
401
411
  - **Custom base URL for built-in OpenAI and Anthropic providers**: the Endpoint field in provider settings now works for the built-in OpenAI and Anthropic providers (marked as `optionalEndpoint`). Point them at a proxy, gateway, or self-hosted endpoint and the URL persists, auto-resolves on connection test, and flows through both the live chat path and the LangGraph agent path (`ChatAnthropic` now receives `anthropicApiUrl`). Existing installs with no custom URL keep using the defaults.
@@ -404,7 +414,7 @@ Operational docs: https://swarmclaw.ai/docs/observability
404
414
  - **Anthropic streaming refactor**: the streaming handler moved from Node's `https.request()` to `fetch()`. Same behavior, cleaner cancellation, and it now respects `session.apiEndpoint` as a full base URL instead of a hostname.
405
415
  - **Connection test body**: Ollama and OpenAI-compatible test requests now send `max_completion_tokens` instead of the legacy `max_tokens`, matching current OpenAI conventions and working correctly with reasoning models that reject `max_tokens`.
406
416
 
407
- Thanks to @Llugaes for the contribution.
417
+ Thanks to [@Llugaes](https://github.com/Llugaes) for the contribution.
408
418
 
409
419
  ### v1.5.45 Highlights
410
420
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.46",
3
+ "version": "1.5.47",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@ const TASK_QG_MIN_EVIDENCE_MIN = 0
26
26
  const TASK_QG_MIN_EVIDENCE_MAX = 8
27
27
  const SESSION_RESET_TIMEOUT_MIN = 0
28
28
  const SESSION_RESET_TIMEOUT_MAX = 365 * 24 * 60 * 60
29
- const SECRET_SETTING_KEYS = ['elevenLabsApiKey', 'tavilyApiKey', 'braveApiKey'] as const
29
+ const SECRET_SETTING_KEYS = ['elevenLabsApiKey', 'tavilyApiKey', 'braveApiKey', 'exaApiKey'] as const
30
30
 
31
31
  function parseIntSetting(value: unknown, fallback: number, min: number, max: number): number {
32
32
  const parsed = typeof value === 'number'
@@ -12,7 +12,7 @@ import { toast } from 'sonner'
12
12
  import { ModelCombobox } from '@/components/shared/model-combobox'
13
13
  import type { ProviderType, ClaudeSkill, AgentPackManifest, AgentRoutingStrategy, AgentRoutingTarget } from '@/types'
14
14
  import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
15
- import { NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS, WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
15
+ import { MCP_INJECTION_PROVIDER_IDS, NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS, WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
16
16
  import { isOrchestratorProviderEligible } from '@/lib/orchestrator-config'
17
17
  import { AgentAvatar } from './agent-avatar'
18
18
  import { AgentPickerList } from '@/components/shared/agent-picker-list'
@@ -179,6 +179,7 @@ export function AgentSheet() {
179
179
  const dynamicSkills = useAppStore((s) => s.skills)
180
180
  const mcpServers = useAppStore((s) => s.mcpServers)
181
181
  const loadSkills = useAppStore((s) => s.loadSkills)
182
+ const loadMcpServersAction = useAppStore((s) => s.loadMcpServers)
182
183
  const [claudeSkills, setClaudeSkills] = useState<ClaudeSkill[]>([])
183
184
  const [claudeSkillsLoading, setClaudeSkillsLoading] = useState(false)
184
185
  const loadClaudeSkills = async () => {
@@ -390,6 +391,7 @@ export function AgentSheet() {
390
391
  loadGatewayProfiles()
391
392
  loadCredentials()
392
393
  loadSkills()
394
+ loadMcpServersAction()
393
395
  loadProjects()
394
396
  loadClaudeSkills()
395
397
  // Fetch enabled extension IDs so we can filter tool toggles
@@ -2007,13 +2009,14 @@ export function AgentSheet() {
2007
2009
  </SectionCard>
2008
2010
  )}
2009
2011
 
2010
- {!WORKER_ONLY_PROVIDER_IDS.has(provider) && (
2012
+ {(!WORKER_ONLY_PROVIDER_IDS.has(provider) || MCP_INJECTION_PROVIDER_IDS.has(provider)) && (
2011
2013
  <AdvancedSettingsSection
2012
2014
  open={showAdvancedSettings}
2013
2015
  onToggle={() => setShowAdvancedSettings((current) => !current)}
2014
2016
  summary={advancedSummary}
2015
2017
  badges={agentAdvancedBadges}
2016
2018
  >
2019
+ {!WORKER_ONLY_PROVIDER_IDS.has(provider) && (<>
2017
2020
  <SectionCard
2018
2021
  title="Context & Tool Access"
2019
2022
  description="Control how many tools are described in this agent's system prompt. Scoped (default) keeps the agent focused and saves ~3 k input tokens per turn; Universal gives it visibility into every built-in tool."
@@ -2450,6 +2453,7 @@ export function AgentSheet() {
2450
2453
  </div>
2451
2454
  )}
2452
2455
  </SectionCard>
2456
+ </>)}
2453
2457
 
2454
2458
  <SectionCard
2455
2459
  title="Tools & Skills"
@@ -2539,13 +2543,13 @@ export function AgentSheet() {
2539
2543
  {provider === 'claude-cli'
2540
2544
  ? 'Claude CLI uses its own built-in capabilities — no additional local tool/platform configuration is needed.'
2541
2545
  : provider === 'codex-cli'
2542
- ? 'OpenAI Codex CLI uses its own built-in tools (shell, files, etc.) no additional local tool configuration is needed.'
2546
+ ? 'OpenAI Codex CLI uses its own built-in tools (shell, files, etc.). Skills and MCP servers assigned below will be injected at runtime.'
2543
2547
  : provider === 'opencode-cli'
2544
2548
  ? 'OpenCode CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration is needed.'
2545
2549
  : provider === 'gemini-cli'
2546
2550
  ? 'Gemini CLI uses its own built-in tools and runtime — SwarmClaw does not inject local platform tools for it.'
2547
2551
  : provider === 'copilot-cli'
2548
- ? 'GitHub Copilot CLI uses its own built-in tools and runtime SwarmClaw does not inject local platform tools for it.'
2552
+ ? 'GitHub Copilot CLI uses its own built-in tools and runtime. Skills and MCP servers assigned below will be injected at runtime.'
2549
2553
  : provider === 'droid-cli'
2550
2554
  ? 'Factory Droid CLI uses its own built-in tools and autonomy controls — SwarmClaw does not inject local platform tools for it.'
2551
2555
  : provider === 'cursor-cli'
@@ -9,3 +9,6 @@ export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli'
9
9
 
10
10
  /** Providers that can only act as workers — no coordinator role, no heartbeat, no advanced settings. */
11
11
  export const WORKER_ONLY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'openclaw', 'hermes'])
12
+
13
+ /** CLI providers that support MCP server and skill injection at runtime (via provider-specific config mechanisms). */
14
+ export const MCP_INJECTION_PROVIDER_IDS = new Set(['copilot-cli', 'codex-cli'])
@@ -6,6 +6,8 @@ import type { StreamChatOptions } from './index'
6
6
  import { log } from '../server/logger'
7
7
  import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
8
8
  import { resolveCliBinary, buildCliEnv, probeCliAuth, attachAbortHandler, symlinkConfigFiles, isStderrNoise } from './cli-utils'
9
+ import { getAgent } from '@/lib/server/agents/agent-repository'
10
+ import { loadMcpServers } from '@/lib/server/storage'
9
11
 
10
12
  const TAG = 'provider-codex'
11
13
 
@@ -31,7 +33,10 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
31
33
  args.push('resume', session.codexThreadId)
32
34
  }
33
35
 
34
- args.push('--json', '--full-auto', '--skip-git-repo-check')
36
+ // Use --dangerously-bypass-approvals-and-sandbox instead of --full-auto so that
37
+ // MCP tool calls are not silently cancelled by codex's approval gate.
38
+ // SwarmClaw runs in its own sandboxed environment so bypassing codex's sandbox is safe.
39
+ args.push('--json', '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check')
35
40
 
36
41
  if (session.model) args.push('-m', session.model)
37
42
  if (codexModelRequiresReasoningDowngrade(session.model)) {
@@ -64,19 +69,73 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
64
69
  }
65
70
  }
66
71
 
67
- // System prompt: write temp AGENTS.override.md in a temp CODEX_HOME
72
+ // System prompt + MCP injection: create a temp CODEX_HOME when needed
68
73
  // Symlink auth files from the real config dir so auth still works
69
74
  let tempCodexHome: string | null = null
70
- if (systemPrompt && !session.codexThreadId) {
75
+ const agentForMcp = session.agentId ? getAgent(session.agentId as string) : null
76
+ const agentMcpServerIds: string[] = agentForMcp?.mcpServerIds || []
77
+ const needsTempHome = (systemPrompt && !session.codexThreadId) || agentMcpServerIds.length > 0
78
+ if (needsTempHome) {
71
79
  const realCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
72
- tempCodexHome = path.join(os.tmpdir(), `swarmclaw-codex-${session.id}`)
80
+ // Use ~/.codex-sessions/ not /tmp — codex refuses to create helper binaries under /tmp
81
+ const sessionsDir = path.join(os.homedir(), '.codex-sessions')
82
+ tempCodexHome = path.join(sessionsDir, session.id)
73
83
  fs.mkdirSync(tempCodexHome, { recursive: true })
74
84
 
75
85
  // Symlink auth/config files from real CODEX_HOME into temp dir
76
86
  symlinkConfigFiles(realCodexHome, tempCodexHome)
77
87
 
78
- // Write system prompt as AGENTS.override.md
79
- fs.writeFileSync(path.join(tempCodexHome, 'AGENTS.override.md'), systemPrompt)
88
+ // Write system prompt as AGENTS.override.md (first turn only)
89
+ if (systemPrompt && !session.codexThreadId) {
90
+ fs.writeFileSync(path.join(tempCodexHome, 'AGENTS.override.md'), systemPrompt)
91
+ }
92
+
93
+ // Inject agent-assigned MCP servers into config.toml
94
+ if (agentMcpServerIds.length > 0) {
95
+ try {
96
+ const allMcpServers = loadMcpServers()
97
+ const tomlParts: string[] = []
98
+ for (const serverId of agentMcpServerIds) {
99
+ const config = allMcpServers[serverId]
100
+ if (!config) continue
101
+ const name = config.name.replace(/[^a-zA-Z0-9_]/g, '_')
102
+ if (config.transport === 'stdio' && config.command) {
103
+ tomlParts.push(`[mcp_servers.${name}]`)
104
+ tomlParts.push(`command = ${JSON.stringify(config.command)}`)
105
+ const argsStr = (config.args || []).map((a: string) => JSON.stringify(a)).join(', ')
106
+ tomlParts.push(`args = [${argsStr}]`)
107
+ if (config.cwd) tomlParts.push(`cwd = ${JSON.stringify(config.cwd)}`)
108
+ tomlParts.push('')
109
+ // Env vars go in a separate subsection: [mcp_servers.name.env]
110
+ if (config.env && Object.keys(config.env).length > 0) {
111
+ tomlParts.push(`[mcp_servers.${name}.env]`)
112
+ for (const [k, v] of Object.entries(config.env as Record<string, string>)) {
113
+ tomlParts.push(`${k} = ${JSON.stringify(v)}`)
114
+ }
115
+ tomlParts.push('')
116
+ }
117
+ } else if ((config.transport === 'sse' || config.transport === 'streamable-http') && config.url) {
118
+ tomlParts.push(`[mcp_servers.${name}]`)
119
+ tomlParts.push(`url = ${JSON.stringify(config.url)}`)
120
+ tomlParts.push('')
121
+ }
122
+ }
123
+ if (tomlParts.length > 0) {
124
+ const realConfigPath = path.join(realCodexHome, 'config.toml')
125
+ const existingConfig = fs.existsSync(realConfigPath)
126
+ ? fs.readFileSync(realConfigPath, 'utf-8')
127
+ : ''
128
+ const tempConfigPath = path.join(tempCodexHome, 'config.toml')
129
+ // Remove symlink created by symlinkConfigFiles before writing our own file
130
+ try { fs.unlinkSync(tempConfigPath) } catch { /* no symlink — ignore */ }
131
+ fs.writeFileSync(tempConfigPath, existingConfig + '\n' + tomlParts.join('\n'))
132
+ log.info('codex-cli', `Injecting ${agentMcpServerIds.length} MCP server(s) via config.toml`)
133
+ }
134
+ } catch (mcpErr) {
135
+ log.warn('codex-cli', `Failed to build MCP config: ${mcpErr}`)
136
+ }
137
+ }
138
+
80
139
  env.CODEX_HOME = tempCodexHome
81
140
  }
82
141
 
@@ -6,6 +6,8 @@ import type { StreamChatOptions } from './index'
6
6
  import { log } from '../server/logger'
7
7
  import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
8
8
  import { resolveCliBinary, buildCliEnv, probeCliAuth, attachAbortHandler, symlinkConfigFiles, isStderrNoise } from './cli-utils'
9
+ import { getAgent } from '@/lib/server/agents/agent-repository'
10
+ import { loadMcpServers } from '@/lib/server/storage'
9
11
 
10
12
  /**
11
13
  * GitHub Copilot CLI provider — spawns `copilot -p <message> --output-format=json -s --yolo`.
@@ -65,6 +67,44 @@ export function streamCopilotCliChat({ session, message, imagePath, systemPrompt
65
67
  env.COPILOT_HOME = tempCopilotHome
66
68
  }
67
69
 
70
+ // Inject agent-assigned MCP servers via --additional-mcp-config flag
71
+ let mcpAdditionalConfigPath: string | null = null
72
+ try {
73
+ const agentForMcp = session.agentId ? getAgent(session.agentId as string) : null
74
+ const agentMcpServerIds: string[] = agentForMcp?.mcpServerIds || []
75
+ if (agentMcpServerIds.length > 0) {
76
+ const allMcpServers = loadMcpServers()
77
+ const mcpServerEntries: Record<string, Record<string, unknown>> = {}
78
+ for (const serverId of agentMcpServerIds) {
79
+ const config = allMcpServers[serverId]
80
+ if (!config) continue
81
+ const name = config.name.replace(/[^a-zA-Z0-9_-]/g, '-')
82
+ if (config.transport === 'stdio' && config.command) {
83
+ mcpServerEntries[name] = {
84
+ command: config.command,
85
+ args: config.args || [],
86
+ ...(config.env && Object.keys(config.env).length > 0 ? { env: config.env } : {}),
87
+ ...(config.cwd ? { cwd: config.cwd } : {}),
88
+ }
89
+ } else if ((config.transport === 'sse' || config.transport === 'streamable-http') && config.url) {
90
+ mcpServerEntries[name] = {
91
+ type: config.transport,
92
+ url: config.url,
93
+ ...(config.headers && Object.keys(config.headers).length > 0 ? { headers: config.headers } : {}),
94
+ }
95
+ }
96
+ }
97
+ if (Object.keys(mcpServerEntries).length > 0) {
98
+ mcpAdditionalConfigPath = path.join(os.tmpdir(), `swarmclaw-copilot-mcp-${session.id}.json`)
99
+ fs.writeFileSync(mcpAdditionalConfigPath, JSON.stringify({ mcpServers: mcpServerEntries }))
100
+ args.push('--additional-mcp-config', `@${mcpAdditionalConfigPath}`)
101
+ log.info('copilot-cli', `Injecting ${Object.keys(mcpServerEntries).length} MCP server(s)`)
102
+ }
103
+ }
104
+ } catch (mcpErr) {
105
+ log.warn('copilot-cli', `Failed to build MCP config: ${mcpErr}`)
106
+ }
107
+
68
108
  log.info('copilot-cli', `Spawning: ${binary}`, {
69
109
  args: args.map((a) => a.length > 100 ? a.slice(0, 100) + '...' : a),
70
110
  cwd: session.cwd,
@@ -221,6 +261,9 @@ export function streamCopilotCliChat({ session, message, imagePath, systemPrompt
221
261
  if (tempCopilotHome) {
222
262
  try { fs.rmSync(tempCopilotHome, { recursive: true }) } catch { /* ignore */ }
223
263
  }
264
+ if (mcpAdditionalConfigPath) {
265
+ try { fs.unlinkSync(mcpAdditionalConfigPath) } catch { /* ignore */ }
266
+ }
224
267
  if ((code ?? 0) !== 0 && !fullResponse.trim()) {
225
268
  const msg = stderrText.trim()
226
269
  ? `Copilot CLI exited with code ${code ?? 'unknown'}${sig ? ` (${sig})` : ''}: ${stderrText.trim().slice(0, 1200)}`
@@ -236,6 +279,9 @@ export function streamCopilotCliChat({ session, message, imagePath, systemPrompt
236
279
  if (tempCopilotHome) {
237
280
  try { fs.rmSync(tempCopilotHome, { recursive: true }) } catch { /* ignore */ }
238
281
  }
282
+ if (mcpAdditionalConfigPath) {
283
+ try { fs.unlinkSync(mcpAdditionalConfigPath) } catch { /* ignore */ }
284
+ }
239
285
  write(`data: ${JSON.stringify({ t: 'err', text: e.message })}\n\n`)
240
286
  resolve(fullResponse)
241
287
  })
@@ -1,5 +1,6 @@
1
1
  import { log } from '@/lib/server/logger'
2
2
  import { genId } from '@/lib/id'
3
+ import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
3
4
  import {
4
5
  loadConnectors,
5
6
  loadSession,
@@ -1022,6 +1023,8 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1022
1023
  }
1023
1024
  }
1024
1025
 
1026
+
1027
+
1025
1028
  // Build system prompt: [identity] \n\n [userPrompt] \n\n [soul] \n\n [systemPrompt]
1026
1029
  const settings = loadSettings()
1027
1030
  const promptParts: string[] = []
@@ -1196,7 +1199,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
1196
1199
  const transcript = typeof params.transcript === 'string' ? params.transcript.trim() : ''
1197
1200
  if (transcript) currentChannelDeliveryRef.current?.transcripts.push(transcript)
1198
1201
  }
1199
- const hasTools = getEnabledCapabilityIds(session).length > 0 && session.provider !== 'claude-cli'
1202
+ const hasTools = getEnabledCapabilityIds(session).length > 0 && !NON_LANGGRAPH_PROVIDER_IDS.has(session.provider as string)
1200
1203
  log.info(TAG, `Routing message to agent "${agent.name}" (${session.provider}/${session.model}), hasTools=${!!hasTools}`)
1201
1204
 
1202
1205
  if (hasTools) {
@@ -33,10 +33,12 @@ const child = cliPath
33
33
  ? spawn(process.execPath, [cliPath], {
34
34
  stdio: ['pipe', 'pipe', 'pipe'],
35
35
  env: sanitizePlaywrightEnv(process.env),
36
+ cwd: process.cwd(),
36
37
  })
37
38
  : spawn('npx', ['@playwright/mcp@latest'], {
38
39
  stdio: ['pipe', 'pipe', 'pipe'],
39
40
  env: sanitizePlaywrightEnv(process.env),
41
+ cwd: process.cwd(),
40
42
  })
41
43
 
42
44
  // Graceful EPIPE handling — dev server restarts break stdio pipes
@@ -0,0 +1,165 @@
1
+ import { describe, it, beforeEach, afterEach, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ describe('getSearchProvider — exa', () => {
5
+ const originalEnv = process.env.EXA_API_KEY
6
+
7
+ afterEach(() => {
8
+ if (originalEnv === undefined) delete process.env.EXA_API_KEY
9
+ else process.env.EXA_API_KEY = originalEnv
10
+ })
11
+
12
+ it('throws when no API key is available', async () => {
13
+ delete process.env.EXA_API_KEY
14
+ const { getSearchProvider } = await import('./search-providers')
15
+ await assert.rejects(
16
+ () => getSearchProvider({ webSearchProvider: 'exa' }),
17
+ (err: Error) => {
18
+ assert.match(err.message, /Exa requires an API key/)
19
+ return true
20
+ },
21
+ )
22
+ })
23
+
24
+ it('resolves an ExaProvider from EXA_API_KEY env var', async () => {
25
+ process.env.EXA_API_KEY = 'test-key-123'
26
+ const { getSearchProvider } = await import('./search-providers')
27
+ const provider = await getSearchProvider({ webSearchProvider: 'exa' })
28
+ assert.equal(provider.id, 'exa')
29
+ assert.equal(provider.name, 'Exa')
30
+ })
31
+
32
+ it('prefers settings key over env var', async () => {
33
+ process.env.EXA_API_KEY = 'env-key'
34
+ const { getSearchProvider } = await import('./search-providers')
35
+ const provider = await getSearchProvider({ webSearchProvider: 'exa', exaApiKey: 'settings-key' })
36
+ assert.equal(provider.id, 'exa')
37
+ })
38
+ })
39
+
40
+ describe('ExaProvider.search — response parsing', () => {
41
+ const FIXTURE = {
42
+ requestId: 'test-req-1',
43
+ results: [
44
+ {
45
+ title: 'Exa AI Search',
46
+ url: 'https://exa.ai',
47
+ publishedDate: '2024-01-15',
48
+ author: 'Exa Team',
49
+ text: 'Exa is a search engine for AI.',
50
+ highlights: ['Exa provides neural search.', 'Built for developers.'],
51
+ highlightScores: [0.95, 0.88],
52
+ summary: 'Exa is an AI-powered search engine built for developers.',
53
+ },
54
+ {
55
+ title: 'Getting Started with Exa',
56
+ url: 'https://docs.exa.ai/getting-started',
57
+ text: 'Learn how to integrate Exa into your application.',
58
+ highlights: [],
59
+ summary: '',
60
+ },
61
+ {
62
+ title: 'Minimal Result',
63
+ url: 'https://example.com/minimal',
64
+ },
65
+ ],
66
+ }
67
+
68
+ let originalFetch: typeof globalThis.fetch
69
+
70
+ beforeEach(() => {
71
+ originalFetch = globalThis.fetch
72
+ })
73
+
74
+ afterEach(() => {
75
+ globalThis.fetch = originalFetch
76
+ })
77
+
78
+ it('parses a full API response into SearchResult[]', async () => {
79
+ globalThis.fetch = mock.fn(async () => new Response(JSON.stringify(FIXTURE), {
80
+ status: 200,
81
+ headers: { 'Content-Type': 'application/json' },
82
+ })) as unknown as typeof globalThis.fetch
83
+
84
+ process.env.EXA_API_KEY = 'test-key'
85
+ const { getSearchProvider } = await import('./search-providers')
86
+ const provider = await getSearchProvider({ webSearchProvider: 'exa' })
87
+ const results = await provider.search('exa search', 10)
88
+
89
+ assert.equal(results.length, 3)
90
+ assert.equal(results[0].title, 'Exa AI Search')
91
+ assert.equal(results[0].url, 'https://exa.ai')
92
+ // Summary is preferred when available
93
+ assert.equal(results[0].snippet, 'Exa is an AI-powered search engine built for developers.')
94
+ })
95
+
96
+ it('falls back to text when summary and highlights are empty', async () => {
97
+ globalThis.fetch = mock.fn(async () => new Response(JSON.stringify(FIXTURE), {
98
+ status: 200,
99
+ headers: { 'Content-Type': 'application/json' },
100
+ })) as unknown as typeof globalThis.fetch
101
+
102
+ process.env.EXA_API_KEY = 'test-key'
103
+ const { getSearchProvider } = await import('./search-providers')
104
+ const provider = await getSearchProvider({ webSearchProvider: 'exa' })
105
+ const results = await provider.search('exa search', 10)
106
+
107
+ // Second result has empty summary and empty highlights, should fall back to text
108
+ assert.equal(results[1].snippet, 'Learn how to integrate Exa into your application.')
109
+ })
110
+
111
+ it('returns empty snippet when no content fields are present', async () => {
112
+ globalThis.fetch = mock.fn(async () => new Response(JSON.stringify(FIXTURE), {
113
+ status: 200,
114
+ headers: { 'Content-Type': 'application/json' },
115
+ })) as unknown as typeof globalThis.fetch
116
+
117
+ process.env.EXA_API_KEY = 'test-key'
118
+ const { getSearchProvider } = await import('./search-providers')
119
+ const provider = await getSearchProvider({ webSearchProvider: 'exa' })
120
+ const results = await provider.search('exa search', 10)
121
+
122
+ // Third result has no summary, no highlights, no text
123
+ assert.equal(results[2].snippet, '')
124
+ assert.equal(results[2].title, 'Minimal Result')
125
+ })
126
+
127
+ it('sends the integration tracking header', async () => {
128
+ let capturedHeaders: Record<string, string> = {}
129
+ globalThis.fetch = mock.fn(async (url: string | URL | Request, init?: RequestInit) => {
130
+ const headers = init?.headers as Record<string, string> | undefined
131
+ if (headers) capturedHeaders = { ...headers }
132
+ return new Response(JSON.stringify({ results: [] }), {
133
+ status: 200,
134
+ headers: { 'Content-Type': 'application/json' },
135
+ })
136
+ }) as unknown as typeof globalThis.fetch
137
+
138
+ process.env.EXA_API_KEY = 'test-key'
139
+ const { getSearchProvider } = await import('./search-providers')
140
+ const provider = await getSearchProvider({ webSearchProvider: 'exa' })
141
+ await provider.search('test', 5)
142
+
143
+ assert.equal(capturedHeaders['x-exa-integration'], 'swarmclaw')
144
+ assert.equal(capturedHeaders['x-api-key'], 'test-key')
145
+ })
146
+
147
+ it('throws on non-OK HTTP response', async () => {
148
+ globalThis.fetch = mock.fn(async () => new Response('Unauthorized', {
149
+ status: 401,
150
+ statusText: 'Unauthorized',
151
+ })) as unknown as typeof globalThis.fetch
152
+
153
+ process.env.EXA_API_KEY = 'bad-key'
154
+ const { getSearchProvider } = await import('./search-providers')
155
+ const provider = await getSearchProvider({ webSearchProvider: 'exa' })
156
+
157
+ await assert.rejects(
158
+ () => provider.search('test', 5),
159
+ (err: Error) => {
160
+ assert.match(err.message, /401/)
161
+ return true
162
+ },
163
+ )
164
+ })
165
+ })
@@ -243,6 +243,65 @@ class BraveProvider implements WebSearchProvider {
243
243
  }
244
244
  }
245
245
 
246
+ // ---------------------------------------------------------------------------
247
+ // Exa (API key required — from secrets or EXA_API_KEY env var)
248
+ // ---------------------------------------------------------------------------
249
+
250
+ interface ExaSearchResult {
251
+ title?: string
252
+ url?: string
253
+ publishedDate?: string | null
254
+ author?: string | null
255
+ text?: string
256
+ highlights?: string[]
257
+ highlightScores?: number[]
258
+ summary?: string
259
+ }
260
+
261
+ class ExaProvider implements WebSearchProvider {
262
+ id = 'exa'
263
+ name = 'Exa'
264
+
265
+ constructor(private apiKey: string) {}
266
+
267
+ async search(query: string, maxResults: number): Promise<SearchResult[]> {
268
+ const res = await fetch('https://api.exa.ai/search', {
269
+ method: 'POST',
270
+ headers: {
271
+ 'Content-Type': 'application/json',
272
+ 'x-api-key': this.apiKey,
273
+ 'x-exa-integration': 'swarmclaw',
274
+ },
275
+ body: JSON.stringify({
276
+ query,
277
+ type: 'auto',
278
+ numResults: maxResults,
279
+ contents: {
280
+ highlights: { numSentences: 3 },
281
+ text: { maxCharacters: 500 },
282
+ summary: true,
283
+ },
284
+ }),
285
+ signal: AbortSignal.timeout(15000),
286
+ })
287
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
288
+ const data = await res.json()
289
+ const rawResults: ExaSearchResult[] = Array.isArray(data.results) ? data.results : []
290
+ return rawResults.slice(0, maxResults).map((r) => ({
291
+ title: r.title || '',
292
+ url: r.url || '',
293
+ snippet: buildExaSnippet(r),
294
+ }))
295
+ }
296
+ }
297
+
298
+ function buildExaSnippet(result: ExaSearchResult): string {
299
+ if (result.summary) return result.summary
300
+ if (result.highlights && result.highlights.length > 0) return result.highlights.join(' … ')
301
+ if (result.text) return result.text
302
+ return ''
303
+ }
304
+
246
305
  // ---------------------------------------------------------------------------
247
306
  // Factory
248
307
  // ---------------------------------------------------------------------------
@@ -279,6 +338,17 @@ export async function getSearchProvider(settings: Partial<AppSettings>): Promise
279
338
  if (!apiKey) throw new Error('Brave Search requires an API key. Set one in Settings > Web Search.')
280
339
  return new BraveProvider(apiKey)
281
340
  }
341
+ case 'exa': {
342
+ let apiKey = settings.exaApiKey
343
+ if (!apiKey) apiKey = process.env.EXA_API_KEY ?? null
344
+ if (!apiKey) {
345
+ const { getSecret } = await import('../storage')
346
+ const secret = await getSecret('exa')
347
+ apiKey = secret?.value ?? null
348
+ }
349
+ if (!apiKey) throw new Error('Exa requires an API key. Set one in Settings > Web Search or set the EXA_API_KEY environment variable.')
350
+ return new ExaProvider(apiKey)
351
+ }
282
352
  default:
283
353
  return new DuckDuckGoProvider()
284
354
  }
@@ -1045,6 +1045,7 @@ const APP_SETTINGS_SECRET_FIELDS = [
1045
1045
  'elevenLabsApiKey',
1046
1046
  'tavilyApiKey',
1047
1047
  'braveApiKey',
1048
+ 'exaApiKey',
1048
1049
  ] as const
1049
1050
 
1050
1051
  const ENCRYPTED_APP_SETTINGS_KEY = '__encryptedAppSettings'
@@ -115,8 +115,10 @@ export interface AppSettings {
115
115
  // Theme
116
116
  themeHue?: string
117
117
  // Web search provider
118
- webSearchProvider?: 'duckduckgo' | 'google' | 'bing' | 'searxng' | 'tavily' | 'brave'
118
+ webSearchProvider?: 'duckduckgo' | 'google' | 'bing' | 'searxng' | 'tavily' | 'brave' | 'exa'
119
119
  searxngUrl?: string
120
+ exaApiKey?: string | null
121
+ exaApiKeyConfigured?: boolean
120
122
  // Task custom field definitions
121
123
  taskCustomFieldDefs?: Array<{ key: string; label: string; type: 'text' | 'number' | 'select'; options?: string[] }>
122
124
  // OpenClaw sync settings
@@ -6,6 +6,7 @@ export function WebSearchSection({ appSettings, patchSettings, inputClass }: Set
6
6
  const provider = appSettings.webSearchProvider || 'duckduckgo'
7
7
  const hasTavilyKey = appSettings.tavilyApiKeyConfigured === true
8
8
  const hasBraveKey = appSettings.braveApiKeyConfigured === true
9
+ const hasExaKey = appSettings.exaApiKeyConfigured === true
9
10
 
10
11
  return (
11
12
  <div className="mb-10">
@@ -30,6 +31,7 @@ export function WebSearchSection({ appSettings, patchSettings, inputClass }: Set
30
31
  <option value="searxng">SearXNG (self-hosted, no key required)</option>
31
32
  <option value="tavily">Tavily (API key required)</option>
32
33
  <option value="brave">Brave Search (API key required)</option>
34
+ <option value="exa">Exa (API key required)</option>
33
35
  </select>
34
36
  </div>
35
37
 
@@ -82,6 +84,24 @@ export function WebSearchSection({ appSettings, patchSettings, inputClass }: Set
82
84
  <p className="text-[11px] text-text-3/60 mt-2">Get your API key from <a href="https://brave.com/search/api/" target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline">brave.com/search/api</a></p>
83
85
  </div>
84
86
  )}
87
+
88
+ {provider === 'exa' && (
89
+ <div>
90
+ <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Exa API Key</label>
91
+ <input
92
+ type="password"
93
+ value={appSettings.exaApiKey || ''}
94
+ onChange={(e) => patchSettings({ exaApiKey: e.target.value || null })}
95
+ placeholder={hasExaKey ? 'Stored securely. Enter a new key to replace it.' : 'exa-...'}
96
+ className={inputClass}
97
+ style={{ fontFamily: 'inherit' }}
98
+ />
99
+ {hasExaKey && (
100
+ <p className="text-[11px] text-emerald-400/90 mt-1.5">Stored securely. Clear the field and save to remove it.</p>
101
+ )}
102
+ <p className="text-[11px] text-text-3/60 mt-2">Get your API key from <a href="https://exa.ai" target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline">exa.ai</a>. You can also set the <code className="text-[11px] font-mono text-text-2">EXA_API_KEY</code> environment variable.</p>
103
+ </div>
104
+ )}
85
105
  </div>
86
106
  </div>
87
107
  )