Skip to content

Commit 869f57d

Browse files
committed
fix(copilot): bound agent CLI grep matching
1 parent e625a4c commit 869f57d

2 files changed

Lines changed: 90 additions & 7 deletions

File tree

‎apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { flushMicrotasks } from '@sim/testing/helpers/async'
12
import { sleep } from '@sim/utils/helpers'
23
import { generateId } from '@sim/utils/id'
34
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -71,6 +72,67 @@ function runtimeWithFilePages(count: number, requested: string[]): AgentCliRunti
7172
}
7273

7374
describe('universal grep', () => {
75+
it('bounds matching time for adversarial patterns across file lines', async () => {
76+
const runtime = runtimeWith({
77+
'/api/v2/files': { data: [{ id: 'input' }], nextCursor: null },
78+
'/api/v2/files/input/text': {
79+
data: { text: Array.from({ length: 12 }, () => `${'a'.repeat(26)}!`).join('\n') },
80+
},
81+
})
82+
const started = performance.now()
83+
const result = await runEngine('grep', ['(a+)+$'], runtime, { scope: 'files', count: true })
84+
expect(performance.now() - started).toBeLessThan(2_000)
85+
expect(result).toMatchObject({ exitCode: 0, stdout: '0' })
86+
}, 15_000)
87+
88+
it('matches unsupported regex syntax literally without falling back to backtracking', async () => {
89+
const result = await runEngine(
90+
'grep',
91+
['(?=NEEDLE)'],
92+
runtimeWith({
93+
'/api/v2/files': { data: [{ id: 'input' }], nextCursor: null },
94+
'/api/v2/files/input/text': { data: { text: '(?=needle)\nneedle' } },
95+
}),
96+
{ scope: 'files', count: true, i: true }
97+
)
98+
expect(result).toMatchObject({ exitCode: 0, stdout: '1 (files=1)' })
99+
})
100+
101+
it('fails an exhausted scan budget instead of returning a complete count', async () => {
102+
vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(6_000)
103+
const result = await runEngine('grep', ['id'], runtimeWith(CATALOG), {
104+
scope: 'blocks',
105+
count: true,
106+
})
107+
expect(result.exitCode).toBe(1)
108+
expect(result.stdout).toBe('')
109+
expect(result.stderr).toMatch(/incomplete.*budget/i)
110+
})
111+
112+
it('yields during scanning so a pending cancellation can stop the search', async () => {
113+
vi.useFakeTimers()
114+
try {
115+
vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(20)
116+
const controller = new AbortController()
117+
setTimeout(() => controller.abort(new Error('Search stopped')), 0)
118+
const pending = runEngine(
119+
'grep',
120+
['id'],
121+
{ ...runtimeWith(CATALOG), signal: controller.signal },
122+
{ scope: 'blocks' }
123+
)
124+
await flushMicrotasks()
125+
await vi.runAllTimersAsync()
126+
expect(await pending).toMatchObject({
127+
exitCode: 1,
128+
stdout: '',
129+
stderr: expect.stringContaining('Search stopped'),
130+
})
131+
} finally {
132+
vi.useRealTimers()
133+
}
134+
})
135+
74136
it('finds field ids inside block definitions and names the path-shaped line', async () => {
75137
const result = await runEngine('grep', ['stream'], runtimeWith(CATALOG), {
76138
scope: 'blocks',

‎apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import { sleep } from '@sim/utils/helpers'
12
import { isRecordLike } from '@sim/utils/object'
23
import type { ReadFileTextResponse } from 'sim/embed'
34
import { listCatalogTools } from '@/lib/catalog/application/list-tools'
45
import { readBlockCatalog } from '@/lib/catalog/application/read-block-catalog'
6+
import { compileLinearRegex, isPlainText, literalRegex } from '@/lib/core/security/linear-regex'
57
import { enginePrincipal } from '@/lib/mothership/agent-cli/engine-principal'
68
import {
79
type AgentCliEngine,
@@ -46,6 +48,9 @@ const CATALOG_ALL = 100_000
4648
const MAX_FILES = 300
4749
const FILE_READ_CONCURRENCY = 5
4850
const MAX_BYTES_PER_FILE = 262_144
51+
/** Bound scanning after materialization without charging backend read latency. */
52+
const MAX_SCAN_TIME_MS = 5_000
53+
const SCAN_YIELD_INTERVAL_MS = 10
4954
/** `workflow:<uuid>` — a prefixed form no world or resource ever prints as its path. */
5055
const PREFIX_SELECTOR = /^\w+:/
5156
const PLATFORM_SCOPES: ReadonlySet<Scope> = new Set<Scope>(['blocks', 'tools'])
@@ -416,13 +421,12 @@ async function materializeWithin(
416421
}
417422

418423
function compilePattern(raw: string, ignoreCase: boolean): (line: string) => boolean {
419-
try {
420-
const regex = new RegExp(raw, ignoreCase ? 'i' : '')
421-
return (line) => regex.test(line)
422-
} catch {
423-
const needle = ignoreCase ? raw.toLowerCase() : raw
424-
return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle)
425-
}
424+
const regex = isPlainText(raw)
425+
? literalRegex(raw, { ignoreCase })
426+
: compileLinearRegex(raw, { ignoreCase })
427+
if (regex) return (line) => regex.test(line)
428+
const needle = ignoreCase ? raw.toLowerCase() : raw
429+
return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle)
426430
}
427431

428432
function clip(line: string): string {
@@ -570,11 +574,27 @@ export const universalGrepCommand: AgentCliEngine = {
570574
let total = 0
571575
let shownMatches = 0
572576
const perScope = new Map<Scope, number>()
577+
const scanStartedAt = performance.now()
578+
let lastYieldAt = scanStartedAt
579+
const checkScan = () => {
580+
runtime.signal?.throwIfAborted()
581+
const now = performance.now()
582+
if (now - scanStartedAt >= MAX_SCAN_TIME_MS) {
583+
throw new Error(
584+
'Search incomplete: scanning exceeded the time budget. Narrow with --scope or --in.'
585+
)
586+
}
587+
return now
588+
}
573589
for (const resource of candidates) {
574590
if (resource.text === null) continue
575591
const lines = resource.text.split('\n')
576592
const selected = new Set<number>()
577593
for (let i = 0; i < lines.length; i++) {
594+
if (checkScan() - lastYieldAt >= SCAN_YIELD_INTERVAL_MS) {
595+
await sleep(0)
596+
lastYieldAt = checkScan()
597+
}
578598
if (!matches(lines[i])) continue
579599
total++
580600
perScope.set(resource.scope, (perScope.get(resource.scope) ?? 0) + 1)
@@ -595,6 +615,7 @@ export const universalGrepCommand: AgentCliEngine = {
595615
if (matches(lines[i])) shownMatches++
596616
}
597617
}
618+
checkScan()
598619

599620
if (countOnly) {
600621
const breakdown = [...perScope.entries()].map(([s, n]) => `${s}=${n}`).join(' ')

0 commit comments

Comments
 (0)