@namzu/sdk 35.0.0 → 36.0.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 +38 -0
- package/dist/provider/token-budget.d.ts.map +1 -1
- package/dist/provider/token-budget.js +56 -8
- package/dist/provider/token-budget.js.map +1 -1
- package/dist/public-runtime.d.ts +2 -2
- 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/executor.d.ts +1 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +50 -10
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +7 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts +3 -3
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +31 -6
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.js +10 -5
- package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
- package/dist/runtime/query/prompt.d.ts.map +1 -1
- package/dist/runtime/query/prompt.js +14 -4
- package/dist/runtime/query/prompt.js.map +1 -1
- package/dist/sandbox/file-walk-program.d.ts +3 -0
- package/dist/sandbox/file-walk-program.d.ts.map +1 -0
- package/dist/sandbox/file-walk-program.js +94 -0
- package/dist/sandbox/file-walk-program.js.map +1 -0
- package/dist/sandbox/file-walk.d.ts +12 -0
- package/dist/sandbox/file-walk.d.ts.map +1 -0
- package/dist/sandbox/file-walk.js +429 -0
- package/dist/sandbox/file-walk.js.map +1 -0
- package/dist/sandbox/index.d.ts +2 -0
- package/dist/sandbox/index.d.ts.map +1 -1
- package/dist/sandbox/index.js +1 -0
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/provider/local.d.ts.map +1 -1
- package/dist/sandbox/provider/local.js +9 -0
- package/dist/sandbox/provider/local.js.map +1 -1
- package/dist/scheduler/completion-inbox.d.ts +3 -2
- package/dist/scheduler/completion-inbox.d.ts.map +1 -1
- package/dist/scheduler/completion-inbox.js +29 -15
- package/dist/scheduler/completion-inbox.js.map +1 -1
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +215 -59
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/builtins/glob.d.ts +2 -1
- package/dist/tools/builtins/glob.d.ts.map +1 -1
- package/dist/tools/builtins/glob.js +95 -103
- package/dist/tools/builtins/glob.js.map +1 -1
- package/dist/tools/builtins/grep.d.ts.map +1 -1
- package/dist/tools/builtins/grep.js +155 -111
- package/dist/tools/builtins/grep.js.map +1 -1
- package/dist/tools/builtins/ls.js +2 -2
- package/dist/tools/builtins/ls.js.map +1 -1
- package/dist/types/sandbox/index.d.ts +20 -0
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/package.json +6 -1
- package/src/provider/token-budget.ts +52 -8
- package/src/public-runtime.ts +2 -1
- package/src/runtime/query/executor.ts +54 -14
- package/src/runtime/query/index.ts +8 -0
- package/src/runtime/query/iteration/index.ts +29 -9
- package/src/runtime/query/iteration/phases/context.ts +2 -0
- package/src/runtime/query/iteration/stream-turn.ts +9 -5
- package/src/runtime/query/prompt.ts +21 -4
- package/src/sandbox/file-walk-program.ts +93 -0
- package/src/sandbox/file-walk.ts +463 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/provider/local.ts +13 -0
- package/src/scheduler/completion-inbox.ts +29 -19
- package/src/tools/builtins/bash.ts +230 -58
- package/src/tools/builtins/glob.ts +104 -122
- package/src/tools/builtins/grep.ts +166 -132
- package/src/tools/builtins/ls.ts +2 -2
- package/src/types/sandbox/index.ts +21 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { isAbsolute, posix } from 'node:path'
|
|
2
|
+
import braceExpansion from 'brace-expansion'
|
|
3
|
+
import { GLOBSTAR, Minimatch } from 'minimatch'
|
|
4
|
+
import type {
|
|
5
|
+
SandboxExecOptions,
|
|
6
|
+
SandboxExecResult,
|
|
7
|
+
SandboxFileEntry,
|
|
8
|
+
SandboxWalkFilesOptions,
|
|
9
|
+
} from '../types/sandbox/index.js'
|
|
10
|
+
import { subscribeToAbort } from '../utils/abort.js'
|
|
11
|
+
import { FILE_WALK_PROGRAM } from './file-walk-program.js'
|
|
12
|
+
|
|
13
|
+
interface WalkPlan {
|
|
14
|
+
root: string
|
|
15
|
+
prefix: string[]
|
|
16
|
+
expressions: { source: string; flags: string }[]
|
|
17
|
+
maxDepth: number | null
|
|
18
|
+
maxEntries: number
|
|
19
|
+
maxVisitedEntries: number
|
|
20
|
+
includeHidden: boolean
|
|
21
|
+
patterns: (string | { source: string; flags: string } | null)[][]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// brace-expansion 2.1.4 implements these bounds; its older DefinitelyTyped
|
|
25
|
+
// declaration only describes the first argument.
|
|
26
|
+
const expandBraces = braceExpansion as (
|
|
27
|
+
pattern: string,
|
|
28
|
+
options: { max: number; maxLength: number },
|
|
29
|
+
) => string[]
|
|
30
|
+
|
|
31
|
+
function positiveInteger(value: number, name: string): number {
|
|
32
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
33
|
+
throw new RangeError(`${name} must be a positive safe integer`)
|
|
34
|
+
}
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function planWalk(root: string, options: SandboxWalkFilesOptions): WalkPlan {
|
|
39
|
+
if (!isAbsolute(root) && !posix.isAbsolute(root)) {
|
|
40
|
+
throw new Error('File walk root must be an absolute path')
|
|
41
|
+
}
|
|
42
|
+
let pattern = options.pattern ?? '**/*'
|
|
43
|
+
while (pattern.startsWith('./')) pattern = pattern.slice(2)
|
|
44
|
+
if (
|
|
45
|
+
!pattern ||
|
|
46
|
+
pattern.startsWith('/') ||
|
|
47
|
+
/^[a-z]:/i.test(pattern) ||
|
|
48
|
+
pattern.split('/').includes('..')
|
|
49
|
+
) {
|
|
50
|
+
throw new Error('File walk pattern must be relative to its root and cannot contain ..')
|
|
51
|
+
}
|
|
52
|
+
if (pattern.length > 4096) throw new RangeError('File walk pattern exceeds 4096 characters')
|
|
53
|
+
// Expansion cannot lengthen a branch beyond its source. Bound both source
|
|
54
|
+
// length and branch count, then disable the library's separate character cap:
|
|
55
|
+
// that cap can silently truncate before our extra branch proves incompleteness.
|
|
56
|
+
const branches = expandBraces(pattern, { max: 257, maxLength: Number.POSITIVE_INFINITY })
|
|
57
|
+
if (branches.length > 256) throw new RangeError('File walk pattern exceeds 256 brace expansions')
|
|
58
|
+
const matchers = branches.map((branch) => {
|
|
59
|
+
if (branch.startsWith('/') || /^[a-z]:/i.test(branch) || branch.split('/').includes('..')) {
|
|
60
|
+
throw new Error('Expanded file walk pattern escapes its root')
|
|
61
|
+
}
|
|
62
|
+
return new Minimatch(branch, {
|
|
63
|
+
nobrace: true,
|
|
64
|
+
nonegate: true,
|
|
65
|
+
nocomment: true,
|
|
66
|
+
dot: options.includeHidden ?? false,
|
|
67
|
+
platform: 'linux',
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
const sets = matchers.flatMap((matcher) => matcher.set)
|
|
71
|
+
if (sets.some((set) => set.some((segment) => segment === '..'))) {
|
|
72
|
+
throw new Error('Expanded file walk pattern escapes its root')
|
|
73
|
+
}
|
|
74
|
+
const patternDepth = Math.max(
|
|
75
|
+
1,
|
|
76
|
+
...sets.map((set) => (set.includes(GLOBSTAR) ? Number.POSITIVE_INFINITY : set.length)),
|
|
77
|
+
)
|
|
78
|
+
const maxDepth = Math.min(
|
|
79
|
+
patternDepth,
|
|
80
|
+
options.maxDepth === undefined
|
|
81
|
+
? Number.POSITIVE_INFINITY
|
|
82
|
+
: positiveInteger(options.maxDepth, 'maxDepth'),
|
|
83
|
+
)
|
|
84
|
+
const prefix: string[] = []
|
|
85
|
+
for (let index = 0; sets.length > 0; index += 1) {
|
|
86
|
+
const segment = sets[0]?.[index]
|
|
87
|
+
if (
|
|
88
|
+
typeof segment !== 'string' ||
|
|
89
|
+
!segment ||
|
|
90
|
+
segment === '.' ||
|
|
91
|
+
sets.some((set) => index >= set.length - 1 || set[index] !== segment)
|
|
92
|
+
)
|
|
93
|
+
break
|
|
94
|
+
prefix.push(segment)
|
|
95
|
+
}
|
|
96
|
+
const expressions = matchers.flatMap((matcher) => {
|
|
97
|
+
const expression = matcher.makeRe()
|
|
98
|
+
return expression ? [{ source: expression.source, flags: expression.flags }] : []
|
|
99
|
+
})
|
|
100
|
+
return {
|
|
101
|
+
root,
|
|
102
|
+
prefix,
|
|
103
|
+
expressions,
|
|
104
|
+
maxDepth: Number.isFinite(maxDepth) ? maxDepth : null,
|
|
105
|
+
maxEntries: positiveInteger(options.maxEntries, 'maxEntries'),
|
|
106
|
+
maxVisitedEntries: positiveInteger(options.maxVisitedEntries ?? 20_000, 'maxVisitedEntries'),
|
|
107
|
+
includeHidden: options.includeHidden ?? false,
|
|
108
|
+
patterns: sets.map((set) =>
|
|
109
|
+
set.map((segment) =>
|
|
110
|
+
segment === GLOBSTAR
|
|
111
|
+
? null
|
|
112
|
+
: typeof segment === 'string'
|
|
113
|
+
? segment
|
|
114
|
+
: { source: segment.source, flags: segment.flags },
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether at least one parsed pattern can match a file below this directory. */
|
|
121
|
+
function directoryMatcher(plan: WalkPlan): (relative: string) => boolean {
|
|
122
|
+
const patterns = plan.patterns.map((set) =>
|
|
123
|
+
set.map((part) =>
|
|
124
|
+
typeof part === 'object' && part !== null ? new RegExp(part.source, `${part.flags}s`) : part,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
return (relative) =>
|
|
128
|
+
patterns.some((pattern) => {
|
|
129
|
+
const closure = (states: Set<number>) => {
|
|
130
|
+
for (const index of states) if (pattern[index] === null) states.add(index + 1)
|
|
131
|
+
return states
|
|
132
|
+
}
|
|
133
|
+
let states = closure(new Set([0]))
|
|
134
|
+
for (const name of relative.split('/')) {
|
|
135
|
+
const next = new Set<number>()
|
|
136
|
+
for (const index of states) {
|
|
137
|
+
const segment = pattern[index]
|
|
138
|
+
if (segment === null) {
|
|
139
|
+
if (plan.includeHidden || !name.startsWith('.')) next.add(index)
|
|
140
|
+
} else if (typeof segment === 'string' ? segment === name : segment?.test(name))
|
|
141
|
+
next.add(index + 1)
|
|
142
|
+
}
|
|
143
|
+
states = closure(next)
|
|
144
|
+
if (states.size === 0) return false
|
|
145
|
+
}
|
|
146
|
+
return [...states].some((index) => index < pattern.length)
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Local traversal uses the same compiled matching/pruning plan as the guest. */
|
|
151
|
+
async function* executeWalk(
|
|
152
|
+
plan: WalkPlan,
|
|
153
|
+
signal?: AbortSignal,
|
|
154
|
+
): AsyncGenerator<SandboxFileEntry> {
|
|
155
|
+
const fs = await import('node:fs/promises')
|
|
156
|
+
const path = await import('node:path')
|
|
157
|
+
const { EventEmitter } = await import('node:events')
|
|
158
|
+
const open: { dir: import('node:fs').Dir; relative: string; depth: number }[] = []
|
|
159
|
+
let visited = 0
|
|
160
|
+
let emitted = 0
|
|
161
|
+
const matchers = plan.expressions.map(({ source, flags }) => new RegExp(source, `${flags}s`))
|
|
162
|
+
const canDescend = directoryMatcher(plan)
|
|
163
|
+
const maxDepth = plan.maxDepth ?? Number.POSITIVE_INFINITY
|
|
164
|
+
const missing = (error: unknown) =>
|
|
165
|
+
['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')
|
|
166
|
+
const close = async (dir: import('node:fs').Dir) => {
|
|
167
|
+
try {
|
|
168
|
+
await dir.close()
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if ((error as NodeJS.ErrnoException).code !== 'ERR_DIR_CLOSED') throw error
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const check = () => signal?.throwIfAborted()
|
|
174
|
+
async function wait<T>(pending: Promise<T>, late?: (value: T) => void): Promise<T> {
|
|
175
|
+
if (!signal) return await pending
|
|
176
|
+
let abandoned = signal.aborted
|
|
177
|
+
let dispose: (() => void) | undefined
|
|
178
|
+
const observed = pending.then((value) => {
|
|
179
|
+
if (abandoned) late?.(value)
|
|
180
|
+
return value
|
|
181
|
+
})
|
|
182
|
+
try {
|
|
183
|
+
return await Promise.race([
|
|
184
|
+
observed,
|
|
185
|
+
new Promise<never>((_, reject) => {
|
|
186
|
+
const abort = () => {
|
|
187
|
+
abandoned = true
|
|
188
|
+
reject(signal.reason)
|
|
189
|
+
}
|
|
190
|
+
if (signal.aborted) {
|
|
191
|
+
abort()
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (typeof EventEmitter.addAbortListener === 'function') {
|
|
195
|
+
const subscription = EventEmitter.addAbortListener(signal, abort)
|
|
196
|
+
dispose = () => subscription[Symbol.dispose]()
|
|
197
|
+
} else {
|
|
198
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
199
|
+
dispose = () => signal.removeEventListener('abort', abort)
|
|
200
|
+
}
|
|
201
|
+
}),
|
|
202
|
+
])
|
|
203
|
+
} finally {
|
|
204
|
+
dispose?.()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const noteVisit = () => {
|
|
208
|
+
visited += 1
|
|
209
|
+
if (visited > plan.maxVisitedEntries) {
|
|
210
|
+
throw Object.assign(
|
|
211
|
+
new Error(
|
|
212
|
+
`File search stopped after examining ${plan.maxVisitedEntries} entries; narrow its root or pattern.`,
|
|
213
|
+
),
|
|
214
|
+
{ code: 'ERR_FILE_WALK_LIMIT' },
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
check()
|
|
220
|
+
if (plan.patterns.length === 0) return
|
|
221
|
+
let canonicalRoot: string
|
|
222
|
+
try {
|
|
223
|
+
canonicalRoot = await wait(fs.realpath(plan.root))
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (missing(error)) return
|
|
226
|
+
throw error
|
|
227
|
+
}
|
|
228
|
+
if (canonicalRoot !== path.resolve(plan.root))
|
|
229
|
+
throw new Error('File walk root follows a symbolic link; use its authorized real directory')
|
|
230
|
+
let start = plan.root
|
|
231
|
+
// Never follow a static-prefix symlink to speed up a pattern: traversal
|
|
232
|
+
// and its optimized starting point must have the same link policy.
|
|
233
|
+
for (const part of plan.prefix) {
|
|
234
|
+
check()
|
|
235
|
+
start = path.join(start, part)
|
|
236
|
+
let info: import('node:fs').Stats
|
|
237
|
+
try {
|
|
238
|
+
info = await wait(fs.lstat(start))
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (missing(error)) return
|
|
241
|
+
throw error
|
|
242
|
+
}
|
|
243
|
+
noteVisit()
|
|
244
|
+
if (!info.isDirectory()) return
|
|
245
|
+
}
|
|
246
|
+
if (plan.prefix.length >= maxDepth) return
|
|
247
|
+
const openDirectory = async (absolute: string, relative: string, depth: number) => {
|
|
248
|
+
check()
|
|
249
|
+
try {
|
|
250
|
+
const dir = await wait(fs.opendir(absolute), (late) => {
|
|
251
|
+
void close(late).catch(() => {})
|
|
252
|
+
})
|
|
253
|
+
open.push({ dir, relative, depth })
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (!missing(error)) throw error
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
await openDirectory(start, plan.prefix.join('/'), plan.prefix.length)
|
|
259
|
+
while (open.length > 0) {
|
|
260
|
+
check()
|
|
261
|
+
const frame = open[open.length - 1] as (typeof open)[number]
|
|
262
|
+
const entry = await wait(frame.dir.read())
|
|
263
|
+
check()
|
|
264
|
+
if (!entry) {
|
|
265
|
+
open.pop()
|
|
266
|
+
await close(frame.dir)
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
noteVisit()
|
|
270
|
+
const relative = frame.relative ? `${frame.relative}/${entry.name}` : entry.name
|
|
271
|
+
const absolute = path.join(plan.root, relative)
|
|
272
|
+
const depth = frame.depth + 1
|
|
273
|
+
if (entry.isDirectory()) {
|
|
274
|
+
if (depth < maxDepth && canDescend(relative)) await openDirectory(absolute, relative, depth)
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (!entry.isFile() || !matchers.some((matcher) => matcher.test(relative))) continue
|
|
278
|
+
let info: import('node:fs').Stats
|
|
279
|
+
try {
|
|
280
|
+
info = await wait(fs.lstat(absolute))
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (missing(error)) continue
|
|
283
|
+
throw error
|
|
284
|
+
}
|
|
285
|
+
check()
|
|
286
|
+
if (!info.isFile()) continue
|
|
287
|
+
emitted += 1
|
|
288
|
+
yield { path: absolute, size: info.size }
|
|
289
|
+
if (emitted >= plan.maxEntries) return
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
for (const { dir } of open.reverse()) {
|
|
293
|
+
if (signal?.aborted) {
|
|
294
|
+
void close(dir).catch(() => {})
|
|
295
|
+
} else await close(dir)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Internal host path; the caller first resolves its authorized filesystem root. */
|
|
301
|
+
export async function* walkFilesLocally(
|
|
302
|
+
rootPath: string,
|
|
303
|
+
options: SandboxWalkFilesOptions,
|
|
304
|
+
): AsyncGenerator<SandboxFileEntry> {
|
|
305
|
+
options.signal?.throwIfAborted()
|
|
306
|
+
yield* executeWalk(planWalk(rootPath, options), options.signal)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The existing sandbox execution seam; no separate worker protocol is needed. */
|
|
310
|
+
export type SandboxFileWalkExec = (
|
|
311
|
+
command: string,
|
|
312
|
+
argv?: string[],
|
|
313
|
+
options?: SandboxExecOptions,
|
|
314
|
+
) => Promise<SandboxExecResult>
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Lazy, bounded JSONL enumeration inside the execution boundary owned by exec.
|
|
318
|
+
* The adapter must implement SandboxExecOptions.signal: iterator cleanup waits
|
|
319
|
+
* for cancellation settlement and preserves failures to confirm remote shutdown.
|
|
320
|
+
*/
|
|
321
|
+
export async function* walkFilesViaExec(
|
|
322
|
+
exec: SandboxFileWalkExec,
|
|
323
|
+
rootPath: string,
|
|
324
|
+
options: SandboxWalkFilesOptions,
|
|
325
|
+
): AsyncGenerator<SandboxFileEntry> {
|
|
326
|
+
options.signal?.throwIfAborted()
|
|
327
|
+
const plan = planWalk(rootPath, options)
|
|
328
|
+
const controller = new AbortController()
|
|
329
|
+
const dispose = options.signal
|
|
330
|
+
? subscribeToAbort(options.signal, () => controller.abort(options.signal?.reason))
|
|
331
|
+
: undefined
|
|
332
|
+
const queue: SandboxFileEntry[] = []
|
|
333
|
+
let wake: (() => void) | undefined
|
|
334
|
+
let buffered = ''
|
|
335
|
+
let observedOutput = false
|
|
336
|
+
let settled = false
|
|
337
|
+
let terminal = false
|
|
338
|
+
let received = 0
|
|
339
|
+
let failure: unknown
|
|
340
|
+
const reject = (error: unknown) => {
|
|
341
|
+
failure ??= error instanceof Error ? error : new Error(String(error))
|
|
342
|
+
controller.abort(error)
|
|
343
|
+
wake?.()
|
|
344
|
+
}
|
|
345
|
+
const consume = (line: string) => {
|
|
346
|
+
if (line.length > 65_536) throw new Error('File walk record exceeded its output bound')
|
|
347
|
+
const value: unknown = JSON.parse(line)
|
|
348
|
+
if (!value || typeof value !== 'object' || terminal) throw new Error('Invalid file walk record')
|
|
349
|
+
const record = value as Record<string, unknown>
|
|
350
|
+
if (record.type === 'done') {
|
|
351
|
+
terminal = true
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
if (record.type === 'error' && typeof record.message === 'string') {
|
|
355
|
+
terminal = true
|
|
356
|
+
throw Object.assign(new Error(record.message), {
|
|
357
|
+
code: typeof record.code === 'string' ? record.code : undefined,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
if (
|
|
361
|
+
record.type !== 'entry' ||
|
|
362
|
+
typeof record.path !== 'string' ||
|
|
363
|
+
typeof record.size !== 'number' ||
|
|
364
|
+
!Number.isSafeInteger(record.size) ||
|
|
365
|
+
record.size < 0
|
|
366
|
+
) {
|
|
367
|
+
throw new Error('Invalid file walk entry')
|
|
368
|
+
}
|
|
369
|
+
const relative = posix.relative(rootPath, record.path)
|
|
370
|
+
if (
|
|
371
|
+
!posix.isAbsolute(record.path) ||
|
|
372
|
+
relative === '..' ||
|
|
373
|
+
relative.startsWith('../') ||
|
|
374
|
+
posix.isAbsolute(relative)
|
|
375
|
+
) {
|
|
376
|
+
throw new Error('File walk entry escaped its requested root')
|
|
377
|
+
}
|
|
378
|
+
if (++received > plan.maxEntries) throw new Error('File walk exceeded its entry bound')
|
|
379
|
+
queue.push({ path: record.path, size: record.size })
|
|
380
|
+
}
|
|
381
|
+
const ingest = (data: string) => {
|
|
382
|
+
try {
|
|
383
|
+
let start = 0
|
|
384
|
+
for (;;) {
|
|
385
|
+
const newline = data.indexOf('\n', start)
|
|
386
|
+
const end = newline < 0 ? data.length : newline
|
|
387
|
+
if (buffered.length + end - start > 65_536)
|
|
388
|
+
throw new Error('File walk record exceeded its output bound')
|
|
389
|
+
buffered += data.slice(start, end)
|
|
390
|
+
if (newline < 0) break
|
|
391
|
+
consume(buffered)
|
|
392
|
+
buffered = ''
|
|
393
|
+
start = newline + 1
|
|
394
|
+
}
|
|
395
|
+
} catch (error) {
|
|
396
|
+
reject(error)
|
|
397
|
+
}
|
|
398
|
+
wake?.()
|
|
399
|
+
}
|
|
400
|
+
const pending = Promise.resolve()
|
|
401
|
+
.then(() => {
|
|
402
|
+
controller.signal.throwIfAborted()
|
|
403
|
+
return exec('node', ['-e', FILE_WALK_PROGRAM, JSON.stringify(plan)], {
|
|
404
|
+
signal: controller.signal,
|
|
405
|
+
onOutput: ({ stream, data }) => {
|
|
406
|
+
if (stream === 'stdout') {
|
|
407
|
+
observedOutput = true
|
|
408
|
+
ingest(data)
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
})
|
|
412
|
+
})
|
|
413
|
+
.then(
|
|
414
|
+
(result) => {
|
|
415
|
+
if (controller.signal.aborted) return
|
|
416
|
+
if (!observedOutput && result.stdout) ingest(result.stdout)
|
|
417
|
+
if (buffered) reject(new Error('File walk ended with an incomplete record'))
|
|
418
|
+
if (result.stdoutTruncated || result.stderrTruncated)
|
|
419
|
+
reject(new Error('File walk transport truncated its output'))
|
|
420
|
+
if (result.timedOut || result.exitCode !== 0)
|
|
421
|
+
reject(
|
|
422
|
+
new Error(
|
|
423
|
+
result.timedOut
|
|
424
|
+
? 'File walk timed out'
|
|
425
|
+
: `File walk failed with exit code ${result.exitCode}: ${result.stderr}`,
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
if (!terminal && !controller.signal.aborted)
|
|
429
|
+
reject(new Error('File walk ended without its completion record'))
|
|
430
|
+
},
|
|
431
|
+
(error) => {
|
|
432
|
+
reject(error)
|
|
433
|
+
},
|
|
434
|
+
)
|
|
435
|
+
.finally(() => {
|
|
436
|
+
settled = true
|
|
437
|
+
wake?.()
|
|
438
|
+
})
|
|
439
|
+
try {
|
|
440
|
+
for (;;) {
|
|
441
|
+
options.signal?.throwIfAborted()
|
|
442
|
+
const entry = queue.shift()
|
|
443
|
+
if (entry) {
|
|
444
|
+
yield entry
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
if (failure) throw failure
|
|
448
|
+
if (settled) break
|
|
449
|
+
await new Promise<void>((resolve) => {
|
|
450
|
+
wake = resolve
|
|
451
|
+
})
|
|
452
|
+
wake = undefined
|
|
453
|
+
}
|
|
454
|
+
} finally {
|
|
455
|
+
dispose?.()
|
|
456
|
+
if (!settled) controller.abort(new Error('File walk consumer stopped'))
|
|
457
|
+
await pending
|
|
458
|
+
// A rejected cancellation can mean remote work is still alive. Preserve
|
|
459
|
+
// that error so the owning backend can retire the uncertain handle.
|
|
460
|
+
// biome-ignore lint/correctness/noUnsafeFinally: a failed remote cleanup must also reject iterator.return().
|
|
461
|
+
if (failure) throw failure
|
|
462
|
+
}
|
|
463
|
+
}
|
package/src/sandbox/index.ts
CHANGED
|
@@ -2,3 +2,5 @@ export { LocalSandboxProvider } from './provider/local.js'
|
|
|
2
2
|
export type { LocalSandboxProviderOptions } from './provider/local.js'
|
|
3
3
|
export { SandboxProviderFactory } from './factory.js'
|
|
4
4
|
export { assertIsolation, describeIsolation, isolationOf, missingIsolation } from './isolation.js'
|
|
5
|
+
export { walkFilesViaExec } from './file-walk.js'
|
|
6
|
+
export type { SandboxFileWalkExec } from './file-walk.js'
|
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
SandboxProvider,
|
|
41
41
|
SandboxSpawnOptions,
|
|
42
42
|
SandboxStatus,
|
|
43
|
+
SandboxWalkFilesOptions,
|
|
43
44
|
} from '../../types/sandbox/index.js'
|
|
44
45
|
import { subscribeToAbort } from '../../utils/abort.js'
|
|
45
46
|
import { generateSandboxId } from '../../utils/id.js'
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
applyEnvironmentOverrides,
|
|
50
51
|
pickEnvironmentEntries,
|
|
51
52
|
} from '../../utils/process-environment.js'
|
|
53
|
+
import { walkFilesLocally } from '../file-walk.js'
|
|
52
54
|
import { assertIsolation, describeIsolation } from '../isolation.js'
|
|
53
55
|
import type { PtyLoader } from '../terminal.js'
|
|
54
56
|
|
|
@@ -779,6 +781,17 @@ class LocalSandbox implements Sandbox {
|
|
|
779
781
|
return entries
|
|
780
782
|
}
|
|
781
783
|
|
|
784
|
+
async *walkFiles(
|
|
785
|
+
rootPath: string,
|
|
786
|
+
options: SandboxWalkFilesOptions,
|
|
787
|
+
): AsyncGenerator<SandboxFileEntry> {
|
|
788
|
+
if (this._status === 'destroyed') throw new Error(`Sandbox ${this.id} is destroyed`)
|
|
789
|
+
options.signal?.throwIfAborted()
|
|
790
|
+
const resolved = await resolveWithinAnyReal(this.roots, rootPath)
|
|
791
|
+
options.signal?.throwIfAborted()
|
|
792
|
+
yield* walkFilesLocally(resolved, options)
|
|
793
|
+
}
|
|
794
|
+
|
|
782
795
|
async destroy(_options?: SandboxDestroyOptions): Promise<void> {
|
|
783
796
|
if (this._status === 'destroyed') {
|
|
784
797
|
return
|
|
@@ -231,38 +231,33 @@ export class CompletionInbox {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
/**
|
|
234
|
-
* Wait for the next completion, or
|
|
234
|
+
* Wait for the next completion, deadline or abort, whichever comes first.
|
|
235
|
+
* Aborting releases only this waiter; work and undelivered results remain owned.
|
|
235
236
|
*
|
|
236
237
|
* Bounded on purpose. A worker that never finishes must not hold a run
|
|
237
238
|
* open forever, and the caller decides how long "long enough" is — the
|
|
238
239
|
* run's own budget is the only thing that knows.
|
|
239
240
|
*/
|
|
240
|
-
waitForArrival(timeoutMs: number): Promise<void> {
|
|
241
|
+
waitForArrival(timeoutMs: number, signal?: AbortSignal): Promise<void> {
|
|
242
|
+
if (signal?.aborted) return Promise.resolve()
|
|
241
243
|
if (this.unheard.size > 0) return Promise.resolve()
|
|
242
244
|
if (this.outstanding.size === 0) return Promise.resolve()
|
|
243
245
|
|
|
244
246
|
return new Promise((resolve) => {
|
|
247
|
+
const finish = (): void => {
|
|
248
|
+
clearTimeout(timer)
|
|
249
|
+
this.arrivals.delete(finish)
|
|
250
|
+
signal?.removeEventListener('abort', finish)
|
|
251
|
+
resolve()
|
|
252
|
+
}
|
|
245
253
|
const timer = setTimeout(finish, timeoutMs)
|
|
246
254
|
// `unref` where the runtime has it, so a pending wait never keeps
|
|
247
255
|
// a process alive past the work it was waiting for.
|
|
248
256
|
;(timer as { unref?: () => void }).unref?.()
|
|
249
257
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
resolve()
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const wake = Object.assign(
|
|
257
|
-
() => {
|
|
258
|
-
if (!wake.done) {
|
|
259
|
-
this.arrivals.delete(wake)
|
|
260
|
-
finish()
|
|
261
|
-
}
|
|
262
|
-
},
|
|
263
|
-
{ done: false },
|
|
264
|
-
)
|
|
265
|
-
this.arrivals.add(wake)
|
|
258
|
+
this.arrivals.add(finish)
|
|
259
|
+
signal?.addEventListener('abort', finish, { once: true })
|
|
260
|
+
if (signal?.aborted) finish()
|
|
266
261
|
})
|
|
267
262
|
}
|
|
268
263
|
|
|
@@ -423,7 +418,21 @@ function neutralizeNotificationDelimiter(content: string): string {
|
|
|
423
418
|
export function formatCompletionNotification(handles: readonly TaskHandle[]): string {
|
|
424
419
|
const blocks = handles.map((handle) => {
|
|
425
420
|
const durationMs = handle.completedAt ? handle.completedAt - handle.createdAt : undefined
|
|
426
|
-
const
|
|
421
|
+
const run = handle.result
|
|
422
|
+
let output = run?.result || run?.lastError || ''
|
|
423
|
+
// A hard guard can stop immediately after a tool round, before the result
|
|
424
|
+
// assembler has a final answer. Preserve visible partial prose, never reasoning.
|
|
425
|
+
if (!output && run?.stopReason && run.stopReason !== 'end_turn') {
|
|
426
|
+
const partial = [...(run.messages ?? [])]
|
|
427
|
+
.reverse()
|
|
428
|
+
.find(
|
|
429
|
+
(message) =>
|
|
430
|
+
message.role === 'assistant' &&
|
|
431
|
+
typeof message.content === 'string' &&
|
|
432
|
+
message.content.length > 0,
|
|
433
|
+
)
|
|
434
|
+
if (partial) output = `Partial output before ${run.stopReason}:\n${partial.content}`
|
|
435
|
+
}
|
|
427
436
|
const overLimit = output.length > NOTIFICATION_OUTPUT_LIMIT
|
|
428
437
|
const shown = overLimit ? output.slice(0, NOTIFICATION_OUTPUT_LIMIT) : output
|
|
429
438
|
|
|
@@ -455,6 +464,7 @@ export function formatCompletionNotification(handles: readonly TaskHandle[]): st
|
|
|
455
464
|
`task_id: ${handle.taskId}`,
|
|
456
465
|
`agent: ${handle.agentId}`,
|
|
457
466
|
`state: ${handle.state}`,
|
|
467
|
+
...(handle.result?.stopReason ? [`stop_reason: ${handle.result.stopReason}`] : []),
|
|
458
468
|
...(durationMs !== undefined ? [`duration_ms: ${durationMs}`] : []),
|
|
459
469
|
'',
|
|
460
470
|
body,
|