tools.spec.ts 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105
  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, 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 ToolJobs 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 async start(spec: ShellExecSpec): Promise<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 setupWithJobs(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(ToolJobs)
  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 async start(spec: ShellExecSpec): Promise<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(ToolJobs)
  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. async function registerFakeAgent(ctx: Context, sessionId: string): Promise<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. await 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 = await 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 = await 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 = await 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 preserves Session cwd spelling; its enforcing provider owns canonicalization.
  513. expect(bash.requests[0]?.sandboxPolicy).toEqual({
  514. mode: 'read-only',
  515. workspaceRoot: sessionCwd,
  516. sessionId: 'policy-session',
  517. })
  518. })
  519. it('falls back to the deployment policy without an agent, and omits the field entirely without a confining executor', async () => {
  520. const { ctx, bash } = await setupSandboxed()
  521. await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
  522. expect(bash.requests[0]?.sandboxPolicy).toEqual({
  523. mode: 'read-only',
  524. workspaceRoot: process.cwd(),
  525. })
  526. // The base FakeBash advertises no sandboxMode, so the tool must not stamp
  527. // any policy (the executor defaulting stays the executor's own).
  528. const plain = await setup()
  529. await call(plain.ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' })
  530. expect(plain.bash.requests[0]).not.toHaveProperty('sandboxPolicy')
  531. })
  532. it('fails load when a confining executor has no shared sandbox-policy resolver', async () => {
  533. const ctx = new Context()
  534. await ctx.plugin(SystemPrompt)
  535. await ctx.plugin(ToolRuntime)
  536. await ctx.plugin(AgentRegistry)
  537. await ctx.plugin(BashEnvPlugin)
  538. await ctx.plugin(ConfiningFakeBash)
  539. await expect(ctx.plugin(ToolPwsh)).rejects.toThrow(
  540. 'tool-pwsh: the mounted bash executor confines but ctx.sandboxPolicy is missing',
  541. )
  542. })
  543. })
  544. describe('sandbox escalation through ctx.approval', () => {
  545. const escalate = {
  546. command: 'Write-Output ok',
  547. description: 'test escalation',
  548. sandbox_permissions: 'workspace-write',
  549. justification: 'the command needs workspace writes',
  550. }
  551. it('advertises the sandbox fields, the escalation clause, and the confined-mode contracts', async () => {
  552. const { ctx } = await setupSandboxed()
  553. const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
  554. const properties = schema.parameters.properties as Record<string, { enum?: string[] }>
  555. expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
  556. expect(schema.description).toContain('approval prompt')
  557. expect(schema.description).toContain('ConstrainedLanguage')
  558. expect(schema.description).toContain('workspace-write stays in FullLanguage')
  559. expect(schema.description).toContain('In both confined modes, programs cannot open named pipes')
  560. expect(schema.description).toContain('fails with EPERM')
  561. for (const args of [
  562. { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write' },
  563. { command: 'Write-Output ok', description: 'd', justification: 'why' },
  564. { command: 'Write-Output ok', description: 'd', sandbox_permissions: 'workspace-write', justification: ' ' },
  565. ]) {
  566. expect((await call(ctx, 'pwsh', args)).isError).toBe(true)
  567. }
  568. })
  569. it('the escalation fields and the confined-mode clauses stay out of sandbox-less compositions', async () => {
  570. const { ctx } = await setup()
  571. const schema = ctx.tools.schemas().find(item => item.name === 'pwsh')!
  572. expect(schema.description).not.toContain('ConstrainedLanguage')
  573. expect(schema.description).not.toContain('named pipes')
  574. expect(schema.description).not.toContain('sandbox_permissions')
  575. expect(schema.parameters.properties).not.toHaveProperty('sandbox_permissions')
  576. })
  577. it('rejects injected escalation without a sandbox and narrower escalation without prompting', async () => {
  578. const plain = await setup()
  579. expect(text(await call(plain.ctx, 'pwsh', escalate))).toContain('not available in this composition')
  580. const { ctx } = await setupSandboxed(true)
  581. const prompted = vi.fn()
  582. ctx.on('approval/request', () => { prompted(); return Promise.resolve<ApprovalOutcome>('allowed-once') })
  583. const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'workspace-write' }, sandboxAgent('danger-full-access'))
  584. expect(text(result)).toContain('not strictly wider')
  585. expect(prompted).not.toHaveBeenCalled()
  586. const malformed = sandboxAgent()
  587. ;(malformed.session.append as unknown as (
  588. type: string,
  589. data: Record<string, unknown>,
  590. ) => unknown)('sandbox/mode', { mode: 'unknown-mode' })
  591. expect(text(await call(ctx, 'pwsh', escalate, malformed))).toContain('not strictly wider')
  592. })
  593. it.each(['workspace-write', 'danger-full-access'] as const)('runs a repeated %s request without approval', async (mode) => {
  594. const { ctx, bash } = await setupSandboxed()
  595. const result = await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: mode }, sandboxAgent(mode))
  596. expect(result.isError).toBe(false)
  597. expect(bash.modes).toEqual([mode])
  598. })
  599. it('fails closed when approval cannot be routed', async () => {
  600. const withoutService = await setupSandboxed()
  601. expect(text(await call(withoutService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval service')
  602. const withService = await setupSandboxed(true)
  603. expect(text(await call(withService.ctx, 'pwsh', escalate))).toContain('no agent to route')
  604. expect(text(await call(withService.ctx, 'pwsh', escalate, sandboxAgent()))).toContain('no approval channel')
  605. })
  606. it.each([
  607. ['rejected', 'user rejected'],
  608. ['cancelled', 'was cancelled'],
  609. ] as const)('maps an approval %s to its distinct failure', async (outcome, message) => {
  610. const { ctx, bash } = await setupSandboxed(true)
  611. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>(outcome))
  612. const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
  613. expect(text(result)).toContain(message)
  614. expect(bash.modes).toEqual([])
  615. })
  616. it('runs a granted foreground or background call under the approved mode', async () => {
  617. const { ctx, bash } = await setupSandboxed(true)
  618. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  619. const agent = sandboxAgent(undefined, ctx)
  620. await ctx.agents.register(agent)
  621. const foreground = await ctx.tools.execute({
  622. callId: ToolCallId('sandbox-signal'),
  623. name: 'pwsh',
  624. arguments: escalate,
  625. agent,
  626. signal: new AbortController().signal,
  627. })
  628. expect(foreground.isError).toBe(false)
  629. const background = await call(ctx, 'pwsh', { ...escalate, run_in_background: true }, agent)
  630. expect(text(background)).toBe('started background job pwsh-1')
  631. expect(bash.modes).toEqual(['workspace-write', 'workspace-write'])
  632. })
  633. it('does not publish detached work when cancellation follows the escalation grant', async () => {
  634. const { ctx, bash } = await setupSandboxed(true)
  635. const controller = new AbortController()
  636. const agent = sandboxAgent(undefined, ctx, (type) => {
  637. if (type === 'approval/decided') controller.abort()
  638. })
  639. await ctx.agents.register(agent)
  640. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  641. const start = vi.spyOn(bash, 'start')
  642. const result = await ctx.tools.execute({
  643. callId: ToolCallId('cancelled-escalation-background'),
  644. name: 'pwsh',
  645. arguments: { ...escalate, run_in_background: true },
  646. agent,
  647. signal: controller.signal,
  648. })
  649. expect(result.error).toEqual({
  650. message: 'tool call aborted',
  651. info: { name: 'AbortError', code: TOOL_ABORTED },
  652. })
  653. expect(text(result)).toBe('Error: tool call aborted')
  654. expect(start).not.toHaveBeenCalled()
  655. })
  656. it('uses the session override for ordinary calls and evaluates widening against it', async () => {
  657. const { ctx, bash } = await setupSandboxed(true)
  658. const agent = sandboxAgent('workspace-write')
  659. await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'ordinary' }, agent)
  660. ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
  661. await call(ctx, 'pwsh', { ...escalate, sandbox_permissions: 'danger-full-access' }, agent)
  662. expect(bash.modes).toEqual(['workspace-write', 'danger-full-access'])
  663. })
  664. it('omits sandbox facts the executor did not acquire from the canonical result', async () => {
  665. const { ctx } = await setupSandboxed()
  666. const result = await call(ctx, 'pwsh', {
  667. command: 'without optional sandbox facts',
  668. description: 'exercise optional sandbox facts',
  669. })
  670. if (result.isError) throw new Error('expected foreground pwsh success')
  671. expect(result.value).toMatchObject({
  672. kind: 'foreground',
  673. sandbox: { mode: 'read-only', denied: false },
  674. })
  675. expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('enforcement')
  676. expect((result.value as { sandbox: object }).sandbox).not.toHaveProperty('runnerFailed')
  677. })
  678. it('keeps the exhaustiveness backstop for a rogue approval implementation', async () => {
  679. const { ctx } = await setupSandboxed(true)
  680. ctx.approval.request = () => Promise.resolve('rogue' as ApprovalOutcome)
  681. const result = await call(ctx, 'pwsh', escalate, sandboxAgent())
  682. expect(text(result)).toContain('unreachable variant in EscalationOutcome')
  683. })
  684. })
  685. describe('background execution through the job runtime', () => {
  686. it('run_in_background acks with the job id, readable through the REAL job_output tool', async () => {
  687. const { ctx } = await setupWithJobs()
  688. const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true })
  689. expect(started.isError).toBe(false)
  690. if (started.isError) throw new Error('expected background pwsh success')
  691. expect(started.value).toEqual({ kind: 'background', jobId: 'pwsh-1' })
  692. expect(text(started)).toBe('started background job pwsh-1')
  693. const read = await callUntilText(ctx, 'job_output', { job_id: 'pwsh-1' }, 'bg-ok')
  694. expect(text(read)).toContain('bg-ok')
  695. // A later read reports the terminal outcome in the generic status line.
  696. const final = await callUntilText(ctx, 'job_output', { job_id: 'pwsh-1' }, '[status: completed, exit code: 0]')
  697. expect(final.isError).toBe(false)
  698. })
  699. it('a running background job is killable through the REAL job_kill tool', async () => {
  700. const { ctx, bash } = await setupWithJobs()
  701. bash.backgroundHandler = () => killableProcess()
  702. await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
  703. const killed = await call(ctx, 'job_kill', { job_id: 'pwsh-1' })
  704. expect(text(killed)).toBe('requested cancellation of job pwsh-1')
  705. // The cancel reached the process handle; the task settles as killed with
  706. // the signal detail mapped by processOutcome.
  707. const final = await call(ctx, 'job_output', { job_id: 'pwsh-1', wait: true })
  708. expect(text(final)).toContain('[status: killed, signal: SIGTERM]')
  709. })
  710. it('a background job started by an agent is registered with that agent as owner', async () => {
  711. const { ctx } = await setupWithJobs()
  712. const agent = await registerFakeAgent(ctx, 'sess-owner')
  713. const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent)
  714. expect(text(started)).toBe('started background job pwsh-1')
  715. const anon = await call(ctx, 'job_output', { job_id: 'pwsh-1' })
  716. expect(anon.isError).toBe(true)
  717. expect(text(anon)).toMatch(/belongs to another session/)
  718. const killed = await call(ctx, 'job_kill', { job_id: 'pwsh-1' }, agent)
  719. expect(killed.isError).toBe(false)
  720. await call(ctx, 'job_output', { job_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan
  721. })
  722. it('fails loud when the job runtime is not loaded', async () => {
  723. const { ctx } = await setup() // no LocalJobRegistry / ToolJobs
  724. const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
  725. expect(result.isError).toBe(true)
  726. expect(text(result)).toContain('background jobs unavailable: load @deepseek-ai/dsh-jobs and @deepseek-ai/dsh-tool-jobs')
  727. })
  728. it('a pre-aborted call is skipped before the process starts', async () => {
  729. const { ctx, bash } = await setupWithJobs()
  730. const controller = new AbortController()
  731. controller.abort()
  732. const result = await ctx.tools.execute({
  733. callId: ToolCallId('call-pre-aborted'),
  734. name: 'pwsh',
  735. arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true },
  736. signal: controller.signal,
  737. })
  738. expect(result.isError).toBe(true)
  739. expect(result.error).toEqual({
  740. message: 'tool call aborted before dispatch',
  741. info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  742. })
  743. expect(bash.startCalls).toBe(0)
  744. })
  745. it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
  746. // With no job controller, preflight fails before the executor can spawn.
  747. const ctx = new Context()
  748. await ctx.plugin(SystemPrompt)
  749. await ctx.plugin(ToolRuntime)
  750. await ctx.plugin(LocalJobRegistry)
  751. await ctx.plugin(BashEnvPlugin)
  752. await ctx.plugin(FakeBash)
  753. await ctx.plugin(ToolPwsh)
  754. const bash = ctx.shell as FakeBash
  755. const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
  756. expect(result.isError).toBe(true)
  757. expect(text(result)).toContain('no job controller serves this agent')
  758. // Declare-then-execute: the failed preflight means no process ever ran.
  759. expect(bash.startCalls).toBe(0)
  760. })
  761. it('enableRunInBackground: false removes the parameter and flips the description', async () => {
  762. const { ctx } = await setup({ enableRunInBackground: false })
  763. const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')!
  764. expect(Object.keys(schema.parameters.properties as Record<string, unknown>))
  765. .toEqual(['command', 'description', 'timeoutMs', 'workdir'])
  766. expect(schema.description).toContain('Background execution is not available')
  767. expect(schema.description).not.toContain('run_in_background')
  768. // Schema omission is advertising; execution must also enforce the opt-out.
  769. const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true })
  770. expect(forced.isError).toBe(true)
  771. expect(text(forced)).toContain('run_in_background is disabled for this deployment')
  772. const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' })
  773. expect(foreground.isError).toBe(false)
  774. })
  775. it('applies the built-in background default when apply() receives a bare config', async () => {
  776. // Bypasses the schemastery defaults on purpose: apply() must stand on its
  777. // own `?? true` fallback when embedded programmatically without the schema.
  778. const ctx = new Context()
  779. await ctx.plugin(SystemPrompt)
  780. await ctx.plugin(ToolRuntime)
  781. await ctx.plugin(BashEnvPlugin)
  782. await ctx.plugin(FakeBash)
  783. ToolPwsh.apply(ctx, {})
  784. const schema = ctx.tools.schemas()[0]!
  785. expect(schema.parameters.properties).toHaveProperty('run_in_background')
  786. expect(schema.description).toContain('job_output')
  787. })
  788. })
  789. describe('UI presentation', () => {
  790. it('a real execute presents a completed foreground run as a terminal card with the parsed exit pill', async () => {
  791. const { ctx, bash } = await setup()
  792. bash.handler = () => runResult('hi\n')
  793. const args = { command: 'Write-Output hi', description: 'say hi' }
  794. const result = await call(ctx, 'pwsh', args)
  795. const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
  796. // A terminal result keeps the RAW bytes (newlines intact) a terminal
  797. // renderer needs; a clean run renders no exit marker, so the body is the
  798. // raw output with a clean exit-0 pill, mirroring the bash tool.
  799. expect(view).toEqual({ card: 'terminal', output: 'hi\n', exitCode: 0 })
  800. })
  801. it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => {
  802. const { ctx } = await setup()
  803. const definition = ctx.tools.get('pwsh')
  804. expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' }))
  805. .toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' })
  806. expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' }))
  807. .toMatchObject({ cwd: 'C:\\work' })
  808. })
  809. it('a background pending call renders the generic card like the bash tool', async () => {
  810. const { ctx } = await setup()
  811. const definition = ctx.tools.get('pwsh')
  812. expect(definition?.presentCall?.({
  813. command: 'Start-Sleep -Seconds 60',
  814. description: 'long wait',
  815. run_in_background: true,
  816. })).toEqual({
  817. card: 'generic',
  818. title: 'Start-Sleep -Seconds 60',
  819. kind: 'execute',
  820. rawInput: 'Start-Sleep -Seconds 60',
  821. content: [{ type: 'text', text: 'long wait' }],
  822. })
  823. })
  824. it('presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
  825. const { ctx } = await setup()
  826. const present = ctx.tools.get('pwsh')
  827. const args = { command: 'x', description: 'x' }
  828. expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }))
  829. .toEqual({ card: 'terminal', output: 'oops', exitCode: 3 })
  830. expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }))
  831. .toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' })
  832. })
  833. it('presentResult: markers a pill CANNOT show (timeout) stay in the terminal output', async () => {
  834. const { ctx } = await setup()
  835. const args = { command: 'x', description: 'x' }
  836. expect(ctx.tools.get('pwsh')?.presentResult?.(
  837. args,
  838. { content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false },
  839. )).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 })
  840. })
  841. it('presentResult exit parse is the inverse of renderPwshResult markers (round-trip)', async () => {
  842. const { ctx } = await setup()
  843. const present = ctx.tools.get('pwsh')!
  844. const base = {
  845. aborted: false,
  846. timeoutMs: 1000,
  847. stdout: { text: 'out', truncated: false },
  848. stderr: { text: '', truncated: false },
  849. }
  850. const cases = [
  851. { result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
  852. { result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
  853. { result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
  854. // A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
  855. { result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
  856. ]
  857. for (const c of cases) {
  858. const rendered = renderPwshResult(c.result)
  859. const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
  860. // Drop card + output; the remaining fields are the parsed exit.
  861. const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
  862. expect(exit).toEqual(c.expect)
  863. // Whatever the parse consumed is gone from the body, so a card with an
  864. // exit pill never shows the same status twice.
  865. expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /)
  866. }
  867. })
  868. it('presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
  869. const { ctx } = await setup()
  870. const args = { command: 'Write-Output "[exit code: 5]"', description: 'print' }
  871. // A successful command may print marker-like text. A clean result appends no marker or
  872. // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
  873. const out = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
  874. expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
  875. // Same for a fake signal marker with no leading newline.
  876. const sig = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
  877. expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })
  878. })
  879. it('presentResult: a run_in_background ack is a generic card and carries no exit pill', async () => {
  880. const { ctx } = await setup()
  881. const result = ctx.tools.get('pwsh')!.presentResult!(
  882. { command: 'Start-Sleep -Seconds 60', description: 'long wait', run_in_background: true },
  883. { content: [{ type: 'text', text: 'started background job pwsh-1' }], isError: false },
  884. )
  885. expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background job pwsh-1\n```' }] })
  886. })
  887. it('presentResult: an isError result is a generic card (no real process exit to report)', async () => {
  888. const { ctx } = await setup()
  889. const out = ctx.tools.get('pwsh')!.presentResult!(
  890. { command: 'x', description: 'x' },
  891. { content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
  892. )
  893. expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
  894. })
  895. it('presentResult falls back to undefined for multi-block or non-text content', async () => {
  896. const { ctx } = await setup()
  897. const definition = ctx.tools.get('pwsh')
  898. const args = { command: 'Write-Output hi', description: 'say hi' }
  899. const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false }
  900. expect(definition?.presentResult?.(args, multi as never)).toBeUndefined()
  901. const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false }
  902. expect(definition?.presentResult?.(args, image as never)).toBeUndefined()
  903. })
  904. })
  905. describe('renderPwshResult sandbox markers', () => {
  906. const base = {
  907. exitCode: 0,
  908. signal: null,
  909. timedOut: false,
  910. timeoutMs: 1000,
  911. stdout: { text: 'out\n', truncated: false },
  912. stderr: { text: '', truncated: false },
  913. }
  914. it('a denied run reports the denial marker before the exit marker', () => {
  915. expect(renderPwshResult({ ...base, exitCode: 2, sandbox: { mode: 'read-only', denied: true } }))
  916. .toBe('out\n[sandbox: file access denied under read-only mode]\n[exit code: 2]')
  917. })
  918. it('hints only when the composition advertises escalation', () => {
  919. const denied = { ...base, sandbox: { mode: 'read-only' as const, denied: true } }
  920. expect(renderPwshResult(denied, ['workspace-write'])).toBe(
  921. 'out\n[sandbox: file access denied under read-only mode]\n'
  922. + '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
  923. + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]',
  924. )
  925. })
  926. it('a confined run without a denial adds no sandbox marker', () => {
  927. expect(renderPwshResult({ ...base, sandbox: { mode: 'read-only', denied: false } })).toBe('out\n')
  928. })
  929. })
  930. describe('renderPwshProcessRead', () => {
  931. const base: ShellProcessRead = { delta: 'out\n', lossy: false }
  932. it('returns the delta verbatim for a lossless read', () => {
  933. expect(renderPwshProcessRead(base)).toBe('out\n')
  934. expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('')
  935. })
  936. it('appends the loss notice with the available spill paths', () => {
  937. expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' }))
  938. .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]')
  939. expect(renderPwshProcessRead({
  940. ...base,
  941. lossy: true,
  942. stdoutSpillPath: 'C:\\spill\\out.log',
  943. stderrSpillPath: 'C:\\spill\\err.log',
  944. }))
  945. .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]')
  946. })
  947. it('reports (unavailable) when a lossy read has no safe spill path', () => {
  948. expect(renderPwshProcessRead({ ...base, lossy: true }))
  949. .toBe('out\n[some output was dropped from memory; full output: (unavailable)]')
  950. })
  951. it('an empty lossy delta is the notice alone', () => {
  952. expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' }))
  953. .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]')
  954. })
  955. it('inserts the separating newline only when the delta lacks one', () => {
  956. expect(renderPwshProcessRead({ delta: 'tail', lossy: true }))
  957. .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
  958. expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true }))
  959. .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]')
  960. })
  961. it('appends the runner-failed notice (denial outranked)', () => {
  962. expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true, runnerFailed: true }))
  963. .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]')
  964. })
  965. it('appends the denial marker and hints only when escalation is advertised', () => {
  966. expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }))
  967. .toBe('x\n[sandbox: file access denied under read-only mode]')
  968. expect(renderPwshProcessRead({ delta: 'x', lossy: false }, { mode: 'read-only', denied: true }, ['workspace-write']))
  969. .toBe('x\n[sandbox: file access denied under read-only mode]\n'
  970. + '[sandbox: escalation available — retry this exact command once with sandbox_permissions '
  971. + '(the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
  972. })
  973. })
  974. describe('processOutcome', () => {
  975. function settled(over: Partial<ShellProcess>): ShellProcess {
  976. return {
  977. status: 'completed',
  978. exitCode: 0,
  979. signal: null,
  980. done: Promise.resolve(),
  981. readOutput: () => ({ delta: '', lossy: false }),
  982. kill: () => false,
  983. ...over,
  984. }
  985. }
  986. it('maps a signal-killed process to killed with the signal detail', () => {
  987. expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' })))
  988. .toEqual({ status: 'killed', detail: 'signal: SIGTERM' })
  989. })
  990. it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => {
  991. expect(processOutcome(settled({ status: 'killed', exitCode: null })))
  992. .toEqual({ status: 'killed', detail: 'killed before exit' })
  993. })
  994. it('maps a completed process to its exit code', () => {
  995. expect(processOutcome(settled({ exitCode: 3 })))
  996. .toEqual({ status: 'completed', detail: 'exit code: 3' })
  997. })
  998. it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => {
  999. expect(processOutcome(settled({ exitCode: null })))
  1000. .toEqual({ status: 'completed', detail: 'exit code: 0' })
  1001. })
  1002. })