|
1 | 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 2 | +import os from 'os'; |
| 3 | +import { join, dirname } from 'path'; |
2 | 4 |
|
3 | 5 | // --- fs / child_process mocks (must come before dynamic import) --- |
4 | 6 | const mockWriteFileSync = vi.fn(); |
@@ -2402,6 +2404,273 @@ describe('Coordinator removeCoordinatedTask', () => { |
2402 | 2404 | }); |
2403 | 2405 | }); |
2404 | 2406 |
|
| 2407 | +// ─── #33: Post-restart coordinator flow integration test ───────────────────── |
| 2408 | + |
| 2409 | +describe('Coordinator restart round-trip integration', () => { |
| 2410 | + let coordinator: InstanceType<typeof Coordinator>; |
| 2411 | + |
| 2412 | + beforeEach(() => { |
| 2413 | + vi.clearAllMocks(); |
| 2414 | + mockExistsSync.mockReturnValue(false); |
| 2415 | + coordinator = new Coordinator(); |
| 2416 | + coordinator.setWindow(mockWin); |
| 2417 | + coordinator.setDefaultProject('proj-1', '/tmp/project'); |
| 2418 | + coordinator.registerCoordinator('coord-1', 'proj-1'); |
| 2419 | + }); |
| 2420 | + |
| 2421 | + it('hydrateTask rewrites config with new subtaskToken (not coordinator token) when server info is already set', () => { |
| 2422 | + coordinator.setMCPServerInfo( |
| 2423 | + 'coord-1', |
| 2424 | + 'http://localhost:3002', |
| 2425 | + 'new-coordinator-secret', |
| 2426 | + 'new-subtask-secret', |
| 2427 | + '/path/server.js', |
| 2428 | + ); |
| 2429 | + |
| 2430 | + const taskId = 'hydrated-restart-1'; |
| 2431 | + const configPath = join(os.tmpdir(), `parallel-code-subtask-${taskId}.json`); |
| 2432 | + |
| 2433 | + mockWriteFileSync.mockClear(); |
| 2434 | + coordinator.hydrateTask({ |
| 2435 | + id: taskId, |
| 2436 | + name: 'hydrated-task', |
| 2437 | + projectId: 'proj-1', |
| 2438 | + projectRoot: '/tmp/project', |
| 2439 | + branchName: 'task/hydrated', |
| 2440 | + worktreePath: '/tmp/hydrated', |
| 2441 | + agentId: 'agent-restart-1', |
| 2442 | + coordinatorTaskId: 'coord-1', |
| 2443 | + mcpConfigPath: configPath, |
| 2444 | + }); |
| 2445 | + |
| 2446 | + const rewrite = mockWriteFileSync.mock.calls.find((c) => c[0] === configPath); |
| 2447 | + expect(rewrite).toBeDefined(); |
| 2448 | + if (!rewrite) throw new Error('expected config rewrite'); |
| 2449 | + const config = JSON.parse(rewrite[1] as string) as { |
| 2450 | + mcpServers: { 'parallel-code': { env: Record<string, string> } }; |
| 2451 | + }; |
| 2452 | + const writtenToken = config.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']; |
| 2453 | + expect(writtenToken).toBe('new-subtask-secret'); |
| 2454 | + expect(writtenToken).not.toBe('new-coordinator-secret'); |
| 2455 | + }); |
| 2456 | + |
| 2457 | + it('waitForIdle resolves after agent output fires post-hydration', async () => { |
| 2458 | + coordinator.setMCPServerInfo( |
| 2459 | + 'coord-1', |
| 2460 | + 'http://localhost:3002', |
| 2461 | + 'new-coordinator-secret', |
| 2462 | + 'new-subtask-secret', |
| 2463 | + '/path/server.js', |
| 2464 | + ); |
| 2465 | + |
| 2466 | + const taskId = 'hydrated-restart-2'; |
| 2467 | + const agentId = 'agent-restart-2'; |
| 2468 | + const configPath = join(os.tmpdir(), `parallel-code-subtask-${taskId}.json`); |
| 2469 | + |
| 2470 | + coordinator.hydrateTask({ |
| 2471 | + id: taskId, |
| 2472 | + name: 'hydrated-task', |
| 2473 | + projectId: 'proj-1', |
| 2474 | + projectRoot: '/tmp/project', |
| 2475 | + branchName: 'task/hydrated', |
| 2476 | + worktreePath: '/tmp/hydrated', |
| 2477 | + agentId, |
| 2478 | + coordinatorTaskId: 'coord-1', |
| 2479 | + mcpConfigPath: configPath, |
| 2480 | + }); |
| 2481 | + |
| 2482 | + // Simulate agent respawn: task transitions from exited → running |
| 2483 | + const task = coordinator.getTask(taskId); |
| 2484 | + expect(task).toBeDefined(); |
| 2485 | + if (!task) throw new Error('task not found after hydration'); |
| 2486 | + task.status = 'running'; |
| 2487 | + |
| 2488 | + // Get the output callback registered during hydrateTask |
| 2489 | + const hydratedCb = mockSubscribeToAgent.mock.calls.find((c) => c[0] === agentId)?.[1] as |
| 2490 | + | ((encoded: string) => void) |
| 2491 | + | undefined; |
| 2492 | + expect(hydratedCb).toBeDefined(); |
| 2493 | + if (!hydratedCb) throw new Error('hydrateTask did not subscribe to agent'); |
| 2494 | + |
| 2495 | + const waitPromise = coordinator.waitForIdle(taskId); |
| 2496 | + hydratedCb(encode('Work done ❯ ')); |
| 2497 | + await expect(waitPromise).resolves.toEqual({ reason: 'idle' }); |
| 2498 | + }); |
| 2499 | +}); |
| 2500 | + |
| 2501 | +// ─── #36: hydrateTask mcpConfigPath directory scoping ──────────────────────── |
| 2502 | + |
| 2503 | +describe('Coordinator hydrateTask — mcpConfigPath directory scoping', () => { |
| 2504 | + let coordinator: InstanceType<typeof Coordinator>; |
| 2505 | + |
| 2506 | + beforeEach(() => { |
| 2507 | + vi.clearAllMocks(); |
| 2508 | + mockExistsSync.mockReturnValue(false); |
| 2509 | + coordinator = new Coordinator(); |
| 2510 | + coordinator.setWindow(mockWin); |
| 2511 | + coordinator.setDefaultProject('proj-1', '/tmp/project'); |
| 2512 | + coordinator.registerCoordinator('coord-1', 'proj-1'); |
| 2513 | + coordinator.setMCPServerInfo( |
| 2514 | + 'coord-1', |
| 2515 | + 'http://localhost:3001', |
| 2516 | + 'tok', |
| 2517 | + 'subtok', |
| 2518 | + '/srv/app/.parallel-code/mcp-server.js', |
| 2519 | + ); |
| 2520 | + }); |
| 2521 | + |
| 2522 | + it('path traversal (../../etc/passwd-style) is rejected — mcpConfigPath is undefined, no config write', () => { |
| 2523 | + coordinator.hydrateTask({ |
| 2524 | + id: 'task-traversal', |
| 2525 | + name: 'evil', |
| 2526 | + projectId: 'proj-1', |
| 2527 | + projectRoot: '/tmp/project', |
| 2528 | + branchName: 'task/evil', |
| 2529 | + worktreePath: '/tmp/evil', |
| 2530 | + agentId: 'agent-evil', |
| 2531 | + coordinatorTaskId: 'coord-1', |
| 2532 | + mcpConfigPath: '../../etc/passwd', |
| 2533 | + }); |
| 2534 | + |
| 2535 | + expect(coordinator.getTask('task-traversal')?.mcpConfigPath).toBeUndefined(); |
| 2536 | + const evilWrite = mockWriteFileSync.mock.calls.find((c) => |
| 2537 | + (c[0] as string).includes('etc/passwd'), |
| 2538 | + ); |
| 2539 | + expect(evilWrite).toBeUndefined(); |
| 2540 | + }); |
| 2541 | + |
| 2542 | + it('right filename in wrong dir is rejected — mcpConfigPath is undefined', () => { |
| 2543 | + const taskId = 'task-wrong-dir'; |
| 2544 | + coordinator.hydrateTask({ |
| 2545 | + id: taskId, |
| 2546 | + name: 'wrong-dir', |
| 2547 | + projectId: 'proj-1', |
| 2548 | + projectRoot: '/tmp/project', |
| 2549 | + branchName: 'task/wrong-dir', |
| 2550 | + worktreePath: '/tmp/wrong-dir', |
| 2551 | + agentId: 'agent-wrong-dir', |
| 2552 | + coordinatorTaskId: 'coord-1', |
| 2553 | + mcpConfigPath: `/tmp/evil/parallel-code-subtask-${taskId}.json`, |
| 2554 | + }); |
| 2555 | + |
| 2556 | + expect(coordinator.getTask(taskId)?.mcpConfigPath).toBeUndefined(); |
| 2557 | + }); |
| 2558 | + |
| 2559 | + it('correct host tmpdir path is accepted and config write occurs', () => { |
| 2560 | + const taskId = 'task-valid-host'; |
| 2561 | + const validPath = join(os.tmpdir(), `parallel-code-subtask-${taskId}.json`); |
| 2562 | + |
| 2563 | + mockWriteFileSync.mockClear(); |
| 2564 | + coordinator.hydrateTask({ |
| 2565 | + id: taskId, |
| 2566 | + name: 'valid-host', |
| 2567 | + projectId: 'proj-1', |
| 2568 | + projectRoot: '/tmp/project', |
| 2569 | + branchName: 'task/valid-host', |
| 2570 | + worktreePath: '/tmp/valid-host', |
| 2571 | + agentId: 'agent-valid-host', |
| 2572 | + coordinatorTaskId: 'coord-1', |
| 2573 | + mcpConfigPath: validPath, |
| 2574 | + }); |
| 2575 | + |
| 2576 | + expect(coordinator.getTask(taskId)?.mcpConfigPath).toBe(validPath); |
| 2577 | + const configWrite = mockWriteFileSync.mock.calls.find((c) => c[0] === validPath); |
| 2578 | + expect(configWrite).toBeDefined(); |
| 2579 | + }); |
| 2580 | + |
| 2581 | + it('Docker mode: dirname(serverPath)/subtask-{id}.json is accepted and config write occurs', () => { |
| 2582 | + const taskId = 'task-valid-docker'; |
| 2583 | + const serverPath = '/srv/app/.parallel-code/mcp-server.js'; |
| 2584 | + const dockerPath = join(dirname(serverPath), `subtask-${taskId}.json`); |
| 2585 | + |
| 2586 | + mockWriteFileSync.mockClear(); |
| 2587 | + coordinator.hydrateTask({ |
| 2588 | + id: taskId, |
| 2589 | + name: 'valid-docker', |
| 2590 | + projectId: 'proj-1', |
| 2591 | + projectRoot: '/tmp/project', |
| 2592 | + branchName: 'task/valid-docker', |
| 2593 | + worktreePath: '/tmp/valid-docker', |
| 2594 | + agentId: 'agent-valid-docker', |
| 2595 | + coordinatorTaskId: 'coord-1', |
| 2596 | + mcpConfigPath: dockerPath, |
| 2597 | + }); |
| 2598 | + |
| 2599 | + expect(coordinator.getTask(taskId)?.mcpConfigPath).toBe(dockerPath); |
| 2600 | + const configWrite = mockWriteFileSync.mock.calls.find((c) => c[0] === dockerPath); |
| 2601 | + expect(configWrite).toBeDefined(); |
| 2602 | + }); |
| 2603 | + |
| 2604 | + it('Docker mode: path in wrong dir is rejected — mcpConfigPath is undefined', () => { |
| 2605 | + const taskId = 'task-evil-docker'; |
| 2606 | + const wrongPath = `/some/other/dir/subtask-${taskId}.json`; |
| 2607 | + |
| 2608 | + coordinator.hydrateTask({ |
| 2609 | + id: taskId, |
| 2610 | + name: 'evil-docker', |
| 2611 | + projectId: 'proj-1', |
| 2612 | + projectRoot: '/tmp/project', |
| 2613 | + branchName: 'task/evil-docker', |
| 2614 | + worktreePath: '/tmp/evil-docker', |
| 2615 | + agentId: 'agent-evil-docker', |
| 2616 | + coordinatorTaskId: 'coord-1', |
| 2617 | + mcpConfigPath: wrongPath, |
| 2618 | + }); |
| 2619 | + |
| 2620 | + expect(coordinator.getTask(taskId)?.mcpConfigPath).toBeUndefined(); |
| 2621 | + }); |
| 2622 | +}); |
| 2623 | + |
| 2624 | +// ─── #39: Docker coordinator child-close isolation ──────────────────────────── |
| 2625 | + |
| 2626 | +describe('Coordinator closeTask — per-task config isolation (two sub-tasks)', () => { |
| 2627 | + let coordinator: InstanceType<typeof Coordinator>; |
| 2628 | + |
| 2629 | + beforeEach(() => { |
| 2630 | + vi.clearAllMocks(); |
| 2631 | + mockExistsSync.mockReturnValue(false); |
| 2632 | + coordinator = new Coordinator(); |
| 2633 | + coordinator.setWindow(mockWin); |
| 2634 | + coordinator.setDefaultProject('proj-1', '/tmp/project'); |
| 2635 | + coordinator.registerCoordinator('coord-1', 'proj-1'); |
| 2636 | + coordinator.setMCPServerInfo( |
| 2637 | + 'coord-1', |
| 2638 | + 'http://localhost:3001', |
| 2639 | + 'tok', |
| 2640 | + 'subtask-tok', |
| 2641 | + '/path/server.js', |
| 2642 | + ); |
| 2643 | + }); |
| 2644 | + |
| 2645 | + it('closing task-1 deletes only its config; task-2 config and task-2 entry are untouched', async () => { |
| 2646 | + mockCreateBackendTask |
| 2647 | + .mockResolvedValueOnce({ id: 'task-1', branch_name: 'task/a', worktree_path: '/tmp/a' }) |
| 2648 | + .mockResolvedValueOnce({ id: 'task-2', branch_name: 'task/b', worktree_path: '/tmp/b' }); |
| 2649 | + |
| 2650 | + await coordinator.createTask({ name: 'task-one', prompt: 'do', coordinatorTaskId: 'coord-1' }); |
| 2651 | + await coordinator.createTask({ name: 'task-two', prompt: 'do', coordinatorTaskId: 'coord-1' }); |
| 2652 | + |
| 2653 | + const config1 = coordinator.getTask('task-1')?.mcpConfigPath; |
| 2654 | + const config2 = coordinator.getTask('task-2')?.mcpConfigPath; |
| 2655 | + expect(config1).toBeDefined(); |
| 2656 | + expect(config2).toBeDefined(); |
| 2657 | + expect(config1).not.toBe(config2); |
| 2658 | + |
| 2659 | + mockUnlinkSync.mockClear(); |
| 2660 | + await coordinator.closeTask('task-1'); |
| 2661 | + |
| 2662 | + // task-1's config was deleted |
| 2663 | + expect(mockUnlinkSync).toHaveBeenCalledWith(config1); |
| 2664 | + // task-2's config was NOT deleted |
| 2665 | + expect(mockUnlinkSync).not.toHaveBeenCalledWith(config2); |
| 2666 | + |
| 2667 | + // task-2 is still present, task-1 is gone |
| 2668 | + const tasks = coordinator.listTasks(); |
| 2669 | + expect(tasks.some((t) => t.id === 'task-2')).toBe(true); |
| 2670 | + expect(tasks.some((t) => t.id === 'task-1')).toBe(false); |
| 2671 | + }); |
| 2672 | +}); |
| 2673 | + |
2405 | 2674 | // ─── preload allowlist regression test ─────────────────────────────────────── |
2406 | 2675 |
|
2407 | 2676 | describe('preload.cjs MCP channel allowlist', () => { |
|
0 commit comments