CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
monitor.test.ts1560 linesDownload Raw Back to tools
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({

Showing the first 1,200 of 1560 lines. Download the file for the rest.

basant307/AI_Governance_Project · CoolFace