/ src / lib / server / extensions.test.ts
extensions.test.ts
  1  import assert from 'node:assert/strict'
  2  import { describe, it } from 'node:test'
  3  import fs from 'node:fs'
  4  import path from 'node:path'
  5  import { getExtensionManager, normalizeMarketplaceExtensionUrl, sanitizeExtensionFilename } from './extensions'
  6  import { canonicalizeExtensionId, expandExtensionIds, extensionIdMatches } from './tool-aliases'
  7  import { DATA_DIR } from './data-dir'
  8  import type { Session } from '@/types'
  9  
 10  let testExtensionSeq = 0
 11  
 12  function uniqueExtensionId(prefix: string): string {
 13    testExtensionSeq += 1
 14    return `${prefix}_${Date.now()}_${testExtensionSeq}`
 15  }
 16  
 17  describe('extension id canonicalization', () => {
 18    it('normalizes built-in aliases to canonical extension families', () => {
 19      assert.equal(canonicalizeExtensionId('session_info'), 'manage_sessions')
 20      assert.equal(canonicalizeExtensionId('connectors'), 'manage_connectors')
 21      assert.equal(canonicalizeExtensionId('subagent'), 'spawn_subagent')
 22      assert.equal(canonicalizeExtensionId('http'), 'web')
 23      assert.equal(canonicalizeExtensionId('human_loop'), 'ask_human')
 24      assert.equal(canonicalizeExtensionId('gws'), 'google_workspace')
 25    })
 26  
 27    it('expands aliases to include the canonical family id', () => {
 28      const expanded = expandExtensionIds(['session_info', 'http', 'human_loop'])
 29      assert.equal(expanded.includes('manage_sessions'), true)
 30      assert.equal(expanded.includes('session_info'), true)
 31      assert.equal(expanded.includes('http_request'), true)
 32      assert.equal(expanded.includes('http'), true)
 33      assert.equal(expanded.includes('ask_human'), true)
 34      assert.equal(expanded.includes('human_loop'), true)
 35    })
 36  
 37    it('matches Google Workspace aliases across canonical and CLI-facing names', () => {
 38      const expanded = expandExtensionIds(['google_workspace'])
 39      assert.equal(expanded.includes('google_workspace'), true)
 40      assert.equal(expanded.includes('gws'), true)
 41      assert.equal(expanded.includes('google-workspace'), true)
 42      assert.equal(extensionIdMatches(['google_workspace'], 'gws'), true)
 43      assert.equal(extensionIdMatches(['gws'], 'google-workspace'), true)
 44    })
 45  
 46    it('does not expand a specific platform tool back into manage_platform', () => {
 47      const expanded = expandExtensionIds(['manage_schedules'])
 48      assert.equal(expanded.includes('manage_schedules'), true)
 49      assert.equal(expanded.includes('manage_platform'), false)
 50      assert.equal(extensionIdMatches(['manage_platform'], 'manage_schedules'), true)
 51      assert.equal(extensionIdMatches(['manage_schedules'], 'manage_platform'), false)
 52    })
 53  })
 54  
 55  describe('extension install helpers', () => {
 56    it('rewrites legacy marketplace URLs to the canonical raw source', () => {
 57      const normalized = normalizeMarketplaceExtensionUrl('https://github.com/swarmclawai/swarmforge/blob/master/foo/bar.js')
 58      assert.equal(normalized, 'https://raw.githubusercontent.com/swarmclawai/swarmforge/main/foo/bar.js')
 59    })
 60  
 61    it('allows .js and .mjs extension filenames and blocks traversal', () => {
 62      assert.equal(sanitizeExtensionFilename('plugin.js'), 'plugin.js')
 63      assert.equal(sanitizeExtensionFilename('plugin.mjs'), 'plugin.mjs')
 64      assert.throws(() => sanitizeExtensionFilename('../plugin.js'), /Invalid filename/)
 65      assert.throws(() => sanitizeExtensionFilename('plugin'), /Filename must end/)
 66    })
 67  })
 68  
 69  describe('extension manager hook execution', () => {
 70    it('applies beforeToolExec mutations only for explicitly enabled extensions', async () => {
 71      const extensionId = uniqueExtensionId('before_tool_exec')
 72      getExtensionManager().registerBuiltin(extensionId, {
 73        name: 'Before Tool Exec Test',
 74        hooks: {
 75          beforeToolExec: ({ input }) => ({ ...(input || {}), patched: true }),
 76        },
 77      })
 78  
 79      const withoutEnable = await getExtensionManager().runBeforeToolExec(
 80        { toolName: 'shell', input: { original: true } },
 81        {},
 82      )
 83      assert.deepEqual(withoutEnable, { original: true })
 84  
 85      const withEnable = await getExtensionManager().runBeforeToolExec(
 86        { toolName: 'shell', input: { original: true } },
 87        { enabledIds: [extensionId] },
 88      )
 89      assert.deepEqual(withEnable, { original: true, patched: true })
 90    })
 91  
 92    it('merges beforePromptBuild context and preserves first system prompt override', async () => {
 93      const extA = uniqueExtensionId('before_prompt_build_a')
 94      const extB = uniqueExtensionId('before_prompt_build_b')
 95      const session = {
 96        id: 'prompt-hook-session',
 97        name: 'Prompt Hook Session',
 98        cwd: process.cwd(),
 99        user: 'tester',
100        provider: 'openai',
101        model: 'gpt-test',
102        claudeSessionId: null,
103        messages: [],
104        createdAt: Date.now(),
105        lastActiveAt: Date.now(),
106        extensions: [extA, extB],
107      } as unknown as Session
108  
109      getExtensionManager().registerBuiltin(extA, {
110        name: 'Before Prompt Build A',
111        hooks: {
112          beforePromptBuild: () => ({
113            systemPrompt: 'system A',
114            prependContext: 'context A',
115            prependSystemContext: 'prepend A',
116          }),
117        },
118      })
119      getExtensionManager().registerBuiltin(extB, {
120        name: 'Before Prompt Build B',
121        hooks: {
122          beforePromptBuild: () => ({
123            systemPrompt: 'system B',
124            prependContext: 'context B',
125            appendSystemContext: 'append B',
126          }),
127        },
128      })
129  
130      const result = await getExtensionManager().runBeforePromptBuild(
131        {
132          session,
133          prompt: 'base prompt',
134          message: 'hello',
135          history: [],
136          messages: [],
137        },
138        { enabledIds: [extA, extB] },
139      )
140  
141      assert.deepEqual(result, {
142        systemPrompt: 'system A',
143        prependContext: 'context A\n\ncontext B',
144        prependSystemContext: 'prepend A',
145        appendSystemContext: 'append B',
146      })
147    })
148  
149    it('applies beforeToolCall params merges and block results before legacy beforeToolExec', async () => {
150      const extA = uniqueExtensionId('before_tool_call_a')
151      const extB = uniqueExtensionId('before_tool_call_b')
152      const session = {
153        id: 'tool-hook-session',
154        name: 'Tool Hook Session',
155        cwd: process.cwd(),
156        user: 'tester',
157        provider: 'openai',
158        model: 'gpt-test',
159        claudeSessionId: null,
160        messages: [],
161        createdAt: Date.now(),
162        lastActiveAt: Date.now(),
163        extensions: [extA, extB],
164      } as unknown as Session
165  
166      getExtensionManager().registerBuiltin(extA, {
167        name: 'Before Tool Call A',
168        hooks: {
169          beforeToolCall: () => ({
170            params: { patched: true },
171            warning: 'tool warning',
172          }),
173        },
174      })
175      getExtensionManager().registerBuiltin(extB, {
176        name: 'Before Tool Call B',
177        hooks: {
178          beforeToolCall: ({ input }) => ({
179            block: true,
180            blockReason: `blocked with patched=${String(input?.patched)}`,
181          }),
182          beforeToolExec: () => ({ shouldNotRun: true }),
183        },
184      })
185  
186      const result = await getExtensionManager().runBeforeToolCall(
187        {
188          session,
189          toolName: 'shell',
190          input: { original: true },
191          runId: 'run-1',
192        },
193        { enabledIds: [extA, extB] },
194      )
195  
196      assert.deepEqual(result, {
197        input: { original: true, patched: true },
198        blockReason: 'blocked with patched=true',
199        warning: 'tool warning',
200      })
201    })
202  
203    it('applies beforeModelResolve overrides in extension order', async () => {
204      const extA = uniqueExtensionId('before_model_resolve_a')
205      const extB = uniqueExtensionId('before_model_resolve_b')
206      const session = {
207        id: 'model-resolve-session',
208        name: 'Model Resolve Session',
209        cwd: process.cwd(),
210        user: 'tester',
211        provider: 'openai',
212        model: 'gpt-test',
213        claudeSessionId: null,
214        messages: [],
215        createdAt: Date.now(),
216        lastActiveAt: Date.now(),
217        extensions: [extA, extB],
218      } as unknown as Session
219  
220      getExtensionManager().registerBuiltin(extA, {
221        name: 'Before Model Resolve A',
222        hooks: {
223          beforeModelResolve: () => ({
224            providerOverride: 'ollama',
225            modelOverride: 'llama-a',
226          }),
227        },
228      })
229      getExtensionManager().registerBuiltin(extB, {
230        name: 'Before Model Resolve B',
231        hooks: {
232          beforeModelResolve: () => ({
233            modelOverride: 'llama-b',
234            apiEndpointOverride: 'http://127.0.0.1:11434',
235          }),
236        },
237      })
238  
239      const result = await getExtensionManager().runBeforeModelResolve(
240        {
241          session,
242          prompt: 'base prompt',
243          message: 'hello',
244          provider: session.provider,
245          model: session.model,
246          apiEndpoint: null,
247        },
248        { enabledIds: [extA, extB] },
249      )
250  
251      assert.deepEqual(result, {
252        providerOverride: 'ollama',
253        modelOverride: 'llama-b',
254        apiEndpointOverride: 'http://127.0.0.1:11434',
255      })
256    })
257  
258    it('chains toolResultPersist and beforeMessageWrite hooks', async () => {
259      const extA = uniqueExtensionId('tool_result_persist_a')
260      const extB = uniqueExtensionId('before_message_write_b')
261      const session = {
262        id: 'message-write-session',
263        name: 'Message Write Session',
264        cwd: process.cwd(),
265        user: 'tester',
266        provider: 'openai',
267        model: 'gpt-test',
268        claudeSessionId: null,
269        messages: [],
270        createdAt: Date.now(),
271        lastActiveAt: Date.now(),
272        extensions: [extA, extB],
273      } as unknown as Session
274  
275      getExtensionManager().registerBuiltin(extA, {
276        name: 'Tool Result Persist A',
277        hooks: {
278          toolResultPersist: ({ message, toolName }) => ({
279            ...message,
280            text: `${message.text} [tool:${toolName}]`,
281          }),
282        },
283      })
284      getExtensionManager().registerBuiltin(extB, {
285        name: 'Before Message Write B',
286        hooks: {
287          beforeMessageWrite: ({ message }) => ({
288            message: {
289              ...message,
290              text: `${message.text} [persisted]`,
291            },
292          }),
293        },
294      })
295  
296      const persisted = await getExtensionManager().runToolResultPersist(
297        {
298          session,
299          message: {
300            role: 'assistant',
301            text: 'tool output',
302            time: Date.now(),
303          },
304          toolName: 'shell',
305          toolCallId: 'call-1',
306        },
307        { enabledIds: [extA, extB] },
308      )
309      const writeResult = await getExtensionManager().runBeforeMessageWrite(
310        {
311          session,
312          message: persisted,
313          phase: 'assistant_final',
314          runId: 'run-1',
315        },
316        { enabledIds: [extA, extB] },
317      )
318  
319      assert.equal(writeResult.block, false)
320      assert.equal(writeResult.message.text, 'tool output [tool:shell] [persisted]')
321    })
322  
323    it('blocks subagent spawning when an extension hook rejects it', async () => {
324      const extensionId = uniqueExtensionId('subagent_spawning')
325  
326      getExtensionManager().registerBuiltin(extensionId, {
327        name: 'Subagent Spawning Hook',
328        hooks: {
329          subagentSpawning: () => ({
330            status: 'error',
331            error: 'blocked by lifecycle hook',
332          }),
333        },
334      })
335  
336      const result = await getExtensionManager().runSubagentSpawning(
337        {
338          parentSessionId: 'parent-1',
339          agentId: 'agent-1',
340          agentName: 'Agent One',
341          message: 'do the work',
342          cwd: process.cwd(),
343          mode: 'run',
344          threadRequested: false,
345        },
346        { enabledIds: [extensionId] },
347      )
348  
349      assert.deepEqual(result, {
350        status: 'error',
351        error: 'blocked by lifecycle hook',
352      })
353    })
354  
355    it('chains text transforms in extension order', async () => {
356      const extA = uniqueExtensionId('transform_a')
357      const extB = uniqueExtensionId('transform_b')
358      getExtensionManager().registerBuiltin(extA, {
359        name: 'Transform A',
360        hooks: {
361          transformOutboundMessage: ({ text }) => `${text} A`,
362        },
363      })
364      getExtensionManager().registerBuiltin(extB, {
365        name: 'Transform B',
366        hooks: {
367          transformOutboundMessage: ({ text }) => `${text} B`,
368        },
369      })
370  
371      const transformed = await getExtensionManager().transformText(
372        'transformOutboundMessage',
373        {
374          session: {
375            id: 's1',
376            name: 'Test Session',
377            cwd: process.cwd(),
378            user: 'tester',
379            provider: 'openai',
380            model: 'gpt-test',
381            claudeSessionId: null,
382            messages: [],
383            createdAt: Date.now(),
384            lastActiveAt: Date.now(),
385            extensions: [extA, extB],
386          } as unknown as Session,
387          text: 'base',
388        },
389        { enabledIds: [extA, extB] },
390      )
391  
392      assert.equal(transformed, 'base A B')
393    })
394  
395    it('does not run generic extension hooks unless scope is provided explicitly', async () => {
396      const extensionId = uniqueExtensionId('scoped_hook')
397      let callCount = 0
398      getExtensionManager().registerBuiltin(extensionId, {
399        name: 'Scoped Hook Test',
400        hooks: {
401          afterChatTurn: () => {
402            callCount += 1
403          },
404        },
405      })
406  
407      await getExtensionManager().runHook(
408        'afterChatTurn',
409        {
410          session: {
411            id: 's2',
412            name: 'Scoped Hook Session',
413            cwd: process.cwd(),
414            user: 'tester',
415            provider: 'openai',
416            model: 'gpt-test',
417            claudeSessionId: null,
418            messages: [],
419            createdAt: Date.now(),
420            lastActiveAt: Date.now(),
421          },
422          message: 'hi',
423          response: 'hello',
424          source: 'chat',
425          internal: false,
426        },
427        {},
428      )
429      assert.equal(callCount, 0)
430  
431      await getExtensionManager().runHook(
432        'afterChatTurn',
433        {
434          session: {
435            id: 's3',
436            name: 'Scoped Hook Session Enabled',
437            cwd: process.cwd(),
438            user: 'tester',
439            provider: 'openai',
440            model: 'gpt-test',
441            claudeSessionId: null,
442            messages: [],
443            createdAt: Date.now(),
444            lastActiveAt: Date.now(),
445            extensions: [extensionId],
446          } as unknown as Session,
447          message: 'hi',
448          response: 'hello',
449          source: 'chat',
450          internal: false,
451        },
452        { enabledIds: [extensionId] },
453      )
454      assert.equal(callCount, 1)
455    })
456  
457    it('stores dependency-aware extensions in managed workspaces', async () => {
458      const filename = `${uniqueExtensionId('workspace_extension')}.js`
459      const manager = getExtensionManager()
460  
461      await manager.saveExtensionSource(
462        filename,
463        'module.exports = { name: "Workspace Extension", tools: [] }',
464        {
465          packageJson: {
466            name: 'workspace-extension',
467            dependencies: {
468              lodash: '^4.17.21',
469            },
470          },
471          packageManager: 'npm',
472        },
473      )
474  
475      const meta = manager.listExtensions().find((ext) => ext.filename === filename)
476      assert.equal(meta?.isBuiltin, false)
477      assert.equal(meta?.hasDependencyManifest, true)
478      assert.equal(meta?.dependencyCount, 1)
479      assert.equal(meta?.packageManager, 'npm')
480      assert.equal(manager.readExtensionSource(filename).includes('Workspace Extension'), true)
481  
482      const shimPath = path.join(DATA_DIR, 'extensions', filename)
483      assert.equal(fs.readFileSync(shimPath, 'utf8').includes('Auto-generated extension workspace shim'), true)
484  
485      assert.equal(manager.deleteExtension(filename), true)
486    })
487  })