@namzu/sdk 3.2.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 +294 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +58 -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/agents/__tests__/supervisor-hands-down-hitl.test.d.ts +2 -0
- package/dist/agents/__tests__/supervisor-hands-down-hitl.test.d.ts.map +1 -0
- package/dist/agents/__tests__/supervisor-hands-down-hitl.test.js +112 -0
- package/dist/agents/__tests__/supervisor-hands-down-hitl.test.js.map +1 -0
- package/dist/connector/index.d.ts +1 -0
- package/dist/connector/index.d.ts.map +1 -1
- package/dist/connector/index.js +1 -0
- package/dist/connector/index.js.map +1 -1
- package/dist/connector/mcp/__tests__/prompt-as-tool.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/prompt-as-tool.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/prompt-as-tool.test.js +122 -0
- package/dist/connector/mcp/__tests__/prompt-as-tool.test.js.map +1 -0
- package/dist/connector/mcp/discovery.d.ts +15 -1
- package/dist/connector/mcp/discovery.d.ts.map +1 -1
- package/dist/connector/mcp/discovery.js +39 -1
- package/dist/connector/mcp/discovery.js.map +1 -1
- package/dist/connector/mcp/index.d.ts +2 -1
- package/dist/connector/mcp/index.d.ts.map +1 -1
- package/dist/connector/mcp/index.js +2 -1
- package/dist/connector/mcp/index.js.map +1 -1
- package/dist/connector/mcp/policy.d.ts +17 -0
- package/dist/connector/mcp/policy.d.ts.map +1 -1
- package/dist/connector/mcp/policy.js +18 -7
- package/dist/connector/mcp/policy.js.map +1 -1
- package/dist/connector/mcp/prompt-adapter.d.ts +39 -0
- package/dist/connector/mcp/prompt-adapter.d.ts.map +1 -0
- package/dist/connector/mcp/prompt-adapter.js +120 -0
- package/dist/connector/mcp/prompt-adapter.js.map +1 -0
- package/dist/manager/agent/__tests__/hitl-across-spawn.test.d.ts +2 -0
- package/dist/manager/agent/__tests__/hitl-across-spawn.test.d.ts.map +1 -0
- package/dist/manager/agent/__tests__/hitl-across-spawn.test.js +226 -0
- package/dist/manager/agent/__tests__/hitl-across-spawn.test.js.map +1 -0
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +13 -0
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/plugin/__tests__/mcp-admission.test.js +37 -2
- package/dist/plugin/__tests__/mcp-admission.test.js.map +1 -1
- package/dist/plugin/lifecycle.d.ts.map +1 -1
- package/dist/plugin/lifecycle.js +10 -0
- package/dist/plugin/lifecycle.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 +2 -2
- 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/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +1 -46
- package/dist/runtime/query/iteration/index.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/telemetry/__tests__/model-call-span.test.js +13 -8
- package/dist/telemetry/__tests__/model-call-span.test.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/agent/base.d.ts +26 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +17 -0
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/agent/task.js.map +1 -1
- 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 +57 -2
- package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +148 -0
- package/src/agents/__tests__/supervisor-hands-down-hitl.test.ts +131 -0
- package/src/connector/index.ts +1 -0
- package/src/connector/mcp/__tests__/prompt-as-tool.test.ts +157 -0
- package/src/connector/mcp/discovery.ts +48 -1
- package/src/connector/mcp/index.ts +2 -1
- package/src/connector/mcp/policy.ts +23 -8
- package/src/connector/mcp/prompt-adapter.ts +149 -0
- package/src/manager/agent/__tests__/hitl-across-spawn.test.ts +273 -0
- package/src/manager/agent/lifecycle.ts +12 -0
- package/src/plugin/__tests__/mcp-admission.test.ts +46 -2
- package/src/plugin/lifecycle.ts +11 -0
- package/src/public-runtime.ts +3 -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/runtime/query/iteration/index.ts +0 -48
- 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/telemetry/__tests__/model-call-span.test.ts +13 -7
- 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/agent/base.ts +27 -0
- package/src/types/agent/task.ts +18 -0
- package/src/types/sandbox/index.ts +10 -0
- package/src/types/toolset/index.ts +0 -25
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import type { ToolContext } from '../../../types/tool/index.js'
|
|
7
|
+
import { EditTool } from '../edit.js'
|
|
8
|
+
import { WriteFileTool } from '../write-file.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The tool's description ordered an idiom its own schema forbade.
|
|
12
|
+
*
|
|
13
|
+
* `edit`'s description says "For insertions, pass insertLine … use
|
|
14
|
+
* insertLine: 'end' to extend a file at the end". `modelInputSchema` listed
|
|
15
|
+
* only path/old_string/new_string/replace_all with
|
|
16
|
+
* `additionalProperties: false`, and `enforceModelInput: true` — so under
|
|
17
|
+
* constrained decoding the append idiom the prompt recommends was the one
|
|
18
|
+
* idiom a model could not emit.
|
|
19
|
+
*
|
|
20
|
+
* A consuming host measured the consequence over 7 days on one tenant: 94 of
|
|
21
|
+
* 159 tool failures were `edit` rejecting an `insertLine` the model had
|
|
22
|
+
* guessed a spelling for. That number is theirs and not reproducible here;
|
|
23
|
+
* the contradiction below is reproducible and is what these pin.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
let dirs: string[] = []
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
|
|
30
|
+
dirs = []
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
async function workdir(): Promise<string> {
|
|
34
|
+
const dir = await mkdtemp(join(tmpdir(), 'namzu-edit-insert-'))
|
|
35
|
+
dirs.push(dir)
|
|
36
|
+
return dir
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ctx = (workingDirectory: string): ToolContext =>
|
|
40
|
+
({
|
|
41
|
+
runId: 'run_e',
|
|
42
|
+
workingDirectory,
|
|
43
|
+
abortSignal: new AbortController().signal,
|
|
44
|
+
env: {},
|
|
45
|
+
log: () => undefined,
|
|
46
|
+
}) as unknown as ToolContext
|
|
47
|
+
|
|
48
|
+
const schema = () =>
|
|
49
|
+
(EditTool as unknown as { modelInputSchema: Record<string, unknown> }).modelInputSchema
|
|
50
|
+
|
|
51
|
+
describe('the model can emit the idiom the description recommends', () => {
|
|
52
|
+
it('advertises insertLine at all', () => {
|
|
53
|
+
const props = schema().properties as Record<string, unknown>
|
|
54
|
+
|
|
55
|
+
expect(props.insertLine).toBeDefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps the closed shape, so an invented field is still refused', () => {
|
|
59
|
+
expect(schema().additionalProperties).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not require old_string, which an insert cannot supply', () => {
|
|
63
|
+
// Requiring it is what made the append idiom unexpressible: an insert
|
|
64
|
+
// has no text to match.
|
|
65
|
+
expect(schema().required).not.toContain('old_string')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('still requires the two fields every operation needs', () => {
|
|
69
|
+
expect(schema().required).toEqual(expect.arrayContaining(['path', 'new_string']))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('admits only "end" as a string, so a synonym cannot be generated', () => {
|
|
73
|
+
const insert = (schema().properties as Record<string, { oneOf?: unknown[] }>).insertLine
|
|
74
|
+
|
|
75
|
+
// The schema is where the synonym problem is solved for a provider that
|
|
76
|
+
// constrains: `"EOF"` is not emittable because `"end"` is the only
|
|
77
|
+
// string the union admits.
|
|
78
|
+
expect(insert?.oneOf).toEqual([{ type: 'integer', minimum: 0 }, { const: 'end' }])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('an insert actually works end to end', () => {
|
|
83
|
+
it('appends with insertLine "end"', async () => {
|
|
84
|
+
const dir = await workdir()
|
|
85
|
+
await writeFile(join(dir, 'a.md'), 'first\n', 'utf-8')
|
|
86
|
+
|
|
87
|
+
const result = await EditTool.execute(
|
|
88
|
+
{ path: 'a.md', insertLine: 'end', new_string: 'second', replace_all: false },
|
|
89
|
+
ctx(dir),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(result.success).toBe(true)
|
|
93
|
+
expect(await readFile(join(dir, 'a.md'), 'utf-8')).toContain('second')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('inserts after a numbered line', async () => {
|
|
97
|
+
const dir = await workdir()
|
|
98
|
+
await writeFile(join(dir, 'a.md'), 'one\ntwo\n', 'utf-8')
|
|
99
|
+
|
|
100
|
+
await EditTool.execute(
|
|
101
|
+
{ path: 'a.md', insertLine: 1, new_string: 'inserted', replace_all: false },
|
|
102
|
+
ctx(dir),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const lines = (await readFile(join(dir, 'a.md'), 'utf-8')).split('\n')
|
|
106
|
+
expect(lines[1]).toBe('inserted')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('a synonym for the end of the file is accepted, not charged for', () => {
|
|
111
|
+
it.each(['EOF', 'append', 'last', 'End_Of_File'])('accepts %s', async (spelling) => {
|
|
112
|
+
const dir = await workdir()
|
|
113
|
+
await writeFile(join(dir, 'a.md'), 'first\n', 'utf-8')
|
|
114
|
+
|
|
115
|
+
const result = await EditTool.execute(
|
|
116
|
+
{ path: 'a.md', insertLine: spelling, new_string: 'second', replace_all: false },
|
|
117
|
+
ctx(dir),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// Liberal here and strict in the schema, which is the right way round.
|
|
121
|
+
// None of these is ambiguous; refusing one costs a full model round
|
|
122
|
+
// trip to be told a synonym.
|
|
123
|
+
expect(result.success).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('still refuses something that is not a line and not the end', async () => {
|
|
127
|
+
const dir = await workdir()
|
|
128
|
+
await writeFile(join(dir, 'a.md'), 'first\n', 'utf-8')
|
|
129
|
+
|
|
130
|
+
const result = await EditTool.execute(
|
|
131
|
+
{ path: 'a.md', insertLine: 'somewhere in the middle', new_string: 'x', replace_all: false },
|
|
132
|
+
ctx(dir),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
expect(result.success).toBe(false)
|
|
136
|
+
// The rejection names what was received, so the retry is informed.
|
|
137
|
+
expect(result.error).toContain('somewhere in the middle')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('still refuses a negative line', async () => {
|
|
141
|
+
const dir = await workdir()
|
|
142
|
+
await writeFile(join(dir, 'a.md'), 'first\n', 'utf-8')
|
|
143
|
+
|
|
144
|
+
const result = await EditTool.execute(
|
|
145
|
+
{ path: 'a.md', insertLine: -3, new_string: 'x', replace_all: false },
|
|
146
|
+
ctx(dir),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
expect(result.success).toBe(false)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('the two mutating tools agree on what a path is', () => {
|
|
154
|
+
it('write refuses a whitespace-only path, as edit already did', async () => {
|
|
155
|
+
const dir = await workdir()
|
|
156
|
+
|
|
157
|
+
const w = await WriteFileTool.execute({ path: ' ', content: 'x' }, ctx(dir))
|
|
158
|
+
const e = await EditTool.execute(
|
|
159
|
+
{ path: ' ', old_string: 'a', new_string: 'b', replace_all: false },
|
|
160
|
+
ctx(dir),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expect(w.success).toBe(false)
|
|
164
|
+
expect(e.success).toBe(false)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -19,7 +19,7 @@ function makeContext(workingDirectory: string): ToolContext {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
describe('EditTool', () => {
|
|
22
|
-
it('publishes one closed
|
|
22
|
+
it('publishes one closed contract covering both operations', () => {
|
|
23
23
|
const schema = EditTool.modelInputSchema
|
|
24
24
|
expect(schema).toEqual({
|
|
25
25
|
type: 'object',
|
|
@@ -38,12 +38,20 @@ describe('EditTool', () => {
|
|
|
38
38
|
description:
|
|
39
39
|
'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
|
|
40
40
|
},
|
|
41
|
+
insertLine: {
|
|
42
|
+
oneOf: [{ type: 'integer', minimum: 0 }, { const: 'end' }],
|
|
43
|
+
description:
|
|
44
|
+
'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.',
|
|
45
|
+
},
|
|
41
46
|
replace_all: {
|
|
42
47
|
type: 'boolean',
|
|
43
48
|
description: 'Replace every occurrence instead of requiring one unique match.',
|
|
44
49
|
},
|
|
45
50
|
},
|
|
46
|
-
|
|
51
|
+
// left out on purpose: an insert has no text to match,
|
|
52
|
+
// and requiring it is what made the append idiom the tool's own
|
|
53
|
+
// description recommends unexpressible under constrained decoding.
|
|
54
|
+
required: ['path', 'new_string'],
|
|
47
55
|
additionalProperties: false,
|
|
48
56
|
})
|
|
49
57
|
|
|
@@ -27,6 +27,86 @@ function makeContext(workingDirectory: string, tracker?: FileReadTracker): ToolC
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The published contract, pinned exactly.
|
|
32
|
+
*
|
|
33
|
+
* This was dropped when the tool gained `newStr`, and dropping it is what let
|
|
34
|
+
* the two contracts drift apart silently. They are deliberately NOT the same
|
|
35
|
+
* contract, and that is the thing worth recording: a host driving `execute`
|
|
36
|
+
* directly may pass `newStr`, and a model may not — because a model given two
|
|
37
|
+
* names for the body has to choose, and choosing is what produces the
|
|
38
|
+
* half-filled calls the closed model schema exists to prevent. Nothing said
|
|
39
|
+
* that out loud once the test asserting it was rewritten to assert the
|
|
40
|
+
* opposite.
|
|
41
|
+
*/
|
|
42
|
+
describe('WriteFileTool — the published contract', () => {
|
|
43
|
+
it('publishes one closed path + content shape to the model', () => {
|
|
44
|
+
expect(WriteFileTool.modelInputSchema).toEqual({
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
path: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
description: 'Relative path to the file to write. Must not be empty.',
|
|
50
|
+
},
|
|
51
|
+
content: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
description:
|
|
54
|
+
'Complete file body. Use "" only for an intentionally empty file. Keep under 12000 characters.',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
required: ['path', 'content'],
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
})
|
|
60
|
+
expect(WriteFileTool.enforceModelInput).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('keeps newStr off the model surface while accepting it from a host', () => {
|
|
64
|
+
const modelProperties = WriteFileTool.modelInputSchema?.properties as Record<string, unknown>
|
|
65
|
+
expect(Object.keys(modelProperties)).not.toContain('newStr')
|
|
66
|
+
|
|
67
|
+
// The execution schema is the wider one, on purpose.
|
|
68
|
+
expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md', newStr: 'body' }).success).toBe(
|
|
69
|
+
true,
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('requires a body under one name or the other', () => {
|
|
74
|
+
expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md' }).success).toBe(false)
|
|
75
|
+
// An empty string is a body — an intentionally empty file is legal.
|
|
76
|
+
expect(WriteFileTool.inputSchema.safeParse({ path: 'doc.md', content: '' }).success).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('refuses a whitespace-only path but not a path with edge whitespace', async () => {
|
|
80
|
+
expect(WriteFileTool.inputSchema.safeParse({ path: ' ', content: 'body' }).success).toBe(
|
|
81
|
+
false,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
// The refusal above must not over-reach: ' fresh.txt ' is a legal name
|
|
85
|
+
// on every filesystem this runs on, and trimming it would write to a
|
|
86
|
+
// different file than the caller asked for.
|
|
87
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
|
|
88
|
+
const result = await WriteFileTool.execute(
|
|
89
|
+
{ path: ' fresh.txt ', content: 'hello' },
|
|
90
|
+
makeContext(dir),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
expect(result.success).toBe(true)
|
|
94
|
+
expect(readFileSync(join(dir, ' fresh.txt '), 'utf-8')).toBe('hello')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('rejects an undeclared field even when execute is called directly', async () => {
|
|
98
|
+
const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
|
|
99
|
+
|
|
100
|
+
const result = await WriteFileTool.execute(
|
|
101
|
+
{ path: 'fresh.txt', content: 'hello', append: true } as never,
|
|
102
|
+
makeContext(dir),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
expect(result.success).toBe(false)
|
|
106
|
+
expect(result.error).toContain('Invalid write input')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
30
110
|
describe('WriteFileTool — read-before-overwrite invariant', () => {
|
|
31
111
|
it('writes a new file without requiring a prior read', async () => {
|
|
32
112
|
const dir = mkdtempSync(join(tmpdir(), 'namzu-write-'))
|
|
@@ -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
|