basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';8import { EventEmitter } from 'node:events';9import type { Readable } from 'node:stream';10import type { ChildProcess } from 'node:child_process';11 12const mockOsPlatform = vi.hoisted(() =>13 vi.fn<() => NodeJS.Platform>(() => 'linux'),14);15vi.mock('node:os', async (importOriginal) => {16 const actual = await importOriginal<typeof import('node:os')>();17 18 return {19 ...actual,20 default: {21 ...actual,22 platform: mockOsPlatform,23 },24 platform: mockOsPlatform,25 };26});27 28// Mock child_process.spawn29const mockSpawn = vi.hoisted(() => vi.fn());30vi.mock('node:child_process', async (importOriginal) => {31 const actual = await importOriginal<typeof import('node:child_process')>();32 33 return {34 ...actual,35 spawn: mockSpawn,36 };37});38 39// Mock shell-utils40function isEnvAssignmentToken(token: string): boolean {41 const equalsIndex = token.indexOf('=');42 if (equalsIndex <= 0) return false;43 44 const name = token.slice(0, equalsIndex);45 const firstChar = name.charCodeAt(0);46 const isAlpha =47 (firstChar >= 65 && firstChar <= 90) ||48 (firstChar >= 97 && firstChar <= 122);49 if (!isAlpha && name[0] !== '_') return false;50 51 for (let i = 1; i < name.length; i++) {52 const code = name.charCodeAt(i);53 const isAlphaNumeric =54 (code >= 65 && code <= 90) ||55 (code >= 97 && code <= 122) ||56 (code >= 48 && code <= 57);57 if (!isAlphaNumeric && name[i] !== '_') return false;58 }59 60 return true;61}62 63function takeLeadingShellToken(command: string): {64 token: string;65 rest: string;66} | null {67 const trimmed = command.trimStart();68 if (!trimmed) return null;69 70 let quote: '"' | "'" | '' = '';71 let escaped = false;72 let idx = 0;73 for (; idx < trimmed.length; idx++) {74 const char = trimmed[idx]!;75 if (escaped) {76 escaped = false;77 continue;78 }79 if (char === '\\') {80 escaped = true;81 continue;82 }83 if (quote) {84 if (char === quote) quote = '';85 continue;86 }87 if (char === '"' || char === "'") {88 quote = char;89 continue;90 }91 if (/\s/.test(char)) break;92 }93 94 return {95 token: trimmed.slice(0, idx),96 rest: trimmed.slice(idx),97 };98}99 100function stripLeadingEnvAssignments(command: string): string {101 let rest = command.trimStart();102 while (true) {103 const token = takeLeadingShellToken(rest);104 if (!token || !isEnvAssignmentToken(token.token)) {105 return rest;106 }107 rest = token.rest.trimStart();108 }109}110 111vi.mock('../utils/shell-utils.js', async (importOriginal) => {112 const actual =113 await importOriginal<typeof import('../utils/shell-utils.js')>();114 115 return {116 ...actual,117 getShellConfiguration: () => ({118 executable: '/bin/bash',119 argsPrefix: ['-c'],120 shell: 'bash',121 }),122 getCommandRoot: (cmd: string) =>123 stripLeadingEnvAssignments(cmd).split(/\s+/)[0],124 splitCommands: (cmd: string) =>125 cmd126 .split(/\s*&&\s*/)127 .map((part) => part.trim())128 .filter(Boolean),129 detectCommandSubstitution: (command: string) =>130 /\$\(|`|<\(|>\(/.test(command),131 };132});133 134const mockIsShellCommandReadOnlyAST = vi.hoisted(() => vi.fn());135const mockExtractCommandRules = vi.hoisted(() => vi.fn());136vi.mock('../utils/shellAstParser.js', () => ({137 isShellCommandReadOnlyAST: mockIsShellCommandReadOnlyAST,138 extractCommandRules: mockExtractCommandRules,139}));140 141import { MonitorTool, sanitizeMonitorLine } from './monitor.js';142import type { Config } from '../config/config.js';143import { MonitorRegistry } from '../services/monitorRegistry.js';144import type { ToolCallConfirmationDetails } from './tools.js';145import { runWithAgentContext } from '../agents/runtime/agent-context.js';146 147/**148 * Create a mock child process with controllable stdout/stderr/events.149 */150function createMockChild(): ChildProcess & {151 stdout: Readable;152 stderr: Readable;153 _emitExit: (code: number | null, signal?: string | null) => void;154 _emitClose: (code: number | null, signal?: string | null) => void;155 _emitError: (err: Error) => void;156} {157 const child = new EventEmitter() as unknown as ChildProcess & {158 stdout: Readable;159 stderr: Readable;160 _emitExit: (code: number | null, signal?: string | null) => void;161 _emitClose: (code: number | null, signal?: string | null) => void;162 _emitError: (err: Error) => void;163 };164 // Use Object.defineProperty to bypass readonly on the mock165 Object.defineProperty(child, 'stdout', {166 value: new EventEmitter(),167 writable: true,168 });169 Object.defineProperty(child, 'stderr', {170 value: new EventEmitter(),171 writable: true,172 });173 Object.defineProperty(child, 'pid', { value: 12345, writable: true });174 175 child._emitExit = (code, signal = null) => {176 child.emit('exit', code, signal);177 };178 child._emitClose = (code, signal = null) => {179 child.emit('close', code, signal);180 };181 child._emitError = (err) => {182 child.emit('error', err);183 };184 185 return child;186}187 188describe('MonitorTool', () => {189 let monitorTool: MonitorTool;190 let mockConfig: Config;191 let monitorRegistry: MonitorRegistry;192 let mockChild: ReturnType<typeof createMockChild>;193 let mockIsPathWithinWorkspace: ReturnType<typeof vi.fn>;194 let originalPager: string | undefined;195 let originalGitPager: string | undefined;196 197 beforeEach(() => {198 originalPager = process.env['PAGER'];199 originalGitPager = process.env['GIT_PAGER'];200 delete process.env['PAGER'];201 delete process.env['GIT_PAGER'];202 203 vi.clearAllMocks();204 mockOsPlatform.mockReturnValue('linux');205 206 monitorRegistry = new MonitorRegistry();207 mockIsPathWithinWorkspace = vi.fn().mockReturnValue(true);208 mockIsShellCommandReadOnlyAST.mockResolvedValue(false);209 mockExtractCommandRules.mockImplementation(async (command: string) => {210 const normalized = stripLeadingEnvAssignments(command);211 return [`${normalized.split(/\s+/).slice(0, 2).join(' ')} *`];212 });213 214 mockConfig = {215 getTargetDir: vi.fn().mockReturnValue('/test/dir'),216 getMonitorRegistry: vi.fn().mockReturnValue(monitorRegistry),217 getPermissionManager: vi.fn().mockReturnValue(undefined),218 getWorkspaceContext: vi.fn().mockReturnValue({219 isPathWithinWorkspace: mockIsPathWithinWorkspace,220 }),221 getSessionId: vi.fn().mockReturnValue('test-session-id'),222 getShellExecutionConfig: vi.fn().mockReturnValue({}),223 storage: {224 getUserSkillsDirs: vi225 .fn()226 .mockReturnValue(['/home/user/.claude/skills']),227 getProjectDir: vi.fn().mockReturnValue('/test/project/.qwen'),228 },229 } as unknown as Config;230 231 monitorTool = new MonitorTool(mockConfig);232 233 mockChild = createMockChild();234 mockSpawn.mockReturnValue(mockChild);235 });236 237 afterEach(() => {238 monitorRegistry.abortAll();239 240 if (originalPager === undefined) {241 delete process.env['PAGER'];242 } else {243 process.env['PAGER'] = originalPager;244 }245 246 if (originalGitPager === undefined) {247 delete process.env['GIT_PAGER'];248 } else {249 process.env['GIT_PAGER'] = originalGitPager;250 }251 });252 253 // Helper to access protected validateToolParamValues254 const validate = (params: Record<string, unknown>) =>255 (256 monitorTool as unknown as {257 validateToolParamValues: (p: Record<string, unknown>) => string | null;258 }259 ).validateToolParamValues(params);260 261 // Helper to create an invocation262 const createInvocation = (params: Record<string, unknown>) =>263 (264 monitorTool as unknown as {265 createInvocation: (p: Record<string, unknown>) => {266 getDescription: () => string;267 getDefaultPermission: () => Promise<string>;268 getConfirmationDetails: (269 s: AbortSignal,270 ) => Promise<ToolCallConfirmationDetails>;271 execute: (272 s: AbortSignal,273 ) => Promise<{ llmContent: string; returnDisplay: string }>;274 };275 }276 ).createInvocation(params);277 278 describe('schema', () => {279 it('declares monitor limits as integers', () => {280 const schema = monitorTool.schema.parametersJsonSchema as {281 properties?: Record<string, { type?: string }>;282 };283 284 expect(schema.properties?.['max_events']?.type).toBe('integer');285 expect(schema.properties?.['idle_timeout_ms']?.type).toBe('integer');286 });287 });288 289 describe('confirmation details', () => {290 it('includes command-scoped permission rules for monitor commands', async () => {291 const invocation = createInvocation({292 command: 'tail -f /tmp/app.log',293 });294 295 const details = (await invocation.getConfirmationDetails(296 new AbortController().signal,297 )) as ToolCallConfirmationDetails & {298 permissionRules?: string[];299 };300 301 expect(details.type).toBe('exec');302 expect(details.permissionRules).toEqual(['Monitor(tail -f *)']);303 });304 305 it('strips a trailing bare ampersand before building confirmation details', async () => {306 const invocation = createInvocation({307 command: 'tail -f /tmp/app.log &',308 });309 310 const details = (await invocation.getConfirmationDetails(311 new AbortController().signal,312 )) as ToolCallConfirmationDetails & {313 command: string;314 permissionRules?: string[];315 };316 317 expect(details.command).toBe('tail -f /tmp/app.log');318 expect(details.permissionRules).toEqual(['Monitor(tail -f *)']);319 });320 321 it('preserves explicit shell wrappers while analyzing the wrapped command', async () => {322 const invocation = createInvocation({323 command: `/bin/bash -c 'tail -f /tmp/app.log &'`,324 });325 326 const details = (await invocation.getConfirmationDetails(327 new AbortController().signal,328 )) as ToolCallConfirmationDetails & {329 command: string;330 rootCommand: string;331 permissionRules?: string[];332 };333 334 expect(details.command).toBe(`/bin/bash -c 'tail -f /tmp/app.log'`);335 expect(details.rootCommand).toBe('tail');336 expect(details.permissionRules).toEqual(['Monitor(tail -f *)']);337 });338 339 it('unwraps quoted env-prefixed shell wrappers for confirmation analysis', async () => {340 const invocation = createInvocation({341 command: `FOO="bar baz" /bin/bash -c 'tail -f /tmp/app.log &'`,342 });343 344 const details = (await invocation.getConfirmationDetails(345 new AbortController().signal,346 )) as ToolCallConfirmationDetails & {347 command: string;348 rootCommand: string;349 permissionRules?: string[];350 };351 352 expect(details.command).toBe(353 `FOO="bar baz" /bin/bash -c 'tail -f /tmp/app.log'`,354 );355 expect(details.rootCommand).toBe('tail');356 expect(details.permissionRules).toEqual(['Monitor(tail -f *)']);357 });358 359 it('does not strip non-trailing or non-bare ampersands in confirmation details', async () => {360 const commands = ['sleep 5 & echo done', 'echo hi &&', 'echo hi \\&'];361 362 for (const command of commands) {363 const invocation = createInvocation({ command });364 const details = (await invocation.getConfirmationDetails(365 new AbortController().signal,366 )) as ToolCallConfirmationDetails & {367 command: string;368 };369 370 expect(details.command).toBe(command);371 }372 });373 374 it('does not consult Bash permission rules for monitor commands', async () => {375 // Monitor should NOT use pm.isCommandAllowed() because that evaluates376 // under 'run_shell_command' context, mixing permission boundaries.377 const pm = {378 isCommandAllowed: vi.fn().mockResolvedValue('allow'),379 };380 mockConfig.getPermissionManager = vi.fn().mockReturnValue(pm);381 382 // Neither subcommand is read-only383 mockIsShellCommandReadOnlyAST.mockResolvedValue(false);384 mockExtractCommandRules385 .mockResolvedValueOnce(['git add *'])386 .mockResolvedValueOnce(['git commit *']);387 388 const invocation = createInvocation({389 command: 'git add file && git commit -m "msg"',390 });391 392 const details = (await invocation.getConfirmationDetails(393 new AbortController().signal,394 )) as ToolCallConfirmationDetails & {395 permissionRules?: string[];396 };397 398 // pm.isCommandAllowed must NOT be called — monitor maintains its own399 // permission boundary separate from run_shell_command400 expect(pm.isCommandAllowed).not.toHaveBeenCalled();401 // Both subcommands remain in confirmation scope402 expect(details.permissionRules).toEqual([403 'Monitor(git add *)',404 'Monitor(git commit *)',405 ]);406 });407 408 it('includes wrapper suffix commands in confirmation analysis', async () => {409 const invocation = createInvocation({410 command: `/bin/bash -c 'tail -f /tmp/app.log' && rm -rf /tmp/owned`,411 });412 413 const details = (await invocation.getConfirmationDetails(414 new AbortController().signal,415 )) as ToolCallConfirmationDetails & {416 rootCommand: string;417 permissionRules?: string[];418 };419 420 expect(details.rootCommand).toBe('tail, rm');421 expect(details.permissionRules).toEqual([422 'Monitor(tail -f *)',423 'Monitor(rm -rf *)',424 ]);425 });426 427 it('falls back to a canonical Monitor rule if command extraction fails', async () => {428 mockExtractCommandRules.mockRejectedValueOnce(new Error('parse failed'));429 430 const invocation = createInvocation({431 command: `/bin/bash --noprofile -c 'tail -f /tmp/app.log &'`,432 });433 434 const details = (await invocation.getConfirmationDetails(435 new AbortController().signal,436 )) as ToolCallConfirmationDetails & {437 permissionRules?: string[];438 };439 440 expect(details.permissionRules).toEqual([441 'Monitor(tail -f /tmp/app.log)',442 ]);443 });444 445 it('keeps sub-command in confirmation scope when AST read-only check fails', async () => {446 mockIsShellCommandReadOnlyAST.mockRejectedValueOnce(447 new Error('AST parse failure'),448 );449 450 const invocation = createInvocation({451 command: 'tail -f /tmp/app.log',452 });453 454 const details = (await invocation.getConfirmationDetails(455 new AbortController().signal,456 )) as ToolCallConfirmationDetails & {457 permissionRules?: string[];458 };459 460 // Sub-command should still be in confirmation scope (not dropped)461 expect(details.permissionRules).toBeDefined();462 expect(details.permissionRules!.length).toBeGreaterThan(0);463 });464 });465 466 describe('getDefaultPermission', () => {467 // Command substitution previously returned 'deny' here. Per #4093 it468 // now falls through to 'ask' (matching ShellToolInvocation and469 // PermissionManager.resolveDefaultPermission); the substitution470 // warning is surfaced via getConfirmationDetails. YOLO mode can now471 // override the prompt; before this change it could not.472 it('asks for command substitution before confirmation', async () => {473 const invocation = createInvocation({474 command: 'echo $(cat secret.txt)',475 });476 477 await expect(invocation.getDefaultPermission()).resolves.toBe('ask');478 });479 480 it('asks for command substitution inside explicit shell wrappers', async () => {481 const invocation = createInvocation({482 command: `/bin/bash -c 'echo $(cat secret.txt)'`,483 });484 485 await expect(invocation.getDefaultPermission()).resolves.toBe('ask');486 });487 488 it('asks for command substitution inside wrapped scripts with argv suffixes', async () => {489 const invocation = createInvocation({490 command: `/bin/bash -c 'echo $(cat secret.txt)' ignored`,491 });492 493 await expect(invocation.getDefaultPermission()).resolves.toBe('ask');494 });495 496 it('asks for command substitution inside quoted env-prefixed wrappers', async () => {497 const invocation = createInvocation({498 command: `FOO="bar baz" /bin/bash -c 'echo $(cat secret.txt)'`,499 });500 501 await expect(invocation.getDefaultPermission()).resolves.toBe('ask');502 });503 504 it('asks for command substitution inside env-prefix assignments', async () => {505 const invocation = createInvocation({506 command: `FOO=$(cat secret.txt) /bin/bash -c 'echo ok'`,507 });508 509 await expect(invocation.getDefaultPermission()).resolves.toBe('ask');510 });511 512 it('allows read-only monitor commands by default', async () => {513 mockIsShellCommandReadOnlyAST.mockResolvedValueOnce(true);514 const invocation = createInvocation({515 command: 'tail -f /tmp/app.log',516 });517 518 await expect(invocation.getDefaultPermission()).resolves.toBe('allow');519 });520 521 it('surfaces a command-substitution warning via getConfirmationDetails (issue #4093)', async () => {522 const invocation = createInvocation({523 command: 'echo $(cat secret.txt)',524 });525 const details = (await invocation.getConfirmationDetails(526 new AbortController().signal,527 )) as { warnings?: string[] };528 529 expect(details.warnings?.[0]).toMatch(/command substitution/i);530 });531 });532 533 describe('validation', () => {534 it('rejects empty command', () => {535 expect(validate({ command: ' ' })).toBe('Command cannot be empty.');536 });537 538 it('rejects invalid max_events (negative)', () => {539 expect(validate({ command: 'tail -f log', max_events: -1 })).toBe(540 'max_events must be a positive integer.',541 );542 });543 544 it('rejects max_events of zero', () => {545 expect(validate({ command: 'tail -f log', max_events: 0 })).toBe(546 'max_events must be a positive integer.',547 );548 });549 550 it('rejects fractional max_events', () => {551 expect(validate({ command: 'tail -f log', max_events: 1.5 })).toBe(552 'max_events must be a positive integer.',553 );554 });555 556 it('rejects max_events over limit', () => {557 expect(validate({ command: 'tail -f log', max_events: 20000 })).toBe(558 'max_events cannot exceed 10000.',559 );560 });561 562 it('rejects invalid idle_timeout_ms', () => {563 expect(validate({ command: 'tail -f log', idle_timeout_ms: -100 })).toBe(564 'idle_timeout_ms must be a positive integer.',565 );566 });567 568 it('rejects fractional idle_timeout_ms', () => {569 expect(validate({ command: 'tail -f log', idle_timeout_ms: 500.5 })).toBe(570 'idle_timeout_ms must be a positive integer.',571 );572 });573 574 it('rejects idle_timeout_ms over limit', () => {575 expect(576 validate({ command: 'tail -f log', idle_timeout_ms: 700_000 }),577 ).toContain('cannot exceed');578 });579 580 it('accepts valid params', () => {581 expect(582 validate({583 command: 'tail -f log',584 max_events: 500,585 idle_timeout_ms: 60000,586 }),587 ).toBeNull();588 });589 590 it('rejects non-string command without throwing', () => {591 // Schema normally blocks this, but SDK/direct callers can bypass it.592 // The validator must return a structured error instead of throwing.593 expect(() => validate({ command: undefined })).not.toThrow();594 expect(validate({ command: undefined })).toBe('Command cannot be empty.');595 expect(validate({ command: 123 })).toBe('Command cannot be empty.');596 expect(validate({ command: null })).toBe('Command cannot be empty.');597 });598 599 it('rejects commands that normalize to empty after stripping trailing &', () => {600 expect(validate({ command: '&' })).toBe('Command cannot be empty.');601 expect(validate({ command: ' & ' })).toBe('Command cannot be empty.');602 });603 604 it('rejects non-final top-level background operators', () => {605 const message =606 'Monitor commands must not contain non-final top-level background operators. Remove "&" and let the monitor manage process lifetime.';607 608 expect(validate({ command: 'tail -f app.log & # watch' })).toBe(message);609 expect(validate({ command: 'tail -f app.log & echo ready' })).toBe(610 message,611 );612 expect(613 validate({ command: "bash -c 'tail -f app.log & echo ready'" }),614 ).toBe(message);615 expect(616 validate({ command: "bash -c 'tail -f app.log' & echo ready" }),617 ).toBe(message);618 });619 620 it('accepts final trailing ampersands that monitor normalization strips', () => {621 expect(validate({ command: 'tail -f app.log &' })).toBeNull();622 expect(validate({ command: "bash -c 'tail -f app.log &'" })).toBeNull();623 });624 625 it('rejects non-absolute directory', () => {626 expect(627 validate({ command: 'tail -f log', directory: 'relative/path' }),628 ).toBe('Directory must be an absolute path.');629 });630 631 it('rejects directory within user skills directory', () => {632 const result = validate({633 command: 'tail -f log',634 directory: '/home/user/.claude/skills/my-skill',635 });636 expect(result).toContain('user skills directory is not allowed');637 });638 639 it('rejects directory outside workspace (delegates to WorkspaceContext)', () => {640 mockIsPathWithinWorkspace.mockReturnValueOnce(false);641 const result = validate({642 command: 'tail -f log',643 directory: '/tmp/project-a-evil/x',644 });645 expect(result).toContain('not within any of the registered workspace');646 expect(mockIsPathWithinWorkspace).toHaveBeenCalledWith(647 '/tmp/project-a-evil/x',648 );649 });650 651 it('rejects directory with parent-reference traversal', () => {652 mockIsPathWithinWorkspace.mockReturnValueOnce(false);653 const result = validate({654 command: 'tail -f log',655 directory: '/tmp/project-a/../etc',656 });657 expect(result).toContain('not within any of the registered workspace');658 });659 660 it('accepts directory within workspace', () => {661 mockIsPathWithinWorkspace.mockReturnValueOnce(true);662 expect(663 validate({664 command: 'tail -f log',665 directory: '/test/dir/sub',666 }),667 ).toBeNull();668 });669 });670 671 describe('execute', () => {672 it('spawns a process and returns monitor ID', async () => {673 const invocation = createInvocation({674 command: 'tail -f /var/log/app.log',675 description: 'watch app logs',676 });677 678 const signal = new AbortController().signal;679 const result = await invocation.execute(signal);680 681 expect(mockSpawn).toHaveBeenCalledOnce();682 expect(mockSpawn).toHaveBeenCalledWith(683 '/bin/bash',684 ['-c', 'tail -f /var/log/app.log'],685 expect.objectContaining({686 cwd: '/test/dir',687 detached: true,688 }),689 );690 expect(result.llmContent).toContain('Monitor started');691 expect(result.llmContent).toContain('mon_');692 expect(result.returnDisplay).toContain('watch app logs');693 });694 695 it('uses default pager env for spawned processes when pager is unset', async () => {696 const invocation = createInvocation({697 command: 'tail -f /var/log/app.log',698 });699 700 await invocation.execute(new AbortController().signal);701 702 const spawnOptions = mockSpawn.mock.calls[0][2];703 expect(spawnOptions.env['PAGER']).toBe('cat');704 expect(spawnOptions.env['GIT_PAGER']).toBeUndefined();705 });706 707 it('preserves inherited git pager values for spawned processes', async () => {708 process.env['GIT_PAGER'] = 'delta';709 const invocation = createInvocation({710 command: 'git log --oneline',711 });712 713 await invocation.execute(new AbortController().signal);714 715 const spawnOptions = mockSpawn.mock.calls[0][2];716 expect(spawnOptions.env['PAGER']).toBe('cat');717 expect(spawnOptions.env['GIT_PAGER']).toBe('delta');718 });719 720 it('does not inject Unix pager defaults into Windows monitor env when unset', async () => {721 mockOsPlatform.mockReturnValue('win32');722 const invocation = createInvocation({723 command: 'tail -f /var/log/app.log',724 });725 726 await invocation.execute(new AbortController().signal);727 728 const spawnOptions = mockSpawn.mock.calls[0][2];729 expect(spawnOptions.env['PAGER']).toBe('');730 expect(spawnOptions.env['GIT_PAGER']).toBeUndefined();731 });732 733 it('propagates explicit pager configuration to spawned processes', async () => {734 vi.mocked(mockConfig.getShellExecutionConfig).mockReturnValue({735 pager: 'more',736 });737 const invocation = createInvocation({738 command: 'tail -f /var/log/app.log',739 });740 741 await invocation.execute(new AbortController().signal);742 743 const spawnOptions = mockSpawn.mock.calls[0][2];744 expect(spawnOptions.env['PAGER']).toBe('more');745 expect(spawnOptions.env['GIT_PAGER']).toBeUndefined();746 });747 748 it('does not spawn when the turn signal is already aborted', async () => {749 const invocation = createInvocation({750 command: 'tail -f /var/log/app.log',751 });752 const ac = new AbortController();753 ac.abort();754 755 const result = await invocation.execute(ac.signal);756 757 expect(mockSpawn).not.toHaveBeenCalled();758 expect(monitorRegistry.getAll()).toHaveLength(0);759 expect(result.llmContent).toContain(760 'Monitor was cancelled before it could start.',761 );762 });763 764 it('truncates long monitor descriptions in display surfaces', async () => {765 const longDescription = 'x'.repeat(120);766 const invocation = createInvocation({767 command: 'tail -f /var/log/app.log',768 description: longDescription,769 });770 771 const result = await invocation.execute(new AbortController().signal);772 773 expect(invocation.getDescription()).toBe(`Monitor: ${'x'.repeat(79)}…`);774 expect(result.returnDisplay).toContain(`${'x'.repeat(79)}…`);775 expect(result.returnDisplay).not.toContain(longDescription);776 expect(result.llmContent).toContain(`description: ${longDescription}`);777 });778 779 it('strips a trailing bare ampersand before spawning', async () => {780 const invocation = createInvocation({781 command: 'tail -f /var/log/app.log &',782 });783 784 await invocation.execute(new AbortController().signal);785 786 expect(mockSpawn).toHaveBeenCalledWith(787 '/bin/bash',788 ['-c', 'tail -f /var/log/app.log'],789 expect.objectContaining({790 cwd: '/test/dir',791 detached: true,792 }),793 );794 expect(monitorRegistry.getRunning()[0]?.command).toBe(795 'tail -f /var/log/app.log',796 );797 });798 799 it('preserves explicit shell wrappers on the spawn path', async () => {800 const invocation = createInvocation({801 command: `/bin/bash -c 'tail -f /var/log/app.log &'`,802 });803 804 await invocation.execute(new AbortController().signal);805 806 expect(mockSpawn).toHaveBeenCalledWith(807 '/bin/bash',808 ['-c', `/bin/bash -c 'tail -f /var/log/app.log'`],809 expect.objectContaining({810 cwd: '/test/dir',811 detached: true,812 }),813 );814 expect(monitorRegistry.getRunning()[0]?.command).toBe(815 `/bin/bash -c 'tail -f /var/log/app.log'`,816 );817 });818 819 it('preserves wrapper flags while stripping trailing ampersands', async () => {820 const invocation = createInvocation({821 command: `/bin/bash --noprofile -c 'tail -f /var/log/app.log &'`,822 });823 824 await invocation.execute(new AbortController().signal);825 826 expect(mockSpawn).toHaveBeenCalledWith(827 '/bin/bash',828 ['-c', `/bin/bash --noprofile -c 'tail -f /var/log/app.log'`],829 expect.objectContaining({830 cwd: '/test/dir',831 detached: true,832 }),833 );834 expect(monitorRegistry.getRunning()[0]?.command).toBe(835 `/bin/bash --noprofile -c 'tail -f /var/log/app.log'`,836 );837 });838 839 it('preserves wrapper argv while stripping trailing ampersands from the script', async () => {840 const invocation = createInvocation({841 command: `/bin/bash -c 'tail -f /var/log/app.log &' ignored`,842 });843 844 await invocation.execute(new AbortController().signal);845 846 expect(mockSpawn).toHaveBeenCalledWith(847 '/bin/bash',848 ['-c', `/bin/bash -c 'tail -f /var/log/app.log' ignored`],849 expect.objectContaining({850 cwd: '/test/dir',851 detached: true,852 }),853 );854 expect(monitorRegistry.getRunning()[0]?.command).toBe(855 `/bin/bash -c 'tail -f /var/log/app.log' ignored`,856 );857 });858 859 it('registers entry in MonitorRegistry', async () => {860 const invocation = createInvocation({861 command: 'tail -f log',862 });863 864 await invocation.execute(new AbortController().signal);865 866 const running = monitorRegistry.getRunning();867 expect(running).toHaveLength(1);868 expect(running[0].command).toBe('tail -f log');869 expect(running[0].pid).toBe(12345);870 expect(running[0].ownerAgentId).toBeUndefined();871 });872 873 it('records the current agent as owner when monitor is started by a subagent', async () => {874 const invocation = createInvocation({875 command: 'tail -f log',876 });877 878 await runWithAgentContext('agent-123', () =>879 invocation.execute(new AbortController().signal),880 );881 882 const running = monitorRegistry.getRunning();883 expect(running).toHaveLength(1);884 expect(running[0].ownerAgentId).toBe('agent-123');885 });886 887 it('kills the spawned child if registry registration fails', async () => {888 const invocation = createInvocation({889 command: 'tail -f log',890 });891 const killSpy = vi892 .spyOn(process, 'kill')893 .mockImplementation(() => true as never);894 const registerSpy = vi895 .spyOn(monitorRegistry, 'register')896 .mockImplementation(() => {897 throw new Error('limit reached');898 });899 900 try {901 const result = await invocation.execute(new AbortController().signal);902 903 expect(result.llmContent).toContain('Monitor failed to start');904 expect(result.returnDisplay).toContain('limit reached');905 if (process.platform === 'win32') {906 expect(mockSpawn).toHaveBeenCalledWith(907 'taskkill',908 ['/pid', '12345', '/f', '/t'],909 expect.objectContaining({ stdio: 'ignore' }),910 );911 } else {912 expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM');913 }914 expect(() => {915 mockChild._emitError(new Error('late cleanup error'));916 }).not.toThrow();917 expect(monitorRegistry.getAll()).toHaveLength(0);918 } finally {919 killSpy.mockRestore();920 registerSpy.mockRestore();921 }922 });923 924 it('uses SIGKILL fallback if registry registration fails after spawn', async () => {925 vi.useFakeTimers();926 const invocation = createInvocation({927 command: 'tail -f log',928 });929 const killSpy = vi930 .spyOn(process, 'kill')931 .mockImplementation(() => true as never);932 const registerSpy = vi933 .spyOn(monitorRegistry, 'register')934 .mockImplementation(() => {935 throw new Error('limit reached');936 });937 938 try {939 await invocation.execute(new AbortController().signal);940 941 if (process.platform === 'win32') {942 expect(mockSpawn).toHaveBeenCalledWith(943 'taskkill',944 ['/pid', '12345', '/f', '/t'],945 expect.objectContaining({ stdio: 'ignore' }),946 );947 } else {948 expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM');949 await vi.advanceTimersByTimeAsync(200);950 expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGKILL');951 }952 } finally {953 killSpy.mockRestore();954 registerSpy.mockRestore();955 vi.useRealTimers();956 }957 });958 959 it('installs the abort handler before registering the monitor', async () => {960 const invocation = createInvocation({961 command: 'tail -f log',962 });963 const killSpy = vi964 .spyOn(process, 'kill')965 .mockImplementation(() => true as never);966 const registerSpy = vi967 .spyOn(monitorRegistry, 'register')968 .mockImplementation((entry) => {969 entry.abortController.abort();970 return MonitorRegistry.prototype.register.call(971 monitorRegistry,972 entry,973 );974 });975 976 try {977 await invocation.execute(new AbortController().signal);978 979 if (process.platform === 'win32') {980 expect(mockSpawn).toHaveBeenCalledWith(981 'taskkill',982 ['/pid', '12345', '/f', '/t'],983 expect.objectContaining({ stdio: 'ignore' }),984 );985 } else {986 expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM');987 }988 } finally {989 killSpy.mockRestore();990 registerSpy.mockRestore();991 }992 });993 994 it('preserves the original spawn error when startup fails synchronously', async () => {995 const invocation = createInvocation({996 command: 'tail -f log',997 });998 const registerCallback = vi.fn();999 monitorRegistry.setRegisterCallback(registerCallback);1000 mockSpawn.mockImplementation(() => {1001 throw new Error('spawn failed');1002 });1003 const registerSpy = vi1004 .spyOn(monitorRegistry, 'register')1005 .mockImplementation(() => {1006 throw new Error('limit reached');1007 });1008 1009 try {1010 const result = await invocation.execute(new AbortController().signal);1011 1012 expect(result.llmContent).toContain('Monitor failed to start');1013 expect(result.llmContent).toContain('spawn failed');1014 expect(result.returnDisplay).toContain('spawn failed');1015 expect(registerSpy).not.toHaveBeenCalled();1016 expect(registerCallback).not.toHaveBeenCalled();1017 expect(monitorRegistry.getAll()).toHaveLength(0);1018 } finally {1019 registerSpy.mockRestore();1020 }1021 });1022 1023 it('replays spawn errors emitted before the late handler is attached', async () => {1024 const callback = vi.fn();1025 monitorRegistry.setNotificationCallback(callback);1026 monitorRegistry.setRegisterCallback(() => {1027 mockChild._emitError(new Error('spawn ENOENT'));1028 });1029 const invocation = createInvocation({1030 command: 'nonexistent',1031 });1032 1033 const result = await invocation.execute(new AbortController().signal);1034 1035 expect(result.llmContent).toContain('Monitor failed to start');1036 expect(result.llmContent).toContain('spawn ENOENT');1037 expect(result.returnDisplay).toContain('spawn ENOENT');1038 const all = monitorRegistry.getAll();1039 expect(all).toHaveLength(1);1040 expect(all[0].status).toBe('failed');1041 expect(callback).toHaveBeenCalledOnce();1042 const [, modelText] = callback.mock.calls[0] as [string, string];1043 expect(modelText).toContain('<status>failed</status>');1044 expect(modelText).toContain('spawn ENOENT');1045 });1046 1047 it('emits events on stdout lines', async () => {1048 const callback = vi.fn();1049 monitorRegistry.setNotificationCallback(callback);1050 1051 const invocation = createInvocation({1052 command: 'echo hello',1053 });1054 1055 await invocation.execute(new AbortController().signal);1056 1057 // Simulate stdout data1058 mockChild.stdout.emit('data', Buffer.from('line one\nline two\n'));1059 1060 expect(callback).toHaveBeenCalledTimes(2);1061 });1062 1063 it('buffers partial lines across chunks', async () => {1064 const callback = vi.fn();1065 monitorRegistry.setNotificationCallback(callback);1066 1067 const invocation = createInvocation({1068 command: 'echo hello',1069 });1070 1071 await invocation.execute(new AbortController().signal);1072 1073 // Send partial line1074 mockChild.stdout.emit('data', Buffer.from('partial'));1075 expect(callback).not.toHaveBeenCalled();1076 1077 // Complete the line1078 mockChild.stdout.emit('data', Buffer.from(' complete\n'));1079 expect(callback).toHaveBeenCalledOnce();1080 });1081 1082 it('waits for stdio close before settling registry after process exit', async () => {1083 const invocation = createInvocation({1084 command: 'echo done',1085 });1086 1087 await invocation.execute(new AbortController().signal);1088 mockChild._emitExit(0);1089 1090 expect(monitorRegistry.getRunning()).toHaveLength(1);1091 mockChild._emitClose(0);1092 1093 const entry = monitorRegistry.getRunning();1094 expect(entry).toHaveLength(0);1095 const all = monitorRegistry.getAll();1096 expect(all[0].status).toBe('completed');1097 });1098 1099 it('drains stdout emitted after exit before completing', async () => {1100 const callback = vi.fn();1101 monitorRegistry.setNotificationCallback(callback);1102 const invocation = createInvocation({1103 command: 'echo done',1104 });1105 1106 await invocation.execute(new AbortController().signal);1107 mockChild._emitExit(0);1108 mockChild.stdout.emit('data', Buffer.from('final line\n'));1109 mockChild._emitClose(0);1110 1111 expect(callback).toHaveBeenCalledTimes(2);1112 const [, eventModelText] = callback.mock.calls[0] as [string, string];1113 const [, terminalModelText] = callback.mock.calls[1] as [string, string];1114 expect(eventModelText).toContain('final line');1115 expect(terminalModelText).toContain('<status>completed</status>');1116 });1117 1118 it('settles as failed on non-zero exit', async () => {1119 const invocation = createInvocation({1120 command: 'false',1121 });1122 1123 await invocation.execute(new AbortController().signal);1124 mockChild._emitExit(1);1125 mockChild._emitClose(1);1126 1127 const all = monitorRegistry.getAll();1128 expect(all[0].status).toBe('failed');1129 });1130 1131 it('settles as failed on spawn error', async () => {1132 const invocation = createInvocation({1133 command: 'nonexistent',1134 });1135 1136 await invocation.execute(new AbortController().signal);1137 mockChild._emitError(new Error('spawn ENOENT'));1138 1139 const all = monitorRegistry.getAll();1140 expect(all[0].status).toBe('failed');1141 });1142 1143 it('settles as failed when killed by signal', async () => {1144 const invocation = createInvocation({1145 command: 'tail -f log',1146 });1147 1148 await invocation.execute(new AbortController().signal);1149 mockChild._emitExit(null, 'SIGTERM');1150 mockChild._emitClose(null, 'SIGTERM');1151 1152 const all = monitorRegistry.getAll();1153 expect(all[0].status).toBe('failed');1154 });1155 1156 it('settles as completed when exit and close both report null code and null signal', async () => {1157 const callback = vi.fn();1158 monitorRegistry.setNotificationCallback(callback);1159 1160 const invocation = createInvocation({1161 command: 'some-cmd',1162 });1163 1164 await invocation.execute(new AbortController().signal);1165 mockChild._emitExit(null, null);1166 mockChild._emitClose(null, null);1167 1168 const all = monitorRegistry.getAll();1169 expect(all[0].status).toBe('completed');1170 // Terminal notification should not include a result tag (exitCode is null)1171 const terminalCall = callback.mock.calls.find(1172 (args) =>1173 typeof args[1] === 'string' &&1174 (args[1] as string).includes('<status>completed</status>'),1175 );1176 expect(terminalCall).toBeDefined();1177 expect(terminalCall![1]).not.toContain('<result>');1178 });1179 1180 it('does not kill monitor on turn signal abort', async () => {1181 const turnAc = new AbortController();1182 const invocation = createInvocation({1183 command: 'tail -f log',1184 });1185 1186 await invocation.execute(turnAc.signal);1187 1188 // Abort the turn signal (simulating Ctrl+C)1189 turnAc.abort();1190 1191 // Monitor should still be running1192 const running = monitorRegistry.getRunning();1193 expect(running).toHaveLength(1);1194 });1195 1196 it('processes stderr data same as stdout', async () => {1197 const callback = vi.fn();1198 monitorRegistry.setNotificationCallback(callback);1199 1200 const invocation = createInvocation({