permission-policy-context.e2e.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. // Web acceptance for current sandbox-policy context. A real Chromium drives
  2. // the shipped /permission command through all three presets; record mode uses
  3. // the real provider, while replay keeps the same provider-authored behavior
  4. // keyless. Assertions read the exact durable header, runtime-context messages,
  5. // and tool calls, so assistant prose alone cannot satisfy the scenario.
  6. import { mkdtemp, readFile, rm } from 'node:fs/promises'
  7. import { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import { fileURLToPath } from 'node:url'
  10. import type { Browser, Page } from 'playwright'
  11. import { chromium } from 'playwright'
  12. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  13. import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
  14. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  15. import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
  16. import type {} from '@deepseek-ai/dsh-api-terminal-controller'
  17. import {
  18. assertFinalWorkspaceSnapshot, assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture,
  19. watchConsole, webSnapshotMode, type WebScaffold,
  20. } from './scaffold.ts'
  21. import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
  22. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context', import.meta.url))
  23. const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/permission-policy-context/session.v3.jsonl', import.meta.url))
  24. const MODE = webSnapshotMode()
  25. const PROMPTS = [
  26. 'Can you create or edit a normal file right now under the current policy? Answer directly in one sentence. Do not call a tool just to discover the policy.',
  27. 'Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools.',
  28. 'Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools.',
  29. 'Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion.',
  30. ] as const
  31. const PRESET_LABELS = ['Read Only', 'Full access', 'Workspace Write'] as const
  32. function systemPrompts(events: readonly SessionEvent[]): string[] {
  33. return events.flatMap((event) => {
  34. if (event.type !== 'system/message') return []
  35. return [event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')]
  36. })
  37. }
  38. function runtimeContexts(events: readonly SessionEvent[]): string[] {
  39. return events.flatMap((event) => {
  40. if (event.type !== 'user/message'
  41. || event.data.source.kind !== 'plugin'
  42. || event.data.source.plugin !== '@deepseek-ai/dsh-system-prompt') return []
  43. return event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
  44. })
  45. }
  46. function assistantTexts(events: readonly SessionEvent[]): string[] {
  47. return events.flatMap((event) => {
  48. if (event.type !== 'assistant/message') return []
  49. const text = event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('').replaceAll('**', '')
  50. return text.length === 0 ? [] : [text]
  51. })
  52. }
  53. function callArgs(event: Extract<SessionEvent, { type: 'tool/call' }>): Record<string, unknown> {
  54. return JSON.parse(event.data.arguments) as Record<string, unknown>
  55. }
  56. describe('web e2e: current sandbox policy reaches the model before tools', () => {
  57. let scaffold: WebScaffold
  58. let browser: Browser
  59. let page: Page
  60. let tripwire: ReturnType<typeof watchConsole>
  61. let disposeApproval: (() => void) | undefined
  62. let sessionWorkspace: string | undefined
  63. let outsideWorkspace: string | undefined
  64. let terminalId: WebTerminalId | undefined
  65. const sessionEvents: SessionEvent[] = []
  66. beforeAll(async () => {
  67. scaffold = await launchWebScaffold({
  68. ...MODE === 'record' ? {} : { replayFixture: FIXTURE, compareReplaySession: true },
  69. ...process.platform === 'win32' ? {} : {
  70. extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)),
  71. },
  72. })
  73. outsideWorkspace = await mkdtemp(join(tmpdir(), 'dsh-user-terminal-'))
  74. disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
  75. scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
  76. sessionWorkspace = session.header.cwd
  77. sessionEvents.push(event)
  78. })
  79. browser = await chromium.launch()
  80. page = await newEnglishPage(browser)
  81. tripwire = watchConsole(page)
  82. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  83. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  84. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  85. }, 120_000)
  86. afterAll(async () => {
  87. try { await browser?.close() } finally {
  88. disposeApproval?.()
  89. try { await scaffold?.close() } finally {
  90. if (outsideWorkspace !== undefined) await rm(outsideWorkspace, { recursive: true, force: true })
  91. }
  92. }
  93. })
  94. async function verifyUserTerminal(preset: string): Promise<void> {
  95. // The pinned interactive Bash profile is POSIX-only; Windows still replays every Agent policy assertion.
  96. if (process.platform === 'win32') return
  97. if (terminalId === undefined) {
  98. const expand = page.locator('[data-sidebar-right-expand]')
  99. if (await expand.isVisible()) await expand.click()
  100. await page.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button', { name: /^New terminal/u }).click()
  101. await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
  102. }
  103. const agent = scaffold.ctx.agents.list()[0]
  104. if (agent === undefined || sessionWorkspace === undefined || outsideWorkspace === undefined) throw new Error('Terminal test has no Session workspace')
  105. const terminals = scaffold.ctx.terminalController.list(agent.id)
  106. expect(terminals).toHaveLength(1)
  107. terminalId ??= terminals[0]!.id
  108. expect(terminals[0]).toMatchObject({ id: terminalId, state: 'running', cwd: sessionWorkspace })
  109. const outsideFile = join(outsideWorkspace, 'terminal-access.txt')
  110. const quotedOutside = `'${outsideFile.replaceAll("'", "'\\''")}'`
  111. const beforeInput = sessionEvents.length
  112. await page.locator('.xterm-helper-textarea:visible').click()
  113. await page.keyboard.insertText(`printf '%s' '${preset}' > terminal-access.txt; printf '%s' '${preset}' > ${quotedOutside}`)
  114. await page.keyboard.press('Enter')
  115. await expect.poll(() => readFile(join(sessionWorkspace!, 'terminal-access.txt'), 'utf8')).toBe(preset)
  116. await expect.poll(() => readFile(outsideFile, 'utf8')).toBe(preset)
  117. expect(sessionEvents).toHaveLength(beforeInput)
  118. await page.keyboard.insertText('rm terminal-access.txt')
  119. await page.keyboard.press('Enter')
  120. await expect.poll(async () => {
  121. try { await readFile(join(sessionWorkspace!, 'terminal-access.txt')); return false } catch (error) {
  122. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  123. return true
  124. }
  125. }).toBe(true)
  126. }
  127. it('switches read-only, danger-full-access, and workspace-write through the real GUI command path', async () => {
  128. onTestFailed(() => saveFailureShot(page, 'web-e2e-permission-policy-context'))
  129. if (MODE !== 'record') {
  130. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
  131. }
  132. const input = page.locator('[data-composer-input][contenteditable="true"]').first()
  133. let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
  134. for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
  135. await writeComposerDraft(page, input, `/permission ${preset}`)
  136. await input.press('Enter')
  137. await page.getByRole('button', { name: `Access mode, current: ${PRESET_LABELS[index]}` })
  138. .waitFor({ timeout: 10_000 })
  139. const settled = scaffold.whenTurnSettled()
  140. await writeComposerDraft(page, input, PROMPTS[index] as string)
  141. await input.press('Enter')
  142. sessionId = await settled
  143. await input.waitFor({ timeout: 10_000 })
  144. await verifyUserTerminal(preset)
  145. }
  146. await writeComposerDraft(page, input, '/permission read-only')
  147. await input.press('Enter')
  148. await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
  149. await verifyUserTerminal('read-only')
  150. const settled = scaffold.whenTurnSettled()
  151. await writeComposerDraft(page, input, PROMPTS[3])
  152. await input.press('Enter')
  153. sessionId = await settled
  154. if (sessionId === undefined) throw new Error('permission-policy scenario completed no model turn')
  155. if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
  156. if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
  157. await assertFinalWorkspaceSnapshot(SNAPSHOT_DIR, sessionWorkspace)
  158. }, 240_000)
  159. it.skipIf(MODE === 'record')('records cache-safe current policy before the corresponding model behavior', async () => {
  160. const systems = systemPrompts(sessionEvents)
  161. expect(systems).toHaveLength(1)
  162. expect(systems[0]).not.toContain('Current DSH file policy:')
  163. expect(systems[0]).not.toContain('Approval policy:')
  164. expect(systems[0]).not.toContain('Approval prompts are disabled in this session')
  165. const contexts = runtimeContexts(sessionEvents)
  166. expect(contexts).toHaveLength(4)
  167. expect(contexts[0]).toContain('Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode.')
  168. expect(contexts[0]).toContain('Do not refuse a required modification from this policy alone')
  169. expect(contexts[0]).toContain('Approval policy: ask.')
  170. expect(contexts[1]).toContain('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.')
  171. expect(contexts[1]).toContain('Approval prompts are disabled in this session')
  172. if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
  173. expect(contexts[2]).toContain(`Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(canonicalPath(sessionWorkspace))}. Some platform temporary areas may also be writable.`)
  174. expect(contexts[2]).toContain('Approval policy: ask.')
  175. expect(contexts[2]).not.toContain('Approval prompts are disabled in this session')
  176. expect(contexts[3]).toContain('Current DSH file policy: read-only.')
  177. const answers = assistantTexts(sessionEvents)
  178. expect(answers.length).toBeGreaterThanOrEqual(4)
  179. expect(answers[0]).toMatch(/read-only.*(?:denied|cannot modify|cannot create or edit)/i)
  180. expect(answers[1]).toMatch(/does not restrict.*(?:file operations|(?:write\/edit tools|write and edit tools).*one-shot bash commands)/i)
  181. expect(answers[2]).toBe('WORKSPACE_POLICY_SEEN')
  182. const calls = sessionEvents.filter(
  183. (event): event is Extract<SessionEvent, { type: 'tool/call' }> => event.type === 'tool/call',
  184. )
  185. expect(calls.every(call => call.data.turn === 4)).toBe(true)
  186. expect(calls.length).toBeGreaterThanOrEqual(2)
  187. const firstCall = calls[0]
  188. if (firstCall === undefined) throw new Error('neutral policy task produced no tool call')
  189. expect(callArgs(firstCall)['sandbox_permissions']).toBeUndefined()
  190. expect(calls.some(call => callArgs(call)['sandbox_permissions'] !== undefined)).toBe(true)
  191. expect(sessionEvents.some(event => event.type === 'tool/result'
  192. && JSON.stringify(event.data).includes('[sandbox: file access denied under read-only mode]'))).toBe(true)
  193. expect(sessionEvents.some(event => event.type === 'approval/asked')).toBe(true)
  194. if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
  195. expect(await readFile(join(sessionWorkspace, 'policy-neutral.txt'), 'utf8')).toBe('POLICY_NEUTRAL_OK')
  196. })
  197. it.skipIf(MODE === 'record')('stays clean and keeps the fixture inventory closed', async () => {
  198. expect(tripwire.pageErrors).toEqual([])
  199. expect(tripwire.warnings).toEqual([])
  200. await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'workspace.expected'])
  201. })
  202. })