@namzu/sdk 62.7.0 → 62.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/manager/session/turn-recorder.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.js +50 -26
- package/dist/manager/session/turn-recorder.js.map +1 -1
- package/dist/runtime/query/context.d.ts.map +1 -1
- package/dist/runtime/query/context.js +28 -2
- package/dist/runtime/query/context.js.map +1 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +24 -2
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
- package/dist/runtime/query/prepare-turn.js +17 -8
- package/dist/runtime/query/prepare-turn.js.map +1 -1
- package/dist/runtime/query/session-storage.d.ts.map +1 -1
- package/dist/runtime/query/session-storage.js +44 -3
- package/dist/runtime/query/session-storage.js.map +1 -1
- package/dist/sandbox/file-walk.d.ts +7 -1
- package/dist/sandbox/file-walk.d.ts.map +1 -1
- package/dist/sandbox/file-walk.js +30 -4
- package/dist/sandbox/file-walk.js.map +1 -1
- package/dist/store/checkpoint/contract.d.ts +12 -0
- package/dist/store/checkpoint/contract.d.ts.map +1 -1
- package/dist/store/checkpoint/contract.js.map +1 -1
- package/dist/store/checkpoint/disk.d.ts.map +1 -1
- package/dist/store/checkpoint/disk.js +34 -2
- package/dist/store/checkpoint/disk.js.map +1 -1
- package/dist/store/checkpoint/prune.d.ts.map +1 -1
- package/dist/store/checkpoint/prune.js +6 -0
- package/dist/store/checkpoint/prune.js.map +1 -1
- package/dist/store/evidence/disk.js +40 -3
- package/dist/store/evidence/disk.js.map +1 -1
- package/dist/store/session-log/incremental.d.ts +23 -0
- package/dist/store/session-log/incremental.d.ts.map +1 -0
- package/dist/store/session-log/incremental.js +117 -0
- package/dist/store/session-log/incremental.js.map +1 -0
- package/dist/tools/admit-facts.d.ts.map +1 -1
- package/dist/tools/admit-facts.js +1 -0
- package/dist/tools/admit-facts.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +10 -1
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/bounded-file-read.d.ts +19 -1
- package/dist/tools/builtins/bounded-file-read.d.ts.map +1 -1
- package/dist/tools/builtins/bounded-file-read.js +56 -3
- package/dist/tools/builtins/bounded-file-read.js.map +1 -1
- package/dist/tools/builtins/grep.d.ts +8 -0
- package/dist/tools/builtins/grep.d.ts.map +1 -1
- package/dist/tools/builtins/grep.js +147 -20
- package/dist/tools/builtins/grep.js.map +1 -1
- package/dist/tools/builtins/search-support.d.ts +8 -0
- package/dist/tools/builtins/search-support.d.ts.map +1 -1
- package/dist/tools/builtins/search-support.js +66 -0
- package/dist/tools/builtins/search-support.js.map +1 -1
- package/dist/types/tool/presentation.d.ts +6 -0
- package/dist/types/tool/presentation.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/manager/session/turn-recorder.ts +53 -27
- package/src/runtime/query/context.ts +33 -6
- package/src/runtime/query/executor.ts +24 -2
- package/src/runtime/query/prepare-turn.ts +16 -8
- package/src/runtime/query/session-storage.ts +42 -3
- package/src/sandbox/file-walk.ts +41 -4
- package/src/store/checkpoint/contract.ts +15 -0
- package/src/store/checkpoint/disk.ts +34 -2
- package/src/store/checkpoint/prune.ts +13 -0
- package/src/store/evidence/disk.ts +57 -2
- package/src/store/session-log/incremental.ts +147 -0
- package/src/tools/admit-facts.ts +1 -0
- package/src/tools/builtins/bash.ts +11 -1
- package/src/tools/builtins/bounded-file-read.ts +59 -2
- package/src/tools/builtins/grep.ts +172 -26
- package/src/tools/builtins/search-support.ts +62 -0
- package/src/types/tool/presentation.ts +6 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { RecordPointer } from '../../types/session/records.js'
|
|
2
|
+
import { type SessionLogEntry, SessionLogIntegrityError } from './chain.js'
|
|
3
|
+
import type { SessionLog } from './core.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reductions over a whole session log, advanced rather than recomputed.
|
|
7
|
+
*
|
|
8
|
+
* A session log only grows, and every turn asks it the same questions (which
|
|
9
|
+
* checkpoints were committed, which turn ids exist, what the folded context
|
|
10
|
+
* is). Answering each from byte 0 parses and verifies every record the
|
|
11
|
+
* session ever wrote, so a turn cost more the longer the session. A reduction
|
|
12
|
+
* held here remembers the last record it folded and, on the next ask, walks
|
|
13
|
+
* only what was appended after it. `SessionLog.read({ after })` checks that
|
|
14
|
+
* record against the log byte for byte before continuing, so a log that was
|
|
15
|
+
* replaced, truncated or rewritten at that record is noticed and the
|
|
16
|
+
* reduction starts again from byte 0 with the original behaviour (strict reads
|
|
17
|
+
* refuse a broken chain, tolerant reads stop at the break).
|
|
18
|
+
*
|
|
19
|
+
* What a resumed read does not do is re-verify the records before the anchor:
|
|
20
|
+
* they were verified when they were first folded, by this process. A byte
|
|
21
|
+
* flipped in them afterwards, with every later record left as it was, is not
|
|
22
|
+
* noticed by this path; a fresh process (a resume) always starts from byte 0.
|
|
23
|
+
*
|
|
24
|
+
* The cache is keyed by session id and the reduction's own name and mode,
|
|
25
|
+
* holds a few sessions, and is anchored by content, so two logs that share a
|
|
26
|
+
* session id never share a result.
|
|
27
|
+
*
|
|
28
|
+
* Cost that remains: the state of the "every message ever recorded" reduction
|
|
29
|
+
* holds each message the log ever recorded, superseded and compacted ones
|
|
30
|
+
* included, so it grows with the log and not with the live context, and it
|
|
31
|
+
* lives for the life of the process (for the sessions kept). Reading it
|
|
32
|
+
* returns a copy of all of them, which is O(history) per turn. Both are small
|
|
33
|
+
* next to the whole-log walks this file removes, and are left as they are.
|
|
34
|
+
* More than SESSIONS_KEPT sessions in use at once evict one another, and each
|
|
35
|
+
* eviction restarts from byte 0, the cost before this file existed.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const SESSIONS_KEPT = 4
|
|
39
|
+
|
|
40
|
+
interface Kept {
|
|
41
|
+
state: unknown
|
|
42
|
+
/** The last record folded; the next read resumes after it. */
|
|
43
|
+
anchor: RecordPointer | undefined
|
|
44
|
+
/** Calls on one reduction take turns: the state is advanced in place. */
|
|
45
|
+
turn: Promise<unknown>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sessions = new Map<string, Map<string, Kept>>()
|
|
49
|
+
|
|
50
|
+
function keptFor(sessionId: string, key: string): Kept {
|
|
51
|
+
let reductions = sessions.get(sessionId)
|
|
52
|
+
if (reductions === undefined) {
|
|
53
|
+
reductions = new Map()
|
|
54
|
+
if (sessions.size >= SESSIONS_KEPT) {
|
|
55
|
+
const oldest = sessions.keys().next().value
|
|
56
|
+
if (oldest !== undefined) sessions.delete(oldest)
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
sessions.delete(sessionId)
|
|
60
|
+
}
|
|
61
|
+
sessions.set(sessionId, reductions)
|
|
62
|
+
let kept = reductions.get(key)
|
|
63
|
+
if (kept === undefined) {
|
|
64
|
+
kept = { state: undefined, anchor: undefined, turn: Promise.resolve() }
|
|
65
|
+
reductions.set(key, kept)
|
|
66
|
+
}
|
|
67
|
+
return kept
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface IncrementalFold<S, R> {
|
|
71
|
+
/** The reduction's name; one cache slot per name and read mode. */
|
|
72
|
+
readonly name: string
|
|
73
|
+
readonly mode: 'strict' | 'tolerant'
|
|
74
|
+
/** A fresh, empty state. */
|
|
75
|
+
create(): S
|
|
76
|
+
/** Fold one record into the state, in place. */
|
|
77
|
+
apply(state: S, entry: SessionLogEntry): void
|
|
78
|
+
/**
|
|
79
|
+
* The answer, from a state that is current for as long as this call runs.
|
|
80
|
+
* It must not keep or hand out the state itself, which later calls advance.
|
|
81
|
+
*/
|
|
82
|
+
result(state: S): Promise<R> | R
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fold `log` into `fold.create()` through `fold.apply`, resuming from the
|
|
87
|
+
* record the previous call for this session stopped at when the log still
|
|
88
|
+
* holds it, and answer with `fold.result`.
|
|
89
|
+
*/
|
|
90
|
+
export async function foldSessionLog<S, R>(
|
|
91
|
+
log: SessionLog,
|
|
92
|
+
fold: IncrementalFold<S, R>,
|
|
93
|
+
): Promise<R> {
|
|
94
|
+
const kept = keptFor(log.sessionId, `${fold.name}:${fold.mode}`)
|
|
95
|
+
const run = async (): Promise<R> => {
|
|
96
|
+
let state = kept.state as S | undefined
|
|
97
|
+
let anchor = kept.anchor
|
|
98
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
99
|
+
const resuming = state !== undefined && anchor !== undefined
|
|
100
|
+
if (!resuming) {
|
|
101
|
+
state = fold.create()
|
|
102
|
+
anchor = undefined
|
|
103
|
+
}
|
|
104
|
+
let moved = false
|
|
105
|
+
try {
|
|
106
|
+
const walk = log.read({ mode: fold.mode, ...(anchor ? { after: anchor } : {}) })
|
|
107
|
+
let summary: Awaited<ReturnType<typeof walk.next>>
|
|
108
|
+
for (;;) {
|
|
109
|
+
summary = await walk.next()
|
|
110
|
+
if (summary.done) break
|
|
111
|
+
moved = true
|
|
112
|
+
fold.apply(state as S, summary.value)
|
|
113
|
+
}
|
|
114
|
+
const ended = summary.value
|
|
115
|
+
if (resuming && !moved && ended.break?.reason === 'anchor-mismatch') {
|
|
116
|
+
kept.state = undefined
|
|
117
|
+
kept.anchor = undefined
|
|
118
|
+
state = undefined
|
|
119
|
+
anchor = undefined
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
kept.state = state
|
|
123
|
+
kept.anchor = ended.head?.pointer ?? anchor
|
|
124
|
+
return await fold.result(state as S)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// The state may be half-advanced: never reuse it.
|
|
127
|
+
kept.state = undefined
|
|
128
|
+
kept.anchor = undefined
|
|
129
|
+
if (
|
|
130
|
+
resuming &&
|
|
131
|
+
!moved &&
|
|
132
|
+
error instanceof SessionLogIntegrityError &&
|
|
133
|
+
error.reason === 'anchor-mismatch'
|
|
134
|
+
) {
|
|
135
|
+
state = undefined
|
|
136
|
+
anchor = undefined
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
throw error
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
throw new Error('unreachable')
|
|
143
|
+
}
|
|
144
|
+
const result = kept.turn.then(run, run)
|
|
145
|
+
kept.turn = result.catch(() => undefined)
|
|
146
|
+
return result
|
|
147
|
+
}
|
package/src/tools/admit-facts.ts
CHANGED
|
@@ -520,6 +520,11 @@ export function execHostShell(
|
|
|
520
520
|
|
|
521
521
|
export { dangerousCommandReason }
|
|
522
522
|
|
|
523
|
+
/** A reason as a sentence a person reads under a row: first letter up. */
|
|
524
|
+
function sentence(reason: string): string {
|
|
525
|
+
return reason ? `${reason.charAt(0).toUpperCase()}${reason.slice(1)}` : reason
|
|
526
|
+
}
|
|
527
|
+
|
|
523
528
|
/** The dangerous-pattern check `bash` applies, shared so the `monitor` tool refuses the same lines. */
|
|
524
529
|
export function isDangerousCommand(command: string): boolean {
|
|
525
530
|
return DANGEROUS_PATTERNS.some((pattern) => pattern.test(command))
|
|
@@ -596,7 +601,12 @@ const BashToolBase = defineTool({
|
|
|
596
601
|
success: false,
|
|
597
602
|
output: '',
|
|
598
603
|
error: `Dangerous command blocked: "${input.command}". Why: ${dangerousCommandReason(input.command)}. Nothing ran. Use a narrower command that avoids it, or ask the user to run this one.`,
|
|
599
|
-
facts: {
|
|
604
|
+
facts: {
|
|
605
|
+
kind: 'failure',
|
|
606
|
+
reason: 'blocked',
|
|
607
|
+
subject: 'bash',
|
|
608
|
+
note: sentence(dangerousCommandReason(input.command)),
|
|
609
|
+
},
|
|
600
610
|
}
|
|
601
611
|
}
|
|
602
612
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { constants } from 'node:fs'
|
|
1
|
+
import { constants, closeSync, fstatSync, openSync, readSync } from 'node:fs'
|
|
2
2
|
import { open } from 'node:fs/promises'
|
|
3
3
|
|
|
4
4
|
const CHUNK_BYTES = 64 * 1024
|
|
@@ -40,10 +40,13 @@ export async function readBoundedFile(
|
|
|
40
40
|
maximum: number,
|
|
41
41
|
signal?: AbortSignal,
|
|
42
42
|
selectLimit?: (prefix: Buffer) => number,
|
|
43
|
+
extraFlags = 0,
|
|
43
44
|
): Promise<Buffer> {
|
|
44
45
|
// O_NONBLOCK prevents a substituted FIFO from blocking open before fstat.
|
|
46
|
+
// `extraFlags` adds to it, for a caller that also needs the last component
|
|
47
|
+
// refused when it is a link.
|
|
45
48
|
signal?.throwIfAborted()
|
|
46
|
-
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK)
|
|
49
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | extraFlags)
|
|
47
50
|
try {
|
|
48
51
|
const info = await file.stat()
|
|
49
52
|
if (!info.isFile()) throw new Error('Read requires a regular file.')
|
|
@@ -78,3 +81,57 @@ function inputTooLarge(limit: number): Error {
|
|
|
78
81
|
`File exceeds the bounded read limit (${limit} bytes). Extract a smaller file with a shell command.`,
|
|
79
82
|
)
|
|
80
83
|
}
|
|
84
|
+
|
|
85
|
+
/** True where the platform can refuse a final-component link at open time. */
|
|
86
|
+
export const CAN_REFUSE_LINKS = typeof constants.O_NOFOLLOW === 'number' && constants.O_NOFOLLOW > 0
|
|
87
|
+
|
|
88
|
+
/** The flag a caller passes to {@link readBoundedFile} to refuse a final-component link. */
|
|
89
|
+
export const REFUSE_LINK_FLAG: number = CAN_REFUSE_LINKS ? constants.O_NOFOLLOW : 0
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The same bounded read as {@link readBoundedFile} through one synchronous
|
|
93
|
+
* handle: open, fstat, read, close. A link in the last path component is
|
|
94
|
+
* refused at open (`O_NOFOLLOW`), so a link swapped in after a walk listed the
|
|
95
|
+
* file cannot be followed. Callers yield between files; one file is at most
|
|
96
|
+
* `maximum` bytes; a file whose size is over it answers `'too-large'`. With a
|
|
97
|
+
* `scratch` buffer, a file that fits one chunk comes back as a view of it, valid
|
|
98
|
+
* until the next call. Requires {@link CAN_REFUSE_LINKS}.
|
|
99
|
+
*
|
|
100
|
+
* With `syncMaximum`, a file whose size is over it (and within `maximum`) is not
|
|
101
|
+
* read here: the answer is `'large'`, so the caller reads it through
|
|
102
|
+
* {@link readBoundedFile}, which does not hold the event loop for the whole file.
|
|
103
|
+
*/
|
|
104
|
+
export function readBoundedFileSync(
|
|
105
|
+
path: string,
|
|
106
|
+
maximum: number,
|
|
107
|
+
scratch?: Buffer,
|
|
108
|
+
syncMaximum = maximum,
|
|
109
|
+
): Buffer | 'too-large' | 'large' {
|
|
110
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW)
|
|
111
|
+
try {
|
|
112
|
+
const info = fstatSync(fd)
|
|
113
|
+
if (!info.isFile()) throw new Error('Read requires a regular file.')
|
|
114
|
+
if (info.size > maximum) return 'too-large'
|
|
115
|
+
if (info.size > syncMaximum) return 'large'
|
|
116
|
+
const chunks: Buffer[] = []
|
|
117
|
+
let offset = 0
|
|
118
|
+
for (;;) {
|
|
119
|
+
const length = Math.min(CHUNK_BYTES, maximum + 1 - offset)
|
|
120
|
+
const buffer =
|
|
121
|
+
offset === 0 && scratch && scratch.length >= length ? scratch : Buffer.allocUnsafe(length)
|
|
122
|
+
let used = 0
|
|
123
|
+
while (used < length) {
|
|
124
|
+
const bytesRead = readSync(fd, buffer, used, length - used, offset + used)
|
|
125
|
+
if (bytesRead === 0) break
|
|
126
|
+
used += bytesRead
|
|
127
|
+
}
|
|
128
|
+
offset += used
|
|
129
|
+
if (offset > maximum) throw inputTooLarge(maximum)
|
|
130
|
+
chunks.push(buffer.subarray(0, used))
|
|
131
|
+
if (used < length)
|
|
132
|
+
return chunks.length === 1 ? (chunks[0] as Buffer) : Buffer.concat(chunks, offset)
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
closeSync(fd)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stat } from 'node:fs/promises'
|
|
2
|
-
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
import { walkFilesLocally } from '../../sandbox/file-walk.js'
|
|
5
5
|
import type { SandboxWalkFilesOptions, SandboxWalkSummary } from '../../types/sandbox/index.js'
|
|
@@ -7,6 +7,7 @@ import type { ToolContext } from '../../types/tool/index.js'
|
|
|
7
7
|
import { subscribeToAbort } from '../../utils/abort.js'
|
|
8
8
|
import { defineTool } from '../defineTool.js'
|
|
9
9
|
import {
|
|
10
|
+
canonicalize,
|
|
10
11
|
resolveWithin,
|
|
11
12
|
resolveWithinAny,
|
|
12
13
|
resolveWithinAnyReal,
|
|
@@ -14,7 +15,13 @@ import {
|
|
|
14
15
|
toolRoots,
|
|
15
16
|
} from '../paths.js'
|
|
16
17
|
import { relativePosix, resolveWithinPosix } from '../posix-path.js'
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
CAN_REFUSE_LINKS,
|
|
20
|
+
REFUSE_LINK_FLAG,
|
|
21
|
+
readBoundedChunks,
|
|
22
|
+
readBoundedFile,
|
|
23
|
+
readBoundedFileSync,
|
|
24
|
+
} from './bounded-file-read.js'
|
|
18
25
|
import {
|
|
19
26
|
MAX_FILES_SEARCHED,
|
|
20
27
|
SEARCH_BUDGET_MS,
|
|
@@ -24,6 +31,7 @@ import {
|
|
|
24
31
|
isReadDenied,
|
|
25
32
|
openSearchBudget,
|
|
26
33
|
plural,
|
|
34
|
+
readAhead,
|
|
27
35
|
stoppedNotice,
|
|
28
36
|
} from './search-support.js'
|
|
29
37
|
|
|
@@ -49,6 +57,125 @@ async function readWithSignal(read: () => Promise<Buffer>, signal?: AbortSignal)
|
|
|
49
57
|
}
|
|
50
58
|
}
|
|
51
59
|
|
|
60
|
+
/** Longest the local read loop holds the event loop before it yields. */
|
|
61
|
+
const READ_SLICE_MS = 8
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Largest file the local reader reads synchronously, by the size on the open
|
|
65
|
+
* handle. One synchronous read of a file is one uninterrupted block, which on a
|
|
66
|
+
* slow mount can last tens of milliseconds; a larger file is read through the
|
|
67
|
+
* asynchronous bounded read instead, which lets the event loop run between
|
|
68
|
+
* chunks.
|
|
69
|
+
*/
|
|
70
|
+
export const LOCAL_SYNC_READ_MAX_BYTES = 64 * 1024
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reads files of a local search root. Each directory is resolved to its real
|
|
74
|
+
* path once and checked against the real root once; a file is then one open
|
|
75
|
+
* (links refused at the last component), fstat, read and close, all
|
|
76
|
+
* synchronous, with a yield to the event loop every few milliseconds. That is
|
|
77
|
+
* the same containment decision as resolving every file, because the walk
|
|
78
|
+
* lists only regular files in real directories and the open refuses a file
|
|
79
|
+
* that has become a link. Not used for a sandbox, nor where the platform cannot
|
|
80
|
+
* refuse links.
|
|
81
|
+
*/
|
|
82
|
+
function createLocalReader(root: string, signal: AbortSignal | undefined) {
|
|
83
|
+
const scratch = Buffer.allocUnsafe(64 * 1024)
|
|
84
|
+
const rootRealPromise = canonicalize(resolve(root))
|
|
85
|
+
const directories = new Map<string, Promise<string>>()
|
|
86
|
+
let sliceStart = performance.now()
|
|
87
|
+
const realDirectory = (directory: string): Promise<string> => {
|
|
88
|
+
let known = directories.get(directory)
|
|
89
|
+
if (!known) {
|
|
90
|
+
known = canonicalize(directory)
|
|
91
|
+
directories.set(directory, known)
|
|
92
|
+
}
|
|
93
|
+
return known
|
|
94
|
+
}
|
|
95
|
+
let rootReal: string | undefined
|
|
96
|
+
return {
|
|
97
|
+
async read(
|
|
98
|
+
filePath: string,
|
|
99
|
+
): Promise<{ buffer: Buffer | 'too-large' | 'gone'; contained: string }> {
|
|
100
|
+
if (performance.now() - sliceStart > READ_SLICE_MS) {
|
|
101
|
+
await new Promise<void>((done) => setImmediate(done))
|
|
102
|
+
sliceStart = performance.now()
|
|
103
|
+
}
|
|
104
|
+
signal?.throwIfAborted()
|
|
105
|
+
const directory = dirname(filePath)
|
|
106
|
+
const real = await realDirectory(directory)
|
|
107
|
+
rootReal ??= await rootRealPromise
|
|
108
|
+
const contained =
|
|
109
|
+
real === directory ? filePath : join(real, filePath.slice(directory.length + 1))
|
|
110
|
+
if (!(contained.startsWith(rootReal + sep) || contained === rootReal)) {
|
|
111
|
+
const rel = relative(rootReal, contained)
|
|
112
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
113
|
+
throw new Error('Path escapes the working directory through a link.')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
signal?.throwIfAborted()
|
|
117
|
+
try {
|
|
118
|
+
const read = readBoundedFileSync(
|
|
119
|
+
contained,
|
|
120
|
+
MAX_FILE_SIZE,
|
|
121
|
+
scratch,
|
|
122
|
+
LOCAL_SYNC_READ_MAX_BYTES,
|
|
123
|
+
)
|
|
124
|
+
if (read !== 'large') return { buffer: read, contained }
|
|
125
|
+
// The handle that measured the file is closed; the asynchronous read opens it
|
|
126
|
+
// again with the same flags (links refused) and repeats every check.
|
|
127
|
+
const large = await readBoundedFile(
|
|
128
|
+
contained,
|
|
129
|
+
MAX_FILE_SIZE,
|
|
130
|
+
signal,
|
|
131
|
+
undefined,
|
|
132
|
+
REFUSE_LINK_FLAG,
|
|
133
|
+
)
|
|
134
|
+
sliceStart = performance.now()
|
|
135
|
+
return { buffer: large, contained }
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// Gone since the walk listed it: absent, as a walk that ran later would find it.
|
|
138
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
139
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return { buffer: 'gone', contained }
|
|
140
|
+
throw error
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A walked path that is already inside `root` in normal form, or null when it needs the full check. */
|
|
147
|
+
const PLAIN_TAIL = /(^|\/)\.{1,2}(\/|$)|\/\/|\/$/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The fast form of what `resolveWithin` and the display path compute for the
|
|
151
|
+
* walk's own entries on a POSIX host: a path the walk built under `root` is
|
|
152
|
+
* already normal, so it is returned as it is and its display name is a
|
|
153
|
+
* prefix plus the tail. Anything else, and every other platform, takes the
|
|
154
|
+
* full computation.
|
|
155
|
+
*/
|
|
156
|
+
function createWalkedPaths(root: string, lexicalRoot: string, workingDirectory: string) {
|
|
157
|
+
if (sep !== '/') return undefined
|
|
158
|
+
const prefix = root === '/' ? '/' : `${root}/`
|
|
159
|
+
const rootRel = relative(workingDirectory, lexicalRoot)
|
|
160
|
+
const display = rootRel.startsWith('..') || isAbsolute(rootRel) ? undefined : rootRel
|
|
161
|
+
return {
|
|
162
|
+
within(path: string): string {
|
|
163
|
+
if (path.startsWith(prefix) && path.length > prefix.length) {
|
|
164
|
+
if (!PLAIN_TAIL.test(path.slice(prefix.length))) return path
|
|
165
|
+
}
|
|
166
|
+
return resolveWithin(root, path)
|
|
167
|
+
},
|
|
168
|
+
displayName(path: string): string | undefined {
|
|
169
|
+
if (display === undefined || !path.startsWith(prefix) || path.length === prefix.length) {
|
|
170
|
+
return undefined
|
|
171
|
+
}
|
|
172
|
+
const tail = path.slice(prefix.length)
|
|
173
|
+
if (PLAIN_TAIL.test(tail)) return undefined
|
|
174
|
+
return display === '' ? tail : `${display}/${tail}`
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
52
179
|
function relativeInclude(include: string, root: string, sandboxed: boolean): string {
|
|
53
180
|
let pattern = sandboxed ? include : include.split(sep).join('/')
|
|
54
181
|
if (pattern.split('/').includes('..'))
|
|
@@ -201,11 +328,7 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5 MB — skip binaries/large files
|
|
|
201
328
|
const BINARY_CHECK_BYTES = 512
|
|
202
329
|
|
|
203
330
|
function isBinaryContent(buffer: Buffer): boolean {
|
|
204
|
-
|
|
205
|
-
for (const byte of check) {
|
|
206
|
-
if (byte === 0) return true
|
|
207
|
-
}
|
|
208
|
-
return false
|
|
331
|
+
return buffer.subarray(0, BINARY_CHECK_BYTES).includes(0)
|
|
209
332
|
}
|
|
210
333
|
|
|
211
334
|
export const GrepTool = defineTool({
|
|
@@ -291,7 +414,11 @@ export const GrepTool = defineTool({
|
|
|
291
414
|
walkEnd = value
|
|
292
415
|
},
|
|
293
416
|
}
|
|
294
|
-
|
|
417
|
+
// A local walk is read ahead so its directory reads overlap with matching; a
|
|
418
|
+
// sandbox's walk is left as the adapter produces it.
|
|
419
|
+
const entries = walkSandbox
|
|
420
|
+
? walkSandbox(root, options)
|
|
421
|
+
: readAhead(walkFilesLocally(root, { ...options, withoutSizes: CAN_REFUSE_LINKS }))
|
|
295
422
|
|
|
296
423
|
// `execute` is reachable without the schema's defaults.
|
|
297
424
|
const mode = input.output_mode ?? 'files_with_matches'
|
|
@@ -321,12 +448,16 @@ export const GrepTool = defineTool({
|
|
|
321
448
|
const filesToCollect =
|
|
322
449
|
headLimit > 0 ? offset + Math.max(headLimit * 4, 1000) : Number.POSITIVE_INFINITY
|
|
323
450
|
let enough = false
|
|
451
|
+
const walked = sandbox
|
|
452
|
+
? undefined
|
|
453
|
+
: createWalkedPaths(root, lexicalRoot, context.workingDirectory)
|
|
454
|
+
const localReader = !sandbox && CAN_REFUSE_LINKS ? createLocalReader(root, signal) : undefined
|
|
324
455
|
try {
|
|
325
456
|
for await (const entry of entries) {
|
|
326
457
|
signal?.throwIfAborted()
|
|
327
458
|
const filePath = sandbox
|
|
328
459
|
? resolveWithinPosix(root, entry.path)
|
|
329
|
-
: resolveWithin(root, entry.path)
|
|
460
|
+
: (walked?.within(entry.path) ?? resolveWithin(root, entry.path))
|
|
330
461
|
if (filesSearched >= MAX_FILES_SEARCHED) {
|
|
331
462
|
fileLimit = true
|
|
332
463
|
break
|
|
@@ -339,21 +470,34 @@ export const GrepTool = defineTool({
|
|
|
339
470
|
let content: string
|
|
340
471
|
let contained: string | undefined
|
|
341
472
|
try {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
473
|
+
let buffer: Buffer
|
|
474
|
+
if (localReader) {
|
|
475
|
+
const read = await localReader.read(filePath)
|
|
476
|
+
contained = read.contained
|
|
477
|
+
// Over the cap by the size on the open handle: skipped, not unreadable.
|
|
478
|
+
if (read.buffer === 'too-large') continue
|
|
479
|
+
// Gone since the walk listed it: as if the walk had not listed it.
|
|
480
|
+
if (read.buffer === 'gone') {
|
|
481
|
+
filesSearched--
|
|
482
|
+
continue
|
|
483
|
+
}
|
|
484
|
+
buffer = read.buffer
|
|
485
|
+
} else
|
|
486
|
+
buffer = await readWithSignal(
|
|
487
|
+
() =>
|
|
488
|
+
sandbox
|
|
489
|
+
? readBoundedChunks(
|
|
490
|
+
(offset, length) =>
|
|
491
|
+
sandbox.readFile(filePath, { offset, length, signal: signal }),
|
|
492
|
+
MAX_FILE_SIZE,
|
|
493
|
+
signal,
|
|
494
|
+
)
|
|
495
|
+
: resolveWithinReal(root, filePath).then((path) => {
|
|
496
|
+
contained = path
|
|
497
|
+
return readBoundedFile(path, MAX_FILE_SIZE, signal)
|
|
498
|
+
}),
|
|
499
|
+
signal,
|
|
500
|
+
)
|
|
357
501
|
signal?.throwIfAborted()
|
|
358
502
|
if (buffer.length > MAX_FILE_SIZE || isBinaryContent(buffer)) continue
|
|
359
503
|
content = buffer.toString('utf-8')
|
|
@@ -367,7 +511,8 @@ export const GrepTool = defineTool({
|
|
|
367
511
|
let fileMatches = 0
|
|
368
512
|
const relPath = sandbox
|
|
369
513
|
? relativePosix(sandbox.rootDir, filePath)
|
|
370
|
-
:
|
|
514
|
+
: (walked?.displayName(filePath) ??
|
|
515
|
+
hostDisplayPath(context.workingDirectory, join(lexicalRoot, relative(root, filePath))))
|
|
371
516
|
|
|
372
517
|
if (mode === 'content' && input.only_matching) {
|
|
373
518
|
for (const fragment of matchingFragments(content, regex, multiline)) {
|
|
@@ -580,7 +725,8 @@ function lineHits(
|
|
|
580
725
|
): [number, number][] {
|
|
581
726
|
const hits: [number, number][] = []
|
|
582
727
|
for (let i = 0; i < lines.length; i++) {
|
|
583
|
-
|
|
728
|
+
// A cancellation is seen within a few hundred lines; per line it cost more than the match.
|
|
729
|
+
if ((i & 255) === 0) context.abortSignal?.throwIfAborted()
|
|
584
730
|
regex.lastIndex = 0
|
|
585
731
|
if (regex.test(lines[i] ?? '')) hits.push([i, i])
|
|
586
732
|
}
|
|
@@ -105,3 +105,65 @@ export function isReadDenied(
|
|
|
105
105
|
if (relative === '..' || relative.startsWith('../') || posix.isAbsolute(relative)) return true
|
|
106
106
|
return context.readDenied(joinPath(context.workingDirectory, relative))
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pulls from `source` ahead of the consumer, up to `ahead` items, so a walk's
|
|
111
|
+
* directory reads overlap with the matching of files already listed. Order is
|
|
112
|
+
* unchanged. An error from the source is thrown to the consumer after the
|
|
113
|
+
* items listed before it; when the consumer stops, the source is closed (its
|
|
114
|
+
* own cleanup runs) before this returns.
|
|
115
|
+
*/
|
|
116
|
+
export async function* readAhead<T>(source: AsyncIterable<T>, ahead = 256): AsyncGenerator<T> {
|
|
117
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
118
|
+
const buffer: T[] = []
|
|
119
|
+
let finished = false
|
|
120
|
+
let failure: { error: unknown } | undefined
|
|
121
|
+
let stop = false
|
|
122
|
+
let wakeConsumer: (() => void) | undefined
|
|
123
|
+
let wakeProducer: (() => void) | undefined
|
|
124
|
+
const notifyConsumer = () => {
|
|
125
|
+
const wake = wakeConsumer
|
|
126
|
+
wakeConsumer = undefined
|
|
127
|
+
wake?.()
|
|
128
|
+
}
|
|
129
|
+
const producer = (async () => {
|
|
130
|
+
try {
|
|
131
|
+
while (!stop) {
|
|
132
|
+
if (buffer.length >= ahead) {
|
|
133
|
+
await new Promise<void>((resolve) => {
|
|
134
|
+
wakeProducer = resolve
|
|
135
|
+
})
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
const next = await iterator.next()
|
|
139
|
+
if (next.done) break
|
|
140
|
+
buffer.push(next.value)
|
|
141
|
+
notifyConsumer()
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
failure = { error }
|
|
145
|
+
}
|
|
146
|
+
finished = true
|
|
147
|
+
notifyConsumer()
|
|
148
|
+
})()
|
|
149
|
+
try {
|
|
150
|
+
for (;;) {
|
|
151
|
+
if (buffer.length > 0) {
|
|
152
|
+
const item = buffer.shift() as T
|
|
153
|
+
wakeProducer?.()
|
|
154
|
+
yield item
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (failure) throw failure.error
|
|
158
|
+
if (finished) return
|
|
159
|
+
await new Promise<void>((resolve) => {
|
|
160
|
+
wakeConsumer = resolve
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
} finally {
|
|
164
|
+
stop = true
|
|
165
|
+
wakeProducer?.()
|
|
166
|
+
await producer
|
|
167
|
+
await iterator.return?.()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -220,6 +220,12 @@ export type ToolFacts =
|
|
|
220
220
|
readonly reason: ToolFailureReason
|
|
221
221
|
/** What it was about: a path, or the tool's name for `unknown_tool`. */
|
|
222
222
|
readonly subject?: string
|
|
223
|
+
/**
|
|
224
|
+
* Why a refused call did not run, as one plain line the kernel wrote
|
|
225
|
+
* (a policy rule, the command guard). Absent when no reason was
|
|
226
|
+
* recorded; a host shows nothing for it rather than a guess.
|
|
227
|
+
*/
|
|
228
|
+
readonly note?: string
|
|
223
229
|
}
|
|
224
230
|
|
|
225
231
|
/** The same shapes, for what a call produced. */
|