tools.spec.ts 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038
  1. import { mkdtempSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import { Context } from 'cordis'
  6. import { CallId } from '@deepseek-ai/dsh-llm'
  7. import { BashExecutor } from '@deepseek-ai/dsh-bash'
  8. import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
  9. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  10. import ToolRegistry from '@deepseek-ai/dsh-tools'
  11. import AgentRegistry from '@deepseek-ai/dsh-agent'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import TaskService from '@deepseek-ai/dsh-tasks'
  14. import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
  15. import ApprovalService from '@deepseek-ai/dsh-user-approval'
  16. import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
  17. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  18. import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
  19. import { processOutcome, renderProcessRead, renderResult } from '@deepseek-ai/dsh-tool-bash'
  20. const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
  21. /** Foreground-only harness: no task runtime (backgrounding fails loud here). */
  22. async function setup() {
  23. const ctx = new Context()
  24. await ctx.plugin(SystemPrompt)
  25. await ctx.plugin(ToolRegistry)
  26. await ctx.plugin(AgentRegistry)
  27. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
  28. ;(ctx.bash as LocalBashExecutor).internals = { spillDir }
  29. await ctx.plugin(ToolBash)
  30. return ctx
  31. }
  32. /** Full harness: the generic task runtime + its control surface, then the bash tool. */
  33. async function setupWithTasks() {
  34. const ctx = new Context()
  35. await ctx.plugin(SystemPrompt)
  36. await ctx.plugin(ToolRegistry)
  37. await ctx.plugin(AgentRegistry)
  38. await ctx.plugin(TaskService)
  39. await ctx.plugin(ToolTasks)
  40. await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
  41. ;(ctx.bash as LocalBashExecutor).internals = { spillDir }
  42. await ctx.plugin(ToolBash)
  43. return ctx
  44. }
  45. /**
  46. * Build a fake {@link Agent} whose session token is `sessionId` and REGISTER it
  47. * in `ctx.agents` (an owned task registration attaches the awaited owner
  48. * cleanup via `ctx.agents.onCleanup`, which requires a live registered agent).
  49. * The agent id is deliberately DIFFERENT from the session token so a
  50. * wrong-field match fails the test.
  51. */
  52. function registerFakeAgent(ctx: Context, sessionId: string): Agent {
  53. const agent = { id: `agent-${sessionId}`, inject: () => {}, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
  54. ctx.agents.register(agent)
  55. return agent
  56. }
  57. let callCounter = 0
  58. function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
  59. return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
  60. }
  61. function text(result: { content: { type: string; text?: string }[] }): string {
  62. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  63. }
  64. async function callUntilText(
  65. ctx: Context,
  66. name: string,
  67. args: unknown,
  68. expected: string,
  69. timeoutMs = 5_000,
  70. ): Promise<Awaited<ReturnType<typeof call>>> {
  71. const deadline = Date.now() + timeoutMs
  72. let last: Awaited<ReturnType<typeof call>> | undefined
  73. while (Date.now() < deadline) {
  74. last = await call(ctx, name, args)
  75. if (text(last).includes(expected)) return last
  76. await new Promise(resolve => setTimeout(resolve, 20))
  77. }
  78. throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`)
  79. }
  80. class RecordingSandboxExecutor extends BashExecutor {
  81. readonly modes: Array<string | undefined> = []
  82. override get sandboxMode() {
  83. return 'read-only' as const
  84. }
  85. resolve(request: BashExecRequest): BashExecSpec {
  86. return {
  87. command: request.command,
  88. workdir: request.workdir ?? process.cwd(),
  89. timeoutMs: request.timeoutMs ?? 1000,
  90. ...request.signal ? { signal: request.signal } : {},
  91. sandboxMode: request.sandboxMode ?? 'read-only',
  92. }
  93. }
  94. run(spec: BashExecSpec): Promise<BashRunResult> {
  95. this.modes.push(spec.sandboxMode)
  96. return Promise.resolve({
  97. exitCode: 0,
  98. signal: null,
  99. timedOut: false,
  100. aborted: false,
  101. timeoutMs: spec.timeoutMs,
  102. stdout: { text: 'ok', truncated: false },
  103. stderr: { text: '', truncated: false },
  104. sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
  105. })
  106. }
  107. start(spec: BashExecSpec): BashProcess {
  108. this.modes.push(spec.sandboxMode)
  109. return {
  110. command: spec.command,
  111. status: 'completed',
  112. exitCode: 0,
  113. signal: null,
  114. done: Promise.resolve(),
  115. sandbox: { mode: spec.sandboxMode ?? 'read-only', denied: false },
  116. readOutput: () => ({ delta: '', lossy: false }),
  117. kill: () => false,
  118. }
  119. }
  120. }
  121. async function setupSandboxed(withApproval = false) {
  122. const ctx = new Context()
  123. await ctx.plugin(SystemPrompt)
  124. await ctx.plugin(ToolRegistry)
  125. await ctx.plugin(AgentRegistry)
  126. await ctx.plugin(TaskService)
  127. await ctx.plugin(ToolTasks)
  128. await ctx.plugin(RecordingSandboxExecutor)
  129. if (withApproval) await ctx.plugin(ApprovalService)
  130. await ctx.plugin(ToolBash)
  131. return { ctx, bash: ctx.bash as RecordingSandboxExecutor }
  132. }
  133. function sandboxAgent(mode?: 'read-only' | 'workspace-write' | 'danger-full-access'): Agent {
  134. const events: Array<{ type: string; data?: Record<string, unknown> }> = [{ type: 'turn/start' }]
  135. if (mode !== undefined) events.push({ type: 'bash/sandbox-mode', data: { mode } })
  136. return {
  137. id: 'sandbox-agent',
  138. session: {
  139. header: { version: 0, id: 'sandbox-session', createdAt: 0 },
  140. events,
  141. append: (type: string, data: Record<string, unknown>) => {
  142. const event = { type, data }
  143. events.push(event)
  144. return event
  145. },
  146. },
  147. } as unknown as Agent
  148. }
  149. describe('bash tool', () => {
  150. it('returns stdout for a successful command', async () => {
  151. const ctx = await setup()
  152. const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' })
  153. expect(result.isError).toBe(false)
  154. expect(text(result)).toBe('hello\n')
  155. })
  156. it('reports (no output) for silent commands', async () => {
  157. const ctx = await setup()
  158. const result = await call(ctx, 'bash', { command: 'true', description: 'test command' })
  159. expect(text(result)).toBe('(no output)')
  160. })
  161. it('marks stderr sections', async () => {
  162. const ctx = await setup()
  163. const result = await call(ctx, 'bash', { command: 'echo out; echo err >&2', description: 'test command' })
  164. expect(text(result)).toBe('out\n[stderr]\nerr\n')
  165. expect(result.isError).toBe(false)
  166. })
  167. it('reports non-zero exits without isError', async () => {
  168. const ctx = await setup()
  169. const result = await call(ctx, 'bash', { command: 'echo failing; exit 3', description: 'test command' })
  170. expect(result.isError).toBe(false)
  171. expect(text(result)).toBe('failing\n[exit code: 3]')
  172. })
  173. it('reports timeout kills with both markers (timeout first)', async () => {
  174. const ctx = await setup()
  175. const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', timeoutMs: 100 })
  176. expect(result.isError).toBe(false)
  177. expect(text(result)).toBe('(no output)\n[timed out after 100ms]\n[killed by signal: SIGTERM]')
  178. })
  179. it('reports a timeout even when the command traps the signal and exits 0', async () => {
  180. // The signal-independent timeout marker: a trapped SIGTERM that exits 0
  181. // after our timer fired must NOT look like a clean success. (bash may
  182. // print "Terminated" to stderr for the killed sleep — environment
  183. // dependent — so assert the marker, not the exact body.)
  184. const ctx = await setup()
  185. const result = await call(ctx, 'bash', { command: 'trap "exit 0" TERM; sleep 60', description: 'test command', timeoutMs: 100 })
  186. expect(result.isError).toBe(false)
  187. expect(text(result)).toContain('[timed out after 100ms]')
  188. expect(text(result)).not.toContain('[exit code:')
  189. })
  190. it('reports truncation with the spill path', async () => {
  191. const ctx = new Context()
  192. await ctx.plugin(SystemPrompt)
  193. await ctx.plugin(ToolRegistry)
  194. await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
  195. ;(ctx.bash as LocalBashExecutor).internals = { spillDir }
  196. await ctx.plugin(ToolBash)
  197. const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
  198. expect(text(result)).toContain('[output truncated; full output: ')
  199. expect(text(result)).toContain('line-0100')
  200. })
  201. it('honors workdir', async () => {
  202. const ctx = await setup()
  203. const result = await call(ctx, 'bash', { command: 'pwd', description: 'test command', workdir: '/tmp' })
  204. expect(text(result).trim()).toMatch(/\/tmp$/)
  205. })
  206. it('surfaces spawn failures as isError', async () => {
  207. const ctx = await setup()
  208. const result = await call(ctx, 'bash', { command: 'true', description: 'test command', workdir: '/nonexistent-dsh' })
  209. expect(result.isError).toBe(true)
  210. expect(text(result)).toMatch(/ENOENT/)
  211. })
  212. it('surfaces foreground aborts as isError', async () => {
  213. const ctx = await setup()
  214. const controller = new AbortController()
  215. const pending = ctx.tools.execute({
  216. callId: CallId('call-abort'),
  217. name: 'bash',
  218. arguments: { command: 'sleep 60', description: 'test command' },
  219. signal: controller.signal,
  220. })
  221. setTimeout(() => { controller.abort() }, 50)
  222. const result = await pending
  223. expect(result.isError).toBe(true)
  224. expect(text(result)).toMatch(/aborted/)
  225. })
  226. // Type and required-key violations are rejected by the harness
  227. // (defineTool validates against the SchemaSpec — the arg-validation RFC) before execute.
  228. it.each([
  229. [{}, /missing required property "command"/],
  230. [{ command: 42, description: 'd' }, /"command" must be a string/],
  231. [{ command: 'x' }, /missing required property "description"/],
  232. [{ command: 'x', description: 7 }, /"description" must be a string/],
  233. [{ command: 'x', description: 'd', timeoutMs: 'soon' }, /"timeoutMs" must be a number/],
  234. [{ command: 'x', description: 'd', workdir: 7 }, /"workdir" must be a string/],
  235. [{ command: 'x', description: 'd', run_in_background: 'yes' }, /"run_in_background" must be a boolean/],
  236. ])('rejects schema-invalid args %j', async (args, pattern) => {
  237. const ctx = await setup()
  238. const result = await call(ctx, 'bash', args)
  239. expect(result.isError).toBe(true)
  240. expect(text(result)).toMatch(pattern)
  241. })
  242. // Value constraints the SchemaSpec can't express stay in the tool body.
  243. it.each([
  244. [{ command: ' ', description: 'd' }, /invalid command/],
  245. [{ command: 'x', description: ' ' }, /invalid description/],
  246. [{ command: 'x', description: 'd', timeoutMs: -1 }, /invalid timeoutMs/],
  247. [{ command: 'x', description: 'd', timeoutMs: Number.NaN }, /invalid timeoutMs/],
  248. ])('rejects value-invalid args %j', async (args, pattern) => {
  249. const ctx = await setup()
  250. const result = await call(ctx, 'bash', args)
  251. expect(result.isError).toBe(true)
  252. expect(text(result)).toMatch(pattern)
  253. })
  254. it('registers the bash schema with run_in_background exposed by default', async () => {
  255. const ctx = await setup()
  256. const schemas = ctx.tools.schemas()
  257. expect(schemas.map(schema => schema.name)).toEqual(['bash'])
  258. const bashSchema = schemas[0]!
  259. expect(bashSchema.parameters).toMatchObject({
  260. type: 'object',
  261. required: ['command', 'description'],
  262. })
  263. expect(Object.keys(bashSchema.parameters.properties as Record<string, unknown>))
  264. .toContain('run_in_background')
  265. expect(bashSchema.description).toContain('task_output')
  266. })
  267. it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => {
  268. const ctx = await setup()
  269. const assembly = await ctx.systemPrompt.assemble()
  270. const section = assembly.sections.find(s => s.name === 'tool:bash')
  271. expect(section?.order).toBe(105)
  272. expect(section?.text).toContain('[exit code: N]')
  273. })
  274. it('unregisters everything when the plugin fiber is disposed (HMR safety)', async () => {
  275. const ctx = new Context()
  276. await ctx.plugin(SystemPrompt)
  277. await ctx.plugin(ToolRegistry)
  278. await ctx.plugin(LocalBashExecutor, {})
  279. const fiber = await ctx.plugin(ToolBash)
  280. expect(ctx.tools.schemas()).toHaveLength(1)
  281. expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'tool:bash'])
  282. await fiber.dispose()
  283. expect(ctx.tools.schemas()).toHaveLength(0)
  284. // Only the system-prompt plugin's own built-in sections remain.
  285. expect((await ctx.systemPrompt.assemble()).sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona'])
  286. })
  287. it('tools depend on the executor: no registration without ctx.bash', async () => {
  288. const ctx = new Context()
  289. await ctx.plugin(SystemPrompt)
  290. await ctx.plugin(ToolRegistry)
  291. // inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
  292. await ctx.plugin(ToolBash)
  293. expect(ctx.tools.schemas()).toHaveLength(0)
  294. await ctx.plugin(LocalBashExecutor, {})
  295. await new Promise(resolve => setTimeout(resolve, 0))
  296. expect(ctx.tools.schemas()).toHaveLength(1)
  297. })
  298. it('applies the built-in background default when apply() receives a bare config', async () => {
  299. // Bypasses the schemastery defaults on purpose: apply() must stand on its
  300. // own `?? true` fallback when embedded programmatically without the schema.
  301. const ctx = new Context()
  302. await ctx.plugin(SystemPrompt)
  303. await ctx.plugin(ToolRegistry)
  304. await ctx.plugin(LocalBashExecutor, {})
  305. ToolBash.apply(ctx, {})
  306. const schema = ctx.tools.schemas()[0]!
  307. expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
  308. .toContain('run_in_background')
  309. })
  310. })
  311. describe('background execution through the task runtime', () => {
  312. it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => {
  313. const ctx = await setupWithTasks()
  314. const started = await call(ctx, 'bash', { command: 'echo bg-ok', description: 'test command', run_in_background: true })
  315. expect(started.isError).toBe(false)
  316. expect(text(started)).toBe('started background task bash-1')
  317. const read = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, 'bg-ok')
  318. expect(text(read)).toContain('bg-ok')
  319. // A later read reports the terminal outcome in the generic status line.
  320. const final = await callUntilText(ctx, 'task_output', { task_id: 'bash-1' }, '[status: completed, exit code: 0]')
  321. expect(final.isError).toBe(false)
  322. })
  323. it('a running background task is killable through the REAL task_kill tool', async () => {
  324. const ctx = await setupWithTasks()
  325. await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
  326. const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' })
  327. expect(text(killed)).toBe('requested cancellation of task bash-1')
  328. // The cancel reached the process handle; the task settles as killed with
  329. // the signal detail mapped by processOutcome.
  330. const final = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true })
  331. expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
  332. })
  333. it('a self-signal background exit is reported as killed through the REAL task_output tool', async () => {
  334. const ctx = await setupWithTasks()
  335. await call(ctx, 'bash', { command: 'kill -TERM $$', description: 'test command', run_in_background: true })
  336. const final = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true })
  337. expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
  338. })
  339. it('a background task started by an agent is registered with that agent as owner', async () => {
  340. // The fence SEMANTICS are pinned in dsh-tasks; this only pins that
  341. // tool-bash forwards exec.agent as the registration's owner.
  342. const ctx = await setupWithTasks()
  343. const agent = registerFakeAgent(ctx, 'sess-owner')
  344. const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true }, agent)
  345. expect(text(started)).toBe('started background task bash-1')
  346. const anon = await call(ctx, 'task_output', { task_id: 'bash-1' })
  347. expect(anon.isError).toBe(true)
  348. expect(text(anon)).toMatch(/belongs to another session/)
  349. const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' }, agent)
  350. expect(killed.isError).toBe(false)
  351. await call(ctx, 'task_output', { task_id: 'bash-1', wait: true }, agent) // await settlement — no orphan
  352. })
  353. it('fails loud when the task runtime is not loaded', async () => {
  354. const ctx = await setup() // no TaskService / ToolTasks
  355. const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
  356. expect(result.isError).toBe(true)
  357. expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
  358. })
  359. it('a pre-aborted call refuses to start: isError, no process spawned', async () => {
  360. class CountingStartExecutor extends BashExecutor {
  361. starts = 0
  362. resolve(request: BashExecRequest): BashExecSpec {
  363. return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, sandboxMode: request.sandboxMode }
  364. }
  365. run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
  366. start(spec: BashExecSpec): BashProcess {
  367. this.starts += 1
  368. return {
  369. command: spec.command,
  370. status: 'completed',
  371. exitCode: 0,
  372. signal: null,
  373. done: Promise.resolve(),
  374. readOutput: () => ({ delta: '', lossy: false }),
  375. kill: () => false,
  376. }
  377. }
  378. }
  379. const ctx = new Context()
  380. await ctx.plugin(SystemPrompt)
  381. await ctx.plugin(ToolRegistry)
  382. await ctx.plugin(AgentRegistry)
  383. await ctx.plugin(TaskService)
  384. await ctx.plugin(ToolTasks)
  385. await ctx.plugin(CountingStartExecutor)
  386. await ctx.plugin(ToolBash)
  387. const controller = new AbortController()
  388. controller.abort()
  389. const result = await ctx.tools.execute({
  390. callId: CallId('call-pre-aborted'),
  391. name: 'bash',
  392. arguments: { command: 'sleep 60', description: 'test command', run_in_background: true },
  393. signal: controller.signal,
  394. })
  395. expect(result.isError).toBe(true)
  396. expect(text(result)).toContain('command aborted')
  397. expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
  398. })
  399. it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
  400. class LeakProbeExecutor extends BashExecutor {
  401. starts = 0
  402. resolve(request: BashExecRequest): BashExecSpec {
  403. return { command: request.command, workdir: request.workdir ?? '/x', timeoutMs: request.timeoutMs ?? 0, sandboxMode: request.sandboxMode }
  404. }
  405. run(): Promise<BashRunResult> { return Promise.reject(new Error('unused')) }
  406. start(spec: BashExecSpec): BashProcess {
  407. this.starts += 1
  408. return {
  409. command: spec.command,
  410. status: 'completed',
  411. exitCode: 0,
  412. signal: null,
  413. done: Promise.resolve(),
  414. readOutput: () => ({ delta: '', lossy: false }),
  415. kill: () => false,
  416. }
  417. }
  418. }
  419. // TaskService WITHOUT any control surface: tasks.start preflights that
  420. // fence BEFORE invoking the producer's run(), so the executor is never
  421. // asked to spawn — there is no orphan to roll back.
  422. const ctx = new Context()
  423. await ctx.plugin(SystemPrompt)
  424. await ctx.plugin(ToolRegistry)
  425. await ctx.plugin(AgentRegistry)
  426. await ctx.plugin(TaskService)
  427. await ctx.plugin(LeakProbeExecutor)
  428. await ctx.plugin(ToolBash)
  429. const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
  430. expect(result.isError).toBe(true)
  431. expect(text(result)).toContain('no control surface is attached')
  432. // Declare-then-execute: the failed preflight means no process ever ran.
  433. expect((ctx.bash as LeakProbeExecutor).starts).toBe(0)
  434. })
  435. it('enableRunInBackground: false removes the parameter and flips the description', async () => {
  436. const ctx = new Context()
  437. await ctx.plugin(SystemPrompt)
  438. await ctx.plugin(ToolRegistry)
  439. await ctx.plugin(LocalBashExecutor, {})
  440. await ctx.plugin(ToolBash, { enableRunInBackground: false })
  441. const schema = ctx.tools.schemas().find(s => s.name === 'bash')!
  442. expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
  443. .toEqual(['command', 'description', 'timeoutMs', 'workdir'])
  444. expect(schema.description).toContain('Background execution is not available')
  445. expect(schema.description).not.toContain('run_in_background')
  446. // The registry-held definition agrees (schema and capability never disagree).
  447. const parameters = ctx.tools.get('bash')!.parameters as { properties: Record<string, unknown> }
  448. expect('run_in_background' in parameters.properties).toBe(false)
  449. // Schema omission is advertising, not enforcement: the arg validator
  450. // allows undeclared keys, so a forced run_in_background must be REFUSED
  451. // at execution time (review finding) — while foreground still works.
  452. const forced = await call(ctx, 'bash', { command: 'echo hi', description: 'test command', run_in_background: true })
  453. expect(forced.isError).toBe(true)
  454. expect(text(forced)).toContain('run_in_background is disabled for this deployment')
  455. const foreground = await call(ctx, 'bash', { command: 'echo hi', description: 'test command' })
  456. expect(foreground.isError).toBe(false)
  457. })
  458. })
  459. describe('sandbox escalation through the generic task producer', () => {
  460. const escalate = {
  461. command: 'true',
  462. description: 'test escalation',
  463. sandbox_permissions: 'workspace-write',
  464. justification: 'the command needs workspace writes',
  465. }
  466. it('advertises the sandbox fields and validates their pairing', async () => {
  467. const { ctx } = await setupSandboxed()
  468. const schema = ctx.tools.schemas().find(item => item.name === 'bash')!
  469. const properties = schema.parameters.properties as Record<string, { enum?: string[] }>
  470. expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
  471. expect(schema.description).toContain('approval prompt')
  472. for (const args of [
  473. { command: 'true', description: 'd', sandbox_permissions: 'workspace-write' },
  474. { command: 'true', description: 'd', justification: 'why' },
  475. { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' },
  476. ]) {
  477. expect((await call(ctx, 'bash', args)).isError).toBe(true)
  478. }
  479. })
  480. it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => {
  481. const plain = await setup()
  482. expect(text(await call(plain, 'bash', escalate))).toContain('not available in this composition')
  483. const { ctx } = await setupSandboxed(true)
  484. const prompted = vi.fn()
  485. ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
  486. const result = await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write'))
  487. expect(text(result)).toContain('not strictly wider')
  488. expect(prompted).not.toHaveBeenCalled()
  489. const malformed = sandboxAgent()
  490. ;(malformed.session.events as unknown as Array<{ type: string; data: { mode: string } }>).push({
  491. type: 'bash/sandbox-mode',
  492. data: { mode: 'unknown-mode' },
  493. })
  494. expect(text(await call(ctx, 'bash', escalate, malformed))).toContain('not strictly wider')
  495. })
  496. it('fails closed when approval cannot be routed', async () => {
  497. const withoutService = await setupSandboxed()
  498. expect(text(await call(withoutService.ctx, 'bash', escalate, sandboxAgent()))).toContain('no approval service')
  499. const withService = await setupSandboxed(true)
  500. expect(text(await call(withService.ctx, 'bash', escalate))).toContain('no agent to route')
  501. expect(text(await call(withService.ctx, 'bash', escalate, sandboxAgent()))).toContain('no approval channel')
  502. })
  503. it.each([
  504. ['rejected', 'user rejected'],
  505. ['cancelled', 'was cancelled'],
  506. ] as const)('maps an approval %s to its distinct failure', async (outcome, message) => {
  507. const { ctx, bash } = await setupSandboxed(true)
  508. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome))
  509. const result = await call(ctx, 'bash', escalate, sandboxAgent())
  510. expect(text(result)).toContain(message)
  511. expect(bash.modes).toEqual([])
  512. })
  513. it('runs a granted foreground or background call under the approved mode', async () => {
  514. const { ctx, bash } = await setupSandboxed(true)
  515. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  516. const agent = sandboxAgent()
  517. ctx.agents.register(agent)
  518. const foreground = await ctx.tools.execute({
  519. callId: CallId('sandbox-signal'),
  520. name: 'bash',
  521. arguments: escalate,
  522. agent,
  523. signal: new AbortController().signal,
  524. })
  525. expect(foreground.isError).toBe(false)
  526. const background = await call(ctx, 'bash', { ...escalate, run_in_background: true }, agent)
  527. expect(text(background)).toBe('started background task bash-1')
  528. expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
  529. })
  530. it('uses the session override for ordinary calls and evaluates widening against it', async () => {
  531. const { ctx, bash } = await setupSandboxed(true)
  532. const agent = sandboxAgent('workspace-write')
  533. await call(ctx, 'bash', { command: 'true', description: 'ordinary' }, agent)
  534. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  535. await call(ctx, 'bash', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent)
  536. expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
  537. })
  538. it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
  539. const { ctx } = await setupSandboxed(true)
  540. ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
  541. const result = await call(ctx, 'bash', escalate, sandboxAgent())
  542. expect(text(result)).toContain('unreachable variant in ApprovalOutcome')
  543. })
  544. })
  545. describe('renderProcessRead', () => {
  546. const base: BashProcessRead = { delta: 'out\n', lossy: false }
  547. it('returns the delta verbatim for a lossless read', () => {
  548. expect(renderProcessRead(base)).toBe('out\n')
  549. expect(renderProcessRead({ delta: '', lossy: false })).toBe('')
  550. })
  551. it('appends the loss notice with the available spill paths', () => {
  552. expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log' }))
  553. .toBe('out\n[some output was dropped from memory; full output: /spill/out.log]')
  554. expect(renderProcessRead({ ...base, lossy: true, stdoutSpillPath: '/spill/out.log', stderrSpillPath: '/spill/err.log' }))
  555. .toBe('out\n[some output was dropped from memory; full output: /spill/out.log, /spill/err.log]')
  556. })
  557. it('reports (unavailable) when a lossy read has no safe spill path', () => {
  558. expect(renderProcessRead({ ...base, lossy: true }))
  559. .toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
  560. })
  561. it('an empty lossy delta is the notice alone', () => {
  562. expect(renderProcessRead({ delta: '', lossy: true, stderrSpillPath: '/spill/err.log' }))
  563. .toBe('[some output was dropped from memory; full output: /spill/err.log]')
  564. })
  565. it('inserts the separating newline only when the delta lacks one', () => {
  566. expect(renderProcessRead({ delta: 'tail', lossy: true }))
  567. .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
  568. expect(renderProcessRead({ delta: 'tail\n', lossy: true }))
  569. .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
  570. })
  571. it('appends settled sandbox denial and runner-failure facts', () => {
  572. expect(renderProcessRead(base, { mode: 'read-only', denied: true }, ['workspace-write']))
  573. .toContain('[sandbox: escalation available')
  574. expect(renderProcessRead({ delta: 'tail', lossy: false }, { mode: 'read-only', denied: true }))
  575. .toBe('tail\n[sandbox: file access denied under read-only mode]')
  576. const runner = renderProcessRead(
  577. { delta: '', lossy: false },
  578. { mode: 'workspace-write', denied: true, runnerFailed: true },
  579. ['danger-full-access'],
  580. )
  581. expect(runner).toContain('sandbox runner itself failed under workspace-write mode')
  582. expect(runner).not.toContain('file access denied')
  583. })
  584. })
  585. describe('processOutcome', () => {
  586. function settled(over: Partial<BashProcess>): BashProcess {
  587. return {
  588. command: 'x',
  589. status: 'completed',
  590. exitCode: 0,
  591. signal: null,
  592. done: Promise.resolve(),
  593. readOutput: () => ({ delta: '', lossy: false }),
  594. kill: () => false,
  595. ...over,
  596. }
  597. }
  598. it('maps a signal-killed process to killed with the signal detail', () => {
  599. expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
  600. .toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
  601. })
  602. it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
  603. expect(processOutcome(settled({ status: 'killed', exitCode: null })))
  604. .toEqual({ status: 'killed', detail: 'killed before exit' })
  605. })
  606. it('maps a completed process to its exit code', () => {
  607. expect(processOutcome(settled({ exitCode: 3 })))
  608. .toEqual({ status: 'completed', detail: 'exit code: 3' })
  609. })
  610. it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
  611. expect(processOutcome(settled({ exitCode: null })))
  612. .toEqual({ status: 'completed', detail: 'exit code: 0' })
  613. })
  614. })
  615. describe('session-cwd routing (per-session workdir)', () => {
  616. // An agent whose session header carries a cwd (what session/new records).
  617. const agentInCwd = (cwd: string) =>
  618. ({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as Agent
  619. it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => {
  620. const ctx = await setup()
  621. const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
  622. expect(text(result).trim()).toMatch(/\/tmp$/)
  623. })
  624. it('an explicit absolute workdir overrides the session cwd', async () => {
  625. const ctx = await setup()
  626. const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: '/tmp' }, agentInCwd('/'))
  627. expect(text(result).trim()).toMatch(/\/tmp$/)
  628. })
  629. it('a relative workdir is resolved against the session cwd', async () => {
  630. const ctx = await setup()
  631. // session cwd /usr + relative 'bin' → /usr/bin
  632. const result = await call(ctx, 'bash', { command: 'pwd', description: 'pwd', workdir: 'bin' }, agentInCwd('/usr'))
  633. expect(text(result).trim()).toMatch(/\/usr\/bin$/)
  634. })
  635. it('two sessions with different cwds each run bash in their own dir', async () => {
  636. const ctx = await setup()
  637. const inUsr = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/usr'))
  638. const inTmp = await call(ctx, 'bash', { command: 'pwd', description: 'pwd' }, agentInCwd('/tmp'))
  639. expect(text(inUsr).trim()).toMatch(/\/usr$/)
  640. expect(text(inTmp).trim()).toMatch(/\/tmp$/)
  641. })
  642. it('falls back to the executor default when the agent has no session cwd', async () => {
  643. const ctx = await setup()
  644. // No exec.agent at all → executor uses its config/process.cwd() default.
  645. const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
  646. expect(result.isError).toBe(false)
  647. expect(text(result).trim().length).toBeGreaterThan(0)
  648. })
  649. })
  650. describe('renderResult', () => {
  651. const base = {
  652. exitCode: 0 as number | null,
  653. signal: null as NodeJS.Signals | null,
  654. timedOut: false,
  655. aborted: false,
  656. timeoutMs: 1000,
  657. stdout: { text: '', truncated: false },
  658. stderr: { text: '', truncated: false },
  659. }
  660. it('renders stderr-only output without a stdout prefix', () => {
  661. expect(renderResult({ ...base, stderr: { text: 'err\n', truncated: false } }))
  662. .toBe('[stderr]\nerr\n')
  663. })
  664. it('adds a separator when stdout does not end with a newline', () => {
  665. expect(renderResult({
  666. ...base,
  667. stdout: { text: 'out', truncated: false },
  668. stderr: { text: 'err', truncated: false },
  669. })).toBe('out\n[stderr]\nerr')
  670. })
  671. it('appends exit-code markers after a newline for unterminated output', () => {
  672. expect(renderResult({ ...base, exitCode: 7, stdout: { text: 'x', truncated: false } }))
  673. .toBe('x\n[exit code: 7]')
  674. })
  675. it('renders signal kills without the timeout marker when not timed out', () => {
  676. expect(renderResult({ ...base, exitCode: null, signal: 'SIGKILL' }))
  677. .toBe('(no output)\n[killed by signal: SIGKILL]')
  678. })
  679. it('reports a timeout that exited 0 (trapped signal) without a kill marker', () => {
  680. expect(renderResult({ ...base, exitCode: 0, signal: null, timedOut: true }))
  681. .toBe('(no output)\n[timed out after 1000ms]')
  682. })
  683. it('orders the timeout marker before a kill marker', () => {
  684. expect(renderResult({ ...base, exitCode: null, signal: 'SIGTERM', timedOut: true }))
  685. .toBe('(no output)\n[timed out after 1000ms]\n[killed by signal: SIGTERM]')
  686. })
  687. it('notes truncation with a fallback when the spill path is missing', () => {
  688. expect(renderResult({ ...base, stdout: { text: 'tail', truncated: true } }))
  689. .toBe('tail\n[output truncated; full output: (unavailable)]')
  690. })
  691. it('reports sandbox denials before exit status and hints only when escalation is advertised', () => {
  692. const result: BashRunResult = {
  693. exitCode: 1,
  694. signal: null,
  695. timedOut: false,
  696. aborted: false,
  697. timeoutMs: 1000,
  698. stdout: { text: '', truncated: false },
  699. stderr: { text: 'denied', truncated: false },
  700. sandbox: { mode: 'read-only', denied: true },
  701. }
  702. expect(renderResult(result)).toMatch(/denied under read-only mode\]\n\[exit code: 1\]$/)
  703. expect(renderResult(result, ['workspace-write'])).toContain('[sandbox: escalation available')
  704. expect(renderResult({ ...result, sandbox: { mode: 'read-only', denied: false } }, ['workspace-write']))
  705. .not.toContain('[sandbox:')
  706. })
  707. })
  708. describe('tool-owned UI presentation (presentCall / presentResult)', () => {
  709. it('bash presentCall: a foreground run is a terminal card (command title, description, workdir → cwd absolute or relative)', async () => {
  710. const ctx = await setup()
  711. // No explicit workdir → a terminal card with no cwd (the UI bridge fills the
  712. // session cwd it owns; the pure presenter can't see it).
  713. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' }))
  714. .toEqual({ card: 'terminal', title: 'ls -la src', description: 'List files in src' })
  715. // An ABSOLUTE workdir is surfaced verbatim as the terminal cwd header.
  716. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' }))
  717. .toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: '/tmp/x' })
  718. // A RELATIVE workdir is passed through AS-IS (the bridge resolves it against
  719. // the session cwd, matching where execution runs) — not dropped.
  720. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' }))
  721. .toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: 'sub' })
  722. })
  723. it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => {
  724. const ctx = await setup()
  725. const present = ctx.tools.get('bash')!.presentResult!(
  726. { command: 'echo hi', description: 'echo' },
  727. { content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
  728. )
  729. // A terminal result keeps the RAW bytes (newlines intact) a terminal renderer
  730. // needs; the bridge derives the fenced fallback. exitCode is parsed back from
  731. // the [exit code: N] marker.
  732. expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 })
  733. })
  734. it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
  735. const ctx = await setup()
  736. const args = { command: 'x', description: 'x' }
  737. const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
  738. expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 })
  739. const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
  740. expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
  741. })
  742. it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
  743. const ctx = await setup()
  744. const present = ctx.tools.get('bash')!
  745. // For each renderResult outcome, the rendered text fed back through
  746. // presentResult recovers the matching structured exit — the parse and the
  747. // marker emission co-evolve in one file, so this pins the pair.
  748. const base = {
  749. aborted: false,
  750. timeoutMs: 1000,
  751. stdout: { text: 'out', truncated: false },
  752. stderr: { text: '', truncated: false },
  753. }
  754. const cases = [
  755. { result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
  756. { result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
  757. { result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
  758. // A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
  759. { result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
  760. ]
  761. for (const c of cases) {
  762. const rendered = renderResult(c.result)
  763. const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
  764. // Drop card + output; the remaining fields are the parsed exit.
  765. const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
  766. expect(exit).toEqual(c.expect)
  767. }
  768. })
  769. it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
  770. const ctx = await setup()
  771. const args = { command: 'printf "[exit code: 5]"', description: 'print' }
  772. // A successful command can print text that looks like a marker. renderResult
  773. // for a clean exit 0 appends NOTHING (and no trailing newline), so the body's
  774. // own tail is `[exit code: 5]`. The parse requires a LEADING newline before
  775. // the marker (renderResult always inserts one before a REAL marker), so this
  776. // no-trailing-newline body is NOT mistaken for a failure → exitCode 0.
  777. const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
  778. expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
  779. // Same for a fake signal marker with no leading newline.
  780. const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
  781. expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })
  782. })
  783. it('bash presentCall/presentResult: a run_in_background call is a generic card and its ack carries no exit pill', async () => {
  784. const ctx = await setup()
  785. // The background start returns a task-id ack, not a streamed run — a generic
  786. // execute card with the command as rawInput and the description as content.
  787. const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true })
  788. expect(call).toEqual({ card: 'generic', title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] })
  789. // The ack result is a generic fenced-text card — no terminal output / exit pill.
  790. const result = ctx.tools.get('bash')!.presentResult!(
  791. { command: 'sleep 100', description: 'wait', run_in_background: true },
  792. { content: [{ type: 'text', text: 'started background task bash-1' }], isError: false },
  793. )
  794. expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task bash-1\n```' }] })
  795. })
  796. it('bash presentResult: an isError result is a generic card (no real process exit to report)', async () => {
  797. const ctx = await setup()
  798. // A spawn failure / abort has no process exit — the body is an error message,
  799. // not renderResult output, so a generic fenced card, no terminal output/exit.
  800. const out = ctx.tools.get('bash')!.presentResult!(
  801. { command: 'x', description: 'x' },
  802. { content: [{ type: 'text', text: 'command aborted' }], isError: true },
  803. )
  804. expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ncommand aborted\n```' }] })
  805. })
  806. it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
  807. const ctx = await setup()
  808. const present = ctx.tools.get('bash')!.presentResult!(
  809. { command: 'x', description: 'x' },
  810. { content: [{ type: 'reasoning', text: 'unexpected' }], isError: false },
  811. )
  812. expect(present).toBeUndefined()
  813. })
  814. it('bash presentResult: a result that is not exactly one block → undefined (no single text to fence)', async () => {
  815. const ctx = await setup()
  816. const args = { command: 'x', description: 'x' }
  817. // Empty content (no block) and multi-block content both fall through.
  818. expect(ctx.tools.get('bash')!.presentResult!(args, { content: [], isError: false })).toBeUndefined()
  819. expect(ctx.tools.get('bash')!.presentResult!(args, {
  820. content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }],
  821. isError: false,
  822. })).toBeUndefined()
  823. })
  824. it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
  825. const ctx = await setup()
  826. // defineTool wraps presentCall to soft-validate against the schema and fall
  827. // back to undefined (a generic UI presentation) rather than throwing on the
  828. // display path — it may run on replay of arbitrary logged args. The
  829. // ToolDefinition.presentCall takes `unknown`, so a malformed shape needs no cast.
  830. expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
  831. })
  832. })
  833. describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
  834. /**
  835. * Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
  836. * test can assert what the model-facing tool DID and DID NOT forward. The `bash`
  837. * tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
  838. * model that power), so it must build its request from named args only and
  839. * never spread unknown tool-call keys into it. This guard's job is to catch a
  840. * future refactor that blindly forwards `...args` — which would silently thread
  841. * model input into the post-scrub `env` merge — NOT to defend a trust boundary
  842. * (the credential scrub in dsh-bash-local is the security control; see the
  843. * bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()`
  844. * hands back an already-settled fake handle so the task registration completes.
  845. */
  846. class RecordingBashExecutor extends BashExecutor {
  847. readonly requests: BashExecRequest[] = []
  848. resolve(request: BashExecRequest): BashExecSpec {
  849. this.requests.push(request)
  850. return {
  851. command: request.command,
  852. workdir: request.workdir ?? process.cwd(),
  853. timeoutMs: request.timeoutMs ?? 0,
  854. ...request.signal ? { signal: request.signal } : {},
  855. ...request.stdin !== undefined ? { stdin: request.stdin } : {},
  856. ...request.env !== undefined ? { env: request.env } : {},
  857. sandboxMode: request.sandboxMode,
  858. }
  859. }
  860. run(): Promise<BashRunResult> {
  861. return Promise.resolve({
  862. exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 0,
  863. stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false },
  864. })
  865. }
  866. start(spec: BashExecSpec): BashProcess {
  867. return {
  868. command: spec.command,
  869. status: 'completed',
  870. exitCode: 0,
  871. signal: null,
  872. done: Promise.resolve(),
  873. readOutput: () => ({ delta: '', lossy: false }),
  874. kill: () => false,
  875. }
  876. }
  877. }
  878. async function setupRecording() {
  879. const ctx = new Context()
  880. await ctx.plugin(SystemPrompt)
  881. await ctx.plugin(ToolRegistry)
  882. await ctx.plugin(AgentRegistry)
  883. await ctx.plugin(TaskService)
  884. await ctx.plugin(ToolTasks)
  885. await ctx.plugin(RecordingBashExecutor)
  886. await ctx.plugin(ToolBash)
  887. return { ctx, bash: ctx.bash as RecordingBashExecutor }
  888. }
  889. it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
  890. const { ctx, bash } = await setupRecording()
  891. // Extra args: the model includes `env` and `stdin` keys hoping they reach the
  892. // executor. The bash tool's schema ignores unknown keys, and execute() builds
  893. // the request from only command/workdir/timeoutMs/signal — so the recorded
  894. // request carries NEITHER. (Not a security wall — the model could set an env
  895. // var or feed stdin via shell syntax anyway; this just keeps the request
  896. // shape honest so a future `...args` spread can't silently forward input.)
  897. await ctx.tools.execute({
  898. callId: CallId('no-forward-1'),
  899. name: 'bash',
  900. arguments: {
  901. command: 'echo hi',
  902. description: 'echo',
  903. env: { SNEAKY_API_KEY: 'leak' },
  904. stdin: 'malicious payload',
  905. },
  906. })
  907. expect(bash.requests).toHaveLength(1)
  908. const request = bash.requests[0]!
  909. expect(request.command).toBe('echo hi')
  910. expect('env' in request).toBe(false)
  911. expect('stdin' in request).toBe(false)
  912. })
  913. it('a background bash call likewise carries no env/stdin', async () => {
  914. const { ctx, bash } = await setupRecording()
  915. const result = await ctx.tools.execute({
  916. callId: CallId('no-forward-2'),
  917. name: 'bash',
  918. arguments: {
  919. command: 'sleep 1',
  920. description: 'sleep',
  921. run_in_background: true,
  922. env: { TOKEN: 'leak' },
  923. stdin: 'x',
  924. },
  925. })
  926. // The call really went down the background path (the recorder sees the real
  927. // request the consumer built, so the absent env/stdin below is a real
  928. // negative, not a recorder that drops everything).
  929. expect(text(result)).toBe('started background task bash-1')
  930. expect(bash.requests).toHaveLength(1)
  931. const request = bash.requests[0]!
  932. expect(request.command).toBe('sleep 1')
  933. expect('env' in request).toBe(false)
  934. expect('stdin' in request).toBe(false)
  935. })
  936. })