Browse Source

fix(test): allocate sandbox snapshot outside temp grants on runner volume

Tianyi Cui 3 weeks ago
parent
commit
350dcad963

+ 2 - 2
.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md
-2026-09-06-pr-ci-runner-temporary-storage.md: aabd208fb1c6ebe3d1e55611a054395170c63e82
-2026-09-06-pr-ci-runner-temporary-storage.zh.md: f83deb9341478568ba09a1666c4d22115c1712ba
+2026-09-06-pr-ci-runner-temporary-storage.md: ea8c96cbcc3944a9ce66a60daa2a0366cd0280d0
+2026-09-06-pr-ci-runner-temporary-storage.zh.md: e131a27e8f9202938583929fd4c663a4f0ca170c

+ 4 - 0
.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md

@@ -20,6 +20,10 @@ The [release rehearsal decision](../process/2026-09-06-release-rehearsal-selfhos
 
 The [ACP diagnostic scenario](../../../../snapshots/session/subagent-acp-diagnostic/cordis.snapshot.yml) holds its scripted background response until `job_output` owns the completion wait. Without that synchronization, a fast child can publish a legitimate job notice between the recorded parent steps. A scenario-local wrapper releases the child after the jobs service registers the completion waiter; the mock watches an exclusive marker in the private test workspace and closes the watcher after release. The fixture restores the wrapped method on disposal. The recorded Session bytes and production job-notice behavior stay unchanged.
 
+## Workspace-grant fixture placement
+
+The headless `session-sandbox-root` fixture declares `workspace.parent: outside-temp`, not a home-filesystem dependency. Its allocator uses a sibling of the canonical platform temp root where that avoids system directories, otherwise home, and rejects a cwd already covered by automatic temporary write grants. On the failover runner this keeps the test on the data volume without making its write succeed through a temporary-directory exemption. Atomic workspace allocation, final cleanup, recorded Session bytes, and the independent expected file remain unchanged.
+
 ## Alternatives considered
 
 **Delete shared temporary files from a PR job.** Another runner may still own those files. Repository jobs must not reclaim a shared directory by pathname or age.

+ 4 - 0
.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.zh.md

@@ -20,6 +20,10 @@ Linux 故障切换池在同一台虚拟机上运行多个 runner 实例。PR 覆
 
 [ACP 诊断场景](../../../../snapshots/session/subagent-acp-diagnostic/cordis.snapshot.yml) 暂停脚本化的后台响应,直到 `job_output` 开始等待完成。没有这种同步,快速子进程可能在录制的父步骤之间发布合法的作业通知。场景本地 wrapper 在 jobs 服务注册完成等待器后释放子进程;mock 在测试私有 workspace 中监听独占创建的标记,并在释放后关闭 watcher。夹具在销毁时恢复被包装的方法。录制的 Session 字节和生产作业通知行为保持不变。
 
+## Workspace 授权夹具的位置
+
+Headless 的 `session-sandbox-root` 夹具声明 `workspace.parent: outside-temp`,而不是依赖 home 所在文件系统。分配器在无需使用系统目录时选择规范化平台临时根目录的同级目录,否则使用 home,并拒绝已被自动临时写授权覆盖的 cwd。在故障切换 runner 上,这让测试留在数据卷中,同时不会让写入借助临时目录豁免而成功。原子 workspace 分配、最终清理、录制的 Session 字节以及独立预期文件保持不变。
+
 ## 考虑过的替代方案
 
 **由 PR 作业删除共享临时文件。** 其他 runner 可能仍在使用这些文件。仓库作业不得按路径或文件年龄回收共享目录。

+ 2 - 2
packages/test-support/session-snapshot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/test-support/session-snapshot/README.md
-README.md: 12acb8d426a2966a852f98d85843d3c246ffeb5f
-README.zh.md: 0b30cbf205f6055ef7b11b791d47adb38f6f704d
+README.md: cd6cc70c4617a5df61fd630571d024fc1bcab7fb
+README.zh.md: 2d425c829ae96f027f322b2c116daf7628e8ef41

+ 1 - 1
packages/test-support/session-snapshot/README.md

@@ -84,7 +84,7 @@ A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.j
 
 ### Platform and composition variants
 
-A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings.
+A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings. Headless manifests use `workspace.parent: outside-temp` when the Session workspace grant itself is under test. The adapter allocates beside the platform temp root where possible, otherwise under home, and rejects any generated cwd already covered by automatic temporary write grants.
 
 ### What can go wrong
 

+ 1 - 1
packages/test-support/session-snapshot/README.zh.md

@@ -84,7 +84,7 @@ defineAcpSnapshotSuite({
 
 ### 平台与组合变体
 
-需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。
+需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。 Headless manifest 在测试 Session workspace 授权本身时使用 `workspace.parent: outside-temp`。适配器优先在平台临时根目录旁分配目录,否则使用 home,并拒绝已被自动临时写授权覆盖的生成 cwd。
 
 ### 可能出什么问题
 

+ 5 - 5
packages/test-support/session-snapshot/src/manifest.ts

@@ -45,8 +45,8 @@ export interface SnapshotWorkspaceManifest {
   setup?: string
   /** Whether `workspace.expected/` owns the complete final world state. */
   final?: true
-  /** Place the generated cwd under the user's home instead of a temporary root. */
-  parent?: 'home'
+  /** Place the generated cwd outside automatically writable temporary roots. */
+  parent?: 'outside-temp'
 }
 
 /** Controller input that cannot enter a session because admission rejects it. */
@@ -314,13 +314,13 @@ export function parseSnapshotManifest(source: string, path = 'snapshot.yml'): Sn
       if (value.final !== undefined && value.final !== true) {
         throw new Error('manifest.workspace.final must equal true when present')
       }
-      if (value.parent !== undefined && value.parent !== 'home') {
-        throw new Error('manifest.workspace.parent must equal home')
+      if (value.parent !== undefined && value.parent !== 'outside-temp') {
+        throw new Error('manifest.workspace.parent must equal outside-temp')
       }
       workspace = {
         ...(value.setup === undefined ? {} : { setup: name(value.setup, 'manifest.workspace.setup') }),
         ...(value.final === true ? { final: true as const } : {}),
-        ...(value.parent === 'home' ? { parent: 'home' as const } : {}),
+        ...(value.parent === 'outside-temp' ? { parent: 'outside-temp' as const } : {}),
       }
       if (Object.keys(workspace).length === 0) throw new Error('manifest.workspace must not be empty')
     }

+ 3 - 3
packages/test-support/session-snapshot/tests/manifest.spec.ts

@@ -89,7 +89,7 @@ describe('snapshot manifest', () => {
       'workspace:',
       '  setup: fixed-mtimes',
       '  final: true',
-      '  parent: home',
+      '  parent: outside-temp',
       'input:',
       '  task: Rejected before persistence.',
       '  attachments:',
@@ -116,7 +116,7 @@ describe('snapshot manifest', () => {
       platform: 'posix',
       permission: 'workspace-write',
       environment: { DSH_SNAPSHOT_FAILURE: 'enabled' },
-      workspace: { setup: 'fixed-mtimes', final: true, parent: 'home' },
+      workspace: { setup: 'fixed-mtimes', final: true, parent: 'outside-temp' },
       input: {
         task: 'Rejected before persistence.',
         attachments: [{ id: 'sha256:abc', mediaType: 'image/png', data: 'aGVsbG8=' }],
@@ -174,7 +174,7 @@ describe('snapshot manifest', () => {
     ['version: 1\nprofile: acp\nenvironment:\n  lower: value\n', 'manifest.environment must map uppercase environment names to strings'],
     ['version: 1\nprofile: acp\nworkspace: {}\n', 'manifest.workspace must not be empty'],
     ['version: 1\nprofile: acp\nworkspace:\n  final: false\n', 'manifest.workspace.final must equal true when present'],
-    ['version: 1\nprofile: acp\nworkspace:\n  parent: temp\n', 'manifest.workspace.parent must equal home'],
+    ['version: 1\nprofile: acp\nworkspace:\n  parent: temp\n', 'manifest.workspace.parent must equal outside-temp'],
     ['version: 1\nprofile: acp\ninput:\n  task: ""\n', 'manifest.input.task must be a non-empty string when present'],
     ['version: 1\nprofile: acp\ninput: {}\n', 'manifest.input must declare task or attachments'],
     ['version: 1\nprofile: acp\ninput:\n  attachments: []\n', 'manifest.input.attachments must be a non-empty array'],

+ 69 - 0
scripts/snapshot-workspace-parent.spec.ts

@@ -0,0 +1,69 @@
+import { existsSync } from 'node:fs'
+import { mkdir, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises'
+import { homedir, tmpdir } from 'node:os'
+import { dirname, join, parse } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
+import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
+import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { describe, expect, it } from 'vitest'
+import { assertWorkspaceOutsideTemp, outsideTempWorkspaceParent } from './snapshot-workspace-parent.ts'
+
+// Host disk exhaustion is not simulated: placement and the real write fence are the regression oracles.
+describe('snapshot workspace parent', () => {
+  it.skipIf(process.platform === 'win32')('places runner-temp siblings on the runner data filesystem instead of home', () => {
+    expect(outsideTempWorkspaceParent('/data_local/ci/_work28/_temp', '/home/ubuntu'))
+      .toBe('/data_local/ci/_work28')
+  })
+
+  it('uses home when a temp sibling would require a system directory or inherit its grant', () => {
+    expect(outsideTempWorkspaceParent('/tmp')).toBe(homedir())
+    expect(outsideTempWorkspaceParent(parse(tmpdir()).root)).toBe(homedir())
+    expect(outsideTempWorkspaceParent(join(canonicalPath('/tmp'), 'runner', '_temp'))).toBe(homedir())
+  })
+
+  it('rejects automatically writable temporary workspaces, including symlink aliases', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-snapshot-parent-'))
+    try {
+      expect(() => { assertWorkspaceOutsideTemp(root) }).toThrow('must be outside temporary writable root')
+      const alias = join(root, 'alias')
+      await symlink(tmpdir(), alias, process.platform === 'win32' ? 'junction' : 'dir')
+      expect(() => { assertWorkspaceOutsideTemp(alias) }).toThrow('must be outside temporary writable root')
+    } finally {
+      await rm(root, { recursive: true, force: true })
+    }
+  })
+
+  it('allows the allocated workspace but denies sibling writes and cleans the complete tree', async () => {
+    const base = await mkdtemp(join(outsideTempWorkspaceParent(), 'dsh-snapshot-parent-'))
+    const ctx = new Context()
+    const fibers: Awaited<ReturnType<Context['plugin']>>[] = []
+    try {
+      const temporary = join(base, '_temp')
+      await mkdir(temporary)
+      expect(outsideTempWorkspaceParent(temporary)).toBe(canonicalPath(base))
+      const workspace = await mkdtemp(join(outsideTempWorkspaceParent(temporary), 'workspace-'))
+      const outside = join(base, 'outside.txt')
+      assertWorkspaceOutsideTemp(workspace)
+      expect(dirname(workspace)).toBe(canonicalPath(base))
+      expect((await stat(workspace)).dev).toBe((await stat(temporary)).dev)
+      fibers.push(await ctx.plugin(SessionProjectionRegistry))
+      fibers.push(await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }))
+      fibers.push(await ctx.plugin(SandboxedFileSystem, { cwd: workspace }))
+      const inside = join(workspace, 'inside.txt')
+      await ctx.fs.writeText(await ctx.fs.resolve(inside), 'inside')
+      expect(await readFile(inside, 'utf8')).toBe('inside')
+      await expect(ctx.fs.writeText(await ctx.fs.resolve(outside), 'outside'))
+        .rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
+      expect(existsSync(outside)).toBe(false)
+    } finally {
+      try {
+        for (const fiber of fibers.reverse()) await fiber.dispose()
+      } finally {
+        await rm(base, { recursive: true, force: true })
+      }
+    }
+    expect(existsSync(base)).toBe(false)
+  })
+})

+ 38 - 0
scripts/snapshot-workspace-parent.ts

@@ -0,0 +1,38 @@
+/** Workspace placement for snapshots that must not inherit temporary-directory write grants. */
+
+import { homedir, tmpdir } from 'node:os'
+import { dirname, isAbsolute, parse, relative, sep } from 'node:path'
+import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
+
+function contains(root: string, path: string): boolean {
+  const suffix = relative(root, path)
+  return suffix === '' || suffix !== '..' && !suffix.startsWith('..' + sep) && !isAbsolute(suffix)
+}
+
+/**
+ * Select a sibling parent of the platform temp directory, or home for the system temp root.
+ * The caller atomically allocates and owns cleanup of the generated workspace.
+ * @param tempRoot - platform temporary directory.
+ * @param home - home directory used when a temp sibling would require a system directory.
+ * @returns existing parent outside the automatic temporary write grants.
+ */
+export function outsideTempWorkspaceParent(tempRoot = tmpdir(), home = homedir()): string {
+  const temporary = canonicalPath(tempRoot)
+  const systemTemporary = canonicalPath('/tmp')
+  const parent = dirname(temporary)
+  return temporary === systemTemporary || parent === parse(parent).root || contains(systemTemporary, parent)
+    ? home
+    : parent
+}
+
+/**
+ * Reject a workspace whose write could succeed without the session's workspace grant.
+ * @param cwd - allocated workspace to check, with symlinks resolved before comparison.
+ * @returns nothing; throws when an automatic temporary write grant contains the workspace.
+ */
+export function assertWorkspaceOutsideTemp(cwd: string): void {
+  const path = canonicalPath(cwd)
+  for (const root of writableRoots({ mode: 'workspace-write', workspaceRoot: '/tmp' })) {
+    if (contains(root, path)) throw new Error('snapshot workspace ' + cwd + ' must be outside temporary writable root ' + root)
+  }
+}

+ 4 - 2
snapshots/session/headless.snapshot.ts

@@ -3,11 +3,12 @@
 import { cp, copyFile, mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'
 import { existsSync } from 'node:fs'
 import { spawnSync } from 'node:child_process'
-import { homedir, tmpdir } from 'node:os'
+import { tmpdir } from 'node:os'
 import { basename, delimiter, dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
 import ts from 'typescript'
+import { assertWorkspaceOutsideTemp, outsideTempWorkspaceParent } from '../../scripts/snapshot-workspace-parent.ts'
 import {
   assertPersistedSessionVersion,
   assertSessionFixtureVersion,
@@ -853,7 +854,7 @@ describe('headless recorded-session snapshots', () => {
         result = await runLoaderSmoke({
           label: `${scenario.name} headless snapshot`,
           tempDirPrefix: 'dsh-log-snap-',
-          ...(scenario.manifest.workspace?.parent === 'home' ? { tempDirParent: homedir() } : {}),
+          ...(scenario.manifest.workspace?.parent === 'outside-temp' ? { tempDirParent: outsideTempWorkspaceParent() } : {}),
           binScript: dshBin,
           configPath: join(baseComposition.dir, 'cordis.yml'),
           binArgs: [
@@ -886,6 +887,7 @@ describe('headless recorded-session snapshots', () => {
             DSH_TELEMETRY_DISABLED: '1',
           },
           prepare: async (cwd) => {
+            if (scenario.manifest.workspace?.parent === 'outside-temp') assertWorkspaceOutsideTemp(cwd)
             await mkdir(join(cwd, patchRoot), { recursive: true })
             patchSources.forEach((source, index) => {
               if (source.endsWith('.snapshot.yml')) {

+ 1 - 1
snapshots/session/session-sandbox-root/cordis.yml

@@ -1,5 +1,5 @@
 # Session-root sandbox snapshot overlay. The generated session cwd lives
-# under the user's home, while this deployment fallback deliberately points at
+# outside the automatic temp grants, while this deployment fallback points at
 # /tmp. A workspace-write mutation can therefore succeed only when the calling
 # session's cwd replaces the process-level fallback root.
 - id: sandbox-policy

+ 1 - 1
snapshots/session/session-sandbox-root/snapshot.yml

@@ -13,4 +13,4 @@ replay:
 permission: workspace-write
 workspace:
   final: true
-  parent: home
+  parent: outside-temp