source-guard.spec.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { dirname, join } from 'node:path'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import { Context } from 'cordis'
  6. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  7. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  8. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  9. import { CallId, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  10. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  11. import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import * as SourceGuard from '@deepseek-ai/dsh-source-guard'
  14. import type { Config } from '@deepseek-ai/dsh-source-guard'
  15. import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
  16. /**
  17. * Behavior suite for the staging-source guard: worktree resolution over REAL
  18. * git metadata fixtures (a staging worktree, a nested task worktree, a plain
  19. * clone, an unrelated repository, a detached HEAD), skill satisfaction replayed
  20. * from the durable session log, and fail-loud config validation — all driven
  21. * through a real agent loop against a scripted mock adapter (no network).
  22. */
  23. const roots: string[] = []
  24. afterEach(async () => {
  25. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
  26. })
  27. /**
  28. * Build a git-metadata fixture tree that mirrors the real installer layout: a
  29. * `master` clone holding the shared git directory, linked worktrees registered
  30. * under `master/.git/worktrees/<name>`, and one file per worktree to target.
  31. */
  32. async function fixture(): Promise<{
  33. /** Absolute path of the fixture container. */
  34. root: string
  35. /** A file inside the staging worktree — the protected target. */
  36. stagingFile: string
  37. /** A file inside a task worktree NESTED under the staging tree. */
  38. taskFile: string
  39. /** A file inside a SIBLING staging worktree of the same repository, on another branch. */
  40. siblingFile: string
  41. /** A file inside the plain master clone. */
  42. masterFile: string
  43. /** A file inside a worktree whose HEAD is detached. */
  44. detachedFile: string
  45. /** A file inside an unrelated repository sharing no git directory. */
  46. outsideFile: string
  47. /** A file under no repository at all. */
  48. looseFile: string
  49. }> {
  50. const root = await mkdtemp(join(tmpdir(), 'source-guard-'))
  51. roots.push(root)
  52. const master = join(root, 'master')
  53. const gitDir = join(master, '.git')
  54. await mkdir(join(gitDir, 'worktrees'), { recursive: true })
  55. await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n')
  56. await writeFile(join(master, 'file.ts'), 'master\n')
  57. /** Register one linked worktree at `path` whose HEAD file holds `head`. */
  58. async function linked(path: string, name: string, head: string): Promise<string> {
  59. const worktreeGitDir = join(gitDir, 'worktrees', name)
  60. await mkdir(worktreeGitDir, { recursive: true })
  61. await writeFile(join(worktreeGitDir, 'HEAD'), head)
  62. await mkdir(path, { recursive: true })
  63. await writeFile(join(path, '.git'), `gitdir: ${worktreeGitDir}\n`)
  64. const file = join(path, 'file.ts')
  65. await writeFile(file, 'content\n')
  66. return file
  67. }
  68. const staging = join(root, 'staging-20260728T022827Z')
  69. const stagingFile = await linked(staging, 'staging-20260728T022827Z', 'ref: refs/heads/dsh-staging/20260728T022827Z\n')
  70. // The prescribed workflow's task worktree lives INSIDE the staging tree.
  71. const taskFile = await linked(join(staging, '.worktrees', 'task', 'x'), 'task-x', 'ref: refs/heads/task/x\n')
  72. // A stale staging worktree from an earlier install: same repository, different branch.
  73. const siblingFile = await linked(
  74. join(root, 'staging-20260727T045831Z'),
  75. 'staging-20260727T045831Z',
  76. 'ref: refs/heads/dsh-staging/20260727T045831Z\n',
  77. )
  78. const detachedFile = await linked(join(root, 'detached'), 'detached', '0123456789abcdef0123456789abcdef01234567\n')
  79. const outside = join(root, 'outside')
  80. await mkdir(join(outside, '.git'), { recursive: true })
  81. await writeFile(join(outside, '.git', 'HEAD'), 'ref: refs/heads/dsh-staging/20260728T022827Z\n')
  82. const outsideFile = join(outside, 'file.ts')
  83. await writeFile(outsideFile, 'outside\n')
  84. const loose = join(root, 'loose')
  85. await mkdir(loose, { recursive: true })
  86. const looseFile = join(loose, 'file.ts')
  87. await writeFile(looseFile, 'loose\n')
  88. return {
  89. root, stagingFile, taskFile, siblingFile, masterFile: join(master, 'file.ts'), detachedFile, outsideFile, looseFile,
  90. }
  91. }
  92. /**
  93. * Boot the core spine, a real local filesystem, and the guard, pointing
  94. * `protectedCheckout` at a fixture path so the guard arms for the fixture
  95. * repository instead of the checkout these tests actually run in.
  96. */
  97. async function harness(protectedCheckout: string, config: Partial<Config> = {}): Promise<Context> {
  98. const ctx = new Context()
  99. await mountAgentLoopTestDependencies(ctx)
  100. await ctx.plugin(LocalFileSystem, {})
  101. await ctx.plugin(AgentLoop, { agents: [] })
  102. await ctx.plugin(SourceGuard, { ...config, protectedCheckout })
  103. for (const name of ['write', 'edit', 'read', 'skill']) {
  104. ctx.tools.register(defineContentToolFixture({
  105. name,
  106. description: name,
  107. parameters: { file_path: { type: 'string' }, name: { type: 'string' } },
  108. async execute() { return [{ type: 'text', text: 'ok' }] },
  109. }))
  110. }
  111. return ctx
  112. }
  113. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  114. return new Promise((resolve) => {
  115. const dispose = ctx.on('agent/status', (subject, status) => {
  116. if (subject === agent && status === 'idle') { dispose(); resolve() }
  117. })
  118. })
  119. }
  120. /** Every tool result in the agent's log as `{ isError, text }`, in log order. */
  121. function results(agent: Agent): { isError: boolean; text: string }[] {
  122. return [...agent.session.events]
  123. .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
  124. .map(event => event.data.message.content[0])
  125. .map(result => ({
  126. isError: result.isError === true,
  127. text: result.content.map(block => block.type === 'text' ? block.text : '').join(''),
  128. }))
  129. }
  130. /**
  131. * Durable events recording completed `skill` calls, as a RESUMED session's seed:
  132. * the guard's satisfaction check then has nothing but the log to read, with no
  133. * in-memory state from an original run to fall back on.
  134. */
  135. function priorSkillCalls(calls: { arguments: string; isError?: boolean }[]): SessionEvent[] {
  136. const events: SessionEvent[] = [
  137. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  138. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  139. ]
  140. for (const [index, call] of calls.entries()) {
  141. const callId = CallId(`prior${index}`)
  142. const seq = events.length
  143. events.push({
  144. type: 'tool/call',
  145. seq,
  146. time: seq + 1,
  147. data: { turn: 1, step: 1, callId, name: 'skill', arguments: call.arguments },
  148. })
  149. events.push({
  150. type: 'tool/result',
  151. seq: seq + 1,
  152. time: seq + 2,
  153. surfaceOp: 'append',
  154. sourceEventSeqs: [seq],
  155. data: {
  156. turn: 1,
  157. step: 1,
  158. message: createToolResultMessage({
  159. callId,
  160. content: [{ type: 'text', text: 'loaded' }],
  161. isError: call.isError ?? false,
  162. }),
  163. },
  164. })
  165. }
  166. const tail = events.length
  167. events.push({ type: 'step/end', seq: tail, time: tail + 1, data: { turn: 1, step: 1 } })
  168. events.push({ type: 'turn/end', seq: tail + 1, time: tail + 2, data: { turn: 1, reason: { kind: 'completed' } } })
  169. return events
  170. }
  171. /** Resume a session from durable seed events and let the model attempt one write at `path`. */
  172. async function resume(ctx: Context, id: string, seed: SessionEvent[], path: string): Promise<Agent> {
  173. const adapter = new MockAdapter([
  174. toolCallResponse(CallId('c0'), 'write', { file_path: path }),
  175. textResponse('done'),
  176. ])
  177. ctx.llm.registerAdapter(['mock'], adapter)
  178. const { agent } = await ctx.agentLoop.createAgent(ctx, {
  179. sessionId: SessionId(id),
  180. seed,
  181. agentOptions: { provider: 'mock', model: 'mock' },
  182. })
  183. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  184. await waitForIdle(ctx, agent)
  185. return agent
  186. }
  187. /** Drive one turn whose scripted model output is the given tool calls, then a closing text. */
  188. async function run(
  189. ctx: Context,
  190. calls: { name: string; args: Record<string, unknown> }[],
  191. cwd?: string,
  192. ): Promise<Agent> {
  193. const adapter = new MockAdapter([
  194. ...calls.map((call, index) => toolCallResponse(CallId(`c${index}`), call.name, call.args)),
  195. textResponse('done'),
  196. ])
  197. ctx.llm.registerAdapter(['mock'], adapter)
  198. const agent = ctx.agentLoop.create(
  199. SessionId('s1'),
  200. { provider: 'mock', model: 'mock' },
  201. cwd === undefined ? {} : { cwd },
  202. )
  203. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  204. await waitForIdle(ctx, agent)
  205. return agent
  206. }
  207. describe('staging protection', () => {
  208. it('denies a write inside the staging worktree and names the path, branch, and skill', async () => {
  209. const paths = await fixture()
  210. const ctx = await harness(paths.stagingFile)
  211. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
  212. const [result] = results(agent)
  213. expect(result?.isError).toBe(true)
  214. expect(result?.text).toBe(
  215. `Error: Editing "${paths.stagingFile}" directly is not allowed: it is inside the dsh checkout this session is running from, `
  216. + 'on branch dsh-staging/20260728T022827Z. Load the dsh-customize skill first and follow it '
  217. + '— implement in a task worktree, then integrate under the staging lock.',
  218. )
  219. })
  220. it('denies an edit inside the staging worktree', async () => {
  221. const paths = await fixture()
  222. const ctx = await harness(paths.stagingFile)
  223. const agent = await run(ctx, [{ name: 'edit', args: { file_path: paths.stagingFile } }])
  224. expect(results(agent)[0]?.isError).toBe(true)
  225. })
  226. it('allows a read inside the staging worktree, since inspection never violates the skill', async () => {
  227. const paths = await fixture()
  228. const ctx = await harness(paths.stagingFile)
  229. const agent = await run(ctx, [{ name: 'read', args: { file_path: paths.stagingFile } }])
  230. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  231. })
  232. it('allows a write inside a task worktree nested under the staging tree', async () => {
  233. const paths = await fixture()
  234. const ctx = await harness(paths.stagingFile)
  235. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }])
  236. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  237. })
  238. it('allows a write in the plain clone that owns the shared git directory', async () => {
  239. const paths = await fixture()
  240. const ctx = await harness(paths.stagingFile)
  241. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.masterFile } }])
  242. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  243. })
  244. it('allows a write on a staging-named branch in an unrelated repository', async () => {
  245. const paths = await fixture()
  246. const ctx = await harness(paths.stagingFile)
  247. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.outsideFile } }])
  248. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  249. })
  250. it('allows a write under a detached HEAD, which names no branch to match', async () => {
  251. const paths = await fixture()
  252. const ctx = await harness(paths.stagingFile)
  253. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.detachedFile } }])
  254. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  255. })
  256. it('allows a write when the git metadata exists but cannot be read', async () => {
  257. const paths = await fixture()
  258. // A `.git` pointer that stats as a file yet fails to read leaves the guard
  259. // with no branch to judge; failing open beats blocking every edit.
  260. const unreadable = join(paths.root, 'unreadable')
  261. await mkdir(unreadable, { recursive: true })
  262. await writeFile(join(unreadable, '.git'), `gitdir: ${join(paths.root, 'master', '.git')}\n`)
  263. await chmod(join(unreadable, '.git'), 0o000)
  264. const file = join(unreadable, 'file.ts')
  265. await writeFile(file, 'content\n')
  266. const ctx = await harness(paths.stagingFile)
  267. const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
  268. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  269. })
  270. it('allows a write under no repository at all', async () => {
  271. const paths = await fixture()
  272. const ctx = await harness(paths.stagingFile)
  273. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.looseFile } }])
  274. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  275. })
  276. it('arms for nothing when its own location is inside no repository', async () => {
  277. const paths = await fixture()
  278. const ctx = await harness(paths.looseFile)
  279. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
  280. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  281. })
  282. it('arms for nothing when the launcher checkout has a detached HEAD', async () => {
  283. const paths = await fixture()
  284. // A detached launcher names no branch, so there is no branch to protect.
  285. const ctx = await harness(paths.detachedFile)
  286. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
  287. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  288. })
  289. it('denies when the target and the protected checkout reach one repository through different symlinks', async () => {
  290. const paths = await fixture()
  291. // macOS reaches the temp directory through both `/var/...` and
  292. // `/private/var/...`; a lexical repository comparison would treat the two
  293. // routes as different repositories and fail open on every write.
  294. const link = join(paths.root, 'link')
  295. await symlink(dirname(paths.stagingFile), link, 'dir')
  296. const ctx = await harness(paths.stagingFile)
  297. const agent = await run(ctx, [{ name: 'write', args: { file_path: join(link, 'file.ts') } }])
  298. expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z')
  299. })
  300. it('denies a RELATIVE target path resolved against the session workspace', async () => {
  301. const paths = await fixture()
  302. const ctx = await harness(paths.stagingFile)
  303. // The filesystem tools resolve a relative `file_path` against the session
  304. // cwd, so judging only absolute paths would leave this as an unguarded
  305. // route to the same file.
  306. const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }], dirname(paths.stagingFile))
  307. expect(results(agent)[0]?.text).toContain('directly is not allowed')
  308. })
  309. it('ignores a relative target path when the session names no workspace', async () => {
  310. const paths = await fixture()
  311. const ctx = await harness(paths.stagingFile)
  312. const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }])
  313. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  314. })
  315. it('ignores a call whose target path is an empty string', async () => {
  316. const paths = await fixture()
  317. const ctx = await harness(paths.stagingFile)
  318. const agent = await run(ctx, [{ name: 'write', args: { file_path: '' } }])
  319. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  320. })
  321. it.each([
  322. ['a non-string file_path', { file_path: 7 }],
  323. ['no file_path at all', { other: 'x' }],
  324. ])('ignores a gated call carrying %s', async (_label, args) => {
  325. const paths = await fixture()
  326. const ctx = await harness(paths.stagingFile)
  327. const agent = await run(ctx, [{ name: 'write', args }])
  328. expect(results(agent)[0]?.text).not.toContain('directly is not allowed')
  329. })
  330. it('ignores a gated call whose arguments are not JSON, which the loop keeps as raw text', async () => {
  331. const paths = await fixture()
  332. const ctx = await harness(paths.stagingFile)
  333. const callId = CallId('raw')
  334. const adapter = new MockAdapter([
  335. [
  336. { type: 'block-start', index: 0, blockType: 'tool-call' },
  337. { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: 'not json' },
  338. { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: 'not json' } },
  339. { type: 'finish', reason: { kind: 'tool-calls' } },
  340. ],
  341. textResponse('done'),
  342. ])
  343. ctx.llm.registerAdapter(['mock'], adapter)
  344. const agent = ctx.agentLoop.create(SessionId('raw'), { provider: 'mock', model: 'mock' })
  345. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
  346. await waitForIdle(ctx, agent)
  347. expect(results(agent)[0]?.text).not.toContain('directly is not allowed')
  348. })
  349. it.each([
  350. ['a `.git` pointer that names no git directory', 'not a gitdir pointer\n'],
  351. ['an empty `.git` pointer', 'gitdir:\n'],
  352. ['a `.git` pointer into a nonexistent git directory', 'gitdir: /nonexistent/worktrees/x\n'],
  353. ])('allows a write behind %s', async (_label, pointer) => {
  354. const paths = await fixture()
  355. const broken = join(paths.root, 'broken')
  356. await mkdir(broken, { recursive: true })
  357. await writeFile(join(broken, '.git'), pointer)
  358. const file = join(broken, 'file.ts')
  359. await writeFile(file, 'content\n')
  360. const ctx = await harness(paths.stagingFile)
  361. const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
  362. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  363. })
  364. it('denies behind a RELATIVE `.git` pointer, which git resolves against the worktree', async () => {
  365. const paths = await fixture()
  366. // `git worktree add` writes an absolute pointer, but a relocated or
  367. // hand-written one may be relative; git accepts both, so the guard must
  368. // resolve both or it would fail open on a real repository layout.
  369. const relative = join(paths.root, 'relative-pointer')
  370. await mkdir(relative, { recursive: true })
  371. await writeFile(join(relative, '.git'), 'gitdir: ../master/.git/worktrees/staging-20260728T022827Z\n')
  372. const file = join(relative, 'file.ts')
  373. await writeFile(file, 'content\n')
  374. const ctx = await harness(paths.stagingFile)
  375. const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
  376. expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z')
  377. })
  378. it('allows a write when the worktree resolves but its HEAD is missing', async () => {
  379. const paths = await fixture()
  380. const gitDir = join(paths.root, 'master', '.git', 'worktrees', 'headless')
  381. await mkdir(gitDir, { recursive: true })
  382. const headless = join(paths.root, 'headless')
  383. await mkdir(headless, { recursive: true })
  384. await writeFile(join(headless, '.git'), `gitdir: ${gitDir}\n`)
  385. const file = join(headless, 'file.ts')
  386. await writeFile(file, 'content\n')
  387. const ctx = await harness(paths.stagingFile)
  388. const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }])
  389. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  390. })
  391. it('reuses one resolution for sibling targets in the same directory', async () => {
  392. const paths = await fixture()
  393. const sibling = join(dirname(paths.stagingFile), 'other.ts')
  394. await writeFile(sibling, 'content\n')
  395. const ctx = await harness(paths.stagingFile)
  396. const agent = await run(ctx, [
  397. { name: 'write', args: { file_path: paths.stagingFile } },
  398. { name: 'write', args: { file_path: sibling } },
  399. ])
  400. expect(results(agent).map(result => result.isError)).toEqual([true, true])
  401. })
  402. it('protects whichever branch the launcher checkout is on, whatever its name', async () => {
  403. const paths = await fixture()
  404. // The protected branch is read from `protectedCheckout`'s own worktree, so
  405. // a checkout on an unconventional branch name is still protected — a
  406. // hardcoded name pattern would have silently guarded nothing.
  407. const ctx = await harness(paths.taskFile)
  408. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }])
  409. expect(results(agent)[0]?.text).toContain('on branch task/x')
  410. })
  411. it('allows a write in a SIBLING checkout of the same repository on another branch', async () => {
  412. const paths = await fixture()
  413. // A stale staging worktree left by an earlier install shares the
  414. // repository but is not the live deployment, so the workflow rule the
  415. // guard enforces does not apply to it.
  416. const ctx = await harness(paths.siblingFile)
  417. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
  418. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  419. })
  420. it('gates only the configured tools', async () => {
  421. const paths = await fixture()
  422. const ctx = await harness(paths.stagingFile, { tools: ['edit'] })
  423. const agent = await run(ctx, [
  424. { name: 'write', args: { file_path: paths.stagingFile } },
  425. { name: 'edit', args: { file_path: paths.stagingFile } },
  426. ])
  427. expect(results(agent).map(result => result.isError)).toEqual([false, true])
  428. })
  429. })
  430. describe('skill satisfaction', () => {
  431. it('allows the write after a successful load of the required skill in the same turn', async () => {
  432. const paths = await fixture()
  433. const ctx = await harness(paths.stagingFile)
  434. const agent = await run(ctx, [
  435. { name: 'skill', args: { name: 'dsh-customize' } },
  436. { name: 'write', args: { file_path: paths.stagingFile } },
  437. ])
  438. expect(results(agent)).toEqual([
  439. { isError: false, text: 'ok' },
  440. { isError: false, text: 'ok' },
  441. ])
  442. })
  443. it('allows the write when the skill load is only in the REPLAYED log, so resume keeps satisfaction', async () => {
  444. const paths = await fixture()
  445. const ctx = await harness(paths.stagingFile)
  446. const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }) }])
  447. const agent = await resume(ctx, 'resumed', seed, paths.stagingFile)
  448. expect(results(agent).at(-1)).toEqual({ isError: false, text: 'ok' })
  449. })
  450. it('does not accept a failed skill load', async () => {
  451. const paths = await fixture()
  452. const ctx = await harness(paths.stagingFile)
  453. const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }), isError: true }])
  454. const agent = await resume(ctx, 'failed', seed, paths.stagingFile)
  455. expect(results(agent).at(-1)?.isError).toBe(true)
  456. })
  457. it('does not accept a different skill', async () => {
  458. const paths = await fixture()
  459. const ctx = await harness(paths.stagingFile)
  460. const agent = await run(ctx, [
  461. { name: 'skill', args: { name: 'dsh-upgrade' } },
  462. { name: 'write', args: { file_path: paths.stagingFile } },
  463. ])
  464. expect(results(agent).map(result => result.isError)).toEqual([false, true])
  465. })
  466. it('does not accept a skill call whose arguments are not a JSON object naming a string', async () => {
  467. const paths = await fixture()
  468. const ctx = await harness(paths.stagingFile)
  469. const seed = priorSkillCalls([
  470. { arguments: 'not json' },
  471. { arguments: '[]' },
  472. { arguments: '{"name":7}' },
  473. { arguments: 'null' },
  474. ])
  475. const agent = await resume(ctx, 'malformed', seed, paths.stagingFile)
  476. expect(results(agent).at(-1)?.isError).toBe(true)
  477. })
  478. it('honours a configured skill name other than the default', async () => {
  479. const paths = await fixture()
  480. const ctx = await harness(paths.stagingFile, { requiredSkill: 'other-skill' })
  481. const agent = await run(ctx, [
  482. { name: 'skill', args: { name: 'other-skill' } },
  483. { name: 'write', args: { file_path: paths.stagingFile } },
  484. ])
  485. expect(results(agent).map(result => result.isError)).toEqual([false, false])
  486. })
  487. })
  488. describe('non-agent callers', () => {
  489. it('leaves a direct registry call ungated, having no session to replay', async () => {
  490. const paths = await fixture()
  491. const ctx = await harness(paths.stagingFile)
  492. const result = await ctx.tools.execute({
  493. callId: CallId('direct'),
  494. name: 'write',
  495. arguments: { file_path: paths.stagingFile },
  496. signal: new AbortController().signal,
  497. })
  498. expect(result.isError).toBe(false)
  499. })
  500. })
  501. describe('config validation', () => {
  502. it.each([
  503. ['tools', { tools: [] }, '`tools` must not be empty'],
  504. ['requiredSkill', { requiredSkill: ' ' }, '`requiredSkill` must not be blank'],
  505. ['protectedCheckout', { protectedCheckout: 'relative/path' }, '`protectedCheckout` must be an absolute path'],
  506. ])('rejects an invalid %s at load', async (_field, config, message) => {
  507. const ctx = new Context()
  508. await mountAgentLoopTestDependencies(ctx)
  509. await ctx.plugin(LocalFileSystem, {})
  510. await expect(ctx.plugin(SourceGuard, config as Config)).rejects.toThrow(message)
  511. })
  512. })
  513. describe('disposal', () => {
  514. it('stops gating once the plugin fiber is disposed', async () => {
  515. const paths = await fixture()
  516. const ctx = new Context()
  517. await mountAgentLoopTestDependencies(ctx)
  518. await ctx.plugin(LocalFileSystem, {})
  519. await ctx.plugin(AgentLoop, { agents: [] })
  520. const fiber = await ctx.plugin(SourceGuard, { protectedCheckout: paths.stagingFile })
  521. for (const name of ['write', 'skill']) {
  522. ctx.tools.register(defineContentToolFixture({
  523. name,
  524. description: name,
  525. parameters: { file_path: { type: 'string' }, name: { type: 'string' } },
  526. async execute() { return [{ type: 'text', text: 'ok' }] },
  527. }))
  528. }
  529. await fiber.dispose()
  530. const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }])
  531. expect(results(agent)).toEqual([{ isError: false, text: 'ok' }])
  532. })
  533. })