@namzu/sdk 3.3.0 → 4.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 +250 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +52 -2
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.d.ts +2 -0
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.d.ts.map +1 -0
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js +122 -0
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js.map +1 -0
- package/dist/connector/mcp/__tests__/prompt-as-tool.test.js +1 -1
- package/dist/connector/mcp/__tests__/prompt-as-tool.test.js.map +1 -1
- package/dist/connector/mcp/prompt-adapter.d.ts.map +1 -1
- package/dist/connector/mcp/prompt-adapter.js +16 -9
- package/dist/connector/mcp/prompt-adapter.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/public-tools.d.ts +2 -0
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +3 -0
- package/dist/public-tools.js.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/registry/tool/execute.d.ts +15 -0
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +19 -0
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/sandbox/__tests__/exec-cancellation.test.d.ts +2 -0
- package/dist/sandbox/__tests__/exec-cancellation.test.d.ts.map +1 -0
- package/dist/sandbox/__tests__/exec-cancellation.test.js +59 -0
- package/dist/sandbox/__tests__/exec-cancellation.test.js.map +1 -0
- package/dist/sandbox/provider/local.d.ts.map +1 -1
- package/dist/sandbox/provider/local.js +30 -5
- package/dist/sandbox/provider/local.js.map +1 -1
- package/dist/session/workspace/shared-run.d.ts +31 -0
- package/dist/session/workspace/shared-run.d.ts.map +1 -1
- package/dist/session/workspace/shared-run.js +31 -0
- package/dist/session/workspace/shared-run.js.map +1 -1
- package/dist/tools/__tests__/file-tool-containment.test.d.ts +2 -0
- package/dist/tools/__tests__/file-tool-containment.test.d.ts.map +1 -0
- package/dist/tools/__tests__/file-tool-containment.test.js +134 -0
- package/dist/tools/__tests__/file-tool-containment.test.js.map +1 -0
- package/dist/tools/__tests__/sandboxed-search.test.js +51 -0
- package/dist/tools/__tests__/sandboxed-search.test.js.map +1 -1
- package/dist/tools/__tests__/untrusted-envelope.test.d.ts +2 -0
- package/dist/tools/__tests__/untrusted-envelope.test.d.ts.map +1 -0
- package/dist/tools/__tests__/untrusted-envelope.test.js +55 -0
- package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -0
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js +114 -0
- package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js.map +1 -0
- package/dist/tools/builtins/__tests__/edit.test.js +9 -2
- package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/write-file.test.js +59 -0
- package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +66 -14
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/ls.d.ts +1 -1
- package/dist/tools/builtins/ls.d.ts.map +1 -1
- package/dist/tools/builtins/ls.js +95 -0
- package/dist/tools/builtins/ls.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +3 -2
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/builtins/write-file.d.ts.map +1 -1
- package/dist/tools/builtins/write-file.js +26 -12
- package/dist/tools/builtins/write-file.js.map +1 -1
- package/dist/tools/coordinator/__tests__/agent.test.js +6 -1
- package/dist/tools/coordinator/__tests__/agent.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js +47 -0
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/empty-roster.test.js +97 -0
- package/dist/tools/coordinator/__tests__/empty-roster.test.js.map +1 -0
- package/dist/tools/coordinator/agent.d.ts +22 -8
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +45 -2
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +80 -3
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/paths.d.ts +40 -0
- package/dist/tools/paths.d.ts.map +1 -1
- package/dist/tools/paths.js +70 -1
- package/dist/tools/paths.js.map +1 -1
- package/dist/tools/untrusted-envelope.d.ts +55 -0
- package/dist/tools/untrusted-envelope.d.ts.map +1 -0
- package/dist/tools/untrusted-envelope.js +65 -0
- package/dist/tools/untrusted-envelope.js.map +1 -0
- package/dist/types/sandbox/index.d.ts +10 -0
- package/dist/types/sandbox/index.d.ts.map +1 -1
- package/dist/types/sandbox/index.js.map +1 -1
- package/dist/types/toolset/index.d.ts +0 -24
- package/dist/types/toolset/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agents/SupervisorAgent.ts +51 -2
- package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +148 -0
- package/src/connector/mcp/__tests__/prompt-as-tool.test.ts +1 -1
- package/src/connector/mcp/prompt-adapter.ts +19 -9
- package/src/public-runtime.ts +1 -0
- package/src/public-tools.ts +4 -0
- package/src/registry/index.ts +1 -1
- package/src/registry/tool/execute.ts +23 -0
- package/src/sandbox/__tests__/exec-cancellation.test.ts +72 -0
- package/src/sandbox/provider/local.ts +30 -4
- package/src/session/workspace/shared-run.ts +31 -0
- package/src/tools/__tests__/file-tool-containment.test.ts +190 -0
- package/src/tools/__tests__/sandboxed-search.test.ts +66 -0
- package/src/tools/__tests__/untrusted-envelope.test.ts +82 -0
- package/src/tools/builtins/__tests__/edit-insert-reachable.test.ts +166 -0
- package/src/tools/builtins/__tests__/edit.test.ts +10 -2
- package/src/tools/builtins/__tests__/write-file.test.ts +80 -0
- package/src/tools/builtins/edit.ts +69 -14
- package/src/tools/builtins/ls.ts +102 -0
- package/src/tools/builtins/read-file.ts +3 -2
- package/src/tools/builtins/write-file.ts +26 -12
- package/src/tools/coordinator/__tests__/agent.test.ts +6 -1
- package/src/tools/coordinator/__tests__/ask-user-question.test.ts +58 -0
- package/src/tools/coordinator/__tests__/empty-roster.test.ts +118 -0
- package/src/tools/coordinator/agent.ts +72 -11
- package/src/tools/coordinator/index.ts +86 -3
- package/src/tools/paths.ts +83 -1
- package/src/tools/untrusted-envelope.ts +78 -0
- package/src/types/sandbox/index.ts +10 -0
- package/src/types/toolset/index.ts +0 -25
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
import { defineTool } from '../defineTool.js'
|
|
5
|
+
import { resolveWithinReal } from '../paths.js'
|
|
5
6
|
import { atomicWriteFile } from './atomic-write-file.js'
|
|
6
7
|
import { fingerprintContent, staleFileError } from './content-fingerprint.js'
|
|
7
8
|
import { withFileMutationLock } from './file-mutation-lock.js'
|
|
@@ -99,12 +100,40 @@ const modelInputSchema: Record<string, unknown> = {
|
|
|
99
100
|
description:
|
|
100
101
|
'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
|
|
101
102
|
},
|
|
103
|
+
insertLine: {
|
|
104
|
+
// The union the execution schema already accepts, stated so a
|
|
105
|
+
// constrained decoder can emit it. Declaring it as `oneOf` of an
|
|
106
|
+
// integer and the literal `"end"` also makes the synonym problem
|
|
107
|
+
// structurally impossible: `"EOF"`, `"append"` and `"last"` are
|
|
108
|
+
// not emittable, because `"end"` is the only string the schema
|
|
109
|
+
// admits.
|
|
110
|
+
oneOf: [{ type: 'integer', minimum: 0 }, { const: 'end' }],
|
|
111
|
+
description:
|
|
112
|
+
'Insert instead of replacing. The new_string goes after this 1-indexed line; 0 inserts before the first line; "end" appends. Omit for a find-and-replace.',
|
|
113
|
+
},
|
|
102
114
|
replace_all: {
|
|
103
115
|
type: 'boolean',
|
|
104
116
|
description: 'Replace every occurrence instead of requiring one unique match.',
|
|
105
117
|
},
|
|
106
118
|
},
|
|
107
|
-
|
|
119
|
+
// `old_string` is deliberately NOT required, and this is the fix.
|
|
120
|
+
//
|
|
121
|
+
// The tool's own description tells the model to append with `insertLine`,
|
|
122
|
+
// and this schema forbade the field while `enforceModelInput` was on — so
|
|
123
|
+
// the idiom the prompt ordered was the one idiom a constrained model could
|
|
124
|
+
// not express. Requiring `old_string` reintroduces that, since an insert
|
|
125
|
+
// has no text to match.
|
|
126
|
+
//
|
|
127
|
+
// Which of `old_string` / `insertLine` is present is decided by the two
|
|
128
|
+
// refinements on the execution schema, which already exist and name what
|
|
129
|
+
// is missing. That is a deliberate choice over a top-level `oneOf`: strict
|
|
130
|
+
// structured-output modes are least surprising with a flat object, and a
|
|
131
|
+
// discriminated union at the root is the construct most likely to be
|
|
132
|
+
// rejected or quietly ignored by a provider. The cost is that an
|
|
133
|
+
// incomplete call is now expressible and caught at execution rather than
|
|
134
|
+
// at generation — paid knowingly, because the alternative is that a
|
|
135
|
+
// working capability stays unreachable.
|
|
136
|
+
required: ['path', 'new_string'],
|
|
108
137
|
additionalProperties: false,
|
|
109
138
|
}
|
|
110
139
|
|
|
@@ -130,7 +159,7 @@ export const EditTool = defineTool({
|
|
|
130
159
|
modelInputSchema,
|
|
131
160
|
enforceModelInput: true,
|
|
132
161
|
validationErrorHint:
|
|
133
|
-
'
|
|
162
|
+
'Two shapes. Replace: {"path":"file.md","old_string":"exact unique text","new_string":"replacement text"} (optional "replace_all": true). Insert: {"path":"file.md","insertLine":"end","new_string":"text to add"} where insertLine is a non-negative line number or "end". Exactly one of old_string or insertLine.',
|
|
134
163
|
category: 'filesystem',
|
|
135
164
|
permissions: ['file_write'],
|
|
136
165
|
readOnly: false,
|
|
@@ -165,14 +194,20 @@ export const EditTool = defineTool({
|
|
|
165
194
|
}
|
|
166
195
|
}
|
|
167
196
|
|
|
168
|
-
|
|
197
|
+
// Host-side containment, on the host branch only. The sandbox has its
|
|
198
|
+
// own root and its own resolver; canonicalizing a sandbox-relative
|
|
199
|
+
// path against the HOST filesystem asks a question about the wrong
|
|
200
|
+
// machine, and answers it with whatever happens to exist there.
|
|
201
|
+
const filePath = context.sandbox
|
|
202
|
+
? undefined
|
|
203
|
+
: await resolveWithinReal(context.workingDirectory, parsed.data.path)
|
|
169
204
|
// Read-modify-write is not atomic on its own: two edits to the same
|
|
170
205
|
// path interleave their reads, and the second write lands on content
|
|
171
206
|
// the first had already replaced — so one edit vanishes and the loser
|
|
172
207
|
// reports "old_string not found", blaming the model for a race. The
|
|
173
208
|
// key spans both branches because sandbox and local are distinct
|
|
174
209
|
// files even when the path string matches.
|
|
175
|
-
const lockKey =
|
|
210
|
+
const lockKey = context.sandbox ? `sandbox:${parsed.data.path}` : `local:${filePath as string}`
|
|
176
211
|
|
|
177
212
|
return withFileMutationLock(lockKey, async () => {
|
|
178
213
|
if (context.sandbox) {
|
|
@@ -190,7 +225,8 @@ export const EditTool = defineTool({
|
|
|
190
225
|
}
|
|
191
226
|
}
|
|
192
227
|
|
|
193
|
-
const
|
|
228
|
+
const hostPath = filePath as string
|
|
229
|
+
const content = await readFile(hostPath, 'utf-8')
|
|
194
230
|
|
|
195
231
|
const result = applyEdit(content, normalized.operation)
|
|
196
232
|
if (!result.success) {
|
|
@@ -213,9 +249,9 @@ export const EditTool = defineTool({
|
|
|
213
249
|
// changed elsewhere in the file, and refusing there would
|
|
214
250
|
// reject safe edits every time anyone touched an unrelated
|
|
215
251
|
// line.
|
|
216
|
-
const seen = context.fileReadTracker?.fingerprint?.(
|
|
252
|
+
const seen = context.fileReadTracker?.fingerprint?.(hostPath)
|
|
217
253
|
if (seen !== undefined && seen !== fingerprintContent(content)) {
|
|
218
|
-
return { success: false as const, output: '', error: staleFileError(
|
|
254
|
+
return { success: false as const, output: '', error: staleFileError(hostPath) }
|
|
219
255
|
}
|
|
220
256
|
return { success: false as const, output: '', error: result.error }
|
|
221
257
|
}
|
|
@@ -223,15 +259,15 @@ export const EditTool = defineTool({
|
|
|
223
259
|
// Temp file, fsync, rename — a reader sees the old body or the new
|
|
224
260
|
// one, never a half-written one. A plain `writeFile` that fails
|
|
225
261
|
// partway leaves the user's source truncated.
|
|
226
|
-
await atomicWriteFile(
|
|
262
|
+
await atomicWriteFile(hostPath, result.content)
|
|
227
263
|
// This runtime is now the last writer, so the next edit in the same
|
|
228
264
|
// turn compares against what we just wrote rather than the read
|
|
229
265
|
// before it.
|
|
230
|
-
context.fileReadTracker?.recordRead(
|
|
266
|
+
context.fileReadTracker?.recordRead(hostPath, result.content)
|
|
231
267
|
return {
|
|
232
268
|
success: true as const,
|
|
233
|
-
output: `Edited ${
|
|
234
|
-
data: { path:
|
|
269
|
+
output: `Edited ${hostPath}: ${result.replacements} replacement(s)`,
|
|
270
|
+
data: { path: hostPath, replacements: result.replacements },
|
|
235
271
|
}
|
|
236
272
|
})
|
|
237
273
|
},
|
|
@@ -274,16 +310,35 @@ function normalizeEditInput(
|
|
|
274
310
|
}
|
|
275
311
|
}
|
|
276
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Spellings of "the end of the file" a model reaches for.
|
|
315
|
+
*
|
|
316
|
+
* Liberal here and strict in the schema, which is the right way round: the
|
|
317
|
+
* schema makes `"end"` the only emittable string for a provider that
|
|
318
|
+
* constrains, and this catches the rest for one that does not. None of these
|
|
319
|
+
* is ambiguous — accepting them is not guessing at intent, it is declining to
|
|
320
|
+
* spend a round trip on a synonym.
|
|
321
|
+
*/
|
|
322
|
+
const END_ALIASES = new Set(['end', 'eof', 'append', 'last', 'end_of_file', 'end-of-file'])
|
|
323
|
+
|
|
277
324
|
function normalizeInsertLine(
|
|
278
325
|
value: string | number,
|
|
279
326
|
): { success: true; value: number | 'end' } | { success: false; error: string } {
|
|
280
327
|
if (typeof value === 'string') {
|
|
281
|
-
|
|
328
|
+
const normalized = value.trim().toLowerCase()
|
|
329
|
+
// `"end"` is the only spelling the model-facing schema admits, so a
|
|
330
|
+
// constrained decoder cannot produce anything else. These aliases are
|
|
331
|
+
// for the providers that do not constrain: a model reading "appends to
|
|
332
|
+
// the file" reaches for the word it knows, and every one of these says
|
|
333
|
+
// the same unambiguous thing. Refusing them bought strictness and cost
|
|
334
|
+
// a full model round trip per occurrence — measured by a consuming host
|
|
335
|
+
// as the single largest source of tool-call waste in its runs.
|
|
336
|
+
if (END_ALIASES.has(normalized)) return { success: true, value: 'end' }
|
|
282
337
|
const parsed = Number(value)
|
|
283
338
|
if (Number.isInteger(parsed) && parsed >= 0) return { success: true, value: parsed }
|
|
284
339
|
return {
|
|
285
340
|
success: false,
|
|
286
|
-
error:
|
|
341
|
+
error: `insertLine must be a non-negative line number or "end" (also accepted: ${[...END_ALIASES].filter((a) => a !== 'end').join(', ')}). Received ${JSON.stringify(value)}.`,
|
|
287
342
|
}
|
|
288
343
|
}
|
|
289
344
|
return { success: true, value }
|
package/src/tools/builtins/ls.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises'
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
3
|
import { z } from 'zod'
|
|
4
|
+
import type { Sandbox } from '../../types/sandbox/index.js'
|
|
5
|
+
import type { ToolResult } from '../../types/tool/index.js'
|
|
4
6
|
import { defineTool } from '../defineTool.js'
|
|
5
7
|
import { resolveWithin } from '../paths.js'
|
|
8
|
+
import { joinPosix, relativePosix, resolveWithinPosix } from '../posix-path.js'
|
|
6
9
|
|
|
7
10
|
const inputSchema = z.object({
|
|
8
11
|
path: z.string().default('.').describe('Directory path to list. Defaults to working directory.'),
|
|
@@ -105,6 +108,101 @@ async function listRecursive(
|
|
|
105
108
|
}
|
|
106
109
|
}
|
|
107
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Enumerate inside the sandbox.
|
|
113
|
+
*
|
|
114
|
+
* This tool read the HOST filesystem through `node:fs` and referenced
|
|
115
|
+
* `context.sandbox` nowhere, so with a container or microVM backend wired in
|
|
116
|
+
* it enumerated the host — in the one builtin whose entire job is telling the
|
|
117
|
+
* model what exists. `glob` carried the identical defect, was fixed, and its
|
|
118
|
+
* fix notes that "every sibling builtin already remembers this branch". This
|
|
119
|
+
* was the sibling that did not, which is why the claim needed checking rather
|
|
120
|
+
* than reading.
|
|
121
|
+
*
|
|
122
|
+
* Worse than a leak on its own: the paths it returned were host-relative,
|
|
123
|
+
* while `read`, `grep` and `glob` all resolve INSIDE the sandbox. So every
|
|
124
|
+
* ls-to-read handoff either failed or opened a different file than the one
|
|
125
|
+
* listed. This returns the sandbox-relative coordinates the others speak.
|
|
126
|
+
*
|
|
127
|
+
* `listFiles` reports files, not directories — every backend implements it as
|
|
128
|
+
* a recursive file walk — so directories are derived from the paths. A
|
|
129
|
+
* directory holding nothing is therefore invisible here, which is a real
|
|
130
|
+
* difference from the host branch and the honest cost of having one
|
|
131
|
+
* enumeration primitive rather than one per backend.
|
|
132
|
+
*/
|
|
133
|
+
async function listInSandbox(
|
|
134
|
+
input: { path: string; all: boolean; recursive: boolean; max_depth: number },
|
|
135
|
+
sandbox: Sandbox,
|
|
136
|
+
): Promise<ToolResult> {
|
|
137
|
+
const root = resolveWithinPosix(sandbox.rootDir, input.path)
|
|
138
|
+
const entries = await sandbox.listFiles(root)
|
|
139
|
+
|
|
140
|
+
// Relative to the LISTED directory, in the sandbox's own coordinates.
|
|
141
|
+
const relativePaths: { segments: string[]; size: number }[] = []
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
const rel = relativePosix(root, joinPosix(root, entry.path))
|
|
144
|
+
if (!rel || rel.startsWith('..')) continue
|
|
145
|
+
const segments = rel.split('/').filter(Boolean)
|
|
146
|
+
if (segments.length === 0) continue
|
|
147
|
+
if (!input.all && segments.some((s) => s.startsWith('.'))) continue
|
|
148
|
+
relativePaths.push({ segments, size: entry.size })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!input.recursive) {
|
|
152
|
+
// One level: a single segment is a file, more than one means the
|
|
153
|
+
// first segment is a directory.
|
|
154
|
+
const files = new Map<string, number>()
|
|
155
|
+
const dirs = new Set<string>()
|
|
156
|
+
for (const { segments, size } of relativePaths) {
|
|
157
|
+
const head = segments[0] as string
|
|
158
|
+
if (segments.length === 1) files.set(head, size)
|
|
159
|
+
else dirs.add(head)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const lines = [
|
|
163
|
+
...[...dirs].sort().map((name) => `${name}/`),
|
|
164
|
+
...[...files.entries()]
|
|
165
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
166
|
+
.map(([name, size]) => `${name}\t${formatSize(size)}`),
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
success: true,
|
|
171
|
+
output: lines.length > 0 ? lines.join('\n') : '(empty directory)',
|
|
172
|
+
data: { count: lines.length, sandboxed: true },
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const seenDirs = new Set<string>()
|
|
177
|
+
const lines: string[] = []
|
|
178
|
+
let count = 0
|
|
179
|
+
for (const { segments, size } of relativePaths.sort((a, b) =>
|
|
180
|
+
a.segments.join('/').localeCompare(b.segments.join('/')),
|
|
181
|
+
)) {
|
|
182
|
+
if (segments.length > input.max_depth) continue
|
|
183
|
+
// Emit each parent directory once, before anything inside it.
|
|
184
|
+
for (let depth = 1; depth < segments.length; depth++) {
|
|
185
|
+
const dir = segments.slice(0, depth).join('/')
|
|
186
|
+
if (seenDirs.has(dir)) continue
|
|
187
|
+
seenDirs.add(dir)
|
|
188
|
+
if (count >= MAX_ENTRIES) break
|
|
189
|
+
lines.push(`./${dir}/`)
|
|
190
|
+
count++
|
|
191
|
+
}
|
|
192
|
+
if (count >= MAX_ENTRIES) break
|
|
193
|
+
lines.push(`./${segments.join('/')} (${formatSize(size)})`)
|
|
194
|
+
count++
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const truncated = count >= MAX_ENTRIES ? `\n(truncated at ${MAX_ENTRIES} entries)` : ''
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
success: true,
|
|
201
|
+
output: lines.length > 0 ? lines.join('\n') + truncated : '(empty directory)',
|
|
202
|
+
data: { count, truncated: count >= MAX_ENTRIES, sandboxed: true },
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
108
206
|
export const LsTool = defineTool({
|
|
109
207
|
name: 'ls',
|
|
110
208
|
description:
|
|
@@ -117,6 +215,10 @@ export const LsTool = defineTool({
|
|
|
117
215
|
concurrencySafe: true,
|
|
118
216
|
|
|
119
217
|
async execute(input, context) {
|
|
218
|
+
if (context.sandbox) {
|
|
219
|
+
return await listInSandbox(input, context.sandbox)
|
|
220
|
+
}
|
|
221
|
+
|
|
120
222
|
// Contained, not merely resolved — see `resolveWithin`.
|
|
121
223
|
const targetPath = resolveWithin(context.workingDirectory, input.path)
|
|
122
224
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
|
-
import { extname
|
|
2
|
+
import { extname } from 'node:path'
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
import { defineTool } from '../defineTool.js'
|
|
5
|
+
import { resolveWithinReal } from '../paths.js'
|
|
5
6
|
|
|
6
7
|
const inputSchema = z.object({
|
|
7
8
|
path: z.string().describe('Path to the file to read (absolute or relative)'),
|
|
@@ -74,7 +75,7 @@ export const ReadFileTool = defineTool({
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
const filePath =
|
|
78
|
+
const filePath = await resolveWithinReal(context.workingDirectory, input.path)
|
|
78
79
|
const buffer = await readFile(filePath)
|
|
79
80
|
const binaryGuidance = describeStructuredBinaryRead(filePath, buffer)
|
|
80
81
|
if (binaryGuidance) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { access, mkdir } from 'node:fs/promises'
|
|
2
|
-
import { dirname
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
import type { ToolContext } from '../../types/tool/index.js'
|
|
5
5
|
import { defineTool } from '../defineTool.js'
|
|
6
|
+
import { resolveWithinReal } from '../paths.js'
|
|
6
7
|
import { atomicWriteFile } from './atomic-write-file.js'
|
|
7
8
|
import { withFileMutationLock } from './file-mutation-lock.js'
|
|
8
9
|
|
|
@@ -11,6 +12,12 @@ const inputSchema = z
|
|
|
11
12
|
path: z
|
|
12
13
|
.string()
|
|
13
14
|
.min(1)
|
|
15
|
+
// `.min(1)` alone admits `" "`, which resolves to the working
|
|
16
|
+
// directory itself and turns a write into a directory-write error
|
|
17
|
+
// nobody can read. `edit` has refused this since it was written;
|
|
18
|
+
// the two tools disagreeing on the same input is the kind of gap a
|
|
19
|
+
// model finds and a reviewer does not.
|
|
20
|
+
.refine((value) => value.trim().length > 0, 'Path must not be empty.')
|
|
14
21
|
.describe(
|
|
15
22
|
'Relative path to the file to write (e.g. "outputs/report.md"). Required. Must be a non-empty string.',
|
|
16
23
|
),
|
|
@@ -87,11 +94,17 @@ export const WriteFileTool = defineTool({
|
|
|
87
94
|
}
|
|
88
95
|
const valid = parsed.data
|
|
89
96
|
const content = valid.content ?? valid.newStr ?? ''
|
|
90
|
-
|
|
97
|
+
// Host-side containment, on the host branch only. The sandbox has its
|
|
98
|
+
// own root and its own resolver; canonicalizing a sandbox-relative
|
|
99
|
+
// path against the HOST filesystem asks a question about the wrong
|
|
100
|
+
// machine and answers it with whatever happens to exist there.
|
|
101
|
+
const filePath = context.sandbox
|
|
102
|
+
? undefined
|
|
103
|
+
: await resolveWithinReal(context.workingDirectory, valid.path)
|
|
91
104
|
// The exists-check and the write are a check-then-act pair. Unlocked,
|
|
92
105
|
// two writers both see "absent", both skip the read-before-overwrite
|
|
93
106
|
// guard, and the second silently discards the first.
|
|
94
|
-
const lockKey =
|
|
107
|
+
const lockKey = context.sandbox ? `sandbox:${valid.path}` : `local:${filePath as string}`
|
|
95
108
|
|
|
96
109
|
return withFileMutationLock(lockKey, async () => {
|
|
97
110
|
if (context.sandbox) {
|
|
@@ -109,23 +122,24 @@ export const WriteFileTool = defineTool({
|
|
|
109
122
|
}
|
|
110
123
|
}
|
|
111
124
|
|
|
112
|
-
const
|
|
125
|
+
const hostPath = filePath as string
|
|
126
|
+
const localExists = await pathExists(hostPath)
|
|
113
127
|
if (localExists) {
|
|
114
|
-
const guard = enforceReadBeforeOverwrite(context,
|
|
128
|
+
const guard = enforceReadBeforeOverwrite(context, hostPath)
|
|
115
129
|
if (guard) return guard
|
|
116
130
|
}
|
|
117
131
|
|
|
118
|
-
await mkdir(dirname(
|
|
132
|
+
await mkdir(dirname(hostPath), { recursive: true })
|
|
119
133
|
// Temp file, fsync, rename. A plain write that fails partway
|
|
120
134
|
// leaves the destination truncated — and this tool overwrites a
|
|
121
135
|
// whole file, so the truncation is the user's previous work.
|
|
122
|
-
await atomicWriteFile(
|
|
123
|
-
context.fileReadTracker?.recordRead(
|
|
136
|
+
await atomicWriteFile(hostPath, content)
|
|
137
|
+
context.fileReadTracker?.recordRead(hostPath)
|
|
124
138
|
|
|
125
139
|
return {
|
|
126
140
|
success: true as const,
|
|
127
|
-
output: `File written successfully: ${
|
|
128
|
-
data: { path:
|
|
141
|
+
output: `File written successfully: ${hostPath} (${content.length} chars)`,
|
|
142
|
+
data: { path: hostPath, size: content.length },
|
|
129
143
|
}
|
|
130
144
|
})
|
|
131
145
|
},
|
|
@@ -144,9 +158,9 @@ function enforceReadBeforeOverwrite(
|
|
|
144
158
|
}
|
|
145
159
|
}
|
|
146
160
|
|
|
147
|
-
async function pathExists(
|
|
161
|
+
async function pathExists(hostPath: string): Promise<boolean> {
|
|
148
162
|
try {
|
|
149
|
-
await access(
|
|
163
|
+
await access(hostPath)
|
|
150
164
|
return true
|
|
151
165
|
} catch {
|
|
152
166
|
return false
|
|
@@ -90,7 +90,12 @@ describe('buildAgentTool', () => {
|
|
|
90
90
|
)
|
|
91
91
|
|
|
92
92
|
expect(result.success).toBe(true)
|
|
93
|
-
|
|
93
|
+
// The subagent's text is present and unaltered, inside a frame that
|
|
94
|
+
// says whose text it is. A host reading the result programmatically
|
|
95
|
+
// gets it verbatim off `data.result` instead.
|
|
96
|
+
expect(result.output).toContain('final report text')
|
|
97
|
+
expect(result.output.startsWith('<namzu-untrusted kind="agent-result"')).toBe(true)
|
|
98
|
+
expect(result.data).toMatchObject({ result: 'final report text' })
|
|
94
99
|
})
|
|
95
100
|
|
|
96
101
|
it('reports failure when run status is failed even though task state is completed', async () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
+
import { ToolRegistry } from '../../../registry/tool/execute.js'
|
|
3
4
|
import type { TaskGateway } from '../../../types/agent/gateway.js'
|
|
4
5
|
import type {
|
|
5
6
|
HITLDecisionRequest,
|
|
@@ -175,6 +176,63 @@ describe('coordinator ask_user_question input schema', () => {
|
|
|
175
176
|
})
|
|
176
177
|
})
|
|
177
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The model-facing schema is a module-level object shared by every tool this
|
|
181
|
+
* builder produces, so it is copied on the way out — TWICE, at two independent
|
|
182
|
+
* boundaries, and each test below pins exactly one of them. The builder clones
|
|
183
|
+
* when it attaches the schema to the definition; the registry clones again
|
|
184
|
+
* when it renders a definition for the wire. Removing either clone fails one
|
|
185
|
+
* of these and not the other, which is how they were confirmed non-vacuous.
|
|
186
|
+
*
|
|
187
|
+
* Both clones survived a version in which neither test did: the defences were
|
|
188
|
+
* still in the source and nothing pinned them, which is precisely the state
|
|
189
|
+
* where a later edit drops one and no gate objects.
|
|
190
|
+
*
|
|
191
|
+
* The failure they prevent is not hypothetical. A caller that mutates a schema
|
|
192
|
+
* it received — normalizing it for one provider, adding a legacy alias — would
|
|
193
|
+
* otherwise be editing the object every OTHER tool instance in the process is
|
|
194
|
+
* also handing out, including definitions already registered in another run.
|
|
195
|
+
*/
|
|
196
|
+
describe('coordinator ask_user_question canonical schema isolation', () => {
|
|
197
|
+
const noopHandler: ResumeHandler = async () => ({ action: 'continue' })
|
|
198
|
+
|
|
199
|
+
it('returns a fresh schema on every render from the registry boundary', () => {
|
|
200
|
+
const registry = new ToolRegistry()
|
|
201
|
+
registry.register(askTool(noopHandler))
|
|
202
|
+
|
|
203
|
+
const first = registry.toLLMTools()[0]?.function.parameters
|
|
204
|
+
expect(first).toEqual(askTool(noopHandler).modelInputSchema)
|
|
205
|
+
const firstProperties = first?.properties as Record<string, unknown>
|
|
206
|
+
firstProperties.legacy_options = { type: 'string' }
|
|
207
|
+
|
|
208
|
+
const next = registry.toLLMTools()[0]?.function.parameters
|
|
209
|
+
expect(JSON.stringify(next)).not.toContain('legacy_options')
|
|
210
|
+
expect(next).toEqual(askTool(noopHandler).modelInputSchema)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('isolates the schema between two results of the public builder', () => {
|
|
214
|
+
const first = askTool(noopHandler)
|
|
215
|
+
const second = askTool(noopHandler)
|
|
216
|
+
expect(first.modelInputSchema).not.toBe(second.modelInputSchema)
|
|
217
|
+
|
|
218
|
+
const firstProperties = first.modelInputSchema?.properties as Record<string, unknown>
|
|
219
|
+
firstProperties.legacy_options = { type: 'string' }
|
|
220
|
+
|
|
221
|
+
expect(JSON.stringify(second.modelInputSchema)).not.toContain('legacy_options')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('rejects an unknown property inside an option, not only at the root', () => {
|
|
225
|
+
const tool = askTool(noopHandler)
|
|
226
|
+
|
|
227
|
+
expect(
|
|
228
|
+
tool.inputSchema.safeParse({
|
|
229
|
+
...baseInput,
|
|
230
|
+
options: [{ label: 'A', weight: 3 }, { label: 'B' }],
|
|
231
|
+
}).success,
|
|
232
|
+
).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
178
236
|
describe('coordinator ask_user_question request synthesis', () => {
|
|
179
237
|
it('synthesizes the park request from its own context — questionId = toolUseId', async () => {
|
|
180
238
|
const { requests } = await executeAsk({
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { TaskGateway } from '../../../types/agent/gateway.js'
|
|
3
|
+
import { buildAgentTool } from '../agent.js'
|
|
4
|
+
import { buildCoordinatorTools } from '../index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `create_task` used to widen its `agent_id` parameter from the roster enum to
|
|
8
|
+
* a bare string whenever the roster was empty — so the one configuration that
|
|
9
|
+
* says "this run may delegate to nobody" was the one that let the model name
|
|
10
|
+
* anybody. Degrading a closed list to an open one because the list is empty is
|
|
11
|
+
* failing open (CWE-636), and Saltzer & Schroeder named the rule it breaks in
|
|
12
|
+
* 1975: fail-safe defaults, §I.A.3(b).
|
|
13
|
+
*
|
|
14
|
+
* The control is that the tool is not mounted at all — refusing per call would
|
|
15
|
+
* reach the same verdict while paying prompt tokens and an iteration for it.
|
|
16
|
+
* The schema stays closed underneath as defence-in-depth for a definition
|
|
17
|
+
* built directly.
|
|
18
|
+
*
|
|
19
|
+
* What was reachable before is worth stating, because it is why this is a
|
|
20
|
+
* break worth taking: the id went to the gateway, which resolves against an
|
|
21
|
+
* `AgentManager` that is typically SHARED — so a name the host deliberately
|
|
22
|
+
* left out of `agentIds` could still launch if it happened to be registered
|
|
23
|
+
* there.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const gateway = {
|
|
27
|
+
dispatch: async () => {
|
|
28
|
+
throw new Error('gateway must not be reached — the schema refuses first')
|
|
29
|
+
},
|
|
30
|
+
listTasks: () => [],
|
|
31
|
+
cancel: () => undefined,
|
|
32
|
+
} as unknown as TaskGateway
|
|
33
|
+
|
|
34
|
+
function toolsFor(allowedAgentIds: string[], resumeHandler?: unknown) {
|
|
35
|
+
return buildCoordinatorTools({
|
|
36
|
+
gateway,
|
|
37
|
+
workingDirectory: '/tmp/test',
|
|
38
|
+
allowedAgentIds,
|
|
39
|
+
...(resumeHandler ? { resumeHandler: resumeHandler as never, runId: 'run_1' as never } : {}),
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createTaskFor(allowedAgentIds: string[]) {
|
|
44
|
+
const tool = toolsFor(allowedAgentIds).find((t) => t.name === 'create_task')
|
|
45
|
+
if (!tool) throw new Error('create_task missing from coordinator builder')
|
|
46
|
+
return tool
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('create_task delegate roster', () => {
|
|
50
|
+
it('does not mount create_task at all when the roster is empty', () => {
|
|
51
|
+
const names = toolsFor([]).map((t) => t.name)
|
|
52
|
+
|
|
53
|
+
expect(names).not.toContain('create_task')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('still mounts the coordinator tools that do not read the roster', () => {
|
|
57
|
+
// "No delegates, but still planning and a human channel" is a
|
|
58
|
+
// supported configuration, so this omits one tool rather than
|
|
59
|
+
// refusing to build.
|
|
60
|
+
const names = toolsFor([], async () => ({ action: 'approve_tools' })).map((t) => t.name)
|
|
61
|
+
|
|
62
|
+
expect(names).toContain('agent_task_list')
|
|
63
|
+
expect(names).toContain('ask_user_question')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('mounts create_task once the roster has an entry', () => {
|
|
67
|
+
expect(toolsFor(['worker']).map((t) => t.name)).toContain('create_task')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('still admits an id that is on a non-empty roster', () => {
|
|
71
|
+
const parsed = createTaskFor(['worker']).inputSchema.safeParse({
|
|
72
|
+
agent_id: 'worker',
|
|
73
|
+
prompt: 'do the thing',
|
|
74
|
+
description: 'a task',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(parsed.success).toBe(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('still refuses an id that is off a non-empty roster', () => {
|
|
81
|
+
const parsed = createTaskFor(['worker']).inputSchema.safeParse({
|
|
82
|
+
agent_id: 'some-other-agent',
|
|
83
|
+
prompt: 'do the thing',
|
|
84
|
+
description: 'a task',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(parsed.success).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('the Agent tool carries the same closed roster', () => {
|
|
92
|
+
it('refuses to build at all with no delegates', () => {
|
|
93
|
+
// Unlike the coordinator builder, this one returns exactly one tool
|
|
94
|
+
// and that tool IS the delegation surface, so "do not mount" and "do
|
|
95
|
+
// not build" are the same statement.
|
|
96
|
+
expect(() =>
|
|
97
|
+
buildAgentTool({ gateway, workingDirectory: '/tmp/test', allowedAgentIds: [] }),
|
|
98
|
+
).toThrow(/at least one entry in allowedAgentIds/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('refuses an off-roster subagent at execution, not only in the schema', async () => {
|
|
102
|
+
// `execute` is reachable without the registry, so a schema-only check
|
|
103
|
+
// leaves the roster unenforced on that path.
|
|
104
|
+
const tool = buildAgentTool({
|
|
105
|
+
gateway,
|
|
106
|
+
workingDirectory: '/tmp/test',
|
|
107
|
+
allowedAgentIds: ['worker'],
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const result = await tool.execute(
|
|
111
|
+
{ description: 'x', prompt: 'y', subagent_type: 'not-on-the-roster' },
|
|
112
|
+
{ workingDirectory: '/tmp/test' } as never,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(result.success).toBe(false)
|
|
116
|
+
expect(result.error).toMatch(/Unknown subagent_type/)
|
|
117
|
+
})
|
|
118
|
+
})
|