tools.spec.ts 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100
  1. /**
  2. * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor,
  3. * exercised through `ctx.tools.execute()` so nothing bypasses the tool
  4. * registry. The fake executor makes every seam outcome scriptable — output
  5. * text, truncation, timeout, abort, nonzero exits, background handles — so
  6. * these tests verify the schema, argument validation, workdir derivation,
  7. * managed `DSH_*` collection, abort translation, canonical result projection,
  8. * sandbox denial rendering with the escalation surface, rendering,
  9. * background job wiring, and the UI presenters. Real-pwsh behavior
  10. * is pinned separately in integration.spec.ts.
  11. */
  12. import { afterEach, describe, expect, it, vi } from 'vitest'
  13. import { Context } from '@deepseek-ai/cordis'
  14. import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
  15. import { tmpdir } from 'node:os'
  16. import { join, resolve as resolvePath } from 'node:path'
  17. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  18. import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
  19. import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
  20. import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
  21. import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
  22. import AgentRegistry from '@deepseek-ai/dsh-agent'
  23. import type { Agent } from '@deepseek-ai/dsh-agent'
  24. import { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
  25. import ApprovalService from '@deepseek-ai/dsh-user-approval'
  26. import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
  27. import { ShellExecutor } from '@deepseek-ai/dsh-shell'
  28. import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
  29. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  30. import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
  31. import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
  32. import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
  33. import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
  34. import type { ShellProcessRead } from '@deepseek-ai/dsh-shell'
  35. import { processOutcome } from '../src/background.ts'
  36. import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts'
  37. const testToolSignal = new AbortController().signal
  38. /** Per-test temp dirs (session cwd/home fixtures), removed after each test. */
  39. const tempDirs: string[] = []
  40. afterEach(() => {
  41. for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
  42. })
  43. /**
  44. * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()`
  45. * returns the armed foreground script, `start()` returns the armed background
  46. * handle.
  47. */
  48. class FakeBash extends ShellExecutor {
  49. requests: ShellExecRequest[] = []
  50. specs: ShellExecSpec[] = []
  51. startCalls = 0
  52. handler: (spec: ShellExecSpec) => ShellRunResult = () => runResult('')
  53. backgroundHandler: (spec: ShellExecSpec) => ShellProcess = () => fakeProcess('bg-ok\n')
  54. override resolve(request: ShellExecRequest): ShellExecSpec {
  55. this.requests.push(request)
  56. return {
  57. command: request.command,
  58. workdir: request.workdir ?? process.cwd(),
  59. timeoutMs: request.timeoutMs ?? 60_000,
  60. stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
  61. ...request.signal ? { signal: request.signal } : {},
  62. ...request.stdin !== undefined ? { stdin: request.stdin } : {},
  63. ...request.env !== undefined ? { env: request.env } : {},
  64. ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
  65. sandboxPolicy: request.sandboxPolicy,
  66. }
  67. }
  68. override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
  69. this.specs.push(spec)
  70. return this.handler(spec)
  71. }
  72. override start(spec: ShellExecSpec): ShellProcess {
  73. this.startCalls++
  74. this.specs.push(spec)
  75. return this.backgroundHandler(spec)
  76. }
  77. }
  78. /** A successful run result over the given stdout; overrides script the failure shapes. */
  79. function runResult(stdout: string, overrides?: Partial<ShellRunResult>): ShellRunResult {
  80. return {
  81. exitCode: 0,
  82. signal: null,
  83. timedOut: false,
  84. aborted: false,
  85. timeoutMs: 60_000,
  86. stdout: { text: stdout, truncated: false },
  87. stderr: { text: '', truncated: false },
  88. ...overrides,
  89. }
  90. }
  91. /** A settled successful background handle; overrides script failure shapes. */
  92. function fakeProcess(delta = 'bg-ok\n'): ShellProcess {
  93. let consumed = false
  94. return {
  95. status: 'completed',
  96. exitCode: 0,
  97. signal: null,
  98. done: Promise.resolve(),
  99. readOutput: () => {
  100. if (consumed) return { delta: '', lossy: false }
  101. consumed = true
  102. return { delta, lossy: false }
  103. },
  104. kill: () => false,
  105. }
  106. }
  107. /** A running background handle whose kill() settles it as killed (like a real job_kill). */
  108. function killableProcess(): ShellProcess {
  109. let resolveDone: () => void = () => {}
  110. const done = new Promise<void>((resolve) => { resolveDone = resolve })
  111. const proc: ShellProcess = {
  112. status: 'running',
  113. exitCode: null,
  114. signal: null,
  115. done,
  116. readOutput: () => ({ delta: '', lossy: false }),
  117. kill: () => {
  118. if (proc.status !== 'running') return false
  119. proc.status = 'killed'
  120. proc.signal = 'SIGTERM'
  121. resolveDone()
  122. return true
  123. },
  124. }
  125. return proc
  126. }
  127. async function setup(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
  128. const ctx = new Context()
  129. await ctx.plugin(SystemPrompt)
  130. await ctx.plugin(ToolRuntime)
  131. await ctx.plugin(AgentRegistry)
  132. await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
  133. await ctx.plugin(FakeBash)
  134. await ctx.plugin(ToolPwsh, toolConfig)
  135. const bash = ctx.shell as FakeBash
  136. return { ctx, bash }
  137. }
  138. /** Full harness: the generic job runtime + its controller, then the pwsh tool. */
  139. async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
  140. const ctx = new Context()
  141. await ctx.plugin(SystemPrompt)
  142. await ctx.plugin(ToolRuntime)
  143. await ctx.plugin(AgentRegistry)
  144. await ctx.plugin(LocalJobRegistry)
  145. await ctx.plugin(ToolTasks)
  146. await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome })
  147. await ctx.plugin(FakeBash)
  148. await ctx.plugin(ToolPwsh, toolConfig)
  149. const bash = ctx.shell as FakeBash
  150. return { ctx, bash }
  151. }
  152. /**
  153. * A CONFINING fake executor (`sandboxMode` advertised): the tool must resolve
  154. * the calling session's standing policy and stamp it on the request, exactly
  155. * like the bash tool — the per-session sandbox-policy regression surface.
  156. * Records each confined mode and returns scriptable sandbox facts so the
  157. * escalation and rendering surfaces are testable without a real backend.
  158. */
  159. class ConfiningFakeBash extends ShellExecutor {
  160. requests: ShellExecRequest[] = []
  161. modes: Array<string | undefined> = []
  162. override get sandboxMode() {
  163. return 'read-only' as const
  164. }
  165. override resolve(request: ShellExecRequest): ShellExecSpec {
  166. this.requests.push(request)
  167. return {
  168. command: request.command,
  169. workdir: request.workdir ?? process.cwd(),
  170. timeoutMs: request.timeoutMs ?? 60_000,
  171. stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
  172. ...request.signal ? { signal: request.signal } : {},
  173. ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
  174. sandboxPolicy: request.sandboxPolicy,
  175. }
  176. }
  177. override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
  178. this.modes.push(spec.sandboxPolicy?.mode)
  179. return runResult('ok\n', {
  180. sandbox: {
  181. mode: spec.sandboxPolicy?.mode ?? 'read-only',
  182. denied: false,
  183. ...spec.command === 'without optional sandbox facts'
  184. ? {}
  185. : { enforcement: 'full' as const, runnerFailed: false },
  186. },
  187. })
  188. }
  189. override start(spec: ShellExecSpec): ShellProcess {
  190. this.modes.push(spec.sandboxPolicy?.mode)
  191. return fakeProcess()
  192. }
  193. }
  194. /** Sandboxed composition: the shared policy service + a confining executor + the pwsh tool (+ optional approval). */
  195. async function setupSandboxed(withApproval = false) {
  196. const ctx = new Context()
  197. await ctx.plugin(SystemPrompt)
  198. await ctx.plugin(ToolRuntime)
  199. await ctx.plugin(AgentRegistry)
  200. await ctx.plugin(LocalJobRegistry)
  201. await ctx.plugin(ToolTasks)
  202. await ctx.plugin(BashEnvPlugin)
  203. await ctx.plugin(SessionProjectionRegistry)
  204. // The loop's turnBoundary unit (the open-turn fold) is not mounted in this
  205. // bench — the loop itself is not composed. Register its open-turn fold so
  206. // the approval service's turn-enclosure gate reads the seeded log shape.
  207. ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
  208. await ctx.plugin(SandboxPolicyService, {})
  209. await ctx.plugin(ConfiningFakeBash)
  210. if (withApproval) await ctx.plugin(ApprovalService)
  211. await ctx.plugin(ToolPwsh)
  212. const bash = ctx.shell as ConfiningFakeBash
  213. return { ctx, bash }
  214. }
  215. /**
  216. * Build a fake {@link Agent} whose session log carries the sandbox-policy
  217. * mode-override event the escalation flow evaluates against, with an
  218. * appendable log (the approval service records decisions through
  219. * `session.append`).
  220. */
  221. function sandboxAgent(
  222. mode?: 'read-only' | 'workspace-write' | 'danger-full-access',
  223. ctx?: Context,
  224. onAppend?: (type: string) => void,
  225. ): Agent {
  226. const events: Array<{
  227. type: string
  228. seq: ReturnType<typeof SessionSeq>
  229. time: number
  230. data: Record<string, unknown>
  231. }> = [
  232. { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } },
  233. ]
  234. if (mode !== undefined) {
  235. events.push({ type: 'sandbox/mode', seq: SessionSeq(1), time: 1, data: { mode } })
  236. }
  237. const id = SessionId('sandbox-session')
  238. return {
  239. id,
  240. ...ctx === undefined ? {} : { ctx: ctx.plugin(() => {}).ctx },
  241. session: {
  242. id,
  243. header: { version: SESSION_FORMAT_VERSION, id, createdAt: 0, isSeeded: false },
  244. inheritedEventCount: SessionLogOffset(0),
  245. firstLiveSeq: SessionLogOffset(0),
  246. get seq() { return SessionLogOffset(events.length) },
  247. eventAt: (seq: ReturnType<typeof SessionSeq>) => events[seq],
  248. snapshotEvents: (
  249. fromSeq = SessionLogOffset(0),
  250. toSeqExclusive = SessionLogOffset(events.length),
  251. ) => events.slice(fromSeq, toSeqExclusive),
  252. append: (type: string, data: Record<string, unknown>) => {
  253. const event = {
  254. type,
  255. seq: SessionSeq(events.length),
  256. time: events.length,
  257. data,
  258. }
  259. events.push(event)
  260. onAppend?.(type)
  261. return event
  262. },
  263. },
  264. } as unknown as Agent
  265. }
  266. /**
  267. * Build a fake {@link Agent} with the shared agent/session identity, give it a
  268. * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`.
  269. * The fake session carries an empty event log (the sandbox-policy resolver
  270. * folds the log for mode overrides, mirroring a real session).
  271. */
  272. function registerFakeAgent(ctx: Context, sessionId: string): Agent {
  273. const scopeFiber = ctx.plugin(() => {})
  274. const id = SessionId(sessionId)
  275. const agent = {
  276. id,
  277. ctx: scopeFiber.ctx,
  278. session: {
  279. id,
  280. header: { version: SESSION_FORMAT_VERSION, id, createdAt: 0, isSeeded: false },
  281. inheritedEventCount: SessionLogOffset(0),
  282. firstLiveSeq: SessionLogOffset(0),
  283. seq: SessionLogOffset(0),
  284. eventAt: () => undefined,
  285. snapshotEvents: () => [],
  286. },
  287. } as unknown as Agent
  288. ctx.agents.register(agent)
  289. return agent
  290. }
  291. let callCounter = 0
  292. function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
  293. return ctx.tools.execute({
  294. signal: testToolSignal,
  295. callId: ToolCallId(`call-${++callCounter}`),
  296. name,
  297. arguments: args,
  298. ...agent ? { agent } : {},
  299. })
  300. }
  301. function text(result: { content: { type: string; text?: string }[] }): string {
  302. return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
  303. }
  304. async function callUntilText(
  305. ctx: Context,
  306. name: string,
  307. args: unknown,
  308. expected: string,
  309. timeoutMs = 5_000,
  310. ): Promise<Awaited<ReturnType<typeof call>>> {
  311. const deadline = Date.now() + timeoutMs
  312. let last: Awaited<ReturnType<typeof call>> | undefined
  313. while (Date.now() < deadline) {
  314. last = await call(ctx, name, args)
  315. if (text(last).includes(expected)) return last
  316. await new Promise(resolve => setTimeout(resolve, 20))
  317. }
  318. throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`)
  319. }
  320. describe('registration', () => {
  321. it('registers the pwsh tool with its prompt section and schema', async () => {
  322. const { ctx } = await setup()
  323. const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')
  324. expect(schema).toBeDefined()
  325. expect(schema?.description).toContain('PowerShell command')
  326. expect(schema?.parameters.properties).toMatchObject({
  327. command: { type: 'string' },
  328. description: { type: 'string' },
  329. timeoutMs: { type: 'number' },
  330. workdir: { type: 'string' },
  331. run_in_background: { type: 'boolean' },
  332. })
  333. expect(schema?.parameters.required).toEqual(['command', 'description'])
  334. const prompt = renderPrompt(await ctx.systemPrompt.assemble())
  335. expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers')
  336. expect(prompt).toContain('without a signal marker')
  337. })
  338. it('stays pending until ctx.shell exists (inject)', async () => {
  339. const ctx = new Context()
  340. await ctx.plugin(SystemPrompt)
  341. await ctx.plugin(ToolRuntime)
  342. await ctx.plugin(ToolPwsh)
  343. expect(ctx.tools.schemas()).toHaveLength(0)
  344. })
  345. it('unregisters everything on fiber disposal (HMR safety)', async () => {
  346. const ctx = new Context()
  347. await ctx.plugin(SystemPrompt)
  348. await ctx.plugin(ToolRuntime)
  349. await ctx.plugin(BashEnvPlugin)
  350. await ctx.plugin(FakeBash)
  351. const fiber = await ctx.plugin(ToolPwsh)
  352. expect(ctx.tools.schemas()).toHaveLength(1)
  353. await fiber.dispose()
  354. expect(ctx.tools.schemas()).toHaveLength(0)
  355. })
  356. })
  357. describe('argument validation', () => {
  358. it('rejects a blank command or description and a non-positive timeoutMs', async () => {
  359. const { ctx } = await setup()
  360. expect(text(await call(ctx, 'pwsh', { command: ' ', description: 'd' }))).toContain('expected a non-empty string')
  361. expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string')
  362. expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 })))
  363. .toContain('invalid timeoutMs: expected a positive number')
  364. })
  365. })
  366. describe('execution through the bash seam', () => {
  367. it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => {
  368. const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-'))
  369. tempDirs.push(dshHome)
  370. const { ctx, bash } = await setup({}, dshHome)
  371. bash.handler = () => runResult('hi\n')
  372. const agent = registerFakeAgent(ctx, 'session-1')
  373. Object.assign(agent.session.header, { cwd: '/sessions/s1' })
  374. const result = await call(ctx, 'pwsh', {
  375. command: 'Write-Output hi',
  376. description: 'say hi',
  377. timeoutMs: 1234,
  378. }, agent)
  379. expect(result.isError).toBe(false)
  380. const request = bash.requests[0]
  381. expect(request?.command).toBe('Write-Output hi')
  382. expect(request?.workdir).toBe('/sessions/s1')
  383. expect(request?.timeoutMs).toBe(1234)
  384. expect(request?.dshEnv).toEqual({
  385. DSH_HOME: dshHome,
  386. DSH_SHELL: '1',
  387. DSH_SESSION_ID: 'session-1',
  388. })
  389. expect(bash.specs[0]?.workdir).toBe('/sessions/s1')
  390. })
  391. it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => {
  392. const { ctx, bash } = await setup()
  393. bash.handler = () => runResult('ok\n')
  394. const agent = registerFakeAgent(ctx, 'session-cwd')
  395. Object.assign(agent.session.header, { cwd: '/sessions/s1' })
  396. await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent)
  397. expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir'))
  398. await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent)
  399. expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path'))
  400. })
  401. it('omits workdir and the session id without an agent, so executor defaulting applies', async () => {
  402. const { ctx, bash } = await setup()
  403. bash.handler = () => runResult('ok\n')
  404. await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' })
  405. expect(bash.requests[0]).not.toHaveProperty('workdir')
  406. const dshEnv = bash.requests[0]?.dshEnv
  407. expect(dshEnv).toBeDefined()
  408. expect(dshEnv?.['DSH_SHELL']).toBe('1')
  409. expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String))
  410. expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID')
  411. })
  412. it('forwards exec.signal into the resolved request', async () => {
  413. const { ctx, bash } = await setup()
  414. const controller = new AbortController()
  415. bash.handler = () => runResult('ok\n')
  416. await ctx.tools.execute({
  417. signal: controller.signal,
  418. callId: ToolCallId('call-signal'),
  419. name: 'pwsh',
  420. arguments: { command: 'Write-Output ok', description: 'ok' },
  421. })
  422. expect(bash.requests[0]?.signal).toBe(controller.signal)
  423. })
  424. it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => {
  425. const { ctx, bash } = await setup()
  426. bash.handler = () => runResult('out\n', {
  427. exitCode: 2,
  428. stderr: { text: 'err\n', truncated: false },
  429. timeoutMs: 5000,
  430. })
  431. const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' })
  432. expect(result.isError).toBe(false)
  433. if (result.isError) throw new Error('expected pwsh success')
  434. expect(result.value).toEqual({
  435. kind: 'foreground',
  436. exitCode: 2,
  437. signal: null,
  438. timedOut: false,
  439. aborted: false,
  440. timeoutMs: 5000,
  441. stdout: { text: 'out\n', truncated: false },
  442. stderr: { text: 'err\n', truncated: false },
  443. })
  444. expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]')
  445. })
  446. it('renders a clean exit without a marker and an empty body as (no output)', async () => {
  447. const { ctx, bash } = await setup()
  448. bash.handler = () => runResult('hi\n')
  449. const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
  450. expect(text(clean)).toBe('hi\n')
  451. bash.handler = () => runResult('')
  452. const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' })
  453. expect(text(empty)).toBe('(no output)')
  454. })
  455. it('renders stderr-only output without a stdout prefix', async () => {
  456. const { ctx, bash } = await setup()
  457. bash.handler = () => runResult('', {
  458. stderr: { text: 'err\n', truncated: false },
  459. exitCode: 1,
  460. })
  461. const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
  462. expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]')
  463. })
  464. it('inserts the separating newline before the stderr section when stdout lacks one', async () => {
  465. const { ctx, bash } = await setup()
  466. bash.handler = () => runResult('out', {
  467. stderr: { text: 'err\n', truncated: false },
  468. exitCode: 1,
  469. })
  470. const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' })
  471. expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]')
  472. })
  473. it('renders the truncation notice with the spill path, then markers', async () => {
  474. const { ctx, bash } = await setup()
  475. bash.handler = () => runResult('tail', {
  476. stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' },
  477. stderr: { text: '', truncated: false },
  478. })
  479. const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
  480. expect(text(result)).toBe('tail\n[output truncated; full output: /spill/out.log]')
  481. bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 })
  482. const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' })
  483. // A timeout kill carries both facts, mirroring the bash tool's markers.
  484. expect(text(timedOut)).toBe('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]')
  485. })
  486. it('renders the truncation notice with (unavailable) when no spill path exists', async () => {
  487. const { ctx, bash } = await setup()
  488. bash.handler = () => runResult('tail', {
  489. stdout: { text: 'tail', truncated: true },
  490. stderr: { text: '', truncated: false },
  491. })
  492. const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' })
  493. expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]')
  494. })
  495. it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => {
  496. const { ctx, bash } = await setup()
  497. bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' })
  498. const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' })
  499. expect(result.isError).toBe(true)
  500. expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
  501. })
  502. })
  503. describe('per-call sandbox policy resolution', () => {
  504. it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => {
  505. const { ctx, bash } = await setupSandboxed()
  506. const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-'))
  507. tempDirs.push(sessionCwd)
  508. const agent = registerFakeAgent(ctx, 'policy-session')
  509. Object.assign(agent.session.header, { cwd: sessionCwd })
  510. const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent)
  511. expect(result.isError).toBe(false)
  512. // The policy's workspace root is the session cwd canonicalized by the
  513. // policy service (realpath + resolve), NEVER the web server's launch dir;
  514. // the calling session's identity rides along for backend per-session state.
  515. expect(bash.requests[0]?.sandboxPolicy).toEqual({
  516. mode: 'read-only',
  517. workspaceRoot: resolvePath(realpathSync.native(sessionCwd)),
  518. sessionId: 'policy-session',
  519. })
  520. })
  521. it('falls back to the deployment policy without an agent, and omits the field entirely without a confining executor', async () => {
  522. const { ctx, bash } = await setupSandboxed()
  523. await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
  524. expect(bash.requests[0]?.sandboxPolicy).toEqual({
  525. mode: 'read-only',
  526. workspaceRoot: resolvePath(realpathSync.native(process.cwd())),
  527. })
  528. // The base FakeBash advertises no sandboxMode, so the tool must not stamp
  529. // any policy (the executor defaulting stays the executor's own).
  530. const plain = await setup()
  531. await call(plain.ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
  532. expect(plain.bash.requests[0]).not.toHaveProperty('sandboxPolicy')
  533. })
  534. it('fails load when a confining executor has no shared sandbox-policy resolver', async () => {
  535. const ctx = new Context()
  536. await ctx.plugin(SystemPrompt)
  537. await ctx.plugin(ToolRuntime)
  538. await ctx.plugin(AgentRegistry)
  539. await ctx.plugin(BashEnvPlugin)
  540. await ctx.plugin(ConfiningFakeBash)
  541. await expect(ctx.plugin(ToolPwsh)).rejects.toThrow(
  542. 'tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing',
  543. )
  544. })
  545. })
  546. describe('sandbox escalation through ctx.approval', () => {
  547. const escalate = {
  548. command: 'Write-Output ok',
  549. description: 'test escalation',
  550. sandbox_permissions: 'workspace-write',
  551. justification: 'the command needs workspace writes',
  552. }
  553. it('advertises the sandbox fields, the escalation clause, and the confined-mode contracts', async () => {
  554. const { ctx } = await setupSandboxed()
  555. const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
  556. const properties = schema.parameters.properties as Record<string, { enum?: string[] }>
  557. expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
  558. expect(schema.description).toContain('approval prompt')
  559. expect(schema.description).toContain('ConstrainedLanguage')
  560. expect(schema.description).toContain('workspace-write stays in FullLanguage')
  561. expect(schema.description).toContain('In both confined modes, programs cannot open named pipes')
  562. expect(schema.description).toContain('fails with EPERM')
  563. for (const args of [
  564. { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' },
  565. { command: 'Write-Output ok', description: 'd', justification: 'why' },
  566. { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' },
  567. ]) {
  568. expect((await call(ctx, 'pwsh', args)).isError).toBe(true)
  569. }
  570. })
  571. it('the escalation fields and the confined-mode clauses stay out of sandbox-less compositions', async () => {
  572. const { ctx } = await setup()
  573. const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
  574. expect(schema.description).not.toContain('ConstrainedLanguage')
  575. expect(schema.description).not.toContain('named pipes')
  576. expect(schema.description).not.toContain('sandbox_permissions')
  577. expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions')
  578. })
  579. it('rejects injected escalation without a sandbox and non-widening escalation without prompting', async () => {
  580. const plain = await setup()
  581. expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition')
  582. const { ctx } = await setupSandboxed(true)
  583. const prompted = vi.fn()
  584. ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
  585. const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('workspace-write'))
  586. expect(text(result)).toContain('not strictly wider')
  587. expect(prompted).not.toHaveBeenCalled()
  588. const malformed = sandboxAgent()
  589. ;(malformed.session.append as unknown as (
  590. type: string,
  591. data: Record<string, unknown>,
  592. ) => unknown)('sandbox/mode', { mode: 'unknown-mode' })
  593. expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider')
  594. })
  595. it('fails closed when approval cannot be routed', async () => {
  596. const withoutService = await setupSandboxed()
  597. expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service')
  598. const withService = await setupSandboxed(true)
  599. expect(text(await call(withService.ctx, 'pwsh', escalate))).toContain('no agent to route')
  600. expect(text(await call(withService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval channel')
  601. })
  602. it.each([
  603. ['rejected', 'user rejected'],
  604. ['cancelled', 'was cancelled'],
  605. ] as const)('maps an approval %s to its distinct failure', async (outcome, message) => {
  606. const { ctx, bash } = await setupSandboxed(true)
  607. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome))
  608. const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
  609. expect(text(result)).toContain(message)
  610. expect(bash.modes).toEqual([])
  611. })
  612. it('runs a granted foreground or background call under the approved mode', async () => {
  613. const { ctx, bash } = await setupSandboxed(true)
  614. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  615. const agent = sandboxAgent(undefined, ctx)
  616. ctx.agents.register(agent)
  617. const foreground = await ctx.tools.execute({
  618. callId: ToolCallId('sandbox-signal'),
  619. name: 'pwsh',
  620. arguments: escalate,
  621. agent,
  622. signal: new AbortController().signal,
  623. })
  624. expect(foreground.isError).toBe(false)
  625. const background = await call(ctx, 'pwsh', { ...escalate, run_in_background: true }, agent)
  626. expect(text(background)).toBe('started background job pwsh-1')
  627. expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
  628. })
  629. it('does not publish detached work when cancellation follows the escalation grant', async () => {
  630. const { ctx, bash } = await setupSandboxed(true)
  631. const controller = new AbortController()
  632. const agent = sandboxAgent(undefined, ctx, (type) => {
  633. if (type === 'approval/decided') controller.abort()
  634. })
  635. ctx.agents.register(agent)
  636. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  637. const start = vi.spyOn(bash, 'start')
  638. const result = await ctx.tools.execute({
  639. callId: ToolCallId('cancelled-escalation-background'),
  640. name: 'pwsh',
  641. arguments: { ...escalate, run_in_background: true },
  642. agent,
  643. signal: controller.signal,
  644. })
  645. expect(result.error).toEqual({
  646. message: 'tool call aborted',
  647. info: { name: 'AbortError', code: TOOL_ABORTED },
  648. })
  649. expect(text(result)).toBe('Error: tool call aborted')
  650. expect(start).not.toHaveBeenCalled()
  651. })
  652. it('uses the session override for ordinary calls and evaluates widening against it', async () => {
  653. const { ctx, bash } = await setupSandboxed(true)
  654. const agent = sandboxAgent('workspace-write')
  655. await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'ordinary' }, agent)
  656. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  657. await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent)
  658. expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
  659. })
  660. it('omits sandbox facts the executor did not acquire from the canonical result', async () => {
  661. const { ctx } = await setupSandboxed()
  662. const result = await call(ctx, 'pwsh', {
  663. command: 'without optional sandbox facts',
  664. description: 'exercise optional sandbox facts',
  665. })
  666. if (result.isError) throw new Error('expected foreground pwsh success')
  667. expect(result.value).toMatchObject({
  668. kind: 'foreground',
  669. sandbox: { mode: 'read-only', denied: false },
  670. })
  671. expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement')
  672. expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed')
  673. })
  674. it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
  675. const { ctx } = await setupSandboxed(true)
  676. ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
  677. const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
  678. expect(text(result)).toContain('unreachable variant in EscalationOutcome')
  679. })
  680. })
  681. describe('background execution through the job runtime', () => {
  682. it('run_in_background acks with the job id, readable through the REAL job_output tool', async () => {
  683. const { ctx } = await setupWithTasks()
  684. const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true })
  685. expect(started.isError).toBe(false)
  686. if (started.isError) throw new Error('expected background pwsh success')
  687. expect(started.value).toEqual({ kind: 'background', jobId: 'pwsh-1' })
  688. expect(text(started)).toBe('started background job pwsh-1')
  689. const read = await callUntilText(ctx, 'job_output', { job_id: 'pwsh-1' }, 'bg-ok')
  690. expect(text(read)).toContain('bg-ok')
  691. // A later read reports the terminal outcome in the generic status line.
  692. const final = await callUntilText(ctx, 'job_output', { job_id: 'pwsh-1' }, '[status: completed, exit code: 0]')
  693. expect(final.isError).toBe(false)
  694. })
  695. it('a running background job is killable through the REAL job_kill tool', async () => {
  696. const { ctx, bash } = await setupWithTasks()
  697. bash.backgroundHandler = () => killableProcess()
  698. await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
  699. const killed = await call(ctx, 'job_kill', { job_id: 'pwsh-1' })
  700. expect(text(killed)).toBe('requested cancellation of job pwsh-1')
  701. // The cancel reached the process handle; the task settles as killed with
  702. // the signal detail mapped by processOutcome.
  703. const final = await call(ctx, 'job_output', { job_id: 'pwsh-1', wait: true })
  704. expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
  705. })
  706. it('a background job started by an agent is registered with that agent as owner', async () => {
  707. const { ctx } = await setupWithTasks()
  708. const agent = registerFakeAgent(ctx, 'sess-owner')
  709. const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent)
  710. expect(text(started)).toBe('started background job pwsh-1')
  711. const anon = await call(ctx, 'job_output', { job_id: 'pwsh-1' })
  712. expect(anon.isError).toBe(true)
  713. expect(text(anon)).toMatch(/belongs to another session/)
  714. const killed = await call(ctx, 'job_kill', { job_id: 'pwsh-1' }, agent)
  715. expect(killed.isError).toBe(false)
  716. await call(ctx, 'job_output', { job_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan
  717. })
  718. it('fails loud when the job runtime is not loaded', async () => {
  719. const { ctx } = await setup() // no LocalJobRegistry / ToolTasks
  720. const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
  721. expect(result.isError).toBe(true)
  722. expect(text(result)).toContain('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
  723. })
  724. it('a pre-aborted call is skipped before the process starts', async () => {
  725. const { ctx, bash } = await setupWithTasks()
  726. const controller = new AbortController()
  727. controller.abort()
  728. const result = await ctx.tools.execute({
  729. callId: ToolCallId('call-pre-aborted'),
  730. name: 'pwsh',
  731. arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true },
  732. signal: controller.signal,
  733. })
  734. expect(result.isError).toBe(true)
  735. expect(result.error).toEqual({
  736. message: 'tool call aborted before dispatch',
  737. info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  738. })
  739. expect(bash.startCalls).toBe(0)
  740. })
  741. it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
  742. // With no job controller, preflight fails before the executor can spawn.
  743. const ctx = new Context()
  744. await ctx.plugin(SystemPrompt)
  745. await ctx.plugin(ToolRuntime)
  746. await ctx.plugin(LocalJobRegistry)
  747. await ctx.plugin(BashEnvPlugin)
  748. await ctx.plugin(FakeBash)
  749. await ctx.plugin(ToolPwsh)
  750. const bash = ctx.shell as FakeBash
  751. const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
  752. expect(result.isError).toBe(true)
  753. expect(text(result)).toContain('no job controller serves this agent')
  754. // Declare-then-execute: the failed preflight means no process ever ran.
  755. expect(bash.startCalls).toBe(0)
  756. })
  757. it('enableRunInBackground: false removes the parameter and flips the description', async () => {
  758. const { ctx } = await setup({ enableRunInBackground: false })
  759. const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')!
  760. expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
  761. .toEqual(['command', 'description', 'timeoutMs', 'workdir'])
  762. expect(schema.description).toContain('Background execution is not available')
  763. expect(schema.description).not.toContain('run_in_background')
  764. // Schema omission is advertising; execution must also enforce the opt-out.
  765. const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true })
  766. expect(forced.isError).toBe(true)
  767. expect(text(forced)).toContain('run_in_background is disabled for this deployment')
  768. const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' })
  769. expect(foreground.isError).toBe(false)
  770. })
  771. it('applies the built-in background default when apply() receives a bare config', async () => {
  772. // Bypasses the schemastery defaults on purpose: apply() must stand on its
  773. // own `?? true` fallback when embedded programmatically without the schema.
  774. const ctx = new Context()
  775. await ctx.plugin(SystemPrompt)
  776. await ctx.plugin(ToolRuntime)
  777. await ctx.plugin(BashEnvPlugin)
  778. await ctx.plugin(FakeBash)
  779. ToolPwsh.apply(ctx, {})
  780. const schema = ctx.tools.schemas()[0]!
  781. expect(schema.parameters.properties).toHaveProperty('run_in_background')
  782. expect(schema.description).toContain('job_output')
  783. })
  784. })
  785. describe('UI presentation', () => {
  786. it('a real execute presents a completed foreground run as a terminal card with the parsed exit pill', async () => {
  787. const { ctx, bash } = await setup()
  788. bash.handler = () => runResult('hi\n')
  789. const args = { command: 'Write-Output hi', description: 'say hi' }
  790. const result = await call(ctx, 'pwsh', args)
  791. const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
  792. // A terminal result keeps the RAW bytes (newlines intact) a terminal
  793. // renderer needs; a clean run renders no exit marker, so the body is the
  794. // raw output with a clean exit-0 pill, mirroring the bash tool.
  795. expect(view).toEqual({ card: 'terminal', output: 'hi\n', exitCode: 0 })
  796. })
  797. it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => {
  798. const { ctx } = await setup()
  799. const definition = ctx.tools.get('pwsh')
  800. expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' }))
  801. .toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' })
  802. expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' }))
  803. .toMatchObject({ cwd: 'C:\\work' })
  804. })
  805. it('a background pending call renders the generic card like the bash tool', async () => {
  806. const { ctx } = await setup()
  807. const definition = ctx.tools.get('pwsh')
  808. expect(definition?.presentCall?.({
  809. command: 'Start-Sleep -Seconds 60',
  810. description: 'long wait',
  811. run_in_background: true,
  812. })).toEqual({
  813. card: 'generic',
  814. title: 'Start-Sleep -Seconds 60',
  815. kind: 'execute',
  816. rawInput: 'Start-Sleep -Seconds 60',
  817. content: [{ type: 'text', text: 'long wait' }],
  818. })
  819. })
  820. it('presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
  821. const { ctx } = await setup()
  822. const present = ctx.tools.get('pwsh')
  823. const args = { command: 'x', description: 'x' }
  824. expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }))
  825. .toEqual({ card: 'terminal', output: 'oops', exitCode: 3 })
  826. expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }))
  827. .toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' })
  828. })
  829. it('presentResult: markers a pill CANNOT show (timeout) stay in the terminal output', async () => {
  830. const { ctx } = await setup()
  831. const args = { command: 'x', description: 'x' }
  832. expect(ctx.tools.get('pwsh')?.presentResult?.(
  833. args,
  834. { content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false },
  835. )).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 })
  836. })
  837. it('presentResult exit parse is the inverse of renderPwshResult markers (round-trip)', async () => {
  838. const { ctx } = await setup()
  839. const present = ctx.tools.get('pwsh')!
  840. const base = {
  841. aborted: false,
  842. timeoutMs: 1000,
  843. stdout: { text: 'out', truncated: false },
  844. stderr: { text: '', truncated: false },
  845. }
  846. const cases = [
  847. { result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
  848. { result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
  849. { result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
  850. // A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
  851. { result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
  852. ]
  853. for (const c of cases) {
  854. const rendered = renderPwshResult(c.result)
  855. const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
  856. // Drop card + output; the remaining fields are the parsed exit.
  857. const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
  858. expect(exit).toEqual(c.expect)
  859. // Whatever the parse consumed is gone from the body, so a card with an
  860. // exit pill never shows the same status twice.
  861. expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /)
  862. }
  863. })
  864. it('presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
  865. const { ctx } = await setup()
  866. const args = { command: 'Write-Output "[exit code: 5]"', description: 'print' }
  867. // A successful command may print marker-like text. A clean result appends no marker or
  868. // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
  869. const out = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
  870. expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
  871. // Same for a fake signal marker with no leading newline.
  872. const sig = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
  873. expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })
  874. })
  875. it('presentResult: a run_in_background ack is a generic card and carries no exit pill', async () => {
  876. const { ctx } = await setup()
  877. const result = ctx.tools.get('pwsh')!.presentResult!(
  878. { command: 'Start-Sleep -Seconds 60', description: 'long wait', run_in_background: true },
  879. { content: [{ type: 'text', text: 'started background job pwsh-1' }], isError: false },
  880. )
  881. expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background job pwsh-1\n```' }] })
  882. })
  883. it('presentResult: an isError result is a generic card (no real process exit to report)', async () => {
  884. const { ctx } = await setup()
  885. const out = ctx.tools.get('pwsh')!.presentResult!(
  886. { command: 'x', description: 'x' },
  887. { content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
  888. )
  889. expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
  890. })
  891. it('presentResult falls back to undefined for multi-block or non-text content', async () => {
  892. const { ctx } = await setup()
  893. const definition = ctx.tools.get('pwsh')
  894. const args = { command: 'Write-Output hi', description: 'say hi' }
  895. const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false }
  896. expect(definition?.presentResult?.(args, multi as never)).toBeUndefined()
  897. const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false }
  898. expect(definition?.presentResult?.(args, image as never)).toBeUndefined()
  899. })
  900. })
  901. describe('renderPwshResult sandbox markers', () => {
  902. const base = {
  903. exitCode: 0,
  904. signal: null,
  905. timedOut: false,
  906. timeoutMs: 1000,
  907. stdout: { text: 'out\n', truncated: false },
  908. stderr: { text: '', truncated: false },
  909. }
  910. it('a denied run reports the denial marker before the exit marker', () => {
  911. expect(renderPwshResult({ ...base, exitCode: 2, sandbox: { mode: 'read-only', denied: true } }))
  912. .toBe('out\n[sandbox: file access denied under read-only mode]\n[exit code: 2]')
  913. })
  914. it('hints only when the composition advertises escalation', () => {
  915. const denied = { ...base, sandbox: { mode: 'read-only' as const, denied: true } }
  916. expect(renderPwshResult(denied, ['workspace-write'])).toBe(
  917. 'out\n[sandbox: file access denied under read-only mode]\n'
  918. + '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
  919. + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]',
  920. )
  921. })
  922. it('a confined run without a denial adds no sandbox marker', () => {
  923. expect(renderPwshResult({ ...base, sandbox: { mode: 'read-only', denied: false } })).toBe('out\n')
  924. })
  925. })
  926. describe('renderPwshProcessRead', () => {
  927. const base: ShellProcessRead = { delta: 'out\n', lossy: false }
  928. it('returns the delta verbatim for a lossless read', () => {
  929. expect(renderPwshProcessRead(base)).toBe('out\n')
  930. expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('')
  931. })
  932. it('appends the loss notice with the available spill paths', () => {
  933. expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' }))
  934. .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]')
  935. expect(renderPwshProcessRead({
  936. ...base,
  937. lossy: true,
  938. stdoutSpillPath: 'C:\\spill\\out.log',
  939. stderrSpillPath: 'C:\\spill\\err.log',
  940. }))
  941. .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]')
  942. })
  943. it('reports (unavailable) when a lossy read has no safe spill path', () => {
  944. expect(renderPwshProcessRead({ ...base, lossy: true }))
  945. .toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
  946. })
  947. it('an empty lossy delta is the notice alone', () => {
  948. expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' }))
  949. .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]')
  950. })
  951. it('inserts the separating newline only when the delta lacks one', () => {
  952. expect(renderPwshProcessRead({ delta: 'tail', lossy: true }))
  953. .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
  954. expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true }))
  955. .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
  956. })
  957. it('appends the runner-failed notice (denial outranked)', () => {
  958. expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true }))
  959. .toBe('x\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]')
  960. })
  961. it('appends the denial marker and hints only when escalation is advertised', () => {
  962. expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }))
  963. .toBe('x\n[sandbox: file access denied under read-only mode]')
  964. expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write']))
  965. .toBe('x\n[sandbox: file access denied under read-only mode]\n'
  966. + '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
  967. + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
  968. })
  969. })
  970. describe('processOutcome', () => {
  971. function settled(over: Partial<ShellProcess>): ShellProcess {
  972. return {
  973. status: 'completed',
  974. exitCode: 0,
  975. signal: null,
  976. done: Promise.resolve(),
  977. readOutput: () => ({ delta: '', lossy: false }),
  978. kill: () => false,
  979. ...over,
  980. }
  981. }
  982. it('maps a signal-killed process to killed with the signal detail', () => {
  983. expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
  984. .toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
  985. })
  986. it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
  987. expect(processOutcome(settled({ status: 'killed', exitCode: null })))
  988. .toEqual({ status: 'killed', detail: 'killed before exit' })
  989. })
  990. it('maps a completed process to its exit code', () => {
  991. expect(processOutcome(settled({ exitCode: 3 })))
  992. .toEqual({ status: 'completed', detail: 'exit code: 3' })
  993. })
  994. it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
  995. expect(processOutcome(settled({ exitCode: null })))
  996. .toEqual({ status: 'completed', detail: 'exit code: 0' })
  997. })
  998. })