tools.spec.ts 46 KB

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