tools.spec.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929
  1. /**
  2. * Consumer API tests over a fake provider and the real policy collaborator: schemas,
  3. * validation, formatting, typed errors, intent dispatch, and observation-driven authorization.
  4. */
  5. import { describe, expect, it, vi } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join, resolve, sep } from 'node:path'
  10. import { CallId } from '@deepseek-ai/dsh-llm'
  11. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  12. import ToolRuntime, { type ToolResult } from '@deepseek-ai/dsh-tools'
  13. import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
  14. import type {
  15. FsDirEntry,
  16. FsEditOutcome,
  17. FsEditRequest,
  18. FsInfo,
  19. FsPathInfo,
  20. FsTarget,
  21. FsWriteIntent,
  22. FsWriteOutcome,
  23. } from '@deepseek-ai/dsh-fs'
  24. import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
  25. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  26. import { STREAM_MIN_SIZE } from '../src/read.ts'
  27. import { formatReadOutput } from '../src/read-render.ts'
  28. import type { FileReadOutcome } from '../src/read-render.ts'
  29. import { sessionCwd } from '../src/session-cwd.ts'
  30. import ApprovalService from '@deepseek-ai/dsh-user-approval'
  31. import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
  32. import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
  33. const testToolSignal = new AbortController().signal
  34. /** An in-memory fake provider; a test can arm a rejection on any primitive. */
  35. class FakeFs extends FileSystem {
  36. files = new Map<string, string>()
  37. rejectWith?: FsError
  38. writeIntents: (FsWriteIntent | undefined)[] = []
  39. editIntents: ({ version: FsVersion } | undefined)[] = []
  40. private throwIfArmed(): void {
  41. if (this.rejectWith) throw this.rejectWith
  42. }
  43. override async resolve(path: string): Promise<FsTarget> {
  44. return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
  45. }
  46. override processPath(target: FsTarget): string { return String(target.targetKey) }
  47. override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` }
  48. override contains(parent: FsTarget, child: FsTarget): boolean {
  49. return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`)
  50. }
  51. override async stat(target: FsTarget): Promise<FsInfo | undefined> {
  52. this.throwIfArmed()
  53. const content = this.files.get(target.targetKey)
  54. if (content === undefined) return undefined
  55. return { version: FsVersion('v1'), type: 'file', size: content.length }
  56. }
  57. override async lstat(path: string): Promise<FsPathInfo | undefined> {
  58. const content = this.files.get(`key:${path}`)
  59. if (content === undefined) return undefined
  60. return { version: FsVersion('v1'), type: 'file', size: content.length }
  61. }
  62. override async readText(target: FsTarget): Promise<string> {
  63. return this.files.get(target.targetKey) ?? ''
  64. }
  65. override async streamText(target: FsTarget): Promise<AsyncIterable<string>> {
  66. const content = this.files.get(target.targetKey) ?? ''
  67. return (async function* () { yield content })()
  68. }
  69. override async readBytes(target: FsTarget, _signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
  70. const bytes = new TextEncoder().encode(this.files.get(target.targetKey) ?? '')
  71. if (bytes.length > maxBytes) {
  72. throw new FsError(`too large: ${target.displayPath}`, 'FS_TOO_LARGE')
  73. }
  74. return bytes
  75. }
  76. override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
  77. return []
  78. }
  79. override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
  80. this.throwIfArmed()
  81. this.writeIntents.push(expected)
  82. const before = this.files.get(target.targetKey) ?? null
  83. this.files.set(target.targetKey, content)
  84. return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content }
  85. }
  86. override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise<FsEditOutcome> {
  87. this.throwIfArmed()
  88. this.editIntents.push(expected)
  89. const content = this.files.get(target.targetKey) ?? ''
  90. const after = content.split(edit.oldString).join(edit.newString)
  91. this.files.set(target.targetKey, after)
  92. return { version: FsVersion('v3'), before: content, after }
  93. }
  94. }
  95. async function setup() {
  96. const ctx = new Context()
  97. await ctx.plugin(SystemPrompt)
  98. await ctx.plugin(ToolRuntime)
  99. await ctx.plugin(FakeFs)
  100. await ctx.plugin(FsPolicy)
  101. await ctx.plugin(ToolFs)
  102. const fs = ctx.fs as FakeFs
  103. return { ctx, fs }
  104. }
  105. let callCounter = 0
  106. function call(ctx: Context, name: string, args: unknown, agent?: object) {
  107. return ctx.tools.execute({
  108. signal: testToolSignal,
  109. callId: CallId(`call-${++callCounter}`),
  110. name,
  111. arguments: args,
  112. ...agent ? { agent: agent as never } : {},
  113. })
  114. }
  115. function text(result: { content: { type: string; text?: string }[] }): string {
  116. return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
  117. }
  118. describe('session cwd resolution', () => {
  119. const execution = (cwd?: string) => cwd === undefined
  120. ? {}
  121. : { agent: { session: { header: { cwd } } } }
  122. it('retains ordinary spelling but resolves the cwd before parent traversal', () => {
  123. const cwd = process.cwd()
  124. const throughParent = `${cwd}${sep}..`
  125. expect(sessionCwd(execution() as never, 'file.txt')).toBeUndefined()
  126. expect(sessionCwd(execution(cwd) as never, 'file.txt')).toBe(cwd)
  127. expect(sessionCwd(execution(throughParent) as never, 'file.txt')).toBe(realpathSync.native(throughParent))
  128. const root = mkdtempSync(join(tmpdir(), 'dsh-tool-fs-session-cwd-'))
  129. const physical = join(root, 'physical')
  130. const link = join(root, 'link')
  131. try {
  132. mkdirSync(physical)
  133. symlinkSync(physical, link, process.platform === 'win32' ? 'junction' : 'dir')
  134. expect(sessionCwd(execution(link) as never, 'child.txt')).toBe(link)
  135. expect(sessionCwd(execution(link) as never, `..${sep}parent.txt`)).toBe(realpathSync.native(link))
  136. } finally {
  137. rmSync(root, { recursive: true, force: true })
  138. }
  139. })
  140. })
  141. describe('registration', () => {
  142. it('registers read, write, and edit', async () => {
  143. const { ctx } = await setup()
  144. expect(ctx.tools.schemas().map(s => s.name).sort()).toEqual(['edit', 'read', 'write'])
  145. })
  146. it('declares read parallel-safe while write/edit remain exclusive', async () => {
  147. const { ctx } = await setup()
  148. expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } }))
  149. .toEqual({ kind: 'parallel' })
  150. expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } }))
  151. .toEqual({ kind: 'exclusive' })
  152. expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } }))
  153. .toEqual({ kind: 'exclusive' })
  154. })
  155. it('registers prompt sections for each tool', async () => {
  156. const { ctx } = await setup()
  157. const prompt = renderPrompt(await ctx.systemPrompt.assemble())
  158. expect(prompt).toContain('Use the read tool')
  159. expect(prompt).toContain('Use the write tool')
  160. expect(prompt).toContain('Use the edit tool')
  161. })
  162. it('stays pending until ctx.fs exists (inject)', async () => {
  163. const ctx = new Context()
  164. await ctx.plugin(SystemPrompt)
  165. await ctx.plugin(ToolRuntime)
  166. await ctx.plugin(ToolFs) // no fs provider
  167. expect(ctx.tools.schemas()).toHaveLength(0)
  168. })
  169. it('unregisters everything on fiber disposal (HMR safety)', async () => {
  170. const ctx = new Context()
  171. await ctx.plugin(SystemPrompt)
  172. await ctx.plugin(ToolRuntime)
  173. await ctx.plugin(FakeFs)
  174. await ctx.plugin(FsPolicy)
  175. const fiber = await ctx.plugin(ToolFs)
  176. // Each tool contributes BOTH a schema and a prompt section; disposal must
  177. // withdraw both, not just the schemas.
  178. expect(ctx.tools.schemas()).toHaveLength(3)
  179. const sectionNames = (a: { sections: { name: string }[] }) => a.sections.map(s => s.name).sort()
  180. expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity', 'tool:edit', 'tool:read', 'tool:write'])
  181. await fiber.dispose()
  182. expect(ctx.tools.schemas()).toHaveLength(0)
  183. // Only the system-prompt plugin's own built-in sections remain.
  184. expect(sectionNames(await ctx.systemPrompt.assemble())).toEqual(['deployment:persona', 'harness:identity'])
  185. })
  186. })
  187. describe('read tool', () => {
  188. it('formats line-numbered content with a footer', async () => {
  189. const { ctx, fs } = await setup()
  190. fs.files.set('key:a.txt', 'hello\nworld')
  191. const result = await call(ctx, 'read', { file_path: 'a.txt' })
  192. expect(result.isError).toBe(false)
  193. if (result.isError) throw new Error('expected read success')
  194. expect(result.value).toEqual({
  195. path: '/abs/a.txt',
  196. offset: 1,
  197. lines: [{ number: 1, text: 'hello' }, { number: 2, text: 'world' }],
  198. totalLines: 2,
  199. })
  200. expect(text(result)).toBe(`<path>/abs/a.txt</path>
  201. <type>file</type>
  202. <content>
  203. 1: hello
  204. 2: world
  205. (End of file - total 2 lines)
  206. </content>`)
  207. })
  208. it('returns an explicit empty canonical line window for an empty file', async () => {
  209. const { ctx, fs } = await setup()
  210. fs.files.set('key:empty.txt', '')
  211. const result = await call(ctx, 'read', { file_path: 'empty.txt' })
  212. if (result.isError) throw new Error('expected empty read success')
  213. expect(result.value).toEqual({ path: '/abs/empty.txt', offset: 1, lines: [], totalLines: 0 })
  214. expect(text(result)).toContain('(End of file - total 0 lines)')
  215. })
  216. it('rejects a non-positive offset via arg validation', async () => {
  217. const { ctx } = await setup()
  218. const result = await call(ctx, 'read', { file_path: 'a.txt', offset: 0 })
  219. expect(result.isError).toBe(true)
  220. expect(text(result)).toContain('offset must be a positive integer')
  221. })
  222. it('rejects a fractional offset and a zero/negative limit', async () => {
  223. const { ctx } = await setup()
  224. for (const args of [
  225. { file_path: 'a.txt', offset: 1.5 },
  226. { file_path: 'a.txt', limit: 0 },
  227. { file_path: 'a.txt', limit: -3 },
  228. ]) {
  229. const result = await call(ctx, 'read', args)
  230. expect(result.isError, JSON.stringify(args)).toBe(true)
  231. expect(text(result)).toMatch(/must be a positive integer/)
  232. }
  233. })
  234. it('rejects a non-JSON numeric offset before tool-specific validation', async () => {
  235. const { ctx } = await setup()
  236. const result = await call(ctx, 'read', { file_path: 'a.txt', offset: Number.NaN })
  237. expect(result.isError).toBe(true)
  238. expect(text(result)).toContain('tool execution arguments must be losslessly JSON-serializable')
  239. })
  240. it('rejects a limit above the cap', async () => {
  241. const { ctx } = await setup()
  242. const result = await call(ctx, 'read', { file_path: 'a.txt', limit: 99999 })
  243. expect(result.isError).toBe(true)
  244. expect(text(result)).toContain('less than or equal to 2000')
  245. })
  246. it('rejects a blank file_path', async () => {
  247. const { ctx } = await setup()
  248. const result = await call(ctx, 'read', { file_path: ' ' })
  249. expect(result.isError).toBe(true)
  250. expect(text(result)).toContain('file_path must be a non-empty string')
  251. })
  252. it('records observed state so a follow-up edit by the same session is authorized', async () => {
  253. const { ctx, fs } = await setup()
  254. const session = { header: {} }
  255. fs.files.set('key:a.txt', 'hello')
  256. expect((await call(ctx, 'read', { file_path: 'a.txt' }, { session })).isError).toBe(false)
  257. const edited = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'hello', new_string: 'bye' }, { session })
  258. expect(edited.isError).toBe(false)
  259. expect(fs.editIntents).toEqual([{ version: 'v1' }])
  260. })
  261. it('propagates FS_NOT_FOUND for an absent file', async () => {
  262. const { ctx } = await setup()
  263. const result = await call(ctx, 'read', { file_path: 'missing.txt' })
  264. expect(result.isError).toBe(true)
  265. expect(result.error).toMatchObject({ info: { code: 'FS_NOT_FOUND' } })
  266. })
  267. it('rejects a non-regular target', async () => {
  268. const { ctx, fs } = await setup()
  269. fs.files.set('key:d', '')
  270. fs.stat = async () => ({ version: FsVersion('v1'), type: 'directory' })
  271. const result = await call(ctx, 'read', { file_path: 'd' })
  272. expect(result.isError).toBe(true)
  273. expect(result.error).toMatchObject({ info: { code: 'FS_NOT_REGULAR_FILE' } })
  274. })
  275. it('streams a large file (size at/above the cap) instead of reading whole', async () => {
  276. const { ctx, fs } = await setup()
  277. fs.files.set('key:big.txt', 'alpha\nbeta')
  278. const readSpy = vi.spyOn(fs, 'readText')
  279. const streamSpy = vi.spyOn(fs, 'streamText')
  280. fs.stat = async () => ({ version: FsVersion('v1'), type: 'file', size: STREAM_MIN_SIZE })
  281. const result = await call(ctx, 'read', { file_path: 'big.txt' })
  282. expect(result.isError).toBe(false)
  283. expect(text(result)).toContain('1: alpha')
  284. expect(streamSpy).toHaveBeenCalled()
  285. expect(readSpy).not.toHaveBeenCalled()
  286. })
  287. it('streams when the backend reports no size (never buffers a size-less file)', async () => {
  288. const { ctx, fs } = await setup()
  289. fs.files.set('key:a.txt', 'alpha')
  290. const streamSpy = vi.spyOn(fs, 'streamText')
  291. fs.stat = async () => ({ version: FsVersion('v1'), type: 'file' }) // no size
  292. const result = await call(ctx, 'read', { file_path: 'a.txt' })
  293. expect(result.isError).toBe(false)
  294. expect(streamSpy).toHaveBeenCalled()
  295. })
  296. it('surfaces a byte-capped read as a truncated footer', async () => {
  297. const { ctx, fs } = await setup()
  298. // Many long lines so the window hits the byte cap before EOF.
  299. fs.files.set('key:big.txt', Array.from({ length: 2000 }, () => 'y'.repeat(100)).join('\n'))
  300. const result = await call(ctx, 'read', { file_path: 'big.txt' })
  301. expect(result.isError).toBe(false)
  302. expect(text(result)).toContain('Output capped.')
  303. })
  304. it('attaches the structured window as presentation meta, and presentResult narrows it into a read card', async () => {
  305. const { ctx, fs } = await setup()
  306. fs.files.set('key:a.ts', 'const x = 1\nconst y = 2')
  307. const result = await call(ctx, 'read', { file_path: 'a.ts' })
  308. expect(result.isError).toBe(false)
  309. if (result.isError) throw new Error('expected read success')
  310. // The extension drives the lang hint; the window rides on persisted meta.
  311. expect(result.meta).toEqual({
  312. path: '/abs/a.ts',
  313. offset: 1,
  314. lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
  315. totalLines: 2,
  316. lang: 'ts',
  317. })
  318. const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result)
  319. expect(view).toEqual({
  320. card: 'read',
  321. path: '/abs/a.ts',
  322. offset: 1,
  323. lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
  324. totalLines: 2,
  325. lang: 'ts',
  326. content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }],
  327. })
  328. })
  329. it('omits the lang hint in meta for an extension that maps to no language', async () => {
  330. const { ctx, fs } = await setup()
  331. fs.files.set('key:notes', 'plain')
  332. const result = await call(ctx, 'read', { file_path: 'notes' })
  333. if (result.isError) throw new Error('expected read success')
  334. expect(result.meta).toEqual({ path: '/abs/notes', offset: 1, lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
  335. })
  336. })
  337. describe('formatReadOutput footer variants', () => {
  338. const base: FileReadOutcome = { offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1 }
  339. it('reports a byte-capped read', () => {
  340. const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
  341. expect(out).toContain('(Output capped. Showing lines 1-1. Use offset=2 to continue.)')
  342. })
  343. it('reports a more-remaining page', () => {
  344. const out = formatReadOutput('/f', { ...base, totalLines: 99 })
  345. expect(out).toContain('(Showing lines 1-1 of 99. Use offset=2 to continue.)')
  346. })
  347. it('reports end-of-file', () => {
  348. expect(formatReadOutput('/f', base)).toContain('(End of file - total 1 lines)')
  349. })
  350. it('renders an empty file as just the footer', () => {
  351. const out = formatReadOutput('/f', { ...base, lines: [], totalLines: 0 })
  352. expect(out).toContain('(End of file - total 0 lines)')
  353. expect(out).not.toContain(': ')
  354. })
  355. })
  356. describe('write tool', () => {
  357. it('formats a create result and uses createIfAbsent (unobserved, with the gate)', async () => {
  358. const { ctx, fs } = await setup()
  359. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' }, { session: { header: {} } })
  360. expect(result.isError).toBe(false)
  361. if (result.isError) throw new Error('expected write success')
  362. expect(result.value).toEqual({ path: '/abs/a.txt', operation: 'create', before: null, after: 'hi' })
  363. expect(text(result)).toContain('Created file')
  364. expect(fs.writeIntents).toEqual([{ kind: 'createIfAbsent' }])
  365. })
  366. it('rejects a blank file_path', async () => {
  367. const { ctx } = await setup()
  368. const result = await call(ctx, 'write', { file_path: ' ', content: 'hi' })
  369. expect(result.isError).toBe(true)
  370. expect(text(result)).toContain('file_path must be a non-empty string')
  371. })
  372. it('propagates a backend FsError as an isError result carrying its code and remedy', async () => {
  373. const { ctx, fs } = await setup()
  374. fs.rejectWith = new FsError('blocked', 'FS_STALE_VERSION')
  375. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'hi' })
  376. expect(result.isError).toBe(true)
  377. expect(result.error).toMatchObject({ info: { name: 'FsError', code: 'FS_STALE_VERSION' } })
  378. expect(text(result)).toContain('re-read the file, then retry')
  379. })
  380. })
  381. describe('edit tool', () => {
  382. it('formats a single-replacement success after a read', async () => {
  383. const { ctx, fs } = await setup()
  384. const session = { header: {} }
  385. fs.files.set('key:a.txt', 'a')
  386. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  387. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session })
  388. if (result.isError) throw new Error('expected edit success')
  389. expect(result.value).toEqual({ path: '/abs/a.txt', before: 'a', after: 'b' })
  390. expect(text(result)).toBe('The file /abs/a.txt has been updated successfully.')
  391. })
  392. it('formats the replace_all success message distinctly', async () => {
  393. const { ctx, fs } = await setup()
  394. const session = { header: {} }
  395. fs.files.set('key:a.txt', 'a a a')
  396. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  397. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b', replace_all: true }, { session })
  398. expect(text(result)).toBe('The file /abs/a.txt has been updated. All occurrences were successfully replaced.')
  399. })
  400. it('rejects identical old/new strings', async () => {
  401. const { ctx } = await setup()
  402. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'x' })
  403. expect(result.isError).toBe(true)
  404. expect(text(result)).toContain('must differ')
  405. })
  406. it('rejects an empty old_string', async () => {
  407. const { ctx } = await setup()
  408. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: '', new_string: 'x' })
  409. expect(result.isError).toBe(true)
  410. expect(text(result)).toContain('old_string must be a non-empty string')
  411. })
  412. it('rejects a blank file_path', async () => {
  413. const { ctx } = await setup()
  414. const result = await call(ctx, 'edit', { file_path: ' ', old_string: 'a', new_string: 'b' })
  415. expect(result.isError).toBe(true)
  416. expect(text(result)).toContain('file_path must be a non-empty string')
  417. })
  418. it('propagates FS_NOT_OBSERVED when the file was never read (the gate decides)', async () => {
  419. const { ctx, fs } = await setup()
  420. fs.files.set('key:a.txt', 'hello')
  421. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'a', new_string: 'b' }, { session: { header: {} } })
  422. expect(result.isError).toBe(true)
  423. expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } })
  424. })
  425. })
  426. describe('tool-owned presentation (pure presentCall)', () => {
  427. // presentCall is a pure display function of args (no I/O); it drives the
  428. // card's title/kind and the `locations` a UI follows along to.
  429. const presentCall = async (name: string, args: unknown) => {
  430. const { ctx } = await setup()
  431. return ctx.tools.get(name)?.presentCall?.(args)
  432. }
  433. const presentResult = async (name: string, args: unknown, result: ToolResult) => {
  434. const { ctx } = await setup()
  435. return ctx.tools.get(name)?.presentResult?.(args, result)
  436. }
  437. it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => {
  438. expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({
  439. card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read',
  440. locations: [{ path: 'src/a.ts', line: 12 }],
  441. })
  442. })
  443. it('read: bare title and line-1 location when offset/limit are unset', async () => {
  444. expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({
  445. card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
  446. })
  447. })
  448. it('read: completed presentation is a read card carrying the structured window with the envelope stripped', async () => {
  449. // The structured line data rides on persisted meta (the raw output object is
  450. // not on the wire); presentResult narrows it and appends the stripped text as
  451. // the no-capability `content` fallback.
  452. const meta = { path: '/tmp/a.ts', offset: 1, lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' }
  453. expect(await presentResult('read', { file_path: 'a.ts' }, {
  454. content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
  455. isError: false,
  456. meta,
  457. })).toEqual({
  458. card: 'read',
  459. path: '/tmp/a.ts',
  460. offset: 1,
  461. lines: [{ number: 1, text: 'hello' }],
  462. totalLines: 1,
  463. lang: 'ts',
  464. content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }],
  465. })
  466. // A window whose extension maps to no language omits `lang` from the card.
  467. expect(await presentResult('read', { file_path: 'notes' }, {
  468. content: [{ type: 'text', text: '<path>/tmp/notes</path>\n<type>file</type>\n<content>\nbody\n</content>' }],
  469. isError: false,
  470. meta: { path: '/tmp/notes', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 },
  471. })).toEqual({
  472. card: 'read',
  473. path: '/tmp/notes',
  474. offset: 1,
  475. lines: [{ number: 1, text: 'body' }],
  476. totalLines: 1,
  477. content: [{ type: 'text', text: 'body' }],
  478. })
  479. // Malformed envelope text with valid meta still declines (the fallback text is unavailable).
  480. expect(await presentResult('read', { file_path: 'a.ts' }, {
  481. content: [{ type: 'text', text: 'malformed replay' }],
  482. isError: false,
  483. meta,
  484. })).toBeUndefined()
  485. // Valid envelope but absent/malformed meta declines to the generic fallback.
  486. expect(await presentResult('read', { file_path: 'a.ts' }, {
  487. content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n</content>' }],
  488. isError: false,
  489. })).toBeUndefined()
  490. expect(await presentResult('read', { file_path: 'a.ts' }, {
  491. content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n</content>' }],
  492. isError: false,
  493. meta: { path: '/tmp/a.ts', lines: 'nope', totalLines: 1 },
  494. })).toBeUndefined()
  495. })
  496. it('read: completed presentation declines errors and non-single-text content', async () => {
  497. const envelope = '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\nbody\n</content>'
  498. const meta = { path: '/tmp/a.txt', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 }
  499. expect(await presentResult('read', { file_path: 'a.txt' }, {
  500. content: [{ type: 'text', text: envelope }],
  501. isError: true,
  502. meta,
  503. })).toBeUndefined()
  504. expect(await presentResult('read', { file_path: 'a.txt' }, {
  505. content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }],
  506. isError: false,
  507. meta,
  508. })).toBeUndefined()
  509. expect(await presentResult('read', { file_path: 'a.txt' }, {
  510. content: [{ type: 'reasoning', text: envelope }],
  511. isError: false,
  512. meta,
  513. })).toBeUndefined()
  514. })
  515. it('read: "from line N" window when only offset is set', async () => {
  516. expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({
  517. card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }],
  518. })
  519. })
  520. it('write: diff card (new-file style, oldText null), location', async () => {
  521. expect(await presentCall('write', { file_path: 'out.txt', content: 'hello' })).toEqual({
  522. card: 'diff', title: 'Write out.txt',
  523. diffs: [{ path: 'out.txt', oldText: null, newText: 'hello' }],
  524. locations: [{ path: 'out.txt' }],
  525. })
  526. })
  527. it('read: a limit with no offset windows from line 1', async () => {
  528. expect(await presentCall('read', { file_path: 'a.txt', limit: 10 })).toEqual({
  529. card: 'generic', title: 'Read a.txt (1 - 10)', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
  530. })
  531. })
  532. it('edit: an empty old_string maps to oldText null (a whole-file replace diff)', async () => {
  533. // presentCall runs on replay of raw logged args, which parseEditArgs does not
  534. // gate — an empty old_string must still produce a valid diff (oldText null).
  535. expect(await presentCall('edit', { file_path: 'a.txt', old_string: '', new_string: 'seed' })).toEqual({
  536. card: 'diff', title: 'Edit a.txt',
  537. diffs: [{ path: 'a.txt', oldText: null, newText: 'seed' }],
  538. locations: [{ path: 'a.txt' }],
  539. })
  540. })
  541. })
  542. describe('result-time contextual diff (meta + presentResult)', () => {
  543. // An edit records the applied contextual hunk on `tool/result` meta, and the tool's
  544. // presentResult narrows it back into a replayable `diff` result card.
  545. const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n'
  546. it('edit: execute attaches the applied hunk as meta { diffs }', async () => {
  547. const { ctx, fs } = await setup()
  548. const session = { header: {} }
  549. fs.files.set('key:a.txt', withContext)
  550. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  551. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
  552. expect(result.isError).toBe(false)
  553. expect(result.meta).toEqual({
  554. diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
  555. })
  556. })
  557. it('edit: presentResult turns the meta into a diff result card', async () => {
  558. const { ctx, fs } = await setup()
  559. const session = { header: {} }
  560. fs.files.set('key:a.txt', withContext)
  561. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  562. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
  563. const view = ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, result)
  564. expect(view).toEqual({
  565. card: 'diff', title: 'Edit a.txt',
  566. diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
  567. })
  568. })
  569. it('write OVERWRITE: execute attaches a contextual hunk; presentResult renders a diff card', async () => {
  570. const { ctx, fs } = await setup()
  571. const session = { header: {} }
  572. fs.files.set('key:a.txt', withContext)
  573. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  574. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'a\nb\nc\nNEW\nd\ne\nf\n' }, { session })
  575. expect(result.isError).toBe(false)
  576. expect(result.meta).toEqual({ diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
  577. const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'x' }, result)
  578. expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
  579. })
  580. it('write CREATE: an empty applied-diff projection still falls back to the whole-file diff card', async () => {
  581. // A create has no prior content, yet the completed replacement view must
  582. // remain a diff instead of clobbering the pending new-file diff with text.
  583. const { ctx } = await setup()
  584. const session = { header: {} }
  585. const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })
  586. expect(result.isError).toBe(false)
  587. expect(result.meta).toEqual({ diffs: [] })
  588. const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)
  589. expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] })
  590. })
  591. it('write OVERWRITE with identical content: an empty applied-diff projection falls back to a whole-file diff', async () => {
  592. const { ctx, fs } = await setup()
  593. const session = { header: {} }
  594. fs.files.set('key:a.txt', 'same\n')
  595. await call(ctx, 'read', { file_path: 'a.txt' }, { session })
  596. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session })
  597. expect(result.isError).toBe(false)
  598. expect(result.meta).toEqual({ diffs: [] })
  599. const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result)
  600. expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] })
  601. })
  602. it('presentResult returns undefined on an error result (nothing applied)', async () => {
  603. const { ctx } = await setup()
  604. const errorResult = { content: [{ type: 'text' as const, text: 'Error: boom' }], isError: true }
  605. expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, errorResult)).toBeUndefined()
  606. expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, errorResult)).toBeUndefined()
  607. })
  608. it('edit presentResult returns undefined on malformed meta (defensive narrowing)', async () => {
  609. // edit has no whole-file fallback (only a literal replacement), so a malformed
  610. // meta yields the generic "updated successfully" rendering.
  611. const { ctx } = await setup()
  612. const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } }
  613. expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, badMeta)).toBeUndefined()
  614. })
  615. it('write presentResult falls back to a whole-file diff on malformed meta (never leaks the result text)', async () => {
  616. // write always renders a diff card so the completed update can't clobber the
  617. // pending diff with the model-facing text; a malformed meta falls back to the
  618. // args-derived whole-file diff, same as a create.
  619. const { ctx } = await setup()
  620. const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } }
  621. const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, badMeta)
  622. expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'y' }] })
  623. })
  624. })
  625. describe('read caps are plugin config', () => {
  626. async function setupWith(config: ToolFs.Config) {
  627. const ctx = new Context()
  628. await ctx.plugin(SystemPrompt)
  629. await ctx.plugin(ToolRuntime)
  630. await ctx.plugin(FakeFs)
  631. await ctx.plugin(FsPolicy)
  632. await ctx.plugin(ToolFs, config)
  633. return { ctx, fs: ctx.fs as FakeFs }
  634. }
  635. it('a configured readLimit is both the default and the cap, and the schema names it', async () => {
  636. const { ctx, fs } = await setupWith({ readLimit: 2 })
  637. fs.files.set('key:a.txt', 'one\ntwo\nthree\nfour')
  638. const result = await call(ctx, 'read', { file_path: 'a.txt' })
  639. expect(text(result)).toContain('(Showing lines 1-2 of 4. Use offset=3 to continue.)')
  640. const overCap = await call(ctx, 'read', { file_path: 'a.txt', limit: 3 })
  641. expect(overCap.isError).toBe(true)
  642. expect(text(overCap)).toContain('less than or equal to 2')
  643. const readSchema = ctx.tools.schemas().find(s => s.name === 'read')
  644. expect(JSON.stringify(readSchema)).toContain('Defaults to 2.')
  645. })
  646. it('a configured readMaxLineLength truncates lines at the configured length', async () => {
  647. const { ctx, fs } = await setupWith({ readMaxLineLength: 4 })
  648. fs.files.set('key:a.txt', 'abcdefgh')
  649. const result = await call(ctx, 'read', { file_path: 'a.txt' })
  650. expect(text(result)).toContain('1: abcd... (line truncated to 4 chars)')
  651. })
  652. it('a configured readMaxBytes caps the window at the configured bytes', async () => {
  653. const { ctx, fs } = await setupWith({ readMaxBytes: 9 })
  654. fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc')
  655. const result = await call(ctx, 'read', { file_path: 'a.txt' })
  656. expect(result.isError).toBe(false)
  657. if (result.isError) throw new Error('expected read success')
  658. expect(result.value).toMatchObject({ totalLines: 3 })
  659. expect(text(result)).toContain('Output capped.')
  660. expect(text(result)).not.toContain('cccc')
  661. })
  662. it('a configured readStreamMinSize routes smaller files to the streaming path', async () => {
  663. const { ctx, fs } = await setupWith({ readStreamMinSize: 5 })
  664. fs.files.set('key:a.txt', 'alpha\nbeta')
  665. const readSpy = vi.spyOn(fs, 'readText')
  666. const streamSpy = vi.spyOn(fs, 'streamText')
  667. const result = await call(ctx, 'read', { file_path: 'a.txt' })
  668. expect(result.isError).toBe(false)
  669. expect(streamSpy).toHaveBeenCalled()
  670. expect(readSpy).not.toHaveBeenCalled()
  671. })
  672. it.each([
  673. ['readLimit', { readLimit: 0 }],
  674. ['readLimit', { readLimit: 2.5 }],
  675. ['readMaxLineLength', { readMaxLineLength: -1 }],
  676. ['readMaxBytes', { readMaxBytes: Number.NaN }],
  677. ['readStreamMinSize', { readStreamMinSize: 0 }],
  678. ] as const)('rejects a non-positive or fractional %s at load', async (name, config) => {
  679. const ctx = new Context()
  680. await ctx.plugin(SystemPrompt)
  681. await ctx.plugin(ToolRuntime)
  682. await ctx.plugin(FakeFs)
  683. await expect(ctx.plugin(ToolFs, config)).rejects.toThrow(new RegExp(`tool-fs: ${name} must be a positive integer`))
  684. })
  685. it('has no default export (namespace plugin export shape)', () => {
  686. expect('default' in ToolFs).toBe(false)
  687. })
  688. })
  689. describe('sandbox escalation API (write/edit)', () => {
  690. /** A confining fake `ctx.fs`: reports a default mode, records each per-call policy, and can arm a sandbox denial. */
  691. class SandboxingFakeFs extends FakeFs {
  692. stamped: (SandboxExecutionPolicy | undefined)[] = []
  693. override get sandboxMode(): SandboxMode {
  694. return 'workspace-write'
  695. }
  696. override async writeText(
  697. target: FsTarget,
  698. content: string,
  699. expected?: FsWriteIntent,
  700. _signal?: AbortSignal,
  701. sandboxPolicy?: SandboxExecutionPolicy,
  702. ): Promise<FsWriteOutcome> {
  703. this.stamped.push(sandboxPolicy)
  704. return super.writeText(target, content, expected)
  705. }
  706. override async editText(
  707. target: FsTarget,
  708. edit: FsEditRequest,
  709. expected?: { version: FsVersion },
  710. _signal?: AbortSignal,
  711. sandboxPolicy?: SandboxExecutionPolicy,
  712. ): Promise<FsEditOutcome> {
  713. this.stamped.push(sandboxPolicy)
  714. return super.editText(target, edit, expected)
  715. }
  716. }
  717. async function setupConfining(opts: { approval?: boolean } = {}) {
  718. const ctx = new Context()
  719. await ctx.plugin(SystemPrompt)
  720. await ctx.plugin(ToolRuntime)
  721. await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' })
  722. await ctx.plugin(SandboxingFakeFs)
  723. await ctx.plugin(FsPolicy)
  724. if (opts.approval === true) await ctx.plugin(ApprovalService)
  725. await ctx.plugin(ToolFs)
  726. return { ctx, fs: ctx.fs as SandboxingFakeFs }
  727. }
  728. /** A fake agent whose session records appends (the approval audit trail), mid-turn, carrying the given events for the fold. */
  729. function escalationAgent(events: Array<{ type: string; data?: Record<string, unknown> }> = []): object {
  730. return {
  731. id: 'agent-fs-esc',
  732. session: {
  733. header: { version: 0, id: 'sess-fs-esc', createdAt: 0, cwd: '/session-project' },
  734. events: [{ type: 'turn/start' }, ...events],
  735. append: (type: string, data: Record<string, unknown>) => { events.push({ type, data }) },
  736. },
  737. }
  738. }
  739. function fsSchema(ctx: Context, name: 'write' | 'edit') {
  740. const schema = ctx.tools.schemas().find(s => s.name === name)
  741. if (!schema) throw new Error(`${name} tool not registered`)
  742. return schema as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
  743. }
  744. it('fails load when a confining filesystem has no shared sandbox-policy resolver', async () => {
  745. const ctx = new Context()
  746. await ctx.plugin(SystemPrompt)
  747. await ctx.plugin(ToolRuntime)
  748. await ctx.plugin(SandboxingFakeFs)
  749. await expect(ctx.plugin(ToolFs)).rejects.toThrow('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing')
  750. })
  751. it('advertises no escalation fields under a non-confining backend', async () => {
  752. const { ctx } = await setup()
  753. expect(ctx.fs.sandboxMode).toBeUndefined()
  754. for (const name of ['write', 'edit'] as const) {
  755. const props = fsSchema(ctx, name).parameters.properties
  756. expect(props['sandbox_permissions']).toBeUndefined()
  757. expect(props['justification']).toBeUndefined()
  758. }
  759. })
  760. it('advertises the closed target vocabulary on write and edit under a confining backend', async () => {
  761. const { ctx } = await setupConfining()
  762. for (const name of ['write', 'edit'] as const) {
  763. const props = fsSchema(ctx, name).parameters.properties
  764. expect(props['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
  765. expect(props['justification']).toBeDefined()
  766. }
  767. })
  768. it('a plain write stamps the default mode with the calling session root', async () => {
  769. const { ctx, fs } = await setupConfining()
  770. await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
  771. expect(fs.stamped).toEqual([{ mode: 'workspace-write', workspaceRoot: resolve('/session-project') }])
  772. })
  773. it('a standing session override folds onto the stamp', async () => {
  774. const { ctx, fs } = await setupConfining()
  775. await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent([{ type: 'sandbox/mode', data: { mode: 'read-only' } }]))
  776. expect(fs.stamped).toEqual([{ mode: 'read-only', workspaceRoot: resolve('/session-project') }])
  777. })
  778. it('a denied write maps to the shared marker plus the escalation hint (isError)', async () => {
  779. const { ctx, fs } = await setupConfining()
  780. fs.rejectWith = new FsError('denied', 'FS_SANDBOX_DENIED')
  781. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
  782. expect(result.isError).toBe(true)
  783. expect(text(result)).toContain('[sandbox: file access denied under workspace-write mode]')
  784. expect(text(result)).toContain('retry this exact operation once with sandbox_permissions')
  785. })
  786. it('a non-FS_SANDBOX_DENIED provider error passes through unchanged', async () => {
  787. const { ctx, fs } = await setupConfining()
  788. fs.rejectWith = new FsError('boom', 'FS_IO_ERROR')
  789. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x' }, escalationAgent())
  790. expect(result.isError).toBe(true)
  791. expect(text(result)).toContain('boom')
  792. expect(text(result)).not.toContain('[sandbox:')
  793. })
  794. it('an approved escalation stamps the granted mode onto that write', async () => {
  795. const { ctx, fs } = await setupConfining({ approval: true })
  796. ctx.on('approval/request', () => Promise.resolve('allowed-once' as const))
  797. // Pass a signal so the escalation ask forwards it to the approval request
  798. // (the request rides the tool-execution abort signal).
  799. await ctx.tools.execute({
  800. callId: CallId('call-fs-esc-grant'),
  801. name: 'write',
  802. arguments: { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' },
  803. agent: escalationAgent() as never,
  804. signal: new AbortController().signal,
  805. })
  806. expect(fs.stamped).toEqual([{ mode: 'danger-full-access', workspaceRoot: resolve('/session-project') }])
  807. })
  808. it('a rejected escalation fails closed with its own text and never mutates', async () => {
  809. const { ctx, fs } = await setupConfining({ approval: true })
  810. ctx.on('approval/request', () => Promise.resolve('rejected' as const))
  811. const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'x', new_string: 'y', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' }, escalationAgent())
  812. expect(result.isError).toBe(true)
  813. expect(text(result)).toContain('the user rejected escalating this operation to "danger-full-access"')
  814. expect(fs.stamped).toEqual([])
  815. })
  816. it('escalation without an approval service fails closed', async () => {
  817. const { ctx } = await setupConfining()
  818. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' }, escalationAgent())
  819. expect(result.isError).toBe(true)
  820. expect(text(result)).toContain('no approval service is composed')
  821. })
  822. it('escalation with an approval service but no agent fails closed', async () => {
  823. const { ctx } = await setupConfining({ approval: true })
  824. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'why' })
  825. expect(result.isError).toBe(true)
  826. expect(text(result)).toContain('no agent to route it through')
  827. })
  828. it('rejects the escalation argument pairing (one field without the other)', async () => {
  829. const { ctx } = await setupConfining()
  830. const missing = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write' }, escalationAgent())
  831. expect(missing.isError).toBe(true)
  832. expect(text(missing)).toContain('sandbox_permissions requires a justification')
  833. })
  834. it('sandbox_permissions under a non-confining backend fails closed (unadvertised field still reaches execute)', async () => {
  835. const { ctx } = await setup()
  836. const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'x', sandbox_permissions: 'workspace-write', justification: 'why' }, escalationAgent())
  837. expect(result.isError).toBe(true)
  838. expect(text(result)).toContain('not available in this composition')
  839. })
  840. })