import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { type SessionEvent } from '@deepseek-ai/dsh-session' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' // The Loader config lives under examples so both launch modes exercise the same // deployable topology: a local fixture adapter plus bare workspace plugins. const configPath = fileURLToPath(new URL( '../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml', import.meta.url, )) const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) /** Every `.jsonl` session log under `dir`. */ async function jsonlFiles(dir: string): Promise { const entries = await readdir(dir, { withFileTypes: true }) const paths = await Promise.all(entries.map(async (entry) => { const path = join(dir, entry.name) if (entry.isDirectory()) return jsonlFiles(path) return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] })) return paths.flat() } /** * Write git metadata mirroring the installer layout — a master clone owning the * shared git directory and one linked worktree on a staging branch — and return * the worktree file the model will try to write. */ async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> { const gitDir = join(cwd, 'master', '.git') const worktreeGitDir = join(gitDir, 'worktrees', 'staging') await mkdir(worktreeGitDir, { recursive: true }) await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n') const checkout = join(cwd, 'staging') await mkdir(checkout, { recursive: true }) await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`) const target = join(checkout, 'guarded.ts') await writeFile(target, 'original\n') return { checkout, target } } describe('source-guard through a real headless cordis.yml', () => { it('denies the model-requested write and leaves the staged file untouched', async () => { let events: SessionEvent[] = [] let contents = '' let target = '' const { stderr } = await runLoaderSmoke({ label: 'source-guard headless smoke', tempDirPrefix: 'source-guard-e2e-', binScript, configPath, tsconfigPath: repoTsconfig, binArgs: ['--config', configPath, 'edit the guarded file'], // The isolated cwd is not known when these options are built, so the // config and adapter resolve their fixture paths against the child's own // cwd, which is that directory. prepare: async (cwd) => { // macOS puts the temp directory behind the /var -> /private/var // symlink; the child resolves its cwd, so compare against the same // real path rather than the symlinked one this process was handed. target = (await stagingFixture(await realpath(cwd))).target }, inspect: async (cwd) => { const logs = await jsonlFiles(join(cwd, '.sessions')) expect(logs).toHaveLength(1) const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) contents = await readFile(target, 'utf8') }, }) expect(stderr).not.toContain('UNHANDLED') const results = events.filter( (event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') expect(results).toHaveLength(1) const result = results[0]?.data.message.content[0] expect(result?.isError).toBe(true) const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('') expect(text).toBe( `Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it ' + '— implement in a task worktree, then integrate under the staging lock.', ) // Enforcement, not advice: the guard denies before dispatch, so the file // the model targeted still holds its original bytes. expect(contents).toBe('original\n') }, LOADER_SMOKE_TEST_TIMEOUT_MS) })