@namzu/sdk 62.7.0 → 62.9.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 +33 -0
- package/dist/compaction/plan.d.ts +12 -0
- package/dist/compaction/plan.d.ts.map +1 -1
- package/dist/compaction/plan.js +39 -11
- package/dist/compaction/plan.js.map +1 -1
- package/dist/compaction/salience/minhash.d.ts +9 -0
- package/dist/compaction/salience/minhash.d.ts.map +1 -1
- package/dist/compaction/salience/minhash.js +20 -0
- package/dist/compaction/salience/minhash.js.map +1 -1
- package/dist/compaction/salience/score.d.ts.map +1 -1
- package/dist/compaction/salience/score.js +87 -45
- package/dist/compaction/salience/score.js.map +1 -1
- package/dist/compaction/salience/working-set.d.ts +14 -0
- package/dist/compaction/salience/working-set.d.ts.map +1 -1
- package/dist/compaction/salience/working-set.js +84 -33
- package/dist/compaction/salience/working-set.js.map +1 -1
- package/dist/config/runtime.d.ts +2 -0
- package/dist/config/runtime.d.ts.map +1 -1
- package/dist/config/runtime.js +14 -0
- package/dist/config/runtime.js.map +1 -1
- package/dist/manager/session/turn-recorder.d.ts +25 -0
- package/dist/manager/session/turn-recorder.d.ts.map +1 -1
- package/dist/manager/session/turn-recorder.js +231 -31
- package/dist/manager/session/turn-recorder.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/runtime/query/cancelled-before-start.js +1 -1
- package/dist/runtime/query/cancelled-before-start.js.map +1 -1
- package/dist/runtime/query/checkpoint.js +2 -1
- package/dist/runtime/query/checkpoint.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/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +4 -1
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +15 -1
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
- package/dist/runtime/query/prepare-turn.js +27 -12
- 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/core.d.ts +31 -1
- package/dist/store/session-log/core.d.ts.map +1 -1
- package/dist/store/session-log/core.js +95 -9
- package/dist/store/session-log/core.js.map +1 -1
- package/dist/store/session-log/disk.d.ts +2 -0
- package/dist/store/session-log/disk.d.ts.map +1 -1
- package/dist/store/session-log/disk.js +37 -0
- package/dist/store/session-log/disk.js.map +1 -1
- package/dist/store/session-log/fold.d.ts +9 -1
- package/dist/store/session-log/fold.d.ts.map +1 -1
- package/dist/store/session-log/fold.js +18 -2
- package/dist/store/session-log/fold.js.map +1 -1
- package/dist/store/session-log/incremental.d.ts +31 -0
- package/dist/store/session-log/incremental.d.ts.map +1 -0
- package/dist/store/session-log/incremental.js +122 -0
- package/dist/store/session-log/incremental.js.map +1 -0
- package/dist/store/session-log/index.d.ts +1 -0
- package/dist/store/session-log/index.d.ts.map +1 -1
- package/dist/store/session-log/index.js +1 -0
- package/dist/store/session-log/index.js.map +1 -1
- package/dist/store/session-log/verified-prefix.d.ts +46 -0
- package/dist/store/session-log/verified-prefix.d.ts.map +1 -0
- package/dist/store/session-log/verified-prefix.js +233 -0
- package/dist/store/session-log/verified-prefix.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/session/records.d.ts +80 -1
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +22 -0
- package/dist/types/session/records.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/compaction/plan.ts +53 -11
- package/src/compaction/salience/minhash.ts +20 -0
- package/src/compaction/salience/score.ts +112 -43
- package/src/compaction/salience/working-set.ts +102 -31
- package/src/config/runtime.ts +14 -0
- package/src/manager/session/turn-recorder.ts +259 -30
- package/src/public-runtime.ts +1 -0
- package/src/runtime/query/cancelled-before-start.ts +1 -1
- package/src/runtime/query/checkpoint.ts +2 -1
- package/src/runtime/query/context.ts +33 -6
- package/src/runtime/query/executor.ts +24 -2
- package/src/runtime/query/index.ts +4 -1
- package/src/runtime/query/iteration/phases/compaction.ts +18 -0
- package/src/runtime/query/prepare-turn.ts +26 -11
- 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/core.ts +129 -10
- package/src/store/session-log/disk.ts +32 -0
- package/src/store/session-log/fold.ts +19 -2
- package/src/store/session-log/incremental.ts +160 -0
- package/src/store/session-log/index.ts +1 -0
- package/src/store/session-log/verified-prefix.ts +243 -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/session/records.ts +25 -0
- package/src/types/tool/presentation.ts +6 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto'
|
|
2
|
+
import type { SessionId } from '../../types/ids/index.js'
|
|
3
|
+
import type { RecordPointer } from '../../types/session/records.js'
|
|
4
|
+
import { LineSplitter, type LogBytes, type SessionLogEntry } from './chain.js'
|
|
5
|
+
import { SessionTurnState } from './fold.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What this process has already verified of a session log, so that checking
|
|
9
|
+
* it again costs the bytes and not the parsing.
|
|
10
|
+
*
|
|
11
|
+
* A ledger remembers, for every record this process verified IN FULL (schema,
|
|
12
|
+
* parse, hash chain, seq, gen, links: everything `SessionLogChain.accept`
|
|
13
|
+
* checks), the byte length of its line and the SHA-256 of those bytes, plus
|
|
14
|
+
* the head pointer, the `gen`, the session id and the turn state folded to
|
|
15
|
+
* the head. A later claim of the same log reads every byte again and hashes
|
|
16
|
+
* each line; a line whose length and digest equal the remembered ones is
|
|
17
|
+
* byte-identical to a line that already passed every rule, so it passes them
|
|
18
|
+
* again without being parsed. Only what lies after the remembered head is
|
|
19
|
+
* verified in full. Nothing about the file's identity is trusted for
|
|
20
|
+
* soundness: the content is compared. Any difference (a changed byte, a
|
|
21
|
+
* shorter file, a replaced file, a line that no longer splits the same way)
|
|
22
|
+
* makes the caller discard the ledger and verify from byte 0 as before.
|
|
23
|
+
*
|
|
24
|
+
* A ledger is fed only from entries that came out of a full verification and
|
|
25
|
+
* that continue it (`seq` = count + 1 and `prev` equal to the head), so what
|
|
26
|
+
* it holds is a prefix of one valid chain and nothing else. It costs 36 bytes
|
|
27
|
+
* per record.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const DIGEST_BYTES = 32
|
|
31
|
+
/** Logs kept; the least recently used is dropped. */
|
|
32
|
+
const LEDGERS_KEPT = 16
|
|
33
|
+
/** A log with more records than this is not remembered (about 72 MB of digests). */
|
|
34
|
+
const MAX_RECORDS = 1 << 21
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The digest of one line as lowercase hex. A string, not a Buffer: a Buffer per
|
|
38
|
+
* line (82,000 of them in a long log) is native memory the collector has to
|
|
39
|
+
* chase, and measured about twice the CPU. The one-shot form where Node has it.
|
|
40
|
+
*/
|
|
41
|
+
const hexDigestOf: (line: Uint8Array) => string =
|
|
42
|
+
typeof crypto.hash === 'function'
|
|
43
|
+
? (line) => crypto.hash('sha256', line, 'hex')
|
|
44
|
+
: (line) => crypto.createHash('sha256').update(line).digest('hex')
|
|
45
|
+
|
|
46
|
+
/** Whether `hex` (64 lowercase hex characters) spells the 32 bytes at `at` in `bytes`. */
|
|
47
|
+
function hexEqualsBytes(hex: string, bytes: Uint8Array, at: number): boolean {
|
|
48
|
+
if (hex.length !== DIGEST_BYTES * 2) return false
|
|
49
|
+
for (let i = 0; i < DIGEST_BYTES; i++) {
|
|
50
|
+
const high = hex.charCodeAt(i * 2)
|
|
51
|
+
const low = hex.charCodeAt(i * 2 + 1)
|
|
52
|
+
const value =
|
|
53
|
+
((high <= 0x39 ? high - 0x30 : high - 0x57) << 4) | (low <= 0x39 ? low - 0x30 : low - 0x57)
|
|
54
|
+
if (value !== bytes[at + i]) return false
|
|
55
|
+
}
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class VerifiedPrefix {
|
|
60
|
+
#count = 0
|
|
61
|
+
#lengths = new Uint32Array(1024)
|
|
62
|
+
#digests = Buffer.allocUnsafe(1024 * DIGEST_BYTES)
|
|
63
|
+
#head: RecordPointer | null = null
|
|
64
|
+
#gen = 0
|
|
65
|
+
#sessionId: SessionId | undefined
|
|
66
|
+
#end = 0
|
|
67
|
+
#dead = false
|
|
68
|
+
readonly #turns = new SessionTurnState()
|
|
69
|
+
|
|
70
|
+
/** Records remembered. */
|
|
71
|
+
get count(): number {
|
|
72
|
+
return this.#count
|
|
73
|
+
}
|
|
74
|
+
get head(): RecordPointer | null {
|
|
75
|
+
return this.#head
|
|
76
|
+
}
|
|
77
|
+
get gen(): number {
|
|
78
|
+
return this.#gen
|
|
79
|
+
}
|
|
80
|
+
get sessionId(): SessionId | undefined {
|
|
81
|
+
return this.#sessionId
|
|
82
|
+
}
|
|
83
|
+
/** Byte offset just after the last remembered record. */
|
|
84
|
+
get end(): number {
|
|
85
|
+
return this.#end
|
|
86
|
+
}
|
|
87
|
+
/** The turn state at the head, as its own copy. */
|
|
88
|
+
turns(): SessionTurnState {
|
|
89
|
+
return this.#turns.clone()
|
|
90
|
+
}
|
|
91
|
+
/** Lengths and digests as they stand now; entries below `count` never change. */
|
|
92
|
+
get lengths(): Uint32Array {
|
|
93
|
+
return this.#lengths
|
|
94
|
+
}
|
|
95
|
+
get digests(): Buffer {
|
|
96
|
+
return this.#digests
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether the remembered record `seq` is exactly `pointer` (length and digest). */
|
|
100
|
+
holds(pointer: RecordPointer): boolean {
|
|
101
|
+
const at = pointer.seq - 1
|
|
102
|
+
if (at < 0 || at >= this.#count) return false
|
|
103
|
+
if (this.#lengths[at] !== pointer.length) return false
|
|
104
|
+
return (
|
|
105
|
+
this.#digests.toString('hex', at * DIGEST_BYTES, (at + 1) * DIGEST_BYTES) === pointer.sha256
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Remember a record that was verified in full. Ignored unless it continues
|
|
111
|
+
* what is held: the next seq, naming the head by its pointer.
|
|
112
|
+
*/
|
|
113
|
+
append(entry: SessionLogEntry): void {
|
|
114
|
+
if (this.#dead) return
|
|
115
|
+
const { record, pointer } = entry
|
|
116
|
+
if (pointer.seq !== this.#count + 1 || pointer.offset !== this.#end) return
|
|
117
|
+
const prev = record.prev
|
|
118
|
+
if (this.#head === null ? prev !== null : !samePointer(prev, this.#head)) return
|
|
119
|
+
if (this.#sessionId !== undefined && record.sessionId !== this.#sessionId) return
|
|
120
|
+
if (this.#count >= MAX_RECORDS) {
|
|
121
|
+
this.#dead = true
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (this.#count === this.#lengths.length) {
|
|
125
|
+
const lengths = new Uint32Array(this.#lengths.length * 2)
|
|
126
|
+
lengths.set(this.#lengths)
|
|
127
|
+
this.#lengths = lengths
|
|
128
|
+
const digests = Buffer.allocUnsafe(this.#digests.length * 2)
|
|
129
|
+
this.#digests.copy(digests)
|
|
130
|
+
this.#digests = digests
|
|
131
|
+
}
|
|
132
|
+
this.#lengths[this.#count] = pointer.length
|
|
133
|
+
this.#digests.write(pointer.sha256, this.#count * DIGEST_BYTES, DIGEST_BYTES, 'hex')
|
|
134
|
+
this.#count += 1
|
|
135
|
+
this.#head = pointer
|
|
136
|
+
this.#gen = record.gen
|
|
137
|
+
this.#end = pointer.offset + pointer.length
|
|
138
|
+
this.#sessionId ??= record.sessionId
|
|
139
|
+
this.#turns.apply(record)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function samePointer(a: RecordPointer | null | undefined, b: RecordPointer): boolean {
|
|
144
|
+
return (
|
|
145
|
+
a !== null &&
|
|
146
|
+
a !== undefined &&
|
|
147
|
+
a.seq === b.seq &&
|
|
148
|
+
a.offset === b.offset &&
|
|
149
|
+
a.length === b.length &&
|
|
150
|
+
a.sha256 === b.sha256
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ledgers = new Map<string, VerifiedPrefix>()
|
|
155
|
+
|
|
156
|
+
/** The ledger of a log, if this process has one; marks it recently used. */
|
|
157
|
+
export function verifiedPrefixOf(key: string): VerifiedPrefix | undefined {
|
|
158
|
+
const found = ledgers.get(key)
|
|
159
|
+
if (found !== undefined) {
|
|
160
|
+
ledgers.delete(key)
|
|
161
|
+
ledgers.set(key, found)
|
|
162
|
+
}
|
|
163
|
+
return found
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Discard what is remembered of a log and start an empty ledger for it. */
|
|
167
|
+
export function startVerifiedPrefix(key: string): VerifiedPrefix {
|
|
168
|
+
ledgers.delete(key)
|
|
169
|
+
if (ledgers.size >= LEDGERS_KEPT) {
|
|
170
|
+
const oldest = ledgers.keys().next().value
|
|
171
|
+
if (oldest !== undefined) ledgers.delete(oldest)
|
|
172
|
+
}
|
|
173
|
+
const fresh = new VerifiedPrefix()
|
|
174
|
+
ledgers.set(key, fresh)
|
|
175
|
+
return fresh
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Forget every remembered log: what a process that has not read them yet looks like. For tests. */
|
|
179
|
+
export function clearVerifiedPrefixes(): void {
|
|
180
|
+
ledgers.clear()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** How many logs are remembered. For tests. */
|
|
184
|
+
export function verifiedPrefixCount(): number {
|
|
185
|
+
return ledgers.size
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read the first `count` lines of `bytes` and compare each one's length and
|
|
190
|
+
* SHA-256 with the ledger's. Resolves `true` only when every one is equal.
|
|
191
|
+
* Parses nothing and keeps nothing. Any difference, a log that ends sooner,
|
|
192
|
+
* or a fragment no record could be, resolves `false`: the caller then verifies
|
|
193
|
+
* in full, which says what is wrong.
|
|
194
|
+
*/
|
|
195
|
+
export async function rehashPrefix(
|
|
196
|
+
bytes: Pick<LogBytes, 'size'> & {
|
|
197
|
+
stream(offset: number): AsyncIterable<Uint8Array>
|
|
198
|
+
scan?(offset: number, visit: (chunk: Uint8Array) => boolean): Promise<void>
|
|
199
|
+
},
|
|
200
|
+
ledger: VerifiedPrefix,
|
|
201
|
+
count: number,
|
|
202
|
+
): Promise<boolean> {
|
|
203
|
+
if (count === 0) return true
|
|
204
|
+
if (count > ledger.count) return false
|
|
205
|
+
const lengths = ledger.lengths
|
|
206
|
+
const digests = ledger.digests
|
|
207
|
+
if ((await bytes.size()) < endOfLine(lengths, count)) return false
|
|
208
|
+
const splitter = new LineSplitter(0)
|
|
209
|
+
let index = 0
|
|
210
|
+
let matched = false
|
|
211
|
+
// True once the answer is known. A chunk is read from a buffer the caller of `visit`
|
|
212
|
+
// may reuse, so nothing here keeps a slice of it: a line is hashed where it stands.
|
|
213
|
+
const visit = (chunk: Uint8Array): boolean => {
|
|
214
|
+
for (const { line } of splitter.push(chunk)) {
|
|
215
|
+
if (line.byteLength !== lengths[index]) return true
|
|
216
|
+
if (!hexEqualsBytes(hexDigestOf(line), digests, index * DIGEST_BYTES)) return true
|
|
217
|
+
index += 1
|
|
218
|
+
if (index === count) {
|
|
219
|
+
matched = true
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
if (bytes.scan !== undefined) {
|
|
227
|
+
await bytes.scan(0, visit)
|
|
228
|
+
} else {
|
|
229
|
+
for await (const chunk of bytes.stream(0)) if (visit(chunk)) break
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
// A fragment longer than any record can be: not the log that was verified.
|
|
233
|
+
if ((error as { name?: string }).name === 'SessionLogIntegrityError') return false
|
|
234
|
+
throw error
|
|
235
|
+
}
|
|
236
|
+
return matched
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function endOfLine(lengths: Uint32Array, count: number): number {
|
|
240
|
+
let total = 0
|
|
241
|
+
for (let i = 0; i < count; i++) total += lengths[i] as number
|
|
242
|
+
return total
|
|
243
|
+
}
|
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
|
+
}
|