|
1 | 1 | import { afterEach, beforeEach, describe, expect, it } from "bun:test" |
2 | | -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" |
| 2 | +import { |
| 3 | + existsSync, |
| 4 | + mkdirSync, |
| 5 | + readdirSync, |
| 6 | + readFileSync, |
| 7 | + rmSync, |
| 8 | + statSync, |
| 9 | + writeFileSync, |
| 10 | +} from "node:fs" |
3 | 11 | import { tmpdir } from "node:os" |
4 | 12 | import { join } from "node:path" |
5 | 13 |
|
@@ -263,6 +271,348 @@ main() |
263 | 271 | }) |
264 | 272 | }) |
265 | 273 |
|
| 274 | + describe("file integrity verification", () => { |
| 275 | + // Helper that creates a postinstall script with integrity validation |
| 276 | + function createPostinstallWithIntegrity(): string { |
| 277 | + return `#!/usr/bin/env node |
| 278 | +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs" |
| 279 | +import { join } from "node:path" |
| 280 | +
|
| 281 | +const AGENTS_SOURCE_DIR = "${agentsSourceDir.replace(/\\/g, "/")}" |
| 282 | +const AGENTS_TARGET_DIR = "${agentsTargetDir.replace(/\\/g, "/")}" |
| 283 | +
|
| 284 | +const MIN_CONTENT_LENGTH = 100 |
| 285 | +const REQUIRED_KEYWORDS = ["agent", "task"] |
| 286 | +
|
| 287 | +function validateAgentContent(filePath) { |
| 288 | + const content = readFileSync(filePath, "utf-8") |
| 289 | +
|
| 290 | + if (content.length < MIN_CONTENT_LENGTH) { |
| 291 | + return { |
| 292 | + valid: false, |
| 293 | + error: \`File too short: \${content.length} characters (minimum \${MIN_CONTENT_LENGTH})\`, |
| 294 | + } |
| 295 | + } |
| 296 | +
|
| 297 | + if (!content.startsWith("# ")) { |
| 298 | + return { |
| 299 | + valid: false, |
| 300 | + error: "File does not start with a markdown header (# )", |
| 301 | + } |
| 302 | + } |
| 303 | +
|
| 304 | + const lowerContent = content.toLowerCase() |
| 305 | + const hasKeyword = REQUIRED_KEYWORDS.some((keyword) => lowerContent.includes(keyword)) |
| 306 | + if (!hasKeyword) { |
| 307 | + return { |
| 308 | + valid: false, |
| 309 | + error: \`File missing required keywords: \${REQUIRED_KEYWORDS.join(", ")}\`, |
| 310 | + } |
| 311 | + } |
| 312 | +
|
| 313 | + return { valid: true } |
| 314 | +} |
| 315 | +
|
| 316 | +function main() { |
| 317 | + console.log("opencode-plugin-opencoder: Installing agents...") |
| 318 | +
|
| 319 | + if (!existsSync(AGENTS_TARGET_DIR)) { |
| 320 | + mkdirSync(AGENTS_TARGET_DIR, { recursive: true }) |
| 321 | + console.log(\` Created \${AGENTS_TARGET_DIR}\`) |
| 322 | + } |
| 323 | +
|
| 324 | + if (!existsSync(AGENTS_SOURCE_DIR)) { |
| 325 | + console.error(\` Error: Source agents directory not found at \${AGENTS_SOURCE_DIR}\`) |
| 326 | + process.exit(1) |
| 327 | + } |
| 328 | +
|
| 329 | + const files = readdirSync(AGENTS_SOURCE_DIR).filter((f) => f.endsWith(".md")) |
| 330 | +
|
| 331 | + if (files.length === 0) { |
| 332 | + console.error(" Error: No agent files found in agents/ directory") |
| 333 | + process.exit(1) |
| 334 | + } |
| 335 | +
|
| 336 | + const successes = [] |
| 337 | + const failures = [] |
| 338 | +
|
| 339 | + for (const file of files) { |
| 340 | + const sourcePath = join(AGENTS_SOURCE_DIR, file) |
| 341 | + const targetPath = join(AGENTS_TARGET_DIR, file) |
| 342 | +
|
| 343 | + try { |
| 344 | + copyFileSync(sourcePath, targetPath) |
| 345 | +
|
| 346 | + const sourceSize = statSync(sourcePath).size |
| 347 | + const targetSize = statSync(targetPath).size |
| 348 | +
|
| 349 | + if (sourceSize !== targetSize) { |
| 350 | + throw new Error( |
| 351 | + \`File size mismatch: source=\${sourceSize} bytes, target=\${targetSize} bytes\`, |
| 352 | + ) |
| 353 | + } |
| 354 | +
|
| 355 | + const validation = validateAgentContent(targetPath) |
| 356 | + if (!validation.valid) { |
| 357 | + throw new Error(\`Invalid agent file content: \${validation.error}\`) |
| 358 | + } |
| 359 | +
|
| 360 | + successes.push(file) |
| 361 | + console.log(\` Installed: \${file}\`) |
| 362 | + } catch (err) { |
| 363 | + failures.push({ file, message: err.message }) |
| 364 | + console.error(\` Failed: \${file} - \${err.message}\`) |
| 365 | + } |
| 366 | + } |
| 367 | +
|
| 368 | + console.log("") |
| 369 | + if (successes.length > 0 && failures.length === 0) { |
| 370 | + console.log(\`opencode-plugin-opencoder: Successfully installed \${successes.length} agent(s)\`) |
| 371 | + } else if (successes.length > 0 && failures.length > 0) { |
| 372 | + console.log(\`opencode-plugin-opencoder: Installed \${successes.length} of \${files.length} agent(s)\`) |
| 373 | + console.error(\` \${failures.length} file(s) failed to install:\`) |
| 374 | + for (const { file, message } of failures) { |
| 375 | + console.error(\` - \${file}: \${message}\`) |
| 376 | + } |
| 377 | + } else { |
| 378 | + console.error("opencode-plugin-opencoder: Failed to install any agents") |
| 379 | + for (const { file, message } of failures) { |
| 380 | + console.error(\` - \${file}: \${message}\`) |
| 381 | + } |
| 382 | + process.exit(1) |
| 383 | + } |
| 384 | +} |
| 385 | +
|
| 386 | +main() |
| 387 | +` |
| 388 | + } |
| 389 | + |
| 390 | + // Valid agent content for tests (meets all requirements) |
| 391 | + const validAgentContent = `# Test Agent |
| 392 | +
|
| 393 | +This is a valid agent file that contains enough content to pass the minimum length requirement. |
| 394 | +
|
| 395 | +## Task Execution |
| 396 | +
|
| 397 | +The agent handles various tasks and operations in the system. |
| 398 | +` |
| 399 | + |
| 400 | + beforeEach(() => { |
| 401 | + // Override the default agent files with valid content |
| 402 | + writeFileSync(join(agentsSourceDir, "opencoder.md"), validAgentContent) |
| 403 | + writeFileSync(join(agentsSourceDir, "opencoder-planner.md"), validAgentContent) |
| 404 | + writeFileSync(join(agentsSourceDir, "opencoder-builder.md"), validAgentContent) |
| 405 | + }) |
| 406 | + |
| 407 | + it("should reject files that are too short", async () => { |
| 408 | + // Create a file that's too short (less than 100 characters) |
| 409 | + writeFileSync(join(agentsSourceDir, "short-agent.md"), "# Short\nToo short content") |
| 410 | + |
| 411 | + const scriptPath = join(mockProjectDir, "test-postinstall.mjs") |
| 412 | + writeFileSync(scriptPath, createPostinstallWithIntegrity()) |
| 413 | + |
| 414 | + const proc = Bun.spawn(["node", scriptPath], { |
| 415 | + cwd: mockProjectDir, |
| 416 | + stdout: "pipe", |
| 417 | + stderr: "pipe", |
| 418 | + }) |
| 419 | + |
| 420 | + const exitCode = await proc.exited |
| 421 | + // Should succeed partially (other files are valid) |
| 422 | + expect(exitCode).toBe(0) |
| 423 | + |
| 424 | + const stderr = await new Response(proc.stderr).text() |
| 425 | + expect(stderr).toContain("short-agent.md") |
| 426 | + expect(stderr).toContain("File too short") |
| 427 | + }) |
| 428 | + |
| 429 | + it("should reject files without markdown header", async () => { |
| 430 | + // Create a file without markdown header |
| 431 | + const contentWithoutHeader = `This file does not start with a markdown header. |
| 432 | +It has enough content and contains the word agent and task. |
| 433 | +This should fail the validation because it doesn't start with # symbol.` |
| 434 | + writeFileSync(join(agentsSourceDir, "no-header.md"), contentWithoutHeader) |
| 435 | + |
| 436 | + const scriptPath = join(mockProjectDir, "test-postinstall.mjs") |
| 437 | + writeFileSync(scriptPath, createPostinstallWithIntegrity()) |
| 438 | + |
| 439 | + const proc = Bun.spawn(["node", scriptPath], { |
| 440 | + cwd: mockProjectDir, |
| 441 | + stdout: "pipe", |
| 442 | + stderr: "pipe", |
| 443 | + }) |
| 444 | + |
| 445 | + await proc.exited |
| 446 | + |
| 447 | + const stderr = await new Response(proc.stderr).text() |
| 448 | + expect(stderr).toContain("no-header.md") |
| 449 | + expect(stderr).toContain("does not start with a markdown header") |
| 450 | + }) |
| 451 | + |
| 452 | + it("should reject files missing required keywords", async () => { |
| 453 | + // Create a file with header and length but missing keywords |
| 454 | + const contentWithoutKeywords = `# Valid Header |
| 455 | +
|
| 456 | +This file has a valid markdown header and enough content length. |
| 457 | +However, it does not contain any of the required keywords. |
| 458 | +It talks about processes and workflows but not the specific terms. |
| 459 | +This should fail the validation check for missing keywords.` |
| 460 | + writeFileSync(join(agentsSourceDir, "no-keywords.md"), contentWithoutKeywords) |
| 461 | + |
| 462 | + const scriptPath = join(mockProjectDir, "test-postinstall.mjs") |
| 463 | + writeFileSync(scriptPath, createPostinstallWithIntegrity()) |
| 464 | + |
| 465 | + const proc = Bun.spawn(["node", scriptPath], { |
| 466 | + cwd: mockProjectDir, |
| 467 | + stdout: "pipe", |
| 468 | + stderr: "pipe", |
| 469 | + }) |
| 470 | + |
| 471 | + await proc.exited |
| 472 | + |
| 473 | + const stderr = await new Response(proc.stderr).text() |
| 474 | + expect(stderr).toContain("no-keywords.md") |
| 475 | + expect(stderr).toContain("missing required keywords") |
| 476 | + }) |
| 477 | + |
| 478 | + it("should accept files with valid content structure", async () => { |
| 479 | + const scriptPath = join(mockProjectDir, "test-postinstall.mjs") |
| 480 | + writeFileSync(scriptPath, createPostinstallWithIntegrity()) |
| 481 | + |
| 482 | + const proc = Bun.spawn(["node", scriptPath], { |
| 483 | + cwd: mockProjectDir, |
| 484 | + stdout: "pipe", |
| 485 | + stderr: "pipe", |
| 486 | + }) |
| 487 | + |
| 488 | + const exitCode = await proc.exited |
| 489 | + expect(exitCode).toBe(0) |
| 490 | + |
| 491 | + const stdout = await new Response(proc.stdout).text() |
| 492 | + expect(stdout).toContain("Successfully installed 3 agent(s)") |
| 493 | + |
| 494 | + // Verify all files were copied |
| 495 | + expect(existsSync(join(agentsTargetDir, "opencoder.md"))).toBe(true) |
| 496 | + expect(existsSync(join(agentsTargetDir, "opencoder-planner.md"))).toBe(true) |
| 497 | + expect(existsSync(join(agentsTargetDir, "opencoder-builder.md"))).toBe(true) |
| 498 | + }) |
| 499 | + |
| 500 | + it("should fail completely when all files are invalid", async () => { |
| 501 | + // Replace all agent files with invalid content |
| 502 | + writeFileSync(join(agentsSourceDir, "opencoder.md"), "Too short") |
| 503 | + writeFileSync(join(agentsSourceDir, "opencoder-planner.md"), "Also short") |
| 504 | + writeFileSync(join(agentsSourceDir, "opencoder-builder.md"), "Short too") |
| 505 | + |
| 506 | + const scriptPath = join(mockProjectDir, "test-postinstall.mjs") |
| 507 | + writeFileSync(scriptPath, createPostinstallWithIntegrity()) |
| 508 | + |
| 509 | + const proc = Bun.spawn(["node", scriptPath], { |
| 510 | + cwd: mockProjectDir, |
| 511 | + stdout: "pipe", |
| 512 | + stderr: "pipe", |
| 513 | + }) |
| 514 | + |
| 515 | + const exitCode = await proc.exited |
| 516 | + expect(exitCode).toBe(1) |
| 517 | + |
| 518 | + const stderr = await new Response(proc.stderr).text() |
| 519 | + expect(stderr).toContain("Failed to install any agents") |
| 520 | + }) |
| 521 | + |
| 522 | + it("should report partial success when some files are invalid", async () => { |
| 523 | + // Keep one valid, make others invalid |
| 524 | + writeFileSync(join(agentsSourceDir, "opencoder-planner.md"), "Too short") |
| 525 | + writeFileSync(join(agentsSourceDir, "opencoder-builder.md"), "Also short") |
| 526 | + |
| 527 | + const scriptPath = join(mockProjectDir, "test-postinstall.mjs") |
| 528 | + writeFileSync(scriptPath, createPostinstallWithIntegrity()) |
| 529 | + |
| 530 | + const proc = Bun.spawn(["node", scriptPath], { |
| 531 | + cwd: mockProjectDir, |
| 532 | + stdout: "pipe", |
| 533 | + stderr: "pipe", |
| 534 | + }) |
| 535 | + |
| 536 | + const exitCode = await proc.exited |
| 537 | + expect(exitCode).toBe(0) |
| 538 | + |
| 539 | + const stdout = await new Response(proc.stdout).text() |
| 540 | + expect(stdout).toContain("Installed 1 of 3 agent(s)") |
| 541 | + |
| 542 | + const stderr = await new Response(proc.stderr).text() |
| 543 | + expect(stderr).toContain("2 file(s) failed to install") |
| 544 | + }) |
| 545 | + |
| 546 | + it("should verify file size matches after copy", async () => { |
| 547 | + // This test verifies the size check logic is present |
| 548 | + // The actual size mismatch is hard to simulate without mocking fs |
| 549 | + // So we verify that valid files pass the size check |
| 550 | + const scriptPath = join(mockProjectDir, "test-postinstall.mjs") |
| 551 | + writeFileSync(scriptPath, createPostinstallWithIntegrity()) |
| 552 | + |
| 553 | + const proc = Bun.spawn(["node", scriptPath], { |
| 554 | + cwd: mockProjectDir, |
| 555 | + stdout: "pipe", |
| 556 | + stderr: "pipe", |
| 557 | + }) |
| 558 | + |
| 559 | + await proc.exited |
| 560 | + |
| 561 | + // Verify files have matching sizes |
| 562 | + const sourceSize = statSync(join(agentsSourceDir, "opencoder.md")).size |
| 563 | + const targetSize = statSync(join(agentsTargetDir, "opencoder.md")).size |
| 564 | + expect(targetSize).toBe(sourceSize) |
| 565 | + }) |
| 566 | + |
| 567 | + it("should accept keyword 'agent' case-insensitively", async () => { |
| 568 | + const contentWithUpperAgent = `# Test File |
| 569 | +
|
| 570 | +This file contains the word AGENT in uppercase and has enough content. |
| 571 | +The validation should accept this because keyword matching is case-insensitive. |
| 572 | +Adding more text to ensure minimum length requirement is satisfied here.` |
| 573 | + writeFileSync(join(agentsSourceDir, "upper-agent.md"), contentWithUpperAgent) |
| 574 | + |
| 575 | + const scriptPath = join(mockProjectDir, "test-postinstall.mjs") |
| 576 | + writeFileSync(scriptPath, createPostinstallWithIntegrity()) |
| 577 | + |
| 578 | + const proc = Bun.spawn(["node", scriptPath], { |
| 579 | + cwd: mockProjectDir, |
| 580 | + stdout: "pipe", |
| 581 | + stderr: "pipe", |
| 582 | + }) |
| 583 | + |
| 584 | + const exitCode = await proc.exited |
| 585 | + expect(exitCode).toBe(0) |
| 586 | + |
| 587 | + // The file should be installed |
| 588 | + expect(existsSync(join(agentsTargetDir, "upper-agent.md"))).toBe(true) |
| 589 | + }) |
| 590 | + |
| 591 | + it("should accept keyword 'task' as alternative to 'agent'", async () => { |
| 592 | + const contentWithTask = `# Test File |
| 593 | +
|
| 594 | +This file contains the word TASK but not the other keyword. |
| 595 | +The validation should accept this because either keyword is sufficient. |
| 596 | +Adding more text to ensure minimum length requirement is satisfied here.` |
| 597 | + writeFileSync(join(agentsSourceDir, "task-only.md"), contentWithTask) |
| 598 | + |
| 599 | + const scriptPath = join(mockProjectDir, "test-postinstall.mjs") |
| 600 | + writeFileSync(scriptPath, createPostinstallWithIntegrity()) |
| 601 | + |
| 602 | + const proc = Bun.spawn(["node", scriptPath], { |
| 603 | + cwd: mockProjectDir, |
| 604 | + stdout: "pipe", |
| 605 | + stderr: "pipe", |
| 606 | + }) |
| 607 | + |
| 608 | + const exitCode = await proc.exited |
| 609 | + expect(exitCode).toBe(0) |
| 610 | + |
| 611 | + // The file should be installed |
| 612 | + expect(existsSync(join(agentsTargetDir, "task-only.md"))).toBe(true) |
| 613 | + }) |
| 614 | + }) |
| 615 | + |
266 | 616 | describe("preuninstall script", () => { |
267 | 617 | it("should remove agent files from target directory", async () => { |
268 | 618 | // First install the agents |
|
0 commit comments