mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
Merge pinned master 84bde6c7 into V3 readiness repair
This commit is contained in:
@@ -36,6 +36,45 @@ describe('CI workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['node-24', 'node-24-coverage', 'node-24-consumers'])(
|
||||
'%s keeps tool and fixture temporary files under runner cleanup',
|
||||
(jobName) => {
|
||||
const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), jobName)
|
||||
if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} must define steps`)
|
||||
expect(job.steps[0]).toEqual({
|
||||
name: 'Use runner-owned temporary storage',
|
||||
run: [
|
||||
'echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"',
|
||||
...(jobName === 'node-24-consumers'
|
||||
? ['echo "PLAYWRIGHT_BROWSERS_PATH=${RUNNER_TEMP%/*}/ms-playwright" >> "$GITHUB_ENV"']
|
||||
: []),
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
if (jobName === 'node-24-consumers') {
|
||||
const browserCache: unknown = job.steps.find(step => isRecord(step) && isRecord(step.with)
|
||||
&& step.with.path === '${{ env.PLAYWRIGHT_BROWSERS_PATH }}')
|
||||
expect(browserCache).toMatchObject({ uses: 'actions/cache/restore@v4' })
|
||||
}
|
||||
const store: unknown = job.steps.find(step => isRecord(step) && step.name === 'Configure pnpm store path')
|
||||
expect(store).toMatchObject({
|
||||
run: [
|
||||
'store_root="$HOME/.local/share/pnpm/store"',
|
||||
'echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"',
|
||||
'store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)',
|
||||
'echo "path=$store_path" >> "$GITHUB_OUTPUT"',
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
for (const step of job.steps) {
|
||||
if (isRecord(step) && isRecord(step.env)) {
|
||||
expect(step.env.TMPDIR).toBeUndefined()
|
||||
expect(step.env.npm_config_cache).toBeUndefined()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('isolates the python SDK exe pnpm setup destination per job', () => {
|
||||
const workflow: unknown = yaml.load(readFileSync(resolve(root, '.github/workflows/build-exe-for-python-sdk.yml'), 'utf8'))
|
||||
if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError('build-exe-for-python-sdk.yml must define jobs')
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Hold the advanced workflow child's first step until its parent records membership. */
|
||||
export const name = 'python-snapshot-workflow-order'
|
||||
|
||||
/**
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local host context.
|
||||
* @param {{ parentSessionId: string, prompt: string }} config - Exact advanced scenario identities.
|
||||
*/
|
||||
export function apply(ctx, config) {
|
||||
const started = new Set()
|
||||
const pending = new Map()
|
||||
let disposed = false
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
disposed = true
|
||||
const waits = [...pending.values()]
|
||||
for (const wait of waits) wait.reject(new Error('workflow snapshot barrier disposed'))
|
||||
await Promise.allSettled(waits.map(wait => wait.done))
|
||||
started.clear()
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (disposed || session.id !== config.parentSessionId || event.type !== 'tool-workflow/agent-start') return
|
||||
started.add(event.data.childId)
|
||||
pending.get(event.data.childId)?.resolve()
|
||||
})
|
||||
ctx.on('agent/pre-step', async ({ agent, messages, turn, step, signal }, next) => {
|
||||
if (agent.session.header.parentSession !== config.parentSessionId || turn !== 1 || step !== 1
|
||||
|| !messages.some(message => message.content.some(block => block.type === 'text' && block.text === config.prompt))) {
|
||||
return next()
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (disposed) throw new Error('workflow snapshot barrier disposed')
|
||||
if (!started.has(agent.id)) {
|
||||
const wait = Promise.withResolvers()
|
||||
const abort = () => { wait.reject(signal.reason) }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
wait.done = wait.promise.finally(() => {
|
||||
signal.removeEventListener('abort', abort)
|
||||
pending.delete(agent.id)
|
||||
})
|
||||
pending.set(agent.id, wait)
|
||||
await wait.done
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (disposed) throw new Error('workflow snapshot barrier disposed')
|
||||
return next()
|
||||
})
|
||||
}
|
||||
@@ -412,6 +412,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
CreateGoalRequest: 'goal.md',
|
||||
EditGoalRequest: 'goal.md',
|
||||
GoalBlockReason: 'goal.md',
|
||||
GoalActivationChanged: 'goal.md',
|
||||
GoalChanged: 'goal.md',
|
||||
GoalRef: 'goal.md',
|
||||
GoalView: 'goal.md',
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { getEventListeners } from 'node:events'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
|
||||
import { MockAdapter, textResponse } from '../packages/core/agent-loop/tests/mock-adapter.ts'
|
||||
import type {} from '@deepseek-ai/dsh-tool-workflow'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
// @ts-expect-error Scenario plugins are runtime JavaScript without declaration artifacts.
|
||||
import * as fixtureModule from './fixtures/python-snapshot-workflow-order.mjs'
|
||||
|
||||
const config = { parentSessionId: 'advanced-parent', prompt: 'workflow child prompt' }
|
||||
const fixture = fixtureModule as unknown as {
|
||||
name: string
|
||||
apply(ctx: Context, config: { parentSessionId: string; prompt: string }): void
|
||||
}
|
||||
const cleanups: (() => Promise<unknown>)[] = []
|
||||
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
|
||||
|
||||
async function harness() {
|
||||
const ctx = new Context()
|
||||
const store = ctx.plugin(SessionStore)
|
||||
await store
|
||||
cleanups.push(() => store.dispose())
|
||||
const fiber = ctx.plugin(fixture, config)
|
||||
await fiber
|
||||
cleanups.push(() => fiber.dispose())
|
||||
const parent = ctx.sessions.create(SessionId(config.parentSessionId))
|
||||
const other = ctx.sessions.create(SessionId('other-parent'))
|
||||
const session = ctx.sessions.create(SessionId('workflow-child'), { meta: { parentSession: parent.id } })
|
||||
// The dispatcher needs only the subject identity; the fixture reads its Session.
|
||||
const agent = { id: session.id, session } as Agent
|
||||
const controller = new AbortController()
|
||||
const messages = [createUserMessage({ content: [{ type: 'text', text: config.prompt }], source: { kind: 'user' } })]
|
||||
const decision: PreStepDecision = { kind: 'enter', messages }
|
||||
const next = vi.fn(async () => decision)
|
||||
const start = (owner = parent, childId = agent.id) => owner.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId,
|
||||
})
|
||||
const step = (overrides = {}) => agentEvents(ctx, agent).waterfall('agent/pre-step', {
|
||||
turn: 1, step: 1, messages, signal: controller.signal, ...overrides,
|
||||
}, next)
|
||||
return { ctx, fiber, parent, other, session, agent, controller, decision, next, start, step }
|
||||
}
|
||||
|
||||
describe('advanced Python snapshot workflow ordering', () => {
|
||||
it('blocks a real spawned child before its descriptor and first model request', async () => {
|
||||
const ctx = new Context()
|
||||
const entered = Promise.withResolvers<Agent>()
|
||||
const order: string[] = []
|
||||
const adapter = new MockAdapter([textResponse('child complete')])
|
||||
const assembly = ctx.plugin({
|
||||
name: 'workflow-order-driver-test',
|
||||
async apply(inner: Context) {
|
||||
await mountAgentLoopTestDependencies(inner)
|
||||
await inner.plugin(AgentLoop, { agents: [] })
|
||||
await inner.plugin(SubagentRuntime)
|
||||
await inner.plugin(spawn, { providerName: 'spawn' })
|
||||
inner.on('agent/pre-step', ({ agent }, next) => {
|
||||
if (agent.session.header.parentSession === config.parentSessionId) entered.resolve(agent)
|
||||
return next()
|
||||
})
|
||||
inner.on('session/event', (_session, event) => {
|
||||
if (event.type === 'tool-workflow/agent-start' || event.type === 'subagent/descriptor') order.push(event.type)
|
||||
})
|
||||
await inner.plugin(fixture, config)
|
||||
},
|
||||
})
|
||||
cleanups.push(() => assembly.dispose())
|
||||
await assembly
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = await ctx.agentLoop.create(SessionId(config.parentSessionId), { provider: 'mock', model: 'mock' })
|
||||
const run = await ctx.subagents.start('spawn', {
|
||||
parent, prompt: [{ type: 'text', text: config.prompt }], signal: new AbortController().signal,
|
||||
})
|
||||
cleanups.push(() => run.dispose())
|
||||
const child = await entered.promise
|
||||
expect(child.id).toBe(run.id)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(child.session.snapshotEvents().some(event => event.type === 'subagent/descriptor')).toBe(false)
|
||||
parent.session.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId: child.id,
|
||||
})
|
||||
expect((await run.result).output).toEqual([{ type: 'text', text: 'child complete' }])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(order).toEqual(['tool-workflow/agent-start', 'subagent/descriptor'])
|
||||
})
|
||||
|
||||
it('holds the child until the exact parent records the exact member', async () => {
|
||||
const h = await harness()
|
||||
const pending = h.step()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(1)
|
||||
h.start(h.other)
|
||||
h.start(h.parent, SessionId('other-child'))
|
||||
h.parent.append('tool-workflow/run-start', { runId: WorkflowRunId('run'), name: 'workflow' })
|
||||
await Promise.resolve()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
h.start()
|
||||
expect(await pending).toBe(h.decision)
|
||||
expect(h.next).toHaveBeenCalledOnce()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retains a start recorded before the child reaches its first step', async () => {
|
||||
const h = await harness()
|
||||
h.start()
|
||||
expect(await h.step()).toBe(h.decision)
|
||||
expect(h.next).toHaveBeenCalledOnce()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['prompt', 'parent', 'turn', 'step'])('does not hold an unrelated %s', async (difference) => {
|
||||
const h = await harness()
|
||||
const overrides = difference === 'prompt' ? { messages: [] }
|
||||
: difference === 'turn' ? { turn: 2 }
|
||||
: difference === 'step' ? { step: 2 } : {}
|
||||
if (difference === 'parent') {
|
||||
const session = h.ctx.sessions.create(SessionId('unrelated-child'), { meta: { parentSession: h.other.id } })
|
||||
Object.assign(h.agent, { session })
|
||||
}
|
||||
expect(await h.step(overrides)).toBe(h.decision)
|
||||
expect(h.next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([false, true])('rejects cancellation and detaches the waiter (already aborted: %s)', async (alreadyAborted) => {
|
||||
const h = await harness()
|
||||
const reason = new Error('cancelled child')
|
||||
if (alreadyAborted) h.controller.abort(reason)
|
||||
const pending = h.step()
|
||||
const rejected = expect(pending).rejects.toBe(reason)
|
||||
h.controller.abort(reason)
|
||||
await rejected
|
||||
h.start()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not admit a cancelled child when start and cancellation share a tick', async () => {
|
||||
const h = await harness()
|
||||
const reason = new Error('cancelled after membership')
|
||||
const pending = h.step()
|
||||
const rejected = expect(pending).rejects.toBe(reason)
|
||||
h.start()
|
||||
h.controller.abort(reason)
|
||||
await rejected
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('settles pending waits before disposal completes and removes both listeners', async () => {
|
||||
const h = await harness()
|
||||
const pending = h.step()
|
||||
const rejected = expect(pending).rejects.toThrow('workflow snapshot barrier disposed')
|
||||
await h.fiber.dispose()
|
||||
await rejected
|
||||
h.start()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
expect(await h.step()).toBe(h.decision)
|
||||
})
|
||||
})
|
||||
@@ -1330,6 +1330,11 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
|
||||
sessions = dsh_home / "sessions"
|
||||
patch = write_advanced_profile_patch(root, "snapshot.patch.yml", sessions)
|
||||
feedback_patch = write_profile_patch(root, "feedback.patch.yml", sessions, [{"insert": [
|
||||
{"id": "snapshot-workflow-order", "name": (
|
||||
Path(__file__).resolve().parent / "fixtures/python-snapshot-workflow-order.mjs"
|
||||
).as_uri(), "config": {
|
||||
"parentSessionId": SNAPSHOT_SESSION_ID, "prompt": SNAPSHOT_WORKFLOW_CHILD_PROMPT,
|
||||
}},
|
||||
{"id": "snapshot-message-feedback", "name": "@deepseek-ai/dsh-message-feedback",
|
||||
"config": {"maxNoteBytes": 1024}},
|
||||
{"id": "snapshot-feedback-producer", "name": (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as adapter from './snapshot-shell-path.ts'
|
||||
|
||||
it.skipIf(process.platform === 'win32')('translates only the exact fixture command and preserves real shell failures', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'snapshot-shell-'))
|
||||
const ctx = new Context()
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
try {
|
||||
const subprocess = ctx.plugin(LocalSubprocessRuntime)
|
||||
disposers.push(() => subprocess.dispose())
|
||||
await subprocess
|
||||
const bash = ctx.plugin(LocalBashExecutor)
|
||||
disposers.push(() => bash.dispose())
|
||||
await bash
|
||||
const command = "printf 'actual bytes' > /fixture/recorded.txt"
|
||||
const livePath = join(root, "space and 'quote.txt")
|
||||
const fork = ctx.plugin(adapter, { command, recordedPath: '/fixture/recorded.txt', livePath })
|
||||
disposers.push(() => fork.dispose())
|
||||
await fork
|
||||
const outcome = await ctx.shell.run(ctx.shell.resolve({ command }))
|
||||
expect(outcome.timedOut).toBe(false)
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(await readFile(livePath, 'utf8')).toBe('actual bytes')
|
||||
const untouched = await ctx.shell.run(ctx.shell.resolve({ command: "printf '/fixture/recorded.txt'; exit 7" }))
|
||||
expect(untouched.exitCode).toBe(7)
|
||||
expect(untouched.stdout.text).toBe('/fixture/recorded.txt')
|
||||
await fork.dispose()
|
||||
await rm(livePath)
|
||||
const restored = await ctx.shell.run(ctx.shell.resolve({ command }))
|
||||
expect(restored.exitCode).not.toBe(0)
|
||||
await expect(readFile(livePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
for (const dispose of disposers.reverse()) await dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Exact recorded shell-command path translation; execution and reported outcomes remain real. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-shell'
|
||||
|
||||
export const name = 'snapshot-shell-path'
|
||||
export const inject = ['shell']
|
||||
|
||||
/** One recorded command and its isolated live filesystem target. */
|
||||
export interface Config {
|
||||
command: string
|
||||
recordedPath: string
|
||||
livePath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one exact fixture command after tool logging and approval.
|
||||
* @param ctx - profile context with its real shell executor.
|
||||
* @param config - recorded command/path and allocated live target.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const shell = ctx.shell
|
||||
// oxlint-disable-next-line typescript/unbound-method -- preserve method identity for restoration; calls bind the receiver.
|
||||
const run = shell.run
|
||||
ctx.effect(() => {
|
||||
shell.run = spec => run.call(shell, spec.command === config.command
|
||||
? { ...spec, command: spec.command.replaceAll(config.recordedPath, "'" + config.livePath.replaceAll("'", "'\"'\"'") + "'") }
|
||||
: spec)
|
||||
return () => { shell.run = run }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocalSpillStore } from '@deepseek-ai/dsh-spill-local'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as locators from './snapshot-spill-locators.ts'
|
||||
|
||||
it.each([false, true])('keeps concurrent physical spill files private while retaining logical locator length and bytes (reverse allocation=%s)', async (reverseAllocation) => {
|
||||
const roots: string[] = []
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
const secondAllocated = Promise.withResolvers<undefined>()
|
||||
try {
|
||||
const runs = await Promise.all([0, 1].map(async (index) => {
|
||||
if (reverseAllocation && index === 0) await secondAllocated.promise
|
||||
const root = await mkdtemp(join(tmpdir(), 'snapshot-locator-'))
|
||||
roots.push(root)
|
||||
if (index === 1) secondAllocated.resolve(undefined)
|
||||
const ctx = new Context()
|
||||
const storeFiber = ctx.plugin(LocalSpillStore, { root, cleanupPeriodDays: 0 })
|
||||
disposers.push(() => storeFiber.dispose())
|
||||
await storeFiber
|
||||
const fsFiber = ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
disposers.push(() => fsFiber.dispose())
|
||||
await fsFiber
|
||||
|
||||
|
||||
const locatorRoot = resolve('/tmp/dsh-acp-snap-123456789')
|
||||
const fork = ctx.plugin(locators, { root, locatorRoot })
|
||||
disposers.push(() => fork.dispose())
|
||||
await fork
|
||||
const content = `physical UTF-8 内容 ${index}`
|
||||
const ref = await ctx.spillStore.saveText({
|
||||
owner: { sessionId: SessionId('same-session') },
|
||||
source: { kind: 'tool', toolName: 'bash', callId: ToolCallId('same-call'), label: 'result' },
|
||||
suggestedName: 'bash.txt', content,
|
||||
})
|
||||
expect(ref.bytes).toBe(Buffer.byteLength(content))
|
||||
expect(ref.locator.startsWith(locatorRoot)).toBe(true)
|
||||
const target = await ctx.fs.resolve(ref.locator)
|
||||
expect(target.displayPath).toBe(ref.locator)
|
||||
const physicalPath = ctx.fs.processPath(target)
|
||||
expect(physicalPath.startsWith(await realpath(root))).toBe(true)
|
||||
expect(await readFile(physicalPath, 'utf8')).toBe(content)
|
||||
expect(await ctx.fs.readText(target)).toBe(content)
|
||||
await expect(ctx.fs.resolve(join(locatorRoot, 'missing.txt'))).rejects.toThrow('not saved by this run')
|
||||
const ordinary = join(root, 'ordinary.txt')
|
||||
await writeFile(ordinary, 'ordinary')
|
||||
expect(await ctx.fs.readText(await ctx.fs.resolve(ordinary))).toBe('ordinary')
|
||||
await fork.dispose()
|
||||
const restored = await ctx.fs.resolve(ref.locator)
|
||||
expect(ctx.fs.processPath(restored)).not.toBe(physicalPath)
|
||||
expect(await ctx.fs.stat(restored)).toBeUndefined()
|
||||
return { root, physicalPath, locator: ref.locator, content }
|
||||
}))
|
||||
expect(runs[0]?.physicalPath).not.toBe(runs[1]?.physicalPath)
|
||||
expect(runs[0]?.locator.length).toBe(runs[1]?.locator.length)
|
||||
// Promise.all preserves input order; allocation completion order may differ.
|
||||
await rm(runs[0]!.root, { recursive: true, force: true })
|
||||
expect(await readFile(runs[1]?.physicalPath as string, 'utf8')).toBe(runs[1]?.content)
|
||||
} finally {
|
||||
for (const dispose of disposers.reverse()) await dispose()
|
||||
await Promise.all(roots.map(root => rm(root, { recursive: true, force: true })))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Fixture-only logical locators over real local spill files; preview budgets retain recorded path lengths. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { join, relative, resolve, sep } from 'node:path'
|
||||
import type { SpillLocator } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
export const name = 'snapshot-spill-locators'
|
||||
export const inject = ['spillStore', 'fs']
|
||||
|
||||
/** Live storage and recorded locator prefixes supplied by the snapshot owner. */
|
||||
export interface Config {
|
||||
root: string
|
||||
locatorRoot: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate only locators returned by this fixture's real spill backend.
|
||||
* @param ctx - profile context with real spill and filesystem providers.
|
||||
* @param config - per-run storage and stable logical prefix.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const root = resolve(config.root)
|
||||
const locatorRoot = resolve(config.locatorRoot)
|
||||
const paths = new Map<string, string>()
|
||||
const store = ctx.spillStore
|
||||
const fs = ctx.fs
|
||||
// oxlint-disable-next-line typescript/unbound-method -- preserve method identity for restoration; calls bind the receiver.
|
||||
const saveText = store.saveText
|
||||
// oxlint-disable-next-line typescript/unbound-method -- preserve method identity for restoration; calls bind the receiver.
|
||||
const resolvePath = fs.resolve
|
||||
ctx.effect(() => {
|
||||
store.saveText = async (input) => {
|
||||
const saved = await saveText.call(store, input)
|
||||
const suffix = relative(root, saved.locator)
|
||||
if (suffix.startsWith('..') || resolve(root, suffix) !== saved.locator) {
|
||||
throw new Error('snapshot spill backend returned a locator outside its live root')
|
||||
}
|
||||
const locator = join(locatorRoot, suffix) as SpillLocator
|
||||
paths.set(locator, saved.locator)
|
||||
return { ...saved, locator }
|
||||
}
|
||||
fs.resolve = async (path, opts) => {
|
||||
const live = paths.get(path)
|
||||
if (live !== undefined) {
|
||||
const target = await resolvePath.call(fs, live, opts)
|
||||
return { ...target, displayPath: path }
|
||||
}
|
||||
if (path.startsWith(locatorRoot + sep)) {
|
||||
throw new Error('snapshot spill locator was not saved by this run')
|
||||
}
|
||||
return resolvePath.call(fs, path, opts)
|
||||
}
|
||||
return () => {
|
||||
store.saveText = saveText
|
||||
fs.resolve = resolvePath
|
||||
paths.clear()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { chmod, 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', () => {
|
||||
// Windows directory permissions and root bypass do not enforce POSIX write bits.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('uses home when the temp parent is not writable', async () => {
|
||||
const base = await mkdtemp(join(outsideTempWorkspaceParent(), '.dsh-snapshot-readonly-'))
|
||||
try {
|
||||
const temporary = join(base, '_temp')
|
||||
await mkdir(temporary)
|
||||
await chmod(base, 0o500)
|
||||
expect(outsideTempWorkspaceParent(temporary)).toBe(homedir())
|
||||
} finally {
|
||||
await chmod(base, 0o700)
|
||||
await rm(base, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Workspace placement for snapshots that must not inherit temporary-directory write grants. */
|
||||
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
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 writable temp sibling parent, or home when that parent is unavailable for writes.
|
||||
* The caller atomically allocates and owns cleanup of the generated workspace.
|
||||
* @param tempRoot - platform temporary directory.
|
||||
* @param home - fallback when temp siblings require a system directory or a non-writable parent.
|
||||
* @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)
|
||||
if (temporary === systemTemporary || parent === parse(parent).root || contains(systemTemporary, parent)) return home
|
||||
try {
|
||||
accessSync(parent, constants.W_OK)
|
||||
} catch (error) {
|
||||
// A non-writable parent cannot host siblings; allocation failures still propagate from mkdtemp.
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return home
|
||||
throw error
|
||||
}
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@ function evaluate(expression: string, context: Record<string, string | boolean>)
|
||||
return runInNewContext(source, { fromJSON: JSON.parse }, { timeout: 1000 }) as unknown
|
||||
}
|
||||
|
||||
function assertSharedPersistentStore(run: string | undefined): void {
|
||||
expect(run).toContain('store_root="$HOME/.local/share/pnpm/store"')
|
||||
expect(run).toContain('echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"')
|
||||
expect(run).toContain('store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)')
|
||||
}
|
||||
|
||||
const trustedPr = {
|
||||
'vars.DSH_CI_FAILOVER_LINUX': 'selfhosted',
|
||||
'github.repository': repository,
|
||||
@@ -108,8 +114,18 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea
|
||||
.toBe('${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}')
|
||||
expect(job.steps.find(step => step.name === 'Install (immutable)')?.run).toBe('pnpm install --frozen-lockfile')
|
||||
})
|
||||
it('uses the persistent store without remote cache reads or writes on self-hosted', () => {
|
||||
expect(job.steps.find(step => step.name === 'Configure pnpm store path')?.run).toContain('store_root="$HOME/.local/share/pnpm/store"')
|
||||
it('retains the configured shared npm cache', () => {
|
||||
expect(JSON.stringify(release)).not.toMatch(/npm_config_cache/i)
|
||||
})
|
||||
it.each(['', 'store_root="${RUNNER_TEMP%/*}/pnpm-store"', 'store_root="$RUNNER_TEMP/pnpm-store"'])(
|
||||
'rejects missing, runner-private, or job-temporary store placement: %s', (replacement) => {
|
||||
const run = job.steps.find(step => step.name === 'Configure pnpm store path')?.run
|
||||
?.replace('store_root="$HOME/.local/share/pnpm/store"', replacement)
|
||||
expect(() => { assertSharedPersistentStore(run) }).toThrow()
|
||||
},
|
||||
)
|
||||
it('uses the shared persistent store without remote cache reads or writes on self-hosted', () => {
|
||||
assertSharedPersistentStore(job.steps.find(step => step.name === 'Configure pnpm store path')?.run)
|
||||
const caches = job.steps.filter(step => step.uses?.startsWith('actions/cache'))
|
||||
expect(caches.map(step => step.uses)).toEqual(['actions/cache/restore@v4'])
|
||||
for (const step of caches) {
|
||||
|
||||
@@ -207,6 +207,11 @@
|
||||
"symbol": "GoalView",
|
||||
"source": "packages/goal/goal/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/goal.md",
|
||||
"symbol": "GoalActivationChanged",
|
||||
"source": "packages/goal/goal/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/goal.md",
|
||||
"symbol": "GoalSnapshotChangeMeta",
|
||||
|
||||
Reference in New Issue
Block a user