loader-composition.e2e.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
  2. import { join } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import { describe, expect, it } from 'vitest'
  5. import { type SessionEvent } from '@deepseek-ai/dsh-session'
  6. import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  7. // The Loader config lives under examples so both launch modes exercise the same
  8. // deployable topology: a local fixture adapter plus bare workspace plugins.
  9. const configPath = fileURLToPath(new URL(
  10. '../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml',
  11. import.meta.url,
  12. ))
  13. const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url))
  14. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
  15. /** Every `.jsonl` session log under `dir`. */
  16. async function jsonlFiles(dir: string): Promise<string[]> {
  17. const entries = await readdir(dir, { withFileTypes: true })
  18. const paths = await Promise.all(entries.map(async (entry) => {
  19. const path = join(dir, entry.name)
  20. if (entry.isDirectory()) return jsonlFiles(path)
  21. return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : []
  22. }))
  23. return paths.flat()
  24. }
  25. /**
  26. * Write git metadata mirroring the installer layout — a master clone owning the
  27. * shared git directory and one linked worktree on a staging branch — and return
  28. * the worktree file the model will try to write.
  29. */
  30. async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> {
  31. const gitDir = join(cwd, 'master', '.git')
  32. const worktreeGitDir = join(gitDir, 'worktrees', 'staging')
  33. await mkdir(worktreeGitDir, { recursive: true })
  34. await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n')
  35. await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n')
  36. const checkout = join(cwd, 'staging')
  37. await mkdir(checkout, { recursive: true })
  38. await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`)
  39. const target = join(checkout, 'guarded.ts')
  40. await writeFile(target, 'original\n')
  41. return { checkout, target }
  42. }
  43. describe('source-guard through a real headless cordis.yml', () => {
  44. it('denies the model-requested write and leaves the staged file untouched', async () => {
  45. let events: SessionEvent[] = []
  46. let contents = ''
  47. let target = ''
  48. const { stderr } = await runLoaderSmoke({
  49. label: 'source-guard headless smoke',
  50. tempDirPrefix: 'source-guard-e2e-',
  51. binScript,
  52. configPath,
  53. tsconfigPath: repoTsconfig,
  54. binArgs: ['--config', configPath, 'edit the guarded file'],
  55. // The isolated cwd is not known when these options are built, so the
  56. // config and adapter resolve their fixture paths against the child's own
  57. // cwd, which is that directory.
  58. prepare: async (cwd) => {
  59. // macOS puts the temp directory behind the /var -> /private/var
  60. // symlink; the child resolves its cwd, so compare against the same
  61. // real path rather than the symlinked one this process was handed.
  62. target = (await stagingFixture(await realpath(cwd))).target
  63. },
  64. inspect: async (cwd) => {
  65. const logs = await jsonlFiles(join(cwd, '.sessions'))
  66. expect(logs).toHaveLength(1)
  67. const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n')
  68. events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent)
  69. contents = await readFile(target, 'utf8')
  70. },
  71. })
  72. expect(stderr).not.toContain('UNHANDLED')
  73. const results = events.filter(
  74. (event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
  75. expect(results).toHaveLength(1)
  76. const result = results[0]?.data.message.content[0]
  77. expect(result?.isError).toBe(true)
  78. const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('')
  79. expect(text).toBe(
  80. `Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, `
  81. + 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it '
  82. + '— implement in a task worktree, then integrate under the staging lock.',
  83. )
  84. // Enforcement, not advice: the guard denies before dispatch, so the file
  85. // the model targeted still holds its original bytes.
  86. expect(contents).toBe('original\n')
  87. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  88. })