subagent-inheritance.snapshot.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. /**
  2. * Assembled-app regression: a parent-only read-only override is seeded into
  3. * its child log and confines a real write under a wider deployment default.
  4. */
  5. import { readFile, readdir, writeFile } from 'node:fs/promises'
  6. import { join } from 'node:path'
  7. import { fileURLToPath } from 'node:url'
  8. import { Context } from 'cordis'
  9. import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot'
  10. import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  11. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  12. import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
  13. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  14. import { describe, expect, it } from 'vitest'
  15. const fixtureDir = fileURLToPath(new URL('./subagent-inheritance-snapshots/parent-override', import.meta.url))
  16. const replayOverride = join(fixtureDir, 'replay.override.json')
  17. const childReplay = join(fixtureDir, 'child.replay.jsonl')
  18. const parentExpected = join(fixtureDir, 'parent.expected.jsonl')
  19. const childExpected = join(fixtureDir, 'child.expected.jsonl')
  20. const configPath = fileURLToPath(new URL('../subagent-inheritance.cordis.snapshot.yml', import.meta.url))
  21. const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
  22. const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  23. const sessionId = SessionId('subagent-inheritance-parent')
  24. const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
  25. const task = 'Delegate the write probe to a subagent.'
  26. /** Seed a completed parent turn with the only read-only fact in the app. */
  27. async function seedReadOnlyParent(root: string, cwd: string): Promise<void> {
  28. const ctx = new Context()
  29. await ctx.plugin(SessionStore)
  30. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  31. const meta: SessionHeader = {
  32. version: SESSION_FORMAT_VERSION,
  33. id: sessionId,
  34. createdAt: 1,
  35. cwd,
  36. delegationDepth: 0,
  37. }
  38. const events: SessionEvent[] = [
  39. { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } },
  40. { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
  41. { type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } },
  42. { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } },
  43. ]
  44. try {
  45. await ctx.sessionPersistence.create(meta)
  46. await ctx.sessionPersistence.append(sessionId, events)
  47. } finally {
  48. await ctx.fiber.dispose()
  49. }
  50. }
  51. describe('parent-only override inheritance snapshot', () => {
  52. it('confines a delegated child through the assembled headless app', async () => {
  53. let cwd = ''
  54. const result = await runLoaderSmoke({
  55. label: 'subagent inheritance headless stream-json snapshot',
  56. tempDirPrefix: 'dsh-subagent-inherit-',
  57. binScript,
  58. configPath,
  59. binArgs: ['--config', configPath, '--output-format', 'stream-json', task],
  60. tsconfigPath,
  61. env: {
  62. // The primary fixture path must exist for llm-replay's config guard;
  63. // the override sidecar fully replaces the derived parent script.
  64. DSH_SNAPSHOT_FILE: replayOverride,
  65. DSH_SNAPSHOT_OVERRIDE: replayOverride,
  66. DSH_SNAPSHOT_CHILD_FILES: childReplay,
  67. },
  68. prepare: async (runCwd) => {
  69. cwd = runCwd
  70. await seedReadOnlyParent(join(runCwd, '.sessions'), runCwd)
  71. },
  72. inspect: async (runCwd) => {
  73. // THE physical fact: the child's write never reached the disk. Under
  74. // the deployment default (workspace-write) alone it would succeed.
  75. await expect(readFile(join(runCwd, 'inherited.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  76. // Collect both persisted logs (parent resumed turn + child run).
  77. const sessionsDir = join(runCwd, '.sessions')
  78. const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl'))
  79. const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8')))
  80. const headerOf = (content: string): Record<string, unknown> =>
  81. JSON.parse(content.split('\n')[0] ?? '{}') as Record<string, unknown>
  82. const parent = logs.find(content => content.includes('"subagent-inheritance-parent"'))
  83. const child = logs.find(content => typeof headerOf(content).parentSession === 'string')
  84. if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log')
  85. const childRecords = child.trimEnd().split('\n').map(
  86. line => JSON.parse(line) as Record<string, unknown>,
  87. )
  88. expect(childRecords[1]).toMatchObject({
  89. type: 'sandbox/mode',
  90. seq: 0,
  91. data: { mode: 'read-only', source: 'delegation' },
  92. })
  93. const runtimeContexts = (content: string): string[] => content.trimEnd().split('\n').flatMap((line) => {
  94. const record = JSON.parse(line) as {
  95. type?: string
  96. data?: { source?: { kind?: string; plugin?: string }; content?: Array<{ type?: string; text?: unknown }> }
  97. }
  98. if (record.type !== 'user/message'
  99. || record.data?.source?.kind !== 'plugin'
  100. || record.data.source.plugin !== '@deepseek-ai/dsh-system-prompt') return []
  101. return record.data.content?.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []) ?? []
  102. })
  103. const policyContexts = [...runtimeContexts(parent), ...runtimeContexts(child)]
  104. expect(policyContexts).toHaveLength(2)
  105. for (const context of policyContexts) {
  106. expect(context).toContain('Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode.')
  107. expect(context).toContain('Do not refuse a required modification from this policy alone')
  108. expect(context).not.toContain('write and edit tools')
  109. expect(context).not.toContain('one-shot bash commands')
  110. expect(context).not.toContain('terminal sessions')
  111. }
  112. const context: NormalizeContext = { sessionIds: [sessionId, String(headerOf(child).id)], cwd }
  113. const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context))
  114. const normalizedChild = scrubRequestHeaders(normalizeSessionLog(child, context))
  115. if (refreshing) {
  116. await writeFile(parentExpected, normalizedParent)
  117. await writeFile(childExpected, normalizedChild)
  118. }
  119. expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8'))
  120. expect(normalizedChild).toBe(await readFile(childExpected, 'utf8'))
  121. // The child's real write was denied by the real fence.
  122. expect(normalizedChild).toContain('file access denied under read-only mode')
  123. },
  124. })
  125. expect(result.stderr).toBe('')
  126. const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
  127. expect(records.at(-1)).toMatchObject({
  128. type: 'result',
  129. sessionId,
  130. output: 'The delegated child was denied by the sandbox. PARENT_DONE',
  131. })
  132. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  133. })