ptc-escalation.e2e.ts 5.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. // Real PTC sandbox denial followed by one approved program execution.
  2. import { readFile } from 'node:fs/promises'
  3. import { fileURLToPath } from 'node:url'
  4. import { join } from 'node:path'
  5. import type { Browser, Page } from 'playwright'
  6. import { chromium } from 'playwright'
  7. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  8. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  9. import type {} from '@deepseek-ai/dsh-user-approval'
  10. import {
  11. assertFinalWorkspaceSnapshot, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
  12. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  13. } from './scaffold.ts'
  14. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  15. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/ptc-escalation-approved', import.meta.url))
  16. const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
  17. const UI_EXPECTED = join(SNAPSHOT_DIR, 'approval.expected.md')
  18. const MODE = webSnapshotMode()
  19. const PROMPT = 'Use run_code with timeoutMs 120000 and direct Node filesystem access to create approved.txt in the working directory containing exactly "approved\\n". '
  20. + 'Use await import("node:fs/promises") and writeFile; do not call nested tools. First attempt the write under the current read-only sandbox without escalation. '
  21. + 'In that first program, catch only filesystem errors with code EPERM, EACCES or EROFS and return exactly "EXPECTED_SANDBOX_DENIAL"; rethrow any other error. '
  22. + 'If the sandbox denies it, explicitly retry the program with sandbox_permissions "workspace-write" and justification "Create the file requested by the user". '
  23. + 'I will answer the approval prompt. After the file is written, reply DONE and stop.'
  24. describe('web e2e: PTC program sandbox escalation', () => {
  25. let scaffold: WebScaffold
  26. let browser: Browser
  27. let page: Page
  28. let tripwire: ReturnType<typeof watchConsole>
  29. const events: SessionEvent[] = []
  30. beforeAll(async () => {
  31. scaffold = await launchWebScaffold({
  32. agentPresets: { roots: [], default: 'ptc' },
  33. compareReplaySession: true,
  34. ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
  35. })
  36. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
  37. browser = await chromium.launch()
  38. page = await newEnglishPage(browser)
  39. tripwire = watchConsole(page)
  40. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  41. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  42. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  43. }, 120_000)
  44. afterAll(async () => {
  45. await browser?.close()
  46. await scaffold?.close()
  47. })
  48. it('keeps the file absent until approval and records the granted program', async () => {
  49. onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-escalation'))
  50. if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  51. const input = page.locator('[data-composer-input]').first()
  52. await input.waitFor({ timeout: 10_000 })
  53. await page.locator('[aria-label^="Access mode"]').click()
  54. await page.getByRole('menuitem', { name: 'Read Only' }).click()
  55. await expect.poll(() => page.locator('[aria-label="Access mode, current: Read Only"]').count()).toBe(1)
  56. const settled = scaffold.whenTurnSettled(MODE === 'record' ? 240_000 : 60_000)
  57. await input.fill(PROMPT)
  58. await input.press('Enter')
  59. const panel = page.locator('[data-approval-key]')
  60. await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 })
  61. const calls = events.filter(event => event.type === 'tool/call')
  62. expect(calls.length).toBeGreaterThanOrEqual(2)
  63. expect(calls.every(event => event.data.name === 'run_code')).toBe(true)
  64. expect(JSON.stringify(calls[0]?.data)).toContain('node:fs/promises')
  65. expect(JSON.stringify(calls.at(-1)?.data)).toContain('workspace-write')
  66. const results = events.filter(event => event.type === 'tool/result')
  67. expect(JSON.stringify(results[0]?.data)).toContain('EXPECTED_SANDBOX_DENIAL')
  68. const file = join(scaffold.workspaceCwd, 'workspace', 'approved.txt')
  69. await expect(readFile(file, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  70. if (MODE !== 'record') {
  71. await compareOrRefreshGolden(UI_EXPECTED, await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd), MODE)
  72. }
  73. await panel.getByRole('button', { name: 'Allow once' }).click()
  74. const sessionId = await settled
  75. expect(await readFile(file, 'utf8')).toBe('approved\n')
  76. expect(events.filter(event => event.type === 'tool/ptc-dispatch')).toHaveLength(0)
  77. expect(events.filter(event => event.type === 'approval/decided').map(event => event.data)).toMatchObject([{ outcome: 'allowed-once' }])
  78. expect(await page.locator('[aria-label="Access mode, current: Read Only"]').count()).toBe(1)
  79. await expect.poll(() => page.getByText('DONE', { exact: true }).count()).toBeGreaterThanOrEqual(1)
  80. expect(await panel.count()).toBe(0)
  81. expect(tripwire.pageErrors).toEqual([])
  82. expect(tripwire.warnings).toEqual([])
  83. if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
  84. await assertFinalWorkspaceSnapshot(SNAPSHOT_DIR, join(scaffold.workspaceCwd, 'workspace'))
  85. }, 300_000)
  86. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  87. await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'approval.expected.md', 'workspace.expected'])
  88. })
  89. })