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 })