Merge master into fix/prune-python-runtime-tests
This commit is contained in:
commit
78be1e5e46
1036 files changed
+25603
-8827
No files matched your search
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './executable-native-pty.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { copyOfficeSidecar } from './build-exe-for-python-sdk-office.ts'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { c as tar } from 'tar'
|
||||
import { downloadOfficeSidecar, runtimeNpmArchive } from './build-exe-for-python-sdk-office.ts'
|
||||
|
||||
import { officePackageDirectories } from './libreoffice-packages.mjs'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
@@ -24,33 +28,8 @@ async function fixture() {
|
||||
return { root, staging, destination, packageAt }
|
||||
}
|
||||
|
||||
it('copies package-owned data, licenses, helper permissions, and nested dependencies', async () => {
|
||||
const { staging, destination, packageAt } = await fixture()
|
||||
const entry = await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' }, dependencies: { decoder: '1' } })
|
||||
const engine = await packageAt('@deepseek-ai/libreoffice-kit-wasm')
|
||||
const decoder = await packageAt('decoder', { dependencies: { codec: '1' } }, entry)
|
||||
await packageAt('codec', {}, decoder)
|
||||
await packageAt('codec', { version: '2.0.0' })
|
||||
const assets = ['assets/soffice.data', 'prebuilds.json', 'licenses/LICENSE', 'bin/helper']
|
||||
for (const name of assets) {
|
||||
const path = join(engine, name)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, name)
|
||||
}
|
||||
await chmod(join(engine, 'bin/helper'), 0o755)
|
||||
|
||||
const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })
|
||||
|
||||
expect(packages).toHaveLength(4)
|
||||
expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data'), 'utf8')).toBe('assets/soffice.data')
|
||||
expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/licenses/LICENSE'), 'utf8')).toBe('licenses/LICENSE')
|
||||
expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit/node_modules/decoder/node_modules/codec/package.json'), 'utf8')).toContain('codec')
|
||||
await expect(stat(join(destination, 'node_modules/codec'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
if (process.platform !== 'win32') expect((await stat(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/bin/helper'))).mode & 0o111).toBe(0o111)
|
||||
})
|
||||
|
||||
it('copies installed target optionals and leaves other platforms and absent optionals out', async () => {
|
||||
const { staging, destination, packageAt } = await fixture()
|
||||
it('locates installed target optionals and leaves other platforms and absent optionals out', async () => {
|
||||
const { staging, packageAt } = await fixture()
|
||||
await packageAt('@deepseek-ai/libreoffice-kit', {
|
||||
optionalDependencies: { native: '1', foreign: '1', absent: '1' },
|
||||
})
|
||||
@@ -58,84 +37,112 @@ it('copies installed target optionals and leaves other platforms and absent opti
|
||||
await packageAt('native', { os: ['linux'], cpu: ['x64'] })
|
||||
await packageAt('foreign', { os: ['darwin'], cpu: ['arm64'] })
|
||||
|
||||
const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })
|
||||
const packages = await officePackageDirectories(staging, { platform: 'linux', arch: 'x64' })
|
||||
|
||||
expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([
|
||||
expect(packages.map(path => path.slice(staging.length + 1).replaceAll('\\', '/'))).toEqual([
|
||||
'node_modules/@deepseek-ai/libreoffice-kit',
|
||||
'node_modules/@deepseek-ai/libreoffice-kit-wasm',
|
||||
'node_modules/native',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a missing required dependency before producing a sidecar', async () => {
|
||||
const { staging, destination, packageAt } = await fixture()
|
||||
it('rejects a missing required dependency in the deployed closure', async () => {
|
||||
const { staging, packageAt } = await fixture()
|
||||
await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'dsh-missing-office-fixture': '1' } })
|
||||
|
||||
await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
|
||||
await expect(officePackageDirectories(staging, { platform: 'linux', arch: 'x64' }))
|
||||
.rejects.toThrow('dsh-missing-office-fixture required by @deepseek-ai/libreoffice-kit is missing')
|
||||
await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects an incomplete installed optional instead of omitting it', async () => {
|
||||
const { staging, destination, packageAt } = await fixture()
|
||||
const { staging, packageAt } = await fixture()
|
||||
await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { broken: '1' } })
|
||||
await mkdir(join(staging, 'node_modules/broken'), { recursive: true })
|
||||
|
||||
await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
|
||||
await expect(officePackageDirectories(staging, { platform: 'linux', arch: 'x64' }))
|
||||
.rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects an ancestor dependency outside the deployed closure', async () => {
|
||||
const { root, staging, destination, packageAt } = await fixture()
|
||||
const { root, staging, packageAt } = await fixture()
|
||||
await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'ancestor-office-fixture': '1' } })
|
||||
await packageAt('ancestor-office-fixture', {}, root)
|
||||
|
||||
await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
|
||||
await expect(officePackageDirectories(staging, { platform: 'linux', arch: 'x64' }))
|
||||
.rejects.toThrow('outside the deployed closure')
|
||||
})
|
||||
|
||||
it('requires the declared WASM engine inside the deployed closure', async () => {
|
||||
const { root, staging, destination, packageAt } = await fixture()
|
||||
await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } })
|
||||
it.each([true, false])('requires a staged WASM engine when declared=%s even when an ancestor has one', async (declared) => {
|
||||
const { root, staging, packageAt } = await fixture()
|
||||
await packageAt('@deepseek-ai/libreoffice-kit', declared ? { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } } : {})
|
||||
await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root)
|
||||
await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
|
||||
await expect(officePackageDirectories(staging, { platform: 'linux', arch: 'x64' }))
|
||||
.rejects.toThrow('Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.')
|
||||
await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['darwin', 'arm64', 'darwin-arm64'], ['darwin', 'x64', 'darwin-x64'],
|
||||
['win32', 'arm64', 'win32-arm64'], ['win32', 'x64', 'win32-x64'],
|
||||
['linux', 'x64', 'linux-x64'], ['linux', 'arm64', 'wasm'], ['freebsd', 'x64', 'wasm'],
|
||||
])('copies only the %s/%s engine even when other engines are staged', async (platform, arch, selected) => {
|
||||
const { staging, destination, packageAt } = await fixture()
|
||||
])('locates only the %s/%s engine even when other engines are staged', async (platform, arch, selected) => {
|
||||
const { staging, packageAt } = await fixture()
|
||||
const targets = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'linux-x64', 'wasm']
|
||||
const names = targets.map(target => `@deepseek-ai/libreoffice-kit-${target}`)
|
||||
await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: Object.fromEntries(names.map(name => [name, '0.0.1'])) })
|
||||
for (const name of names) await packageAt(name)
|
||||
const expected = `@deepseek-ai/libreoffice-kit-${selected}`
|
||||
const packages = await copyOfficeSidecar(staging, destination, { platform, arch })
|
||||
expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([
|
||||
const packages = await officePackageDirectories(staging, { platform, arch })
|
||||
expect(packages.map(path => path.slice(staging.length + 1).replaceAll('\\', '/'))).toEqual([
|
||||
'node_modules/@deepseek-ai/libreoffice-kit', `node_modules/${expected}`,
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['darwin', 'win32', 'linux'])('%s requires its native package even when WASM is staged', async (platform) => {
|
||||
const { staging, destination, packageAt } = await fixture()
|
||||
const { staging, packageAt } = await fixture()
|
||||
await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: {
|
||||
'@deepseek-ai/libreoffice-kit-wasm': '0.0.1', [`@deepseek-ai/libreoffice-kit-${platform}-arm64`]: '0.0.1',
|
||||
} })
|
||||
await packageAt('@deepseek-ai/libreoffice-kit-wasm')
|
||||
await expect(copyOfficeSidecar(staging, destination, { platform, arch: 'arm64' }))
|
||||
await expect(officePackageDirectories(staging, { platform, arch: 'arm64' }))
|
||||
.rejects.toThrow(`Office engine @deepseek-ai/libreoffice-kit-${platform}-arm64 required for ${platform}/arm64 is missing.`)
|
||||
await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('requires a staged Linux WASM engine even when an ancestor has one', async () => {
|
||||
const { root, staging, destination, packageAt } = await fixture()
|
||||
await packageAt('@deepseek-ai/libreoffice-kit')
|
||||
await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root)
|
||||
await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }))
|
||||
.rejects.toThrow('Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.')
|
||||
await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
it('downloads pinned npm packages into the sidecar without resolving or executing package scripts', async () => {
|
||||
const { root, destination } = await fixture()
|
||||
const source = join(root, 'package')
|
||||
await mkdir(source)
|
||||
await writeFile(join(source, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { install: 'must-not-run' } }))
|
||||
await writeFile(join(source, 'worker.js'), 'worker')
|
||||
const archive = join(root, 'package.tgz')
|
||||
await tar({ file: archive, cwd: root, gzip: true }, ['package'])
|
||||
const bytes = await readFile(archive)
|
||||
const checksum = createHash('sha512').update(bytes).digest()
|
||||
const cache = join(root, 'archives')
|
||||
await mkdir(cache)
|
||||
await writeFile(join(cache, checksum.toString('hex')), bytes)
|
||||
const artifact = { name: 'fixture', version: '1.0.0', directory: 'node_modules/fixture',
|
||||
url: 'https://unused.invalid/fixture.tgz', integrity: `sha512-${checksum.toString('base64')}` }
|
||||
await downloadOfficeSidecar([artifact], destination, cache)
|
||||
expect(await readFile(join(destination, 'node_modules/fixture/worker.js'), 'utf8')).toBe('worker')
|
||||
await expect(downloadOfficeSidecar([{ ...artifact, directory: 'node_modules/../../outside' }], destination, cache))
|
||||
.rejects.toThrow('invalid package directory')
|
||||
await expect(downloadOfficeSidecar([{ ...artifact, version: '2.0.0' }], destination, cache))
|
||||
.rejects.toThrow('package identity mismatch')
|
||||
await writeFile(join(cache, checksum.toString('hex')), 'corrupted archive')
|
||||
await expect(downloadOfficeSidecar([artifact], destination, cache)).rejects.toThrow('checksum mismatch')
|
||||
})
|
||||
|
||||
|
||||
it('takes npm integrity from the workspace lock without querying registry metadata', async () => {
|
||||
const { root } = await fixture()
|
||||
const lockfile = join(root, 'pnpm-lock.yaml')
|
||||
const integrity = `sha512-${Buffer.alloc(64, 7).toString('base64')}`
|
||||
await writeFile(lockfile, JSON.stringify({ packages: { '@scope/[email protected]': { resolution: { integrity } } } }))
|
||||
expect(await runtimeNpmArchive('@scope/fixture', '1.2.3', lockfile)).toEqual({
|
||||
name: '@scope/fixture', version: '1.2.3', integrity,
|
||||
url: 'https://registry.npmjs.org/@scope/fixture/-/fixture-1.2.3.tgz',
|
||||
})
|
||||
await expect(runtimeNpmArchive('@scope/fixture', '1.2.4', lockfile)).rejects.toThrow('missing SHA-512 integrity')
|
||||
await writeFile(lockfile, JSON.stringify({ packages: { '@scope/[email protected]': { resolution: { integrity: 'sha256-invalid' } } } }))
|
||||
await expect(runtimeNpmArchive('@scope/fixture', '1.2.3', lockfile)).rejects.toThrow('missing SHA-512 integrity')
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Keep LibreOffice workers, prebuilt engines, and their dependencies on the real filesystem. */
|
||||
import { cp, mkdir, rm } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, sep } from 'node:path'
|
||||
import { downloadRuntimeNpmPackage, type RuntimeNpmArchive } from './primary-runtime/prepare.ts'
|
||||
import { load as parseYaml } from 'js-yaml'
|
||||
import { officePackageDirectories } from './libreoffice-packages.mjs'
|
||||
|
||||
/** pkg applies these exclusions to dependency `files` as well as root asset globs. */
|
||||
@@ -9,29 +11,53 @@ export const OFFICE_ASSET_IGNORES = [
|
||||
'**/node_modules/@deepseek-ai/libreoffice-kit-*/**',
|
||||
]
|
||||
|
||||
/** One npm package at its existing sidecar dependency location. */
|
||||
export interface OfficePackageArchive extends RuntimeNpmArchive {
|
||||
/** Relative package directory inside the sidecar. */
|
||||
readonly directory: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the installed Office dependency tree without changing package contents or executable modes.
|
||||
* Harness sidecars require the kit's declared target native engine, or WASM for other targets.
|
||||
* Missing target engines fail with their package name; missing required dependencies or paths outside the deployed closure also fail.
|
||||
* @param staging - Symlink-free deployed Node closure.
|
||||
* @param destination - Target-specific Office directory beside the executable; replaced when present.
|
||||
* @param target - Node platform and CPU of the executable.
|
||||
* @returns Relative package directories included in the sidecar.
|
||||
* Resolve one installed npm version against its reviewed workspace lock integrity.
|
||||
* @param name - Published package name.
|
||||
* @param version - Exact installed version.
|
||||
* @param lockfile - Workspace lock recording the exact published archive.
|
||||
* @returns Locked npm tarball identity; rejects versions without SHA-512 integrity.
|
||||
*/
|
||||
export async function copyOfficeSidecar(
|
||||
staging: string,
|
||||
destination: string,
|
||||
target: { platform: string; arch: string },
|
||||
): Promise<string[]> {
|
||||
export async function runtimeNpmArchive(name: string, version: string, lockfile = join(import.meta.dirname, '../pnpm-lock.yaml')): Promise<RuntimeNpmArchive> {
|
||||
const lock = parseYaml(await readFile(lockfile, 'utf8')) as { packages?: Record<string, { resolution?: { integrity?: string } }> }
|
||||
const integrity = lock.packages?.[`${name}@${version}`]?.resolution?.integrity
|
||||
if (typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(integrity)) {
|
||||
throw new Error(`runtime npm lock: missing SHA-512 integrity for ${name}@${version}`)
|
||||
}
|
||||
return { name, version, url: `https://registry.npmjs.org/${name}/-/${name.split('/').at(-1)}-${version}.tgz`, integrity }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the installed Office closure to published npm tarballs, preserving nested dependency locations.
|
||||
* @param staging - Complete installed Node closure.
|
||||
* @param target - Sidecar platform and CPU.
|
||||
* @returns Download records for the selected engine and all dependencies.
|
||||
*/
|
||||
export async function officeSidecarArchives(staging: string, target: { platform: string; arch: string }): Promise<OfficePackageArchive[]> {
|
||||
const directories = await officePackageDirectories(staging, target)
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
await mkdir(destination, { recursive: true })
|
||||
for (const source of directories) {
|
||||
const nestedModules = join(source, 'node_modules')
|
||||
await cp(source, join(destination, relative(staging, source)), {
|
||||
recursive: true,
|
||||
filter: path => path !== nestedModules && !path.startsWith(nestedModules + sep),
|
||||
})
|
||||
return Promise.all(directories.map(async (directory) => {
|
||||
const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as { name: string; version: string }
|
||||
return { ...await runtimeNpmArchive(manifest.name, manifest.version), directory: relative(staging, directory).split(sep).join('/') }
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the complete pinned Office sidecar without npm or package lifecycle scripts.
|
||||
* @param packages - Build-locked npm packages and dependency locations.
|
||||
* @param destination - Empty sidecar directory.
|
||||
* @param cache - Verified archive cache.
|
||||
* @returns Resolves after all packages are unpacked and checked.
|
||||
*/
|
||||
export async function downloadOfficeSidecar(packages: readonly OfficePackageArchive[], destination: string, cache: string): Promise<void> {
|
||||
for (const artifact of packages) {
|
||||
if (isAbsolute(artifact.directory) || artifact.directory.split(/[\\/]/u).some(part => part === '..')
|
||||
|| !artifact.directory.startsWith('node_modules/')) throw new Error(`Office download: invalid package directory ${artifact.directory}`)
|
||||
await downloadRuntimeNpmPackage(artifact, join(destination, artifact.directory), cache)
|
||||
}
|
||||
return directories.map(directory => relative(staging, directory))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Prepare a symlink-free Python runtime payload with shared workspace module instances. */
|
||||
/** Prepare a symlink-free executable runtime payload with shared workspace module instances. */
|
||||
import { cp, lstat, readFile, readdir, realpath, rm, unlink } from 'node:fs/promises'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
@@ -78,7 +78,9 @@ export async function materializeStagedLinks(staging: string): Promise<void> {
|
||||
const segments = remaining.slice(nodeModules.length + 1).split(sep)
|
||||
const binIndex = segments.lastIndexOf('.bin')
|
||||
if (binIndex >= 0) {
|
||||
await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true })
|
||||
const directory = join(nodeModules, ...segments.slice(0, binIndex + 1))
|
||||
if ((await lstat(directory)).isSymbolicLink()) await unlink(directory)
|
||||
else await rm(directory, { recursive: true, force: true })
|
||||
remaining = await findSymlink(nodeModules)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -175,7 +175,8 @@ describe('Python runtime executable builder CLI', () => {
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('exec pkg')
|
||||
expect(result.stdout).toContain('--sea --targets node24-macos-x64')
|
||||
expect(result.stdout).toContain('prepare Python and Office skills for mac-x64')
|
||||
expect(result.stdout).toContain('keep only node-pty prebuilds darwin-x64')
|
||||
expect(result.stdout).toContain('lock optional resource downloads and copy Office skills')
|
||||
expect(result.stdout).toContain(join(root, 'dist-exe', 'macos-x64'))
|
||||
})
|
||||
|
||||
|
||||
@@ -6,16 +6,20 @@
|
||||
* runtime imports that pkg cannot discover statically.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { build as bundle } from 'tsdown'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { pnpmInvocation, restoreLegacyHoists } from './executable-packaging.ts'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { chmod, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, extname, join, resolve, sep } from 'node:path'
|
||||
import { chmod, copyFile, cp, glob, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
|
||||
import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './executable-native-pty.ts'
|
||||
import { officeSidecarArchives, runtimeNpmArchive, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts'
|
||||
import { deduplicateStagedWorkspacePackages, materializeStagedLinks } from './build-exe-for-python-sdk-staging.ts'
|
||||
import { preparePrimaryRuntime, smokePrimaryRuntime, type PrimaryRuntimeTarget } from './primary-runtime/prepare.ts'
|
||||
import { prepareOfficeSkillAssets } from './primary-runtime/prepare.ts'
|
||||
import primaryLock from './primary-runtime/lock.json' with { type: 'json' }
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -44,6 +48,7 @@ const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
|
||||
*/
|
||||
const ASSET_GLOBS = [
|
||||
'package.json',
|
||||
'*.mjs',
|
||||
'node_modules/**/*.js',
|
||||
'node_modules/**/*.cjs',
|
||||
'node_modules/**/*.mjs',
|
||||
@@ -232,29 +237,6 @@ class BuildCli {
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmInvocation(args: string[]): [command: string, args: string[]] {
|
||||
const entrypoint = process.env.npm_execpath?.trim()
|
||||
if (entrypoint !== undefined && entrypoint !== '') {
|
||||
const extension = extname(entrypoint).toLowerCase()
|
||||
if (extension === '.js' || extension === '.cjs' || extension === '.mjs') {
|
||||
return [process.execPath, [entrypoint, ...args]]
|
||||
}
|
||||
if (extension !== '.cmd') return [entrypoint, args]
|
||||
}
|
||||
const home = process.env.PNPM_HOME?.trim()
|
||||
if (home !== undefined && home !== '') {
|
||||
const packageBin = resolve(home, '..', 'pnpm', 'bin')
|
||||
for (const filename of ['pnpm.mjs', 'pnpm.cjs']) {
|
||||
const candidate = resolve(packageBin, filename)
|
||||
if (existsSync(candidate)) return [process.execPath, [candidate, ...args]]
|
||||
}
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
throw new Error('build-exe-for-python-sdk: pnpm must expose a JavaScript entrypoint through npm_execpath or PNPM_HOME on Windows.')
|
||||
}
|
||||
return ['pnpm', args]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a command for logs and errors, quoting arguments with spaces.
|
||||
* @param command - the executable.
|
||||
@@ -300,7 +282,7 @@ class SingleExeBuild {
|
||||
}
|
||||
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`)
|
||||
else await rm(this.staging, { recursive: true, force: true })
|
||||
await this.runPnpm('deploy', [
|
||||
try { await this.runPnpm('deploy', [
|
||||
'--filter',
|
||||
DEPLOY_ROOT_PACKAGE,
|
||||
'deploy',
|
||||
@@ -313,13 +295,19 @@ class SingleExeBuild {
|
||||
'--config.link-workspace-packages=true',
|
||||
'--config.hoist-workspace-packages=false',
|
||||
this.staging,
|
||||
])
|
||||
]) } finally {
|
||||
// Legacy deploy records production-only workspace state; restore the development installation before exec.
|
||||
await this.runPnpm('restore development dependencies', ['install', '--offline', '--frozen-lockfile', '--prod=false', '--ignore-scripts'])
|
||||
}
|
||||
await this.restoreLegacyHoists()
|
||||
await this.materializeStagedLinks()
|
||||
if (this.cli.dryRun) {
|
||||
for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`)
|
||||
} else {
|
||||
await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true })))
|
||||
for await (const file of glob('node_modules/**/{README*.md,CHANGELOG*.md,HISTORY*.md,*.map,*.d.ts,*.d.mts,*.d.cts}', {
|
||||
cwd: this.staging, exclude: ['node_modules/**/assets/**'],
|
||||
})) await rm(join(this.staging, file), { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,38 +322,8 @@ class SingleExeBuild {
|
||||
console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy')
|
||||
return
|
||||
}
|
||||
const manifestPath = join(this.staging, 'package.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
const sourceNodeModules = resolve(root, DEPLOY_SOURCE_NODE_MODULES)
|
||||
const restored: string[] = []
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) {
|
||||
const destination = join(this.staging, 'node_modules', dependency)
|
||||
if (existsSync(destination)) continue
|
||||
const source = join(sourceNodeModules, dependency)
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(
|
||||
`build-exe-for-python-sdk: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`,
|
||||
)
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
restored.push(dependency)
|
||||
}
|
||||
const stillMissing = Object.keys(manifest.dependencies ?? {})
|
||||
.filter(dependency => !existsSync(join(this.staging, 'node_modules', dependency)))
|
||||
if (stillMissing.length > 0) {
|
||||
throw new Error(`build-exe-for-python-sdk: staged dependencies remain missing: ${stillMissing.join(', ')}.`)
|
||||
}
|
||||
if (restored.length > 0) {
|
||||
console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`)
|
||||
}
|
||||
const restored = await restoreLegacyHoists(this.staging, resolve(root, DEPLOY_SOURCE_NODE_MODULES))
|
||||
if (restored.length > 0) console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`)
|
||||
}
|
||||
|
||||
/** Replace deploy-time package links with files and reject any remaining link. */
|
||||
@@ -380,7 +338,14 @@ class SingleExeBuild {
|
||||
|
||||
/** Add the executable entry and pkg assets to the staged manifest. */
|
||||
async injectPkgConfig(): Promise<void> {
|
||||
const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS, ignore: OFFICE_ASSET_IGNORES } }
|
||||
const sourceModules = join(root, 'node_modules').replaceAll('\\', '/')
|
||||
const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS, ignore: [
|
||||
...OFFICE_ASSET_IGNORES,
|
||||
`${sourceModules}/**`, `${sourceModules}/.pnpm/**`,
|
||||
...['packages', 'apps', 'vendor', 'native', 'scripts'].map(directory => `${join(root, directory).replaceAll('\\', '/')}/**`),
|
||||
`${root.replaceAll('\\', '/')}/tsconfig*.json`,
|
||||
'**/*.map', '**/*.d.ts', '**/*.d.mts', '**/*.d.cts',
|
||||
] } }
|
||||
const manifestPath = join(this.staging, 'package.json')
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`)
|
||||
@@ -392,8 +357,28 @@ class SingleExeBuild {
|
||||
if (!existsSync(join(this.staging, ENTRY_BIN))) {
|
||||
throw new Error(`build-exe-for-python-sdk: staged bootstrap ${join(this.staging, ENTRY_BIN)} is missing.`)
|
||||
}
|
||||
const helpers = [
|
||||
{ name: 'primary-runtime', source: 'scripts/primary-runtime/prepare.ts', exports: 'downloadNodeRuntime, preparePrimaryRuntime' },
|
||||
{ name: 'office-sidecar', source: 'scripts/build-exe-for-python-sdk-office.ts', exports: 'downloadOfficeSidecar' },
|
||||
]
|
||||
const entries: Record<string, string> = {}
|
||||
for (const helper of helpers) {
|
||||
const entry = join(this.staging, `.${helper.name}-entry.ts`)
|
||||
entries[helper.name] = entry
|
||||
await writeFile(entry, `export { ${helper.exports} } from ${JSON.stringify(join(root, helper.source).replaceAll('\\', '/'))}\n`)
|
||||
}
|
||||
try {
|
||||
await bundle({ config: false, tsconfig: false, inputOptions: { tsconfig: false }, target: 'es2024', entry: entries,
|
||||
outDir: this.staging, clean: false, format: 'esm', platform: 'node', dts: false,
|
||||
deps: { neverBundle: [/^@deepseek-ai\//u], alwaysBundle: [/.*/u] }, shims: true, outExtensions: () => ({ js: '.mjs' }),
|
||||
define: { 'import.meta.main': 'false' } })
|
||||
} finally {
|
||||
for (const entry of Object.values(entries)) await rm(entry, { force: true })
|
||||
}
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
|
||||
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`)
|
||||
await this.run('resource helper imports', process.execPath, ['--input-type=module', '--eval',
|
||||
['primary-runtime.mjs', 'office-sidecar.mjs'].map(file => `await import(${JSON.stringify(pathToFileURL(join(this.staging, file)).href)})`).join('\n')])
|
||||
console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`)
|
||||
}
|
||||
|
||||
@@ -406,6 +391,7 @@ class SingleExeBuild {
|
||||
const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
|
||||
const product = target.platform === 'win' ? `${productBase}.exe` : productBase
|
||||
await this.prepareNativePty(target)
|
||||
const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
|
||||
if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true })
|
||||
await this.runPnpm(`pkg ${target.spec}`, [
|
||||
'exec',
|
||||
@@ -420,26 +406,22 @@ class SingleExeBuild {
|
||||
if (!this.cli.dryRun && !existsSync(product)) {
|
||||
throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
|
||||
}
|
||||
const office = `${productBase}-office`
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] copy Office dependency closure from ${this.staging} to ${office}`)
|
||||
} else {
|
||||
const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
|
||||
const packages = await copyOfficeSidecar(this.staging, office, { platform, arch: target.arch })
|
||||
console.log(`build-exe-for-python-sdk: copied ${packages.length} Office packages to ${office}`)
|
||||
}
|
||||
const ripgrep = await this.copyRipgrepSidecar(target, product)
|
||||
const resources = join(this.outDir, `${target.platform}-${target.arch}`)
|
||||
const runtimeTarget = `${target.platform === 'macos' ? 'mac' : target.platform}-${target.arch}` as PrimaryRuntimeTarget
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] prepare Python and Office skills for ${runtimeTarget} in ${resources}`)
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] lock optional resource downloads and copy Office skills to ${resources}`)
|
||||
} else {
|
||||
const { version } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version: string }
|
||||
await preparePrimaryRuntime({ target: runtimeTarget, output: resources,
|
||||
cache: join(tmpdir(), 'dsh-primary-runtime-downloads'), version })
|
||||
smokePrimaryRuntime(join(resources, 'primary-runtime'))
|
||||
await rm(resources, { recursive: true, force: true })
|
||||
await mkdir(resources, { recursive: true })
|
||||
const { version, packageManager } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version: string; packageManager: string }
|
||||
const office = await officeSidecarArchives(this.staging, { platform, arch: target.arch })
|
||||
const pnpm = await runtimeNpmArchive('pnpm', packageManager.replace('pnpm@', ''))
|
||||
const inputs = { version, target: `${target.platform === 'macos' ? 'mac' : target.platform}-${target.arch}`, office, pnpm }
|
||||
const identity = createHash('sha256').update(JSON.stringify({ ...inputs, primaryLock })).digest('hex')
|
||||
await writeFile(join(resources, 'downloads.json'), JSON.stringify({ ...inputs, identity }, undefined, 2) + '\n')
|
||||
await prepareOfficeSkillAssets(join(this.staging, 'node_modules/@deepseek-ai/dsh-skill-office/assets'), join(resources, 'office-skills'))
|
||||
}
|
||||
if (target.platform !== 'macos') return [product, ripgrep, office, resources]
|
||||
if (target.platform !== 'macos') return [product, ripgrep, resources]
|
||||
const spawnHelper = `${product}-spawn-helper`
|
||||
const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper')
|
||||
if (this.cli.dryRun) {
|
||||
@@ -448,7 +430,7 @@ class SingleExeBuild {
|
||||
await copyFile(source, spawnHelper)
|
||||
await chmod(spawnHelper, 0o755)
|
||||
}
|
||||
return [product, ripgrep, spawnHelper, office, resources]
|
||||
return [product, ripgrep, spawnHelper, resources]
|
||||
}
|
||||
|
||||
/** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */
|
||||
@@ -495,6 +477,17 @@ class SingleExeBuild {
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
)
|
||||
const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform
|
||||
const selected = `${platform}-${target.arch}`
|
||||
const prebuilds = join(this.staging, 'node_modules/node-pty/prebuilds')
|
||||
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] keep only node-pty prebuilds ${selected}`)
|
||||
else {
|
||||
const source = join(packageDirectory, 'prebuilds', selected)
|
||||
if (existsSync(source)) await cp(source, join(prebuilds, selected), { recursive: true })
|
||||
for (const entry of await readdir(prebuilds, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name !== selected) await rm(join(prebuilds, entry.name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
if (target.platform === 'win') {
|
||||
if (target.arch !== 'x64') {
|
||||
throw new Error('build-exe-for-python-sdk: Windows supports x64 only.')
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/** Build a relocatable, native-host SSH helper archive with its private Node workers. */
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { existsSync, globSync } from 'node:fs'
|
||||
import { chmod, copyFile, cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { build } from 'tsdown'
|
||||
import { create as createTar } from 'tar'
|
||||
import { verifyRuntimeClosure } from './verify-runtime-closure.ts'
|
||||
import { pnpmInvocation, restoreLegacyHoists } from './executable-packaging.ts'
|
||||
import { materializeStagedLinks, deduplicateStagedWorkspacePackages } from './build-exe-for-python-sdk-staging.ts'
|
||||
import { resolveLinuxNodePtyAddon } from './executable-native-pty.ts'
|
||||
import { artifactManifestSchema, fileDigest, payloadFiles, SSH_HELPER_TARGETS, verifyPayload, type SshHelperTarget } from './ssh-helper/artifact.ts'
|
||||
import { probeHelper } from './ssh-helper/probe.ts'
|
||||
import { verifyNativePayload } from './ssh-helper/native.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const closureDirectory = 'packages/ssh/ssh-helper-runtime'
|
||||
const closureName = '@deepseek-ai/dsh-ssh-helper-runtime'
|
||||
const assets = ['package.json', 'lib/**/*.js', ...['js', 'cjs', 'mjs', 'json', 'node', 'so', 'so.*', 'dylib', 'wasm', 'sh', 'bash'].map(extension => `node_modules/**/*.${extension}`)]
|
||||
|
||||
interface Manifest { name: string; version: string; license?: string; dependencies?: Record<string, string> }
|
||||
|
||||
async function manifest(path: string): Promise<Manifest> { return JSON.parse(await readFile(path, 'utf8')) as Manifest }
|
||||
|
||||
async function run(command: string, args: string[]): Promise<void> {
|
||||
console.log(`ssh-helper build: ${[command, ...args].join(' ')}`)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, { cwd: root, stdio: 'inherit', env: { ...process.env, CI: 'true' } })
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0 && signal === null) resolve()
|
||||
else reject(new Error(`SSH helper build command failed (${code ?? signal}): ${command}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function pnpm(...args: string[]): Promise<void> { const [command, argv] = pnpmInvocation(args); await run(command, argv) }
|
||||
|
||||
async function compileRuntime(dependencies: Readonly<Record<string, string>>): Promise<void> {
|
||||
await pnpm('exec', 'tsx', 'native/system/scripts/build.ts')
|
||||
await pnpm('exec', 'tsc', '-b', closureDirectory, 'native/system/packages/entry')
|
||||
const names = new Set([closureName, ...Object.keys(dependencies)])
|
||||
for (const path of globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }).sort()) {
|
||||
if (!names.has((await manifest(join(root, path))).name)) continue
|
||||
const cwd = dirname(join(root, path))
|
||||
const config = join(cwd, 'tsdown.config.ts')
|
||||
if (existsSync(config)) await build({ cwd, config })
|
||||
else await build({ cwd, config: false, entry: ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false })
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareNative(staging: string, product: string, platform: string, arch: 'x64' | 'arm64'): Promise<void> {
|
||||
const pty = join(staging, 'node_modules', 'node-pty')
|
||||
await rm(join(pty, 'build'), { recursive: true, force: true })
|
||||
for (const entry of await readdir(join(pty, 'prebuilds'))) {
|
||||
if (entry !== `${platform}-${arch}`) await rm(join(pty, 'prebuilds', entry), { recursive: true, force: true })
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
const addon = resolveLinuxNodePtyAddon(join(root, 'packages/subprocess/subprocess-local/node_modules/node-pty'), arch)
|
||||
await mkdir(join(pty, 'build/Release'), { recursive: true })
|
||||
await copyFile(addon, join(pty, 'build/Release/pty.node'))
|
||||
} else {
|
||||
const destination = join(product, 'dsh-ssh-helper-spawn-helper')
|
||||
await copyFile(join(pty, 'prebuilds', `darwin-${arch}`, 'spawn-helper'), destination)
|
||||
await chmod(destination, 0o755)
|
||||
}
|
||||
const source = join(root, 'native/system/packages', `${platform}-${arch}`)
|
||||
const destination = join(product, 'native/system')
|
||||
await mkdir(destination, { recursive: true })
|
||||
await copyFile(join(source, 'package.json'), join(destination, 'package.json'))
|
||||
await cp(join(source, 'bin'), join(destination, 'bin'), { recursive: true })
|
||||
// The Linux carrier targets glibc; no unused musl addon travels in this archive.
|
||||
if (platform === 'linux') await rm(join(destination, 'bin/musl'), { recursive: true, force: true })
|
||||
const binaries = platform === 'linux' ? ['bin/landlock-run', 'bin/glibc/system.node'] : ['bin/system.node']
|
||||
for (const binary of binaries) {
|
||||
if (!(await lstat(join(destination, binary))).isFile()) throw new Error(`Missing SSH native resource: ${binary}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLicenses(staging: string, product: string): Promise<void> {
|
||||
const destination = join(product, 'LICENSES')
|
||||
await mkdir(destination, { recursive: true })
|
||||
const inventory: { name: string; version: string; license?: string }[] = []
|
||||
for (const path of globSync(['node_modules/*/package.json', 'node_modules/@*/*/package.json'], { cwd: staging }).sort()) {
|
||||
const directory = dirname(join(staging, path))
|
||||
const data = await manifest(join(staging, path))
|
||||
inventory.push({ name: data.name, version: data.version, ...(data.license === undefined ? {} : { license: data.license }) })
|
||||
const licenses = (await readdir(directory)).filter(name => /^(?:licen[cs]e|copying|notice)(?:[.-]|$)/i.test(name))
|
||||
for (const name of licenses) {
|
||||
const source = join(directory, name)
|
||||
if (!(await lstat(source)).isFile()) continue
|
||||
const output = join(destination, data.name.replaceAll('/', '__'), name)
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
await copyFile(source, output)
|
||||
}
|
||||
}
|
||||
await copyFile(join(root, 'LICENSE'), join(destination, 'deepseek-harness-MIT.txt'))
|
||||
await copyFile(join(root, 'native/system/LICENSE'), join(destination, 'node-addon-system-BSD.txt'))
|
||||
await writeFile(join(destination, 'packages.json'), `${JSON.stringify(inventory, null, 2)}\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the selected native target and retain only its complete release archive.
|
||||
* @param argv - command-line arguments, excluding the interpreter and script.
|
||||
*/
|
||||
export async function buildSshHelper(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({ args: argv, options: {
|
||||
target: { type: 'string' }, output: { type: 'string' },
|
||||
'skip-build': { type: 'boolean', default: false }, 'dry-run': { type: 'boolean', default: false },
|
||||
} })
|
||||
const host = `node24-${process.platform === 'darwin' ? 'macos' : process.platform}-${process.arch}`
|
||||
const selected = values.target ?? host
|
||||
if (!SSH_HELPER_TARGETS.some(target => target === selected)) throw new Error(`Unsupported SSH helper target: ${selected}`)
|
||||
if (!values['dry-run'] && selected !== host) throw new Error(`SSH helper target ${selected} requires its native host; current host is ${host}`)
|
||||
const target = selected as SshHelperTarget
|
||||
const definition = await manifest(join(root, closureDirectory, 'package.json'))
|
||||
const closure = await verifyRuntimeClosure(root, `${closureDirectory}/package.json`, false)
|
||||
if (closure.failures.length > 0) throw new Error(`SSH helper runtime dependencies are incomplete:\n${closure.failures.join('\n')}`)
|
||||
const output = resolve(root, values.output ?? 'dist-exe/ssh-helper')
|
||||
if (values['dry-run']) {
|
||||
console.log(`SSH helper ${target}: ${values['skip-build'] ? 'reuse built modules' : 'compile runtime'} -> deploy ${closureName} -> pkg --sea -> handshake -> ${output}`)
|
||||
return
|
||||
}
|
||||
if (!values['skip-build']) await compileRuntime(definition.dependencies ?? {})
|
||||
await mkdir(join(root, '.dsh-build'), { recursive: true })
|
||||
const temporary = await mkdtemp(join(root, '.dsh-build/ssh-helper-'))
|
||||
try {
|
||||
const staging = join(temporary, 'staging')
|
||||
const product = join(temporary, 'dsh-ssh-helper')
|
||||
await mkdir(product)
|
||||
try {
|
||||
await pnpm('--filter', closureName, 'deploy', '--legacy', '--prod', '--config.allow-unused-patches=true', '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', '--config.hoist-workspace-packages=false', staging)
|
||||
} finally {
|
||||
// Legacy deploy records production-only workspace state; pnpm's next exec otherwise prunes build tools.
|
||||
await pnpm('install', '--offline', '--frozen-lockfile', '--prod=false', '--ignore-scripts')
|
||||
}
|
||||
await restoreLegacyHoists(staging, join(root, closureDirectory, 'node_modules'))
|
||||
await materializeStagedLinks(staging)
|
||||
await deduplicateStagedWorkspacePackages(staging, root)
|
||||
await prepareNative(staging, product, process.platform, process.arch as 'x64' | 'arm64')
|
||||
await verifyNativePayload(join(staging, 'node_modules'), target)
|
||||
const stagedManifest = await manifest(join(staging, 'package.json'))
|
||||
await writeFile(join(staging, 'runtime-bootstrap.mjs'), 'import { runSshHelperRuntime } from "./lib/index.js";\nawait runSshHelperRuntime();\n')
|
||||
await writeFile(join(staging, 'package.json'), `${JSON.stringify({ ...stagedManifest,
|
||||
bin: 'runtime-bootstrap.mjs', pkg: { assets, ignore: ['node_modules/@deepseek-ai/node-addon-system-*/**/*'] },
|
||||
}, null, 2)}\n`)
|
||||
const executable = join(product, 'dsh-ssh-helper')
|
||||
await pnpm('exec', 'pkg', staging, '--sea', '--targets', target, '--output', executable)
|
||||
await chmod(executable, 0o755)
|
||||
await verifyNativePayload(product, target)
|
||||
await copyLicenses(staging, product)
|
||||
const hello = await probeHelper(executable, product)
|
||||
if (hello.kind !== 'executable' || hello.executable !== executable) throw new Error('Packaged SSH helper did not report its physical executable')
|
||||
const metadata = artifactManifestSchema.parse({
|
||||
format: 1, version: definition.version, target,
|
||||
commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(),
|
||||
dirty: execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim() !== '',
|
||||
protocol: hello.protocol, nodeVersion: hello.nodeVersion,
|
||||
files: await Promise.all((await payloadFiles(product)).map(async path => ({
|
||||
path, sha256: await fileDigest(join(product, path)), mode: (await lstat(join(product, path))).mode & 0o777,
|
||||
}))),
|
||||
})
|
||||
await writeFile(join(product, 'manifest.json'), `${JSON.stringify(metadata, null, 2)}\n`)
|
||||
await verifyPayload(product)
|
||||
await mkdir(output, { recursive: true })
|
||||
const archive = join(output, `dsh-ssh-helper-${definition.version}-${target.slice('node24-'.length)}.tar.gz`)
|
||||
await createTar({ cwd: temporary, file: archive, gzip: true, portable: true }, ['dsh-ssh-helper'])
|
||||
await writeFile(`${archive}.sha256`, `${await fileDigest(archive)} ${basename(archive)}\n`)
|
||||
console.log(`SSH helper archive: ${archive}`)
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await buildSshHelper(process.argv.slice(2))
|
||||
@@ -56,11 +56,6 @@ def runtime_filenames(executable_name: str) -> tuple[str, ...]:
|
||||
return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names
|
||||
|
||||
|
||||
def office_sidecar_name(executable_name: str) -> str:
|
||||
"""Return the complete Office dependency directory for one executable."""
|
||||
return f"{executable_name.removesuffix('.exe')}-office"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--package", choices=("sdk", "runtime"), required=True)
|
||||
@@ -229,54 +224,18 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_
|
||||
source_directory = executable.parent
|
||||
for filename in runtime_filenames(executable_name):
|
||||
shutil.copy2(source_directory / filename, runtime_dir / filename)
|
||||
office = office_sidecar_name(executable_name)
|
||||
shutil.copytree(source_directory / office, runtime_dir / office)
|
||||
resources = executable_name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe")
|
||||
shutil.copytree(source_directory / resources, runtime_dir / resources)
|
||||
|
||||
|
||||
def verify_office_payload(archive: zipfile.ZipFile, office_modules: str, platform_tag: str) -> None:
|
||||
"""Check packaged engine assets and native helper executable permissions."""
|
||||
names = set(archive.namelist())
|
||||
adapter = f"{office_modules}/@deepseek-ai/libreoffice-kit/package.json"
|
||||
if adapter not in names:
|
||||
raise RuntimeError("Office dependency is missing: libreoffice-kit")
|
||||
engines = f"{office_modules}/@deepseek-ai"
|
||||
target = next(name for name, value in PLATFORMS.items() if value[0] == platform_tag)
|
||||
native_target = target.replace("win-", "win32-").replace("macos-", "darwin-")
|
||||
declared = json.loads(archive.read(adapter)).get("optionalDependencies", {})
|
||||
selected = native_target if f"@deepseek-ai/libreoffice-kit-{native_target}" in declared else "wasm"
|
||||
for required in (f"libreoffice-kit-{selected}/prebuilds.json",):
|
||||
if f"{engines}/{required}" not in names:
|
||||
raise RuntimeError(f"Office dependency is missing: {required}")
|
||||
manifests = (
|
||||
name for name in names
|
||||
if name.startswith(f"{engines}/libreoffice-kit-")
|
||||
and name.endswith("/prebuilds.json")
|
||||
and name.count("/") == engines.count("/") + 2
|
||||
)
|
||||
for manifest_path in manifests:
|
||||
if manifest_path != f"{engines}/libreoffice-kit-{selected}/prebuilds.json":
|
||||
raise RuntimeError(f"Unexpected Office engine for {platform_tag}: {manifest_path}")
|
||||
manifest = json.loads(archive.read(manifest_path))
|
||||
engine = manifest["engine"]
|
||||
native = engine["kind"] == "native"
|
||||
fields = ("executable",) if native else ("loader", "wasm", "data", "metadata")
|
||||
for field in fields:
|
||||
asset = f"{manifest_path.rsplit('/', 1)[0]}/{engine[field]}"
|
||||
if asset not in names:
|
||||
raise RuntimeError(f"Office engine asset is missing: {asset}")
|
||||
mode = archive.getinfo(asset).external_attr >> 16
|
||||
if native and platform_tag != "win_amd64" and mode & stat.S_IXUSR == 0:
|
||||
raise RuntimeError(f"Office helper lost its executable bit: {asset}")
|
||||
|
||||
|
||||
def verify_wheel(
|
||||
wheel: Path,
|
||||
package: str,
|
||||
version: str,
|
||||
platform: tuple[str, str] | None,
|
||||
) -> None:
|
||||
if wheel.stat().st_size > 100_000_000:
|
||||
raise RuntimeError(f"{wheel} is {wheel.stat().st_size} bytes and exceeds the PyPI single-file limit of 100000000 bytes")
|
||||
expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}"
|
||||
expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION
|
||||
dist_info = f"{expected_distribution.replace('-', '_')}-{version}.dist-info"
|
||||
@@ -308,15 +267,12 @@ def verify_wheel(
|
||||
]
|
||||
if package == "runtime":
|
||||
assert platform is not None
|
||||
office = office_sidecar_name(platform[1])
|
||||
resources = platform[1].removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe")
|
||||
expected_files = sorted((*runtime_filenames(platform[1]), office, resources))
|
||||
expected_files = sorted((*runtime_filenames(platform[1]), resources))
|
||||
found_files = sorted({name.split("/runtime/", 1)[1].split("/", 1)[0] for name in runtime_payload})
|
||||
if found_files != expected_files:
|
||||
raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}")
|
||||
office_modules = f"deepseek_harness_runtime/runtime/{office}/node_modules"
|
||||
verify_office_payload(archive, office_modules, platform[0])
|
||||
validate = runpy.run_path(str(ROOT / "python/sdk-runtime/src/deepseek_harness_runtime/_resources.py"))["validate_resources"]
|
||||
validate = runpy.run_path(str(ROOT / "python/sdk-runtime/src/deepseek_harness_runtime/_resources.py"))["validate_downloads"]
|
||||
target = next(name for name, value in PLATFORMS.items() if value == platform)
|
||||
validate(zipfile.Path(archive, f"deepseek_harness_runtime/runtime/{resources}/"), target)
|
||||
for runtime_file in runtime_payload:
|
||||
|
||||
@@ -378,6 +378,8 @@ export function checkExperimentalNameExceptions(manifests: readonly WorkspaceMan
|
||||
}
|
||||
|
||||
function isReleaseMemberDirectory(dir: string): boolean {
|
||||
// The SSH carrier is distributed as executable archives, never as an npm application.
|
||||
if (dir === 'packages/ssh/ssh-helper-runtime') return false
|
||||
return standardReleaseMemberDirectory.test(dir) || isPublicExperimentalPackageDirectory(dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -646,6 +646,28 @@ describe('CI workflow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes the scaled benchmark report even when a budget fails', () => {
|
||||
const benchmark = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-bench')
|
||||
if (!Array.isArray(benchmark.steps)) throw new TypeError('benchmark job must define steps')
|
||||
|
||||
expect(benchmark.steps).toContainEqual({
|
||||
name: 'Upload scaled benchmark report',
|
||||
if: '${{ !cancelled() }}',
|
||||
uses: 'actions/[email protected]',
|
||||
with: {
|
||||
name: 'benchmark-report',
|
||||
path: 'benchmarks/.dsh-report/',
|
||||
'include-hidden-files': true,
|
||||
'if-no-files-found': 'warn',
|
||||
'retention-days': 7,
|
||||
},
|
||||
})
|
||||
|
||||
const uploadIndex = benchmark.steps.findIndex(step => isRecord(step) && step.name === 'Upload scaled benchmark report')
|
||||
const benchmarkIndex = benchmark.steps.findIndex(step => isRecord(step) && step.name === 'Run performance benchmarks')
|
||||
expect(uploadIndex).toBeGreaterThan(benchmarkIndex)
|
||||
})
|
||||
|
||||
it('gives the Wine Host TypeScript compile the repository heap budget', () => {
|
||||
const wineGates = readFileSync(resolve(root, 'scripts/wine-windows-gates.sh'), 'utf8')
|
||||
|
||||
@@ -693,6 +715,7 @@ describe('CI workflow', () => {
|
||||
const NOT_PUSH_REACHABLE = new Set([
|
||||
"github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'",
|
||||
"github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'",
|
||||
"github.event_name == 'workflow_dispatch' && inputs.suite == 'ssh-helper'",
|
||||
])
|
||||
const pushReachable = Object.entries(workflow.jobs)
|
||||
.filter(([, job]) => {
|
||||
@@ -778,6 +801,104 @@ describe('CI workflow', () => {
|
||||
expect(aggregate.needs).toContain('python-runtime')
|
||||
})
|
||||
|
||||
it('builds SSH executables only for explicit manual or release requests', () => {
|
||||
const ci = loadWorkflow('.github/workflows/ci.yml')
|
||||
if (!isRecord(ci.jobs)) throw new Error('PR CI must define jobs')
|
||||
expect(Object.values(ci.jobs)).not.toContainEqual(expect.objectContaining({ uses: './.github/workflows/build-exe-for-ssh-helper.yml' }))
|
||||
expect(workflowJob(ci, 'all-checks-passed').needs).not.toContain('ssh-helper')
|
||||
const master = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), 'ssh-helper')
|
||||
if (typeof master.if !== 'string') throw new Error('SSH manual caller must define its trigger condition')
|
||||
for (const [event, suite, expected] of [
|
||||
['push', 'ssh-helper', false], ['pull_request', 'ssh-helper', false],
|
||||
['workflow_dispatch', 'larger-runner-benchmark', false], ['workflow_dispatch', 'ssh-helper', true],
|
||||
] as const) {
|
||||
expect(runInNewContext(master.if, { github: { event_name: event, ref: 'refs/heads/master' }, inputs: { suite } }, { timeout: 1000 })).toBe(expected)
|
||||
}
|
||||
expect(master.with).toEqual({ targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64' })
|
||||
const workflow = loadWorkflow('.github/workflows/build-exe-for-ssh-helper.yml')
|
||||
if (!isRecord(workflow.on)) throw new Error('SSH builder must define triggers')
|
||||
expect(Object.keys(workflow.on).sort()).toEqual(['workflow_call', 'workflow_dispatch'])
|
||||
})
|
||||
|
||||
it('requires SSH helper acceptance before uploading artifacts or publishing', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/build-exe-for-ssh-helper.yml')
|
||||
const build = workflowJob(workflow, 'build')
|
||||
if (!Array.isArray(build.steps)) throw new Error('SSH build must define its verification steps')
|
||||
const steps = build.steps.filter(isRecord)
|
||||
const artifact = steps.findIndex(step => String(step.run).includes('scripts/verify-ssh-helper-artifact.ts'))
|
||||
const transport = steps.findIndex(step => String(step.run).includes('scripts/verify-ssh-helper-ssh.ts'))
|
||||
const landlock = steps.findIndex(step => String(step.run).includes('--backend=landlock-run'))
|
||||
const upload = steps.findIndex(step => String(step.uses).startsWith('actions/upload-artifact@'))
|
||||
expect(artifact).toBeGreaterThan(-1)
|
||||
expect(landlock).toBeGreaterThan(artifact)
|
||||
expect(transport).toBeGreaterThan(artifact)
|
||||
expect(upload).toBeGreaterThan(transport)
|
||||
for (const index of [artifact, transport, landlock, upload]) expect(steps[index]).not.toHaveProperty('continue-on-error', true)
|
||||
const publish = loadWorkflow('.github/workflows/publish-ssh-helper.yml')
|
||||
expect(publish.permissions).toEqual({ contents: 'read' })
|
||||
expect(workflowJob(publish, 'build')).toMatchObject({ needs: 'validate', with: { release: true } })
|
||||
const verify = workflowJob(publish, 'verify')
|
||||
expect(verify.needs).toEqual(['validate', 'build'])
|
||||
expect(verify).not.toHaveProperty('if')
|
||||
expect(verify).not.toHaveProperty('permissions')
|
||||
if (!Array.isArray(verify.steps)) throw new Error('Release verification must define steps')
|
||||
const verification = verify.steps.findIndex(step => isRecord(step) && String(step.run).includes('scripts/ssh-helper/release.ts'))
|
||||
const checksums = verify.steps.findIndex(step => isRecord(step) && String(step.uses).startsWith('actions/upload-artifact@'))
|
||||
expect(verification).toBeGreaterThan(-1)
|
||||
expect(checksums).toBeGreaterThan(verification)
|
||||
expect(verify.steps[verification]).not.toHaveProperty('continue-on-error', true)
|
||||
const publication = workflowJob(publish, 'publish')
|
||||
expect(publication).toMatchObject({ if: 'inputs.publish', needs: ['validate', 'verify'], permissions: { contents: 'write' } })
|
||||
for (const requested of [false, true]) {
|
||||
expect(runInNewContext(String(publication.if), { inputs: { publish: requested } }, { timeout: 1000 })).toBe(requested)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates any ref by default but refuses publication without the matching SSH release tag', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/publish-ssh-helper.yml')
|
||||
if (!isRecord(workflow.on)) throw new Error('SSH release must define triggers')
|
||||
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
|
||||
expect(workflowEvent(workflow, 'workflow_dispatch').inputs).toMatchObject({ publish: { type: 'boolean', default: false } })
|
||||
const validate = workflowJob(workflow, 'validate')
|
||||
expect(validate.steps).toContainEqual(expect.objectContaining({ id: 'source', env: { PUBLISH: '${{ inputs.publish }}' } }))
|
||||
const source = nodeStepSource(validate, 'source')
|
||||
const evaluate = (publish: boolean, type: string, name: string): string => {
|
||||
let output = ''
|
||||
runInNewContext(source, {
|
||||
process: { env: { PUBLISH: String(publish), GITHUB_REF_TYPE: type, GITHUB_REF_NAME: name, GITHUB_OUTPUT: 'output' } },
|
||||
readFileSync: () => JSON.stringify({ version: '1.2.3' }),
|
||||
execFileSync: () => `${'a'.repeat(40)}\n`,
|
||||
appendFileSync: (_path: string, text: string) => { output += text },
|
||||
}, { timeout: 1000 })
|
||||
return output
|
||||
}
|
||||
const expected = `version=1.2.3\ncommit=${'a'.repeat(40)}\n`
|
||||
expect(evaluate(false, 'branch', 'worktree/helper')).toBe(expected)
|
||||
expect(evaluate(false, 'tag', 'arbitrary-tag')).toBe(expected)
|
||||
expect(evaluate(true, 'tag', 'dsh-v1.2.3')).toBe(expected)
|
||||
for (const [type, name] of [['branch', 'dsh-v1.2.3'], ['tag', 'dsh-v1.2.2'], ['tag', 'python-v1.2.3']] as const) {
|
||||
expect(() => evaluate(true, type, name)).toThrow('Publication requires the matching dsh-v<version> tag')
|
||||
}
|
||||
})
|
||||
|
||||
it('validates the SSH native matrix before allocating runners', () => {
|
||||
const plan = workflowJob(loadWorkflow('.github/workflows/build-exe-for-ssh-helper.yml'), 'plan')
|
||||
const source = nodeStepSource(plan, 'targets')
|
||||
const evaluate = (requested: string): unknown => {
|
||||
let output = ''
|
||||
runInNewContext(source, {
|
||||
process: { env: { REQUESTED_TARGETS: requested, GITHUB_OUTPUT: 'output' } },
|
||||
appendFileSync: (_path: string, text: string) => { output += text },
|
||||
}, { timeout: 1000 })
|
||||
return JSON.parse(output.slice('matrix='.length))
|
||||
}
|
||||
expect(evaluate('')).toHaveLength(4)
|
||||
expect(evaluate('node24-linux-arm64')).toEqual([{ target: 'node24-linux-arm64', runner: 'ubuntu-24.04-arm' }])
|
||||
for (const invalid of ['node24-linux-arm64,node24-linux-arm64', 'node24-win-x64', 'node24-linux-x64,', ' ']) {
|
||||
expect(() => evaluate(invalid)).toThrow('Invalid or duplicate')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every Vitest project process-isolated on native Windows', () => {
|
||||
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
|
||||
|
||||
@@ -1416,6 +1537,15 @@ function loadWorkflow(path: string): Record<string, unknown> {
|
||||
return workflow
|
||||
}
|
||||
|
||||
function nodeStepSource(job: Record<string, unknown>, id: string): string {
|
||||
if (!Array.isArray(job.steps)) throw new Error('Node workflow job must define steps')
|
||||
const step: unknown = job.steps.find(value => isRecord(value) && value.id === id)
|
||||
if (!isRecord(step) || typeof step.run !== 'string') throw new Error(`Missing Node workflow step: ${id}`)
|
||||
const source = step.run.split("<<'JS'\n")[1]?.split('\nJS')[0]?.replace(/^import .*;\n/gm, '')
|
||||
if (source === undefined) throw new Error(`Workflow step ${id} must contain an executable Node script`)
|
||||
return source
|
||||
}
|
||||
|
||||
function workflowEvent(workflow: Record<string, unknown>, event: string): Record<string, unknown> {
|
||||
if (!isRecord(workflow.on) || !isRecord(workflow.on[event])) {
|
||||
throw new TypeError(`workflow must define the ${event} event`)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { runGate } from './run-gates.ts'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, type MockInstance, vi } from 'vitest'
|
||||
import { ForksPoolWorker } from 'vitest/node'
|
||||
import type { PoolOptions, PoolWorker, TestProject, WorkerRequest, WorkerResponse } from 'vitest/node'
|
||||
import { coverageForkPool } from './coverage-fork-diagnostics.ts'
|
||||
@@ -55,6 +55,43 @@ function poolOptions(root: string) {
|
||||
return { options, error, outputStream, errorStream }
|
||||
}
|
||||
|
||||
/** Observers a pool registers on its worker, once `stubPool` has started it. */
|
||||
interface WorkerObservers {
|
||||
off: MockInstance
|
||||
exit?: (code: number | null, signal?: NodeJS.Signals | null) => void
|
||||
error?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a pool whose worker prototypes are stubs, and capture the observers it registers.
|
||||
* The test's cleanups own the pool and its streams.
|
||||
*/
|
||||
async function stubPool(): Promise<{
|
||||
pool: PoolWorker
|
||||
error: MockInstance<(message: string) => void>
|
||||
observers: WorkerObservers
|
||||
}> {
|
||||
const observers: WorkerObservers = {
|
||||
off: vi.spyOn(ForksPoolWorker.prototype, 'off').mockImplementation(() => undefined),
|
||||
}
|
||||
vi.spyOn(ForksPoolWorker.prototype, 'start').mockResolvedValue(undefined)
|
||||
vi.spyOn(ForksPoolWorker.prototype, 'stop').mockResolvedValue(undefined)
|
||||
vi.spyOn(ForksPoolWorker.prototype, 'send').mockImplementation(() => undefined)
|
||||
vi.spyOn(ForksPoolWorker.prototype, 'on').mockImplementation((event, callback) => {
|
||||
if (event === 'exit') observers.exit = callback
|
||||
if (event === 'error') observers.error = callback as () => void
|
||||
})
|
||||
const { options, error, outputStream, errorStream } = poolOptions('/unused')
|
||||
const pool = coverageForkPool.createPoolWorker(options)
|
||||
cleanups.push(async () => {
|
||||
await pool.stop()
|
||||
outputStream.destroy()
|
||||
errorStream.destroy()
|
||||
})
|
||||
await pool.start()
|
||||
return { pool, error, observers }
|
||||
}
|
||||
|
||||
async function start(root: string, diagnostic = true) {
|
||||
const { options, error, outputStream, errorStream } = poolOptions(root)
|
||||
const pool: PoolWorker = diagnostic ? coverageForkPool.createPoolWorker(options) : new ForksPoolWorker(options)
|
||||
@@ -113,21 +150,33 @@ async function configuredPool(pool: 'stock' | 'diagnostic', outcome: 'exit' | 'p
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vitest reports one unexpected worker exit either as the clean exit path or, when
|
||||
* the worker's channel closes under a send, as a runner-level worker error.
|
||||
*/
|
||||
function workerExitReport(output: string, pool: string): string | undefined {
|
||||
return ['Worker exited unexpectedly', `Worker ${pool} emitted error`].find(message => output.includes(message))
|
||||
}
|
||||
|
||||
describe('coverage fork configuration entry', () => {
|
||||
it('preserves the real Vitest failure and adds metadata before its error report', async ({ signal }) => {
|
||||
const [stock, diagnostic] = await Promise.all([
|
||||
configuredPool('stock', 'exit', signal),
|
||||
configuredPool('diagnostic', 'exit', signal),
|
||||
])
|
||||
for (const result of [stock, diagnostic]) {
|
||||
const stockOutput = stock.output.map(chunk => chunk.text).join('')
|
||||
const output = diagnostic.output.map(chunk => chunk.text).join('')
|
||||
for (const [pool, result, text] of [
|
||||
['forks', stock, stockOutput],
|
||||
['coverage-forks', diagnostic, output],
|
||||
] as const) {
|
||||
expect(result.aborted).not.toBe(true)
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.signalCode).toBeNull()
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.output.map(chunk => chunk.text).join('')).toContain('Worker exited unexpectedly')
|
||||
expect(workerExitReport(text, pool)).toBeDefined()
|
||||
}
|
||||
const output = diagnostic.output.map(chunk => chunk.text).join('')
|
||||
expect(stock.output.map(chunk => chunk.text).join('')).not.toContain('coverage-worker-exit:')
|
||||
expect(stockOutput).not.toContain('coverage-worker-exit:')
|
||||
const encoded = /coverage-worker-exit: (\{[^\r\n]*\})/u.exec(output)?.[1]
|
||||
expect(encoded).toBeDefined()
|
||||
const record = JSON.parse(encoded!) as {
|
||||
@@ -147,7 +196,8 @@ describe('coverage fork configuration entry', () => {
|
||||
})
|
||||
expect(record.files.map(file => file.replaceAll('\\', '/')))
|
||||
.toEqual([resolve('scripts/fixtures/coverage-fork.fixture.ts').replaceAll('\\', '/')])
|
||||
expect(output.indexOf('coverage-worker-exit:')).toBeLessThan(output.indexOf('Worker exited unexpectedly'))
|
||||
expect(output.indexOf('coverage-worker-exit:'))
|
||||
.toBeLessThan(output.indexOf(workerExitReport(output, 'coverage-forks')!))
|
||||
})
|
||||
|
||||
it('keeps a successful configured run silent and successful', async ({ signal }) => {
|
||||
@@ -165,33 +215,36 @@ describe('coverage fork exit callback', () => {
|
||||
['native receiver', { pid: 41 }, 41],
|
||||
['unavailable receiver', undefined, null],
|
||||
] as const)('retains a signal with %s and silences expected shutdown', async (_name, receiver, pid) => {
|
||||
let exitListener: ((value: unknown) => void) | undefined
|
||||
vi.spyOn(ForksPoolWorker.prototype, 'start').mockResolvedValue(undefined)
|
||||
vi.spyOn(ForksPoolWorker.prototype, 'stop').mockResolvedValue(undefined)
|
||||
vi.spyOn(ForksPoolWorker.prototype, 'send').mockImplementation(() => undefined)
|
||||
vi.spyOn(ForksPoolWorker.prototype, 'off').mockImplementation(() => undefined)
|
||||
vi.spyOn(ForksPoolWorker.prototype, 'on').mockImplementation((event, callback) => {
|
||||
if (event === 'exit') exitListener = callback
|
||||
})
|
||||
const { options, error, outputStream, errorStream } = poolOptions('/unused')
|
||||
const pool = coverageForkPool.createPoolWorker(options)
|
||||
cleanups.push(async () => {
|
||||
await pool.stop()
|
||||
outputStream.destroy()
|
||||
errorStream.destroy()
|
||||
})
|
||||
await pool.start()
|
||||
const { pool, error, observers } = await stubPool()
|
||||
pool.send(request('/signal.spec.ts', 9))
|
||||
if (exitListener === undefined) throw new Error('exit observer was not registered')
|
||||
Reflect.apply(exitListener, receiver, [null, 'SIGTERM'])
|
||||
const exit = observers.exit
|
||||
if (exit === undefined) throw new Error('exit observer was not registered')
|
||||
Reflect.apply(exit, receiver, [null, 'SIGTERM'])
|
||||
expect(error).toHaveBeenCalledWith(`coverage-worker-exit: ${JSON.stringify({
|
||||
pid, project: 'fixture', workerId: 9, files: ['/signal.spec.ts'], exitCode: null, signalCode: 'SIGTERM',
|
||||
})}`)
|
||||
error.mockClear()
|
||||
pool.send(stop)
|
||||
expect(() => { Reflect.apply(exitListener!, undefined, [null, 'SIGTERM']) }).not.toThrow()
|
||||
expect(() => { Reflect.apply(exit, undefined, [null, 'SIGTERM']) }).not.toThrow()
|
||||
expect(error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an exit whose worker errored even when a teardown raced it', async () => {
|
||||
const { pool, error, observers } = await stubPool()
|
||||
pool.send(request('/raced.spec.ts', 11))
|
||||
const failed = observers.error
|
||||
if (failed === undefined) throw new Error('error observer was not registered')
|
||||
// Node reports the failed reply before it reports the exit of the same child.
|
||||
failed()
|
||||
await pool.stop()
|
||||
expect(observers.off).not.toHaveBeenCalled()
|
||||
const exit = observers.exit
|
||||
if (exit === undefined) throw new Error('exit observer was not registered')
|
||||
Reflect.apply(exit, { pid: 42 }, [23, null])
|
||||
expect(error).toHaveBeenCalledWith(`coverage-worker-exit: ${JSON.stringify({
|
||||
pid: 42, project: 'fixture', workerId: 11, files: ['/raced.spec.ts'], exitCode: 23, signalCode: null,
|
||||
})}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coverage fork exit diagnostics', () => {
|
||||
|
||||
@@ -5,12 +5,17 @@ import type { PoolOptions, PoolRunnerInitializer, WorkerRequest, WorkerResponse
|
||||
|
||||
const FINISHED_RESPONSE_TYPE: WorkerResponse['type'] = 'testfileFinished'
|
||||
|
||||
/** Attribute only outstanding requests; stop silences exits, while cancel does not. */
|
||||
/**
|
||||
* Attribute only outstanding requests; stop silences exits, while cancel and a
|
||||
* worker error observed before that stop do not.
|
||||
*/
|
||||
class DiagnosticForkWorker extends ForksPoolWorker {
|
||||
private expectedExit = false
|
||||
private observing = false
|
||||
private workerErrored = false
|
||||
private request: Extract<WorkerRequest, { type: 'run' | 'collect' }> | undefined
|
||||
private readonly reportExit: (this: ChildProcess | undefined, code: number | null, signal?: NodeJS.Signals | null) => void
|
||||
private readonly observeError: () => void
|
||||
|
||||
constructor(options: PoolOptions) {
|
||||
super(options)
|
||||
@@ -27,17 +32,24 @@ class DiagnosticForkWorker extends ForksPoolWorker {
|
||||
}
|
||||
// ForksPoolWorker.on forwards Node ChildProcess events and their receiver.
|
||||
this.reportExit = function (code, signal) { report(this, code, signal) }
|
||||
this.observeError = () => {
|
||||
// A channel that closed under a send means the worker is not shutting down
|
||||
// orderly, so the exit that follows keeps its code and signal. A stop
|
||||
// requested before this error still silences that exit.
|
||||
if (!this.expectedExit) this.workerErrored = true
|
||||
}
|
||||
}
|
||||
|
||||
override async start(): Promise<void> {
|
||||
await super.start()
|
||||
this.on('exit', this.reportExit)
|
||||
this.on('error', this.observeError)
|
||||
this.observing = true
|
||||
}
|
||||
|
||||
override send(message: WorkerRequest): void {
|
||||
if (message.type === 'run' || message.type === 'collect') this.request = message
|
||||
if (message.type === 'stop') this.expectedExit = true
|
||||
if (message.type === 'stop' && !this.workerErrored) this.expectedExit = true
|
||||
super.send(message)
|
||||
}
|
||||
|
||||
@@ -53,10 +65,14 @@ class DiagnosticForkWorker extends ForksPoolWorker {
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
this.expectedExit = true
|
||||
if (this.observing) {
|
||||
this.off('exit', this.reportExit)
|
||||
this.observing = false
|
||||
// A worker that already errored is not shutting down orderly; keep the exit
|
||||
// observer so its code and signal still reach the coverage log.
|
||||
if (!this.workerErrored) {
|
||||
this.expectedExit = true
|
||||
if (this.observing) {
|
||||
this.off('exit', this.reportExit)
|
||||
this.observing = false
|
||||
}
|
||||
}
|
||||
await super.stop()
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/sandbox/sandbox-windows-acl': 'Windows ACL sandbox library consumed by sandbox-local.',
|
||||
'packages/sdk/client': 'Client-process library; the spawned runtime owns plugin behavior.',
|
||||
'packages/sdk/protocol': 'Wire-protocol library with type declarations only.',
|
||||
'packages/ssh/ssh-helper-runtime': 'Private executable entry consumed by the packaging pipeline; no Cordis plugin export.',
|
||||
'packages/session/session-format': 'Pure Session format planning, codec dispatch, and lossless JSON library.',
|
||||
'packages/session/session-format-catalog': 'Generated build-static Session format inventory with no plugin registration.',
|
||||
'packages/session/session-format-v0-to-v1': 'Pure released-v0 codec and adjacent migration library.',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Resolve the native node-pty input used by the Python SDK runtime builder. */
|
||||
/** Resolve the native node-pty input used by the SDK and SSH executable builders. */
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
@@ -18,7 +18,7 @@ export function resolveLinuxNodePtyAddon(
|
||||
const prebuilt = join(packageDirectory, 'prebuilds', `linux-${arch}`, 'pty.node')
|
||||
if (existsSync(prebuilt)) return prebuilt
|
||||
throw new Error(
|
||||
`build-exe-for-python-sdk: node-pty addon is absent from both ${built} and ${prebuilt}.`,
|
||||
`executable-native-pty: node-pty addon is absent from both ${built} and ${prebuilt}.`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function resolveWindowsNodePtyAddons(
|
||||
]
|
||||
const missing = addons.filter(path => !existsSync(path))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`build-exe-for-python-sdk: Windows node-pty addons are missing: ${missing.join(', ')}.`)
|
||||
throw new Error(`executable-native-pty: Windows node-pty addons are missing: ${missing.join(', ')}.`)
|
||||
}
|
||||
return addons
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { restoreLegacyHoists } from './executable-packaging.ts'
|
||||
import { materializeStagedLinks } from './build-exe-for-python-sdk-staging.ts'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-exe-deploy-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const source = join(root, 'source')
|
||||
const staging = join(root, 'staging')
|
||||
await mkdir(source)
|
||||
await mkdir(join(staging, 'node_modules'), { recursive: true })
|
||||
return { root, source, staging, modules: join(staging, 'node_modules') }
|
||||
}
|
||||
|
||||
it('restores omitted direct peers without copying their nested module instances', async () => {
|
||||
const test = await fixture()
|
||||
await writeFile(join(test.staging, 'package.json'), JSON.stringify({ dependencies: { peer: 'workspace:^' } }))
|
||||
await mkdir(join(test.source, 'peer/node_modules/shadow'), { recursive: true })
|
||||
await writeFile(join(test.source, 'peer/index.js'), 'export const identity = 1\n')
|
||||
await writeFile(join(test.source, 'peer/node_modules/shadow/index.js'), 'shadow')
|
||||
expect(await restoreLegacyHoists(test.staging, test.source)).toEqual(['peer'])
|
||||
expect(await readFile(join(test.modules, 'peer/index.js'), 'utf8')).toBe('export const identity = 1\n')
|
||||
await expect(lstat(join(test.modules, 'peer/node_modules'))).rejects.toThrow('ENOENT')
|
||||
expect(await restoreLegacyHoists(test.staging, test.source)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a missing dependency instead of packaging an incomplete graph', async () => {
|
||||
const test = await fixture()
|
||||
await writeFile(join(test.staging, 'package.json'), JSON.stringify({ dependencies: { missing: 'workspace:^' } }))
|
||||
await expect(restoreLegacyHoists(test.staging, test.source)).rejects.toThrow('absent from both')
|
||||
})
|
||||
|
||||
it('materializes a package junction without deleting its source and drops linked command directories', async () => {
|
||||
const test = await fixture()
|
||||
await mkdir(join(test.source, 'peer'))
|
||||
await writeFile(join(test.source, 'peer/index.js'), 'intact')
|
||||
await symlink(join(test.source, 'peer'), join(test.modules, 'peer'), 'junction')
|
||||
await symlink(join(test.source, 'peer'), join(test.modules, '.bin'), 'junction')
|
||||
await materializeStagedLinks(test.staging)
|
||||
expect((await lstat(join(test.modules, 'peer'))).isSymbolicLink()).toBe(false)
|
||||
expect(await readFile(join(test.modules, 'peer/index.js'), 'utf8')).toBe('intact')
|
||||
expect(await readFile(join(test.source, 'peer/index.js'), 'utf8')).toBe('intact')
|
||||
await expect(lstat(join(test.modules, '.bin'))).rejects.toThrow('ENOENT')
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Shared production-deploy preparation for the SDK and SSH executable carriers. */
|
||||
import { existsSync } from 'node:fs'
|
||||
import { cp, mkdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, extname, join, resolve, sep } from 'node:path'
|
||||
|
||||
/**
|
||||
* Resolve pnpm without invoking a command shell, including Windows shims.
|
||||
* @param args - pnpm arguments.
|
||||
* @returns the executable and its argument vector.
|
||||
*/
|
||||
export function pnpmInvocation(args: string[]): [command: string, args: string[]] {
|
||||
const entrypoint = process.env.npm_execpath?.trim()
|
||||
if (entrypoint !== undefined && entrypoint !== '') {
|
||||
const extension = extname(entrypoint).toLowerCase()
|
||||
if (extension === '.js' || extension === '.cjs' || extension === '.mjs') {
|
||||
return [process.execPath, [entrypoint, ...args]]
|
||||
}
|
||||
if (extension !== '.cmd') return [entrypoint, args]
|
||||
}
|
||||
const home = process.env.PNPM_HOME?.trim()
|
||||
if (home !== undefined && home !== '') {
|
||||
const packageBin = resolve(home, '..', 'pnpm', 'bin')
|
||||
for (const filename of ['pnpm.mjs', 'pnpm.cjs']) {
|
||||
const candidate = resolve(packageBin, filename)
|
||||
if (existsSync(candidate)) return [process.execPath, [candidate, ...args]]
|
||||
}
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
throw new Error('executable-packaging: pnpm must expose a JavaScript entrypoint through npm_execpath or PNPM_HOME on Windows.')
|
||||
}
|
||||
return ['pnpm', args]
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore direct dependencies omitted by pnpm's legacy hoister, excluding nested module trees.
|
||||
* @param staging - deployed runtime root.
|
||||
* @param sourceNodeModules - deploy source's installed dependencies.
|
||||
* @returns names restored from the source installation.
|
||||
*/
|
||||
export async function restoreLegacyHoists(staging: string, sourceNodeModules: string): Promise<string[]> {
|
||||
const manifestPath = join(staging, 'package.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
const restored: string[] = []
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) {
|
||||
const destination = join(staging, 'node_modules', dependency)
|
||||
if (existsSync(destination)) continue
|
||||
const source = join(sourceNodeModules, dependency)
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(
|
||||
`executable-packaging: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`,
|
||||
)
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
restored.push(dependency)
|
||||
}
|
||||
const stillMissing = Object.keys(manifest.dependencies ?? {})
|
||||
.filter(dependency => !existsSync(join(staging, 'node_modules', dependency)))
|
||||
if (stillMissing.length > 0) {
|
||||
throw new Error(`executable-packaging: staged dependencies remain missing: ${stillMissing.join(', ')}.`)
|
||||
}
|
||||
return restored
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export async function apply(ctx, config) {
|
||||
if (json === undefined) throw new Error('Office skill did not supply CLI paths')
|
||||
const { libreofficeKit: { node, cli } } = JSON.parse(json)
|
||||
const options = { env: { ...process.env, PATH: '' }, timeout: 120_000 }
|
||||
const capabilities = await promisify(execFile)(node, [cli, 'capabilities'], options)
|
||||
const capabilities = await promisify(execFile)(node, [cli, 'capabilities', '--json'], options)
|
||||
await promisify(execFile)(node, [cli, 'convert', '--input', config.input, '--output', config.output + '.cli.pdf'], options)
|
||||
const pdf = await readFile(config.output + '.cli.pdf')
|
||||
if (pdf.subarray(0, 5).toString() !== '%PDF-') throw new Error('Skill CLI did not create a PDF')
|
||||
|
||||
@@ -772,7 +772,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Agent Teams coordination domain',
|
||||
mode: 'core',
|
||||
consumers: ['experimental-tool-agent-team'],
|
||||
note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls.',
|
||||
note: 'Owns the implicit-root roster, direct peer inbox delivery, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls.',
|
||||
},
|
||||
{
|
||||
key: 'claudeCodeMods',
|
||||
|
||||
@@ -563,7 +563,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
dir: 'tool-agent-team',
|
||||
source: 'packages/experimental/tool-agent-team/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.agentTeams', 'an exact live Team member Agent'],
|
||||
writes: ['tool/call', 'team/member', 'team/message/queued', 'team/message/delivered', 'team/task', 'tool/result'],
|
||||
writes: ['tool/call', 'team/member', 'team/task', 'agent/inbox/spliced', 'user/message on inbox claim', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
@@ -387,9 +387,6 @@
|
||||
"packages/bundle/web-app/tests/startup.spec.ts": {
|
||||
"2e78263d16d54ccfd8d322bcdf1bf738bd8134e15c24063ece9a1d1b52f4ebd9": 1
|
||||
},
|
||||
"packages/bundle/web-app/tests/web-app.spec.ts": {
|
||||
"603938cefaa75059bc3902dedc4cd5cbfb2d6d3f6e56bb3cd89788e21ece0389": 1
|
||||
},
|
||||
"packages/client/connection/src/browser-auth.ts": {
|
||||
"98acffd14d7c77e3412ce5a7d8e9c555b66de76eb12c14d4b22b251684b60447": 1
|
||||
},
|
||||
@@ -462,9 +459,6 @@
|
||||
"packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx": {
|
||||
"ab22ebe23f59b5aa76d42502a0421f3953d1d170e8a4f900a0c442daf6f1397d": 1
|
||||
},
|
||||
"packages/client/ui-chat/src/client/chat/ChatView.tsx": {
|
||||
"50ce0a6085bdfd7032e5a391ed34cd078768ad8f46cc885eb54571cf8edd4bee": 1
|
||||
},
|
||||
"packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts": {
|
||||
"a7d8cc8d42e37ad5cc4882241492ac572ed8163c7fb8663180e893c7178e906f": 1
|
||||
},
|
||||
@@ -1050,10 +1044,6 @@
|
||||
"packages/experimental/agent-team/src/journal.ts": {
|
||||
"364930a7ddcc79316a9ffc126602f45ae56a3cecdb80484c5c8a79686f8f1057": 1
|
||||
},
|
||||
"packages/experimental/agent-team/tests/persistence.spec.ts": {
|
||||
"3ae979a4364fe22772c89133e79ea216cdb4f3163c7cb02c47eb1a5d4788e661": 1,
|
||||
"a90b2e60ce1efd280f1956999454dd0b57ea788864c0162cc6e201ba0c7a90a8": 1
|
||||
},
|
||||
"packages/experimental/agent-team/tests/projection-events.spec.ts": {
|
||||
"71ffbcca0bb71d3b595143632491d4c5d580aaf6ac384c22116e038995b2f2b5": 1,
|
||||
"a66c8967969859328890e6049a0bd9e90403861eb19ed5d6937da5505aa79c05": 1,
|
||||
@@ -1061,8 +1051,8 @@
|
||||
"d8076a64f8d940fb0a5c3df15e9e1c8fb7ac05e84762a6198dbbfcffcf379821": 1
|
||||
},
|
||||
"packages/experimental/agent-team/tests/team.spec.ts": {
|
||||
"3ae979a4364fe22772c89133e79ea216cdb4f3163c7cb02c47eb1a5d4788e661": 4,
|
||||
"ae5776af8c1e9965a4eca94bcbc07b65da8d2e9e24e4570d2141b34d60b4f90f": 3
|
||||
"3ae979a4364fe22772c89133e79ea216cdb4f3163c7cb02c47eb1a5d4788e661": 3,
|
||||
"ae5776af8c1e9965a4eca94bcbc07b65da8d2e9e24e4570d2141b34d60b4f90f": 1
|
||||
},
|
||||
"packages/experimental/auto-review/tests/auto-review.spec.ts": {
|
||||
"5bb6193769cfbb9fc09abd705fed714917784de269546fde14ac1171fb00644b": 1
|
||||
@@ -1454,11 +1444,6 @@
|
||||
"aa5de2d4958ec64219565d53ec5a9d7f40f1f60037a2d52d1a9c3c65db034d73": 1,
|
||||
"be027af1ef5cb3bfe41eeb19b54dbb176ec7c23ae367d709059839f9851833d5": 1
|
||||
},
|
||||
"packages/ptc-runtime/ptc-runtime-node/tests/launch.spec.ts": {
|
||||
"57f598f462c77fae2b147b07c2e1228199c53dfd43652021af57712b4860732a": 1,
|
||||
"9ddf7336f94d8ff4be8fe0ae7977c1846e6e92e7ea66180f8e2e6f8902b3c915": 1,
|
||||
"e02136d8ab1f1fb84c6e772bbc863ceb3a0a3801f6eb158d5b9be074baa0885c": 1
|
||||
},
|
||||
"packages/ptc-runtime/ptc-runtime-node/tests/output-json.spec.ts": {
|
||||
"8c4c486bbc27559714f4e0aaf376c72c49ac9157539b03c6c12853b8d3fc6e74": 1
|
||||
},
|
||||
|
||||
@@ -67,49 +67,26 @@ describe.each(['web', 'desktop'] as const)('%s optional bundle overrides', (surf
|
||||
const warnings: string[] = []
|
||||
const entries = composeEntries([...shipped, ...selectedNames.map(name => bundle(name).patches), [
|
||||
{ id: 'skill-badge', disabled: true },
|
||||
...['preset-standard', 'preset-cordis', 'preset-ptc'].map(preset => ({ preset, id: 'tool-ralph', config, disabled: true })),
|
||||
{ id: 'tool-ralph', config, disabled: true },
|
||||
]], warning => warnings.push(warning))
|
||||
expect(warnings).toEqual([])
|
||||
expect(entries.find(row => row.id === 'skill-badge')).toMatchObject({ name: '@deepseek-ai/dsh-skill-badge', disabled: true })
|
||||
for (const preset of ['preset-standard', 'preset-cordis', 'preset-ptc']) {
|
||||
const roots = presetRows(entries, preset)
|
||||
expect(flattenRows(roots).find(row => row.id === 'tool-ralph'))
|
||||
.toMatchObject({ name: '@deepseek-ai/dsh-tool-ralph', config, disabled: true })
|
||||
expect(roots.find(row => row.id === 'optional-ralph')?.isolate).toEqual({ workflowEngine: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a later complete preset replacement and its manually declared disabled tool', () => {
|
||||
const shipped = profileLayers(surface)
|
||||
const baseline = composeEntries(shipped)
|
||||
const preset = baseline.find(row => row.id === 'preset-standard')!
|
||||
const config = { ...preset.config as object, plugins: [...presetRows(baseline, 'preset-standard'), {
|
||||
id: 'tool-ralph', name: '@deepseek-ai/dsh-tool-ralph', disabled: true,
|
||||
config: { subagentProvider: 'spawn', maxRounds: 2 },
|
||||
}] }
|
||||
const warnings: string[] = []
|
||||
const entries = composeEntries([...shipped, ...selectedNames.map(name => bundle(name).patches), [
|
||||
{ id: 'preset-standard', config },
|
||||
]], warning => warnings.push(warning))
|
||||
expect(warnings).toEqual([])
|
||||
expect(entries.find(row => row.id === 'preset-standard')?.config).toEqual(config)
|
||||
expect(presetRows(entries, 'preset-standard').some(row => row.id === 'optional-ralph')).toBe(false)
|
||||
const inherited = flattenRows(presetRows(entries, 'preset-cordis')).find(row => row.id === 'tool-ralph')
|
||||
expect(inherited).toMatchObject({ name: '@deepseek-ai/dsh-tool-ralph' })
|
||||
expect(inherited?.disabled).not.toBe(true)
|
||||
expect(flattenRows(entries).find(row => row.id === 'tool-ralph'))
|
||||
.toMatchObject({ name: '@deepseek-ai/dsh-tool-ralph', config, disabled: true })
|
||||
expect(entries.find(row => row.id === 'optional-ralph')?.isolate).toEqual({ workflowEngine: true })
|
||||
})
|
||||
|
||||
it('does not select absent bundles through enable-only row overrides', () => {
|
||||
const warnings: string[] = []
|
||||
const entries = composeEntries([...profileLayers(surface), [
|
||||
{ id: 'skill-badge', disabled: false },
|
||||
{ preset: 'preset-standard', id: 'tool-ralph', disabled: false },
|
||||
{ id: 'tool-ralph', disabled: false },
|
||||
]], warning => warnings.push(warning))
|
||||
expect(warnings).toHaveLength(2)
|
||||
expect(warnings.some(warning => warning.includes('skill-badge'))).toBe(true)
|
||||
expect(warnings.some(warning => warning.includes('tool-ralph'))).toBe(true)
|
||||
expect(entries.some(row => row.id === 'skill-badge')).toBe(false)
|
||||
expect(flattenRows(presetRows(entries, 'preset-standard')).some(row => row.id === 'tool-ralph')).toBe(false)
|
||||
expect(flattenRows(entries).some(row => row.id === 'tool-ralph')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,10 +106,9 @@ describe('optional bundles', () => {
|
||||
const warnings: string[] = []
|
||||
const composed = composeEntries([...shipped, patches], message => warnings.push(message))
|
||||
expect(warnings).toEqual([])
|
||||
// Inserted ids remain addressable inside their selected profile or preset tree.
|
||||
// Inserted ids remain addressable in the composed profile tree.
|
||||
const ids = new Set(flattenRows(composed).map(entry => entry.id))
|
||||
for (const patch of patches) {
|
||||
const rows = typeof patch.preset === 'string' ? presetRows(composed, patch.preset) : composed
|
||||
const ids = new Set(flattenRows(rows).map(entry => entry.id))
|
||||
for (const row of patch.insert ?? []) {
|
||||
expect(typeof row.id).toBe('string')
|
||||
expect(ids.has(row.id)).toBe(true)
|
||||
@@ -146,7 +122,7 @@ describe('optional bundles', () => {
|
||||
// row, and the override keeps the package the shipped layer declared on it.
|
||||
const shippedComposed = composeEntries([...shipped])
|
||||
for (const patch of patches) {
|
||||
if (patch.insert !== undefined || typeof patch.id !== 'string' || patch.preset !== undefined) continue
|
||||
if (patch.insert !== undefined || typeof patch.id !== 'string') continue
|
||||
const matches = composed.filter(entry => entry.id === patch.id)
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]?.name).toBe(shippedComposed.find(entry => entry.id === patch.id)?.name)
|
||||
@@ -172,7 +148,7 @@ describe('optional bundles', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('composes every lightweight selection without changing minimal or adding host tools', () => {
|
||||
it('composes every lightweight selection as Host rows without changing any preset', () => {
|
||||
const baseline = composeEntries(shipped)
|
||||
const layers = lightweight.map(name => bundle(name).patches)
|
||||
for (let mask = 0; mask < 2 ** lightweight.length; mask += 1) {
|
||||
@@ -180,35 +156,29 @@ describe('optional bundles', () => {
|
||||
const warnings: string[] = []
|
||||
const composed = composeEntries([...shipped, ...selected], warning => warnings.push(warning))
|
||||
expect(warnings).toEqual([])
|
||||
expect(presetRows(composed, 'preset-minimal')).toEqual(presetRows(baseline, 'preset-minimal'))
|
||||
const addedHost = composed.filter(row => !baseline.some(existing => existing.id === row.id))
|
||||
expect(addedHost.every(row => ['skill-badge', 'optional-session-title-all-prompts'].includes(row.id ?? ''))).toBe(true)
|
||||
for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc']) {
|
||||
const rows = flattenRows(presetRows(composed, id))
|
||||
const ids = rows.map(row => row.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc', 'preset-minimal']) {
|
||||
expect(presetRows(composed, id)).toEqual(presetRows(baseline, id))
|
||||
}
|
||||
const ids = flattenRows(composed).map(row => row.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('contributes tools inside all full presets with independently isolated services', () => {
|
||||
it('contributes global tools from Host rows with independently isolated services', () => {
|
||||
const composed = composeEntries([...shipped, ...lightweight.map(name => bundle(name).patches)])
|
||||
for (const id of ['preset-standard', 'preset-cordis', 'preset-ptc']) {
|
||||
const roots = presetRows(composed, id)
|
||||
const names = flattenRows(roots).map(row => row.name)
|
||||
for (const name of [
|
||||
'@deepseek-ai/dsh-tool-session-query',
|
||||
'@deepseek-ai/dsh-tool-ralph', '@deepseek-ai/dsh-tool-terminal',
|
||||
]) expect(names).toContain(name)
|
||||
expect(roots.find(row => row.id === 'optional-ralph')?.isolate).toEqual({ workflowEngine: true })
|
||||
expect(roots.find(row => row.id === 'optional-persistent-terminals')?.isolate).toEqual({ terminals: true })
|
||||
const search = roots.find(row => row.id === 'optional-session-search')
|
||||
expect(search?.isolate).toEqual({ sessionQuery: true })
|
||||
expect(search?.config).toEqual([
|
||||
{ id: 'optional-session-query-sqlite', name: '@deepseek-ai/dsh-session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
{ id: 'optional-tool-session-query', name: '@deepseek-ai/dsh-tool-session-query' },
|
||||
])
|
||||
}
|
||||
const names = flattenRows(composed).map(row => row.name)
|
||||
for (const name of [
|
||||
'@deepseek-ai/dsh-tool-session-query',
|
||||
'@deepseek-ai/dsh-tool-ralph', '@deepseek-ai/dsh-tool-terminal',
|
||||
]) expect(names).toContain(name)
|
||||
expect(composed.find(row => row.id === 'optional-ralph')?.isolate).toEqual({ workflowEngine: true })
|
||||
expect(composed.find(row => row.id === 'optional-persistent-terminals')?.isolate).toEqual({ terminals: true })
|
||||
const search = composed.find(row => row.id === 'optional-session-search')
|
||||
expect(search?.isolate).toEqual({ sessionQuery: true })
|
||||
expect(search?.config).toEqual([
|
||||
{ id: 'optional-session-query-sqlite', name: '@deepseek-ai/dsh-session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
{ id: 'optional-tool-session-query', name: '@deepseek-ai/dsh-tool-session-query' },
|
||||
])
|
||||
expect(composed.find(row => row.id === 'session-query-sqlite')?.config).toEqual({ path: ':memory:', openAt: 'never' })
|
||||
expect(composed.find(row => row.id === 'session-title-llm')?.disabled).toBe(true)
|
||||
expect(composed.find(row => row.id === 'optional-session-title-all-prompts')?.name)
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('accepted persistence baseline', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['required field', 'changed type'] as const)('refuses a breaking V4 %s before rendering or writing', (kind) => {
|
||||
it.each(['required field', 'changed type'] as const)('requires an explicit decision for V4 %s before rendering or writing', (kind) => {
|
||||
const root = fixture()
|
||||
finalize(root)
|
||||
const after = inventory(kind === 'required field' ? { value: 'number', label: 'string' } : { value: 'boolean' }, 4)
|
||||
@@ -234,13 +234,13 @@ describe('accepted persistence baseline', () => {
|
||||
rendered = true
|
||||
return []
|
||||
})) as { ok: boolean; code: string; changes: PersistenceTypeChange[] }
|
||||
expect(result).toMatchObject({ ok: false, code: 'finalized-format-changed' })
|
||||
expect(result).toMatchObject({ ok: false, code: 'decision-required' })
|
||||
expect(result.changes.length).toBeGreaterThan(0)
|
||||
expect(result.changes.some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(result.changes.some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
expect(rendered).toBe(false)
|
||||
expect(contents(root)).toEqual(before)
|
||||
commitCurrent(root, after)
|
||||
expect(() => verifyPersistenceChanges(root, after)).toThrow('Breaking changes relative to the accepted Session format 4 baseline')
|
||||
expect(() => verifyPersistenceChanges(root, after)).toThrow('unacknowledged persistence type changes')
|
||||
})
|
||||
|
||||
it('allows qualified V4 attribution additions through a new same-version acknowledgement', () => {
|
||||
@@ -251,7 +251,7 @@ describe('accepted persistence baseline', () => {
|
||||
finalize(root, current)
|
||||
const added = attributedRoot([EXISTING_SOURCE, { kind: 'new-attribution' }], { policy: attributionPolicy(['new-attribution']) })
|
||||
const after = { ...current, roots: current.roots.map(root => root.key === source.key ? added : root) }
|
||||
expect(classifyPersistenceChange(source, added)).toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
|
||||
expect(classifyPersistenceChange(source, added)).toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresCompatibilityReview: false })])
|
||||
const before = contents(root)
|
||||
runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after)
|
||||
expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
|
||||
@@ -262,7 +262,7 @@ describe('accepted persistence baseline', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps later unaccepted compatible records editable and rejects a breaking successor without its own version transition', () => {
|
||||
it('keeps later unaccepted records editable and requires an explicit decision for a changed optional field', () => {
|
||||
const root = fixture()
|
||||
finalize(root)
|
||||
const first = inventory({ value: 'number', 'label?': 'string' }, 4)
|
||||
@@ -273,10 +273,10 @@ describe('accepted persistence baseline', () => {
|
||||
expect(loadPersistenceFinalization(root, loadPersistenceHistory(root))?.acceptedRecords.has(COMPATIBLE_ID)).toBe(false)
|
||||
const breaking = inventory({ value: 'number', 'label?': 'number', 'extra?': 'boolean' }, 4)
|
||||
const accepted = inventory({ value: 'number' }, 4).roots[2]!
|
||||
expect(classifyPersistenceChange(accepted, breaking.roots[2]!).every(change => !change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(accepted, breaking.roots[2]!).every(change => !change.requiresCompatibilityReview)).toBe(true)
|
||||
const before = contents(root)
|
||||
expect(() => runPersistenceChanges(['--record', '2026-09-20-breaking-v4', '--prose', proseFile(root)], root, () => breaking))
|
||||
.toThrow("this record's own increasing SessionHeader.version")
|
||||
.toThrow('--decision')
|
||||
expect(contents(root)).toEqual(before)
|
||||
})
|
||||
|
||||
@@ -303,7 +303,7 @@ describe('accepted persistence baseline', () => {
|
||||
expect(() => runPersistenceChanges(['--update', FINALIZED_ID], root, () => after)).toThrow('cannot update finalized acknowledgement')
|
||||
const later = inventory({ value: 'string' }, 5)
|
||||
expect(() => runPersistenceChanges(['--record', '2026-09-20-without-version', '--prose', proseFile(root)], root, () => later))
|
||||
.toThrow("this record's own increasing SessionHeader.version")
|
||||
.toThrow('--decision')
|
||||
})
|
||||
|
||||
it.each([4, 5])('detects a consistently rewritten accepted record while the writer is V%s', (version) => {
|
||||
@@ -429,7 +429,7 @@ describe('persistence change classification', () => {
|
||||
finalize(root, before)
|
||||
const after = { ...before, roots: before.roots.map(root => root.kind === 'event' ? nextEvent : root) }
|
||||
expect(classifyPersistenceChange(oldEvent, nextEvent))
|
||||
.toEqual([expect.objectContaining({ kind: 'payload-version-added', requiresVersionBump: false })])
|
||||
.toEqual([expect.objectContaining({ kind: 'payload-version-added', requiresCompatibilityReview: false })])
|
||||
runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after)
|
||||
verifyPersistenceChanges(root, after)
|
||||
})
|
||||
@@ -443,29 +443,29 @@ describe('persistence change classification', () => {
|
||||
versionedEvent([...variants, { version: 0.5, mode: 'unknown' }]),
|
||||
versionedEvent([{ version: 0, mode: 'one-shot', extra: true }, variants[1]!, { version: 1, mode: 'unknown' }]),
|
||||
versionedEvent([{ version: 1, mode: 'unknown' }]),
|
||||
]) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
|
||||
]) expect(classifyPersistenceChange(before, after).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
const expanded = versionedEvent([...variants, { version: 1, mode: 'unknown' }])
|
||||
expect(classifyPersistenceChange(expanded, before).some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(expanded, before).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
expect(classifyPersistenceChange({ ...before, surface: true }, { ...expanded, surface: true })
|
||||
.some(change => change.requiresVersionBump)).toBe(true)
|
||||
.some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
expect(classifyPersistenceChange({ ...before, kind: 'header' }, { ...expanded, kind: 'header' })
|
||||
.some(change => change.requiresVersionBump)).toBe(true)
|
||||
.some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
const unversioned = unionBody([[{ name: 'mode', type: 2, optional: false }]])
|
||||
expect(classifyPersistenceChange(unversioned, expanded).some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(unversioned, expanded).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a new optional payload subtree as one additive change even with required descendants', () => {
|
||||
const before = typeRoot('event:example/value', { value: 'string' })
|
||||
const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } })
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([{ path: 'event:example/value.data.details', kind: 'optional-property-added', description: 'optional property added', requiresVersionBump: false }])
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([{ path: 'event:example/value.data.details', kind: 'optional-property-added', description: 'optional property added', requiresCompatibilityReview: false }])
|
||||
})
|
||||
|
||||
it('permits required-to-optional payload properties while rejecting opposite changes, type changes, and removals', () => {
|
||||
const required = typeRoot('event:example/value', { value: 'string' })
|
||||
const optional = typeRoot('event:example/value', { 'value?': 'string' })
|
||||
expect(classifyPersistenceChange(required, optional).every(change => !change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(required, optional).every(change => !change.requiresCompatibilityReview)).toBe(true)
|
||||
for (const after of [required, typeRoot('event:example/value', { 'value?': 'number' }), typeRoot('event:example/value', {})]) {
|
||||
expect(classifyPersistenceChange(optional, after).some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(optional, after).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -485,7 +485,7 @@ describe('persistence change classification', () => {
|
||||
}
|
||||
const differences = classifyPersistenceChange(unionRoot(false), unionRoot(true))
|
||||
expect(differences.length).toBeGreaterThan(0)
|
||||
expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
|
||||
expect(differences.every(change => !change.requiresCompatibilityReview)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows optional additions across undiscriminated union arms', () => {
|
||||
@@ -493,7 +493,7 @@ describe('persistence change classification', () => {
|
||||
const second = [{ name: 'b', type: 3, optional: false }]
|
||||
const extra = { name: 'x', type: 4, optional: true }
|
||||
const differences = classifyPersistenceChange(unionBody([first, second]), unionBody([[...first, extra], [...second, extra]]))
|
||||
expect(differences).toEqual([expect.objectContaining({ path: 'event:example/value.data.x', requiresVersionBump: false })])
|
||||
expect(differences).toEqual([expect.objectContaining({ path: 'event:example/value.data.x', requiresCompatibilityReview: false })])
|
||||
})
|
||||
|
||||
it('allows required-to-optional fields across undiscriminated union arms', () => {
|
||||
@@ -501,7 +501,7 @@ describe('persistence change classification', () => {
|
||||
const after = unionBody([[{ name: 'a', type: 2, optional: true }], [{ name: 'b', type: 3, optional: true }]])
|
||||
const differences = classifyPersistenceChange(before, after)
|
||||
expect(differences.map(change => change.path).sort()).toEqual(['event:example/value.data.a', 'event:example/value.data.b'])
|
||||
expect(differences.every(change => change.kind === 'property-made-optional' && !change.requiresVersionBump)).toBe(true)
|
||||
expect(differences.every(change => change.kind === 'property-made-optional' && !change.requiresCompatibilityReview)).toBe(true)
|
||||
})
|
||||
|
||||
it('finds a complete matching when one union arm has multiple additive successors', () => {
|
||||
@@ -513,10 +513,10 @@ describe('persistence change classification', () => {
|
||||
for (const after of [unionBody([flexible, constrained]), unionBody([constrained, flexible])]) {
|
||||
const differences = classifyPersistenceChange(before, after)
|
||||
expect(differences.length).toBeGreaterThan(0)
|
||||
expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
|
||||
expect(differences.every(change => !change.requiresCompatibilityReview)).toBe(true)
|
||||
}
|
||||
const noCompleteMatching = unionBody([flexible, [{ name: 'different', type: 4, optional: true }]])
|
||||
expect(classifyPersistenceChange(before, noCompleteMatching).some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(before, noCompleteMatching).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps union cardinality, property removal, new required fields, and value types strict', () => {
|
||||
@@ -531,7 +531,9 @@ describe('persistence change classification', () => {
|
||||
unionBody([[...first, { name: 'x', type: 4, optional: false }], [...second, { name: 'x', type: 4, optional: true }]]),
|
||||
unionBody([[{ name: 'a', type: 3, optional: true }], [{ name: 'b', type: 3, optional: true }]]),
|
||||
]
|
||||
for (const after of variants) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
|
||||
for (const after of variants) {
|
||||
expect(classifyPersistenceChange(before, after).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('checks all recursive union arms without retaining provisional successes between candidates', () => {
|
||||
@@ -541,10 +543,10 @@ describe('persistence change classification', () => {
|
||||
const extra = { name: 'x', type: 4, optional: true }
|
||||
const before = unionBody([first, second])
|
||||
const additive = unionBody([[...first, extra], [...second, extra]])
|
||||
expect(classifyPersistenceChange(before, additive).every(change => !change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(before, additive).every(change => !change.requiresCompatibilityReview)).toBe(true)
|
||||
const invalid = unionBody([[...first, extra], [{ name: 'b', type: 2, optional: false }, next, extra]])
|
||||
expect(classifyPersistenceChange(before, invalid).some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(invalid, before).some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(before, invalid).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
expect(classifyPersistenceChange(invalid, before).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves body scope through arrays and tuples and rejects a simultaneous value-type change', () => {
|
||||
@@ -559,18 +561,18 @@ describe('persistence change classification', () => {
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
|
||||
}
|
||||
expect(classifyPersistenceChange(wrapped(false), wrapped(true)).every(change => !change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(wrapped(false), wrapped(true, 'number')).some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(wrapped(false), wrapped(true)).every(change => !change.requiresCompatibilityReview)).toBe(true)
|
||||
expect(classifyPersistenceChange(wrapped(false), wrapped(true, 'number')).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps optional header/envelope properties and surface event additions strict', () => {
|
||||
for (const key of ['SessionHeader', 'JsonlHeaderLine', 'SessionEventEnvelope']) {
|
||||
expect(classifyPersistenceChange(typeRoot(key, {}), typeRoot(key, { 'metadata?': 'string' }))[0]?.requiresVersionBump).toBe(true)
|
||||
expect(classifyPersistenceChange(typeRoot(key, {}), typeRoot(key, { 'metadata?': 'string' }))[0]?.requiresCompatibilityReview).toBe(true)
|
||||
}
|
||||
const added = typeRoot('event:example/added', { value: 'string' })
|
||||
expect(classifyPersistenceChange(null, added)[0]?.requiresVersionBump).toBe(false)
|
||||
expect(classifyPersistenceChange(null, { ...added, surface: true })[0]?.requiresVersionBump).toBe(true)
|
||||
expect(classifyPersistenceChange(added, null)[0]?.requiresVersionBump).toBe(true)
|
||||
expect(classifyPersistenceChange(null, added)[0]?.requiresCompatibilityReview).toBe(false)
|
||||
expect(classifyPersistenceChange(null, { ...added, surface: true })[0]?.requiresCompatibilityReview).toBe(true)
|
||||
expect(classifyPersistenceChange(added, null)[0]?.requiresCompatibilityReview).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -594,10 +596,10 @@ describe('persistence history verification', () => {
|
||||
expect(() => validatePersistenceHistory([base, cycle])).toThrow('cycle')
|
||||
})
|
||||
|
||||
it('binds breaking decisions to the same record header version transition', () => {
|
||||
it('binds version-bump decisions to the same record header version transition', () => {
|
||||
const base = entry(BASE_ID, inventory(), null, true)
|
||||
const breaking = inventory({ value: 'number' }, 4)
|
||||
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID)])).toThrow('requires a format version bump')
|
||||
expect(validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID)]).tips.get('event:example/value')?.id).toBe(NEXT_ID)
|
||||
expect(() => validatePersistenceHistory([base, entry(NEXT_ID, onlyEvent(breaking), BASE_ID, false, 'version-bump')])).toThrow("this record's own")
|
||||
const touched = { ...breaking, roots: breaking.roots.filter(root => root.key === 'SessionHeader' || root.kind === 'event') }
|
||||
expect(validatePersistenceHistory([base, entry(NEXT_ID, touched, BASE_ID, false, 'version-bump')]).tips.get('SessionHeader')?.id).toBe(NEXT_ID)
|
||||
@@ -704,7 +706,7 @@ describe('persistence changes current-tree commands', () => {
|
||||
verifyPersistenceChanges(root, after)
|
||||
})
|
||||
|
||||
it('rejects inferred bumps without a header transition and explicit incorrect assertions before writing', () => {
|
||||
it('requires an explicit decision for structural changes before writing', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const prose = proseFile(root)
|
||||
@@ -712,16 +714,14 @@ describe('persistence changes current-tree commands', () => {
|
||||
const files = readdirSync(directory).sort()
|
||||
const inventoryPath = join(root, 'docs/persistence-schema.json')
|
||||
const before = readFileSync(inventoryPath, 'utf8')
|
||||
for (const [args, code] of [
|
||||
[[], 'version-transition-required'],
|
||||
[['--decision', 'same-version'], 'version-bump-required'],
|
||||
] as const) {
|
||||
const result = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--prose', prose, '--json', ...args], root,
|
||||
() => inventory({ value: 'number' })))
|
||||
expect(result).toMatchObject({ ok: false, code, files: [], changes: [expect.objectContaining({ requiresVersionBump: true })] })
|
||||
expect(readFileSync(inventoryPath, 'utf8')).toBe(before)
|
||||
expect(readdirSync(directory).sort()).toEqual(files)
|
||||
}
|
||||
const result = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--prose', prose, '--json'], root,
|
||||
() => inventory({ value: 'number' })))
|
||||
expect(result).toMatchObject({ ok: false, code: 'decision-required', files: [],
|
||||
changes: [expect.objectContaining({ requiresCompatibilityReview: true })] })
|
||||
expect(readFileSync(inventoryPath, 'utf8')).toBe(before)
|
||||
expect(readdirSync(directory).sort()).toEqual(files)
|
||||
expect(jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', prose, '--json'], root,
|
||||
() => inventory({ value: 'number' }))).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('authors a complete pair from explicit prose and reports source changes even when generated artifacts are stale', () => {
|
||||
@@ -731,7 +731,7 @@ describe('persistence changes current-tree commands', () => {
|
||||
const pending = jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after))
|
||||
expect(pending).toMatchObject({ ok: false, operation: 'check', code: 'stale-artifacts',
|
||||
roots: [{ root: 'event:example/value', kind: 'event', before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }], changes: [
|
||||
{ root: 'event:example/value', path: 'event:example/value.data.label', kind: 'optional-property-added', requiresVersionBump: false },
|
||||
{ root: 'event:example/value', path: 'event:example/value.data.label', kind: 'optional-property-added', requiresCompatibilityReview: false },
|
||||
] })
|
||||
const written = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', proseFile(root), '--json'], root, () => after))
|
||||
expect(written).toMatchObject({ ok: true, operation: 'record', recordId: NEXT_ID })
|
||||
@@ -769,8 +769,9 @@ describe('persistence changes current-tree commands', () => {
|
||||
it('completes a scaffold through update and rejects invalid prose or decisions before changing files', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const after = inventory({ value: 'string', 'label?': 'string' })
|
||||
runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
|
||||
const after = inventory({ value: 'number' })
|
||||
const draft = runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
|
||||
expect(draft).toContain(`--update ${NEXT_ID} --decision same-version --prose FILE`)
|
||||
const prose = proseFile(root)
|
||||
runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
|
||||
expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
|
||||
@@ -781,9 +782,9 @@ describe('persistence changes current-tree commands', () => {
|
||||
expect(pairedPaths.map(path => readFileSync(path, 'utf8'))).toEqual(completed)
|
||||
const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
|
||||
const before = readFileSync(path, 'utf8')
|
||||
const invalid = inventory({ value: 'number' })
|
||||
const result = jsonResult(runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--json'], root, () => invalid))
|
||||
expect(result).toMatchObject({ ok: false, code: 'version-bump-required', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })] })
|
||||
const invalid = inventory({ value: 'boolean' })
|
||||
const result = jsonResult(runPersistenceChanges(['--update', NEXT_ID, '--json'], root, () => invalid))
|
||||
expect(result).toMatchObject({ ok: false, code: 'decision-required', changes: [expect.objectContaining({ kind: 'type-changed', requiresCompatibilityReview: true })] })
|
||||
expect(readFileSync(path, 'utf8')).toBe(before)
|
||||
for (const value of [
|
||||
{ ...AUTHORED_PROSE, extra: 'unsupported' },
|
||||
@@ -797,7 +798,7 @@ describe('persistence changes current-tree commands', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Six real CLI processes and TypeScript extraction share the Windows coverage test budget.
|
||||
// Real CLI processes and TypeScript extraction share the Windows coverage test budget.
|
||||
it('executes the real CLI against a source-only temporary checkout without Git history', { timeout: 90_000 }, () => {
|
||||
const root = fixture()
|
||||
const physical = join(root, 'packages/session/session-persistence-jsonl/src')
|
||||
@@ -861,15 +862,36 @@ describe('persistence changes current-tree commands', () => {
|
||||
expect(rejected.error).toBeUndefined()
|
||||
expect(rejected.signal).toBeNull()
|
||||
expect(rejected.status).toBe(1)
|
||||
expect(String(rejected.stderr)).toContain('version-bump required')
|
||||
expect(String(rejected.stderr)).toContain('compatibility review required')
|
||||
const structured = cli('--check', '--json')
|
||||
expect(structured.error).toBeUndefined()
|
||||
expect(structured.signal).toBeNull()
|
||||
expect(structured.status).toBe(1)
|
||||
const structuredResult: unknown = JSON.parse(String(structured.stdout))
|
||||
expect(structuredResult).toMatchObject({
|
||||
ok: false, code: 'unacknowledged-changes', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })],
|
||||
ok: false, code: 'unacknowledged-changes', changes: [expect.objectContaining({ kind: 'type-changed', requiresCompatibilityReview: true })],
|
||||
})
|
||||
const missingDecision = cli('--record', FUTURE_ID, '--prose', prose, '--json')
|
||||
expect(missingDecision.error).toBeUndefined()
|
||||
expect(missingDecision.signal).toBeNull()
|
||||
expect(missingDecision.status).toBe(1)
|
||||
expect(JSON.parse(String(missingDecision.stdout))).toMatchObject({ ok: false, code: 'decision-required', files: [] })
|
||||
const chosen = cli('--record', FUTURE_ID, '--decision', 'same-version', '--prose', prose, '--json')
|
||||
expect(chosen.error).toBeUndefined()
|
||||
expect(chosen.signal).toBeNull()
|
||||
expect(chosen.status, String(chosen.stderr)).toBe(0)
|
||||
expect(JSON.parse(String(chosen.stdout))).toMatchObject({ ok: true, operation: 'record' })
|
||||
const missingUpdateDecision = cli('--update', FUTURE_ID, '--json')
|
||||
expect(missingUpdateDecision.error).toBeUndefined()
|
||||
expect(missingUpdateDecision.signal).toBeNull()
|
||||
expect(missingUpdateDecision.status).toBe(1)
|
||||
expect(JSON.parse(String(missingUpdateDecision.stdout))).toMatchObject({ ok: false, code: 'decision-required', files: [] })
|
||||
const chosenUpdate = cli('--update', FUTURE_ID, '--decision', 'same-version', '--json')
|
||||
expect(chosenUpdate.error).toBeUndefined()
|
||||
expect(chosenUpdate.signal).toBeNull()
|
||||
expect(chosenUpdate.status, String(chosenUpdate.stderr)).toBe(0)
|
||||
expect(JSON.parse(String(chosenUpdate.stdout))).toMatchObject({ ok: true, operation: 'update' })
|
||||
verifyPersistenceChanges(root, extractPersistenceSchema(root))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -927,7 +949,7 @@ describe('recorded source compatibility policy', () => {
|
||||
const before = attributedRoot([EXISTING_SOURCE], { role })
|
||||
const after = attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { role, policy: attributionPolicy([NEW_SOURCE.kind], role) })
|
||||
expect(after.digest).not.toBe(before.digest)
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresCompatibilityReview: false })])
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [after], types: [] }
|
||||
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))).toEqual(snapshot)
|
||||
})
|
||||
@@ -937,7 +959,7 @@ describe('recorded source compatibility policy', () => {
|
||||
const after = attributedRoot([{ ...EXISTING_SOURCE, extra: 'optional' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })
|
||||
const changes = classifyPersistenceChange(before, after)
|
||||
expect(changes.map(change => change.kind).sort()).toEqual(['attribution-kind-added', 'optional-property-added'])
|
||||
expect(changes.every(change => !change.requiresVersionBump)).toBe(true)
|
||||
expect(changes.every(change => !change.requiresCompatibilityReview)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -948,35 +970,38 @@ describe('recorded source compatibility policy', () => {
|
||||
['renamed existing kind', [{ ...EXISTING_SOURCE, kind: 'renamed' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) }],
|
||||
['unrelated required payload', [EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]), extra: true }],
|
||||
['removed promise', [EXISTING_SOURCE, NEW_SOURCE], { policy: null }],
|
||||
] as const)('keeps %s breaking despite qualified additions', (_name, sources, options) => {
|
||||
] as const)('requires review for %s despite qualified additions', (_name, sources, options) => {
|
||||
const changes = classifyPersistenceChange(attributedRoot([EXISTING_SOURCE]), attributedRoot(sources, options))
|
||||
expect(changes.some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(changes.some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps variants within an existing wire-kind group strict', () => {
|
||||
const before = attributedRoot([EXISTING_SOURCE])
|
||||
const after = attributedRoot([EXISTING_SOURCE, { ...EXISTING_SOURCE, value: 'number' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })
|
||||
expect(classifyPersistenceChange(before, after)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'union-variants-changed', requiresVersionBump: true }),
|
||||
expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false }),
|
||||
expect.objectContaining({ kind: 'union-variants-changed', requiresCompatibilityReview: true }),
|
||||
expect.objectContaining({ kind: 'attribution-kind-added', requiresCompatibilityReview: false }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('treats qualification changes for an existing kind as policy changes', () => {
|
||||
const before = attributedRoot([EXISTING_SOURCE])
|
||||
const after = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([EXISTING_SOURCE.kind]) })
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'source-policy-changed', requiresVersionBump: true })])
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'source-policy-changed', requiresCompatibilityReview: true })])
|
||||
})
|
||||
|
||||
it('keeps legacy source transitions strict and never infers promises from the successor', () => {
|
||||
it('flags legacy source transitions while accepting explicit same-version history', () => {
|
||||
const before = attributedRoot([EXISTING_SOURCE], { policy: null })
|
||||
const after = attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })
|
||||
expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
|
||||
expect(classifyPersistenceChange(before, after).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
const legacyAfter = attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { policy: null })
|
||||
const snapshots = [before, legacyAfter].map(root => ({ formatVersion: 1 as const, roots: [root], types: [] }))
|
||||
expect(() => validatePersistenceHistory([
|
||||
const snapshots: PersistenceSchemaInventory[] = [
|
||||
{ formatVersion: 1, roots: [...inventory().roots.filter(root => root.kind !== 'event'), before], types: [] },
|
||||
{ formatVersion: 1, roots: [legacyAfter], types: [] },
|
||||
]
|
||||
expect(validatePersistenceHistory([
|
||||
entry(BASE_ID, snapshots[0]!, null, true), entry(NEXT_ID, snapshots[1]!, BASE_ID),
|
||||
])).toThrow('requires a format version bump')
|
||||
]).tips.get('event:example/source')?.id).toBe(NEXT_ID)
|
||||
})
|
||||
|
||||
it('validates policy-bearing history without source names or type records', () => {
|
||||
@@ -1013,3 +1038,73 @@ describe('recorded source compatibility policy', () => {
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [unknownKind] })).toThrow('invalid source compatibility')
|
||||
})
|
||||
})
|
||||
|
||||
describe('explicit same-version decisions', () => {
|
||||
it.each(['optional header', 'removed field', 'changed field'] as const)('admits an explicitly acknowledged %s after finalization', (kind) => {
|
||||
const root = fixture()
|
||||
finalize(root)
|
||||
const previous = contents(root)
|
||||
let after: PersistenceSchemaInventory
|
||||
if (kind === 'optional header') {
|
||||
const current = inventory({ value: 'number' }, 4)
|
||||
after = { ...current, roots: current.roots.map((root) => {
|
||||
if (root.key === 'SessionHeader') return typeRoot('SessionHeader', { id: 'string', 'env?': 'string' }, 4)
|
||||
return root
|
||||
}) }
|
||||
} else if (kind === 'removed field') {
|
||||
after = inventory({}, 4)
|
||||
} else {
|
||||
after = inventory({ value: 'boolean' }, 4)
|
||||
}
|
||||
const prose = proseFile(root)
|
||||
expect(jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', prose, '--json'], root, () => after)).ok).toBe(true)
|
||||
const saved = loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)!
|
||||
expect(saved.record.changes.every(change => change.decision === 'same-version')).toBe(true)
|
||||
expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
|
||||
for (const [path, content] of Object.entries(previous).filter(([path]) => path.startsWith('docs/persistence-changes/'))) {
|
||||
expect(readFileSync(join(root, path), 'utf8')).toBe(content)
|
||||
}
|
||||
})
|
||||
|
||||
it('cannot waive an actual version transition with a same-version decision', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const before = contents(root)
|
||||
for (const version of [2, 4]) {
|
||||
expect(jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--json'], root, () => inventory({ value: 'string' }, version))))
|
||||
.toMatchObject({ ok: false, code: 'version-transition-required' })
|
||||
}
|
||||
expect(jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'version-bump', '--json'], root, () => inventory({ value: 'string' }, 2))))
|
||||
.toMatchObject({ ok: false, code: 'version-transition-required' })
|
||||
expect(contents(root)).toEqual(before)
|
||||
})
|
||||
|
||||
it.each(['unchanged', 'changed'] as const)('requires an explicit decision when updating %s flagged schemas', (kind) => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const after = inventory({ value: 'number' })
|
||||
runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', proseFile(root)], root, () => after)
|
||||
const current = kind === 'changed' ? inventory({ value: 'boolean' }) : after
|
||||
const before = contents(root)
|
||||
expect(jsonResult(runPersistenceChanges(['--update', NEXT_ID, '--json'], root, () => current)))
|
||||
.toMatchObject({ ok: false, code: 'decision-required', files: [] })
|
||||
expect(contents(root)).toEqual(before)
|
||||
runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version'], root, () => current)
|
||||
expect(runPersistenceChanges(['--check'], root, () => current)).toContain('roots match')
|
||||
})
|
||||
|
||||
it('binds same-version decisions to exact history snapshots and leaves uncovered roots rejected', () => {
|
||||
const root = fixture()
|
||||
baseline(root)
|
||||
const after = inventory({ value: 'number' })
|
||||
runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', proseFile(root)], root, () => after)
|
||||
const uncovered = { ...after, roots: after.roots.map(root => root.key === 'SessionHeader'
|
||||
? typeRoot('SessionHeader', { id: 'string', 'env?': 'string' }) : root) }
|
||||
commitCurrent(root, uncovered)
|
||||
expect(() => verifyPersistenceChanges(root, uncovered)).toThrow('unacknowledged persistence type changes')
|
||||
const history = loadPersistenceHistory(root)
|
||||
const recorded = history.entries.find(entry => entry.record.id === NEXT_ID)!
|
||||
const invalid = { ...recorded, snapshot: onlyEvent(inventory({ value: 'boolean' })) }
|
||||
expect(() => validatePersistenceHistory([...history.entries.filter(entry => entry !== recorded), invalid])).toThrow('after digest mismatch')
|
||||
})
|
||||
})
|
||||
@@ -50,7 +50,7 @@ export interface PersistenceTypeChange {
|
||||
readonly kind: PersistenceTypeChangeKind
|
||||
readonly path: string
|
||||
readonly description: string
|
||||
readonly requiresVersionBump: boolean
|
||||
readonly requiresCompatibilityReview: boolean
|
||||
}
|
||||
|
||||
const CHANGE_DESCRIPTIONS = {
|
||||
@@ -380,19 +380,19 @@ function matchUnionVariants(candidates: readonly (readonly number[])[]): number[
|
||||
/** Classify structural differences using the reader promises saved with each schema.
|
||||
* @param before - predecessor root, or absence for an addition.
|
||||
* @param after - successor root, or absence for deletion.
|
||||
* @returns concrete changes and their format-bump requirement.
|
||||
* @returns concrete changes and their compatibility-review requirement.
|
||||
*/
|
||||
export function classifyPersistenceChange(before: PersistenceRoot | null, after: PersistenceRoot | null): PersistenceTypeChange[] {
|
||||
if (before === null) {
|
||||
return after === null ? [] : [{ path: after.key, kind: 'root-added', description: 'root added',
|
||||
requiresVersionBump: after.kind !== 'event' || after.surface !== false }]
|
||||
requiresCompatibilityReview: after.kind !== 'event' || after.surface !== false }]
|
||||
}
|
||||
if (after === null) return [{ path: before.key, kind: 'root-removed', description: 'root removed', requiresVersionBump: true }]
|
||||
if (after === null) return [{ path: before.key, kind: 'root-removed', description: 'root removed', requiresCompatibilityReview: true }]
|
||||
const key = after.key
|
||||
const oldRoot = before
|
||||
const newRoot = after
|
||||
const describe = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): PersistenceTypeChange =>
|
||||
({ path, kind, description: CHANGE_DESCRIPTIONS[kind], requiresVersionBump })
|
||||
const describe = (path: string, kind: PersistenceTypeChangeKind, requiresCompatibilityReview = true): PersistenceTypeChange =>
|
||||
({ path, kind, description: CHANGE_DESCRIPTIONS[kind], requiresCompatibilityReview })
|
||||
const changes: PersistenceTypeChange[] = []
|
||||
if (before.kind !== after.kind || before.surface !== after.surface) changes.push(describe(key, 'root-classification-changed'))
|
||||
if (before.digest === after.digest) return changes
|
||||
@@ -413,8 +413,8 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after:
|
||||
// or unmatched union arm cannot leave a cached success for another candidate.
|
||||
const active = new Set(ancestors).add(pair)
|
||||
const differences: PersistenceTypeChange[] = []
|
||||
const add = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): void => {
|
||||
differences.push(describe(path, kind, requiresVersionBump))
|
||||
const add = (path: string, kind: PersistenceTypeChangeKind, requiresCompatibilityReview = true): void => {
|
||||
differences.push(describe(path, kind, requiresCompatibilityReview))
|
||||
}
|
||||
const descend = (oldType: number, newType: number, child: string, childScope: Scope): void => {
|
||||
differences.push(...compare(oldType, newType, child, childScope, active))
|
||||
@@ -511,7 +511,7 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after:
|
||||
if (oldTypes.length !== newTypes.length) return [describe(path, 'union-variants-changed')]
|
||||
const candidates = oldTypes.map(oldType => newTypes.map(newType => compare(oldType, newType, path, scope, active)))
|
||||
const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
|
||||
candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
|
||||
candidate.every(change => !change.requiresCompatibilityReview) ? [index] : [])))
|
||||
if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
|
||||
const oldByHash = new Map(oldTypes.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
|
||||
const newByHash = new Map(newTypes.map(index => [fingerprint(newRoot.schema, index, 1), index]))
|
||||
@@ -522,7 +522,8 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after:
|
||||
}
|
||||
changes.push(...compare(0, 0, key, before.kind === 'event' ? 'event' : 'strict', new Set()))
|
||||
if (changes.length === 0) changes.push(describe(key, 'type-changed'))
|
||||
return [...new Map(changes.map(change => [JSON.stringify([change.path, change.kind, change.requiresVersionBump]), change])).values()]
|
||||
return [...new Map(changes.map(change =>
|
||||
[JSON.stringify([change.path, change.kind, change.requiresCompatibilityReview]), change])).values()]
|
||||
}
|
||||
|
||||
function parseDocument(source: string, filename: string, allowIncomplete = false): PersistenceChangeRecord {
|
||||
@@ -604,11 +605,9 @@ export function validatePersistenceHistory(entries: readonly PersistenceHistoryE
|
||||
if (!found.entry.record.baseline) {
|
||||
const differences = classifyPersistenceChange(before, after)
|
||||
if (differences.length === 0) throw new Error(`${id}: unchanged acknowledgement for ${root}`)
|
||||
if (differences.some(change => change.requiresVersionBump) && found.change.decision !== 'version-bump') {
|
||||
throw new PersistenceChangeFailure(
|
||||
`${id}: ${root} requires a format version bump (${differences.filter(change => change.requiresVersionBump).map(change => change.path + ': ' + change.description).join('; ')})`,
|
||||
'version-bump-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)],
|
||||
)
|
||||
if (root === 'SessionHeader' && headerVersion(before) !== headerVersion(after) && found.change.decision !== 'version-bump') {
|
||||
throw new PersistenceChangeFailure(`${id}: SessionHeader.version changes require an increasing version-bump transition`,
|
||||
'version-transition-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)])
|
||||
}
|
||||
if (found.change.decision === 'version-bump') {
|
||||
const header = found.entry.record.changes.find(change => change.root === 'SessionHeader')
|
||||
@@ -690,24 +689,6 @@ function verifyFinalization(
|
||||
if (writer === undefined || writer < finalized.version) {
|
||||
throw new PersistenceChangeFailure(`Session writer must not precede finalized format ${finalized.version}`, 'finalized-version-order')
|
||||
}
|
||||
if (writer > finalized.version) return
|
||||
const afterRoots = new Map(current.roots.map(root => [root.key, root]))
|
||||
const changes: ReportedChange[] = []
|
||||
const transitions: RootTransition[] = []
|
||||
for (const key of [...new Set([...finalized.roots.keys(), ...afterRoots.keys()])].sort()) {
|
||||
const before = finalized.roots.get(key) ?? null
|
||||
const after = afterRoots.get(key) ?? null
|
||||
const differences = classifyPersistenceChange(before, after)
|
||||
if (differences.length === 0) continue
|
||||
changes.push(...differences.map(change => ({ root: key, ...change })))
|
||||
transitions.push(rootTransition(before, after))
|
||||
}
|
||||
if (changes.some(change => change.requiresVersionBump)) {
|
||||
throw new PersistenceChangeFailure(
|
||||
`Breaking changes relative to the accepted Session format ${writer} baseline require format ${writer + 1} or later`,
|
||||
'finalized-format-changed', changes, transitions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify current generated output and acknowledgement tips together.
|
||||
@@ -729,7 +710,7 @@ export function verifyPersistenceChanges(root: string, current: PersistenceSchem
|
||||
throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions)
|
||||
}
|
||||
if (differences.length !== 0) {
|
||||
const details = differences.map(change => ` ${change.path}: ${change.description} (${change.requiresVersionBump ? 'version-bump required' : 'same-version allowed'})`)
|
||||
const details = differences.map(change => ` ${change.path}: ${change.description} (${change.requiresCompatibilityReview ? 'compatibility review required' : 'same-version allowed'})`)
|
||||
throw new PersistenceChangeFailure(`unacknowledged persistence type changes:\n${details.join('\n')}`, 'unacknowledged-changes', differences, transitions)
|
||||
}
|
||||
return history
|
||||
@@ -858,7 +839,16 @@ function executeCommand(
|
||||
const changed = baseline ? current.roots.map(root => root.key) : currentDifferences(history as PersistenceHistory, current)
|
||||
if (changed.length === 0) throw new Error('no persistence type changes to acknowledge')
|
||||
const differences = history === undefined ? [] : reportedDifferences(history, current)
|
||||
const decision = values.decision ?? (differences.some(change => change.requiresVersionBump) ? 'version-bump' : 'same-version')
|
||||
const previousHeader = history?.tips.get('SessionHeader')?.root ?? null
|
||||
const currentHeader = current.roots.find(root => root.key === 'SessionHeader') ?? null
|
||||
const versionChanged = !baseline && headerVersion(previousHeader) !== headerVersion(currentHeader)
|
||||
if (values.decision === undefined && !versionChanged && differences.some(change => change.requiresCompatibilityReview)) {
|
||||
throw new PersistenceChangeFailure(
|
||||
`${id}: review reader compatibility and choose --decision same-version or --decision version-bump`,
|
||||
'decision-required', differences, rootTransitions(history, current),
|
||||
)
|
||||
}
|
||||
const decision = values.decision ?? (versionChanged ? 'version-bump' : 'same-version')
|
||||
const roots = current.roots.filter(root => changed.includes(root.key))
|
||||
const change: PersistenceChangeRecord = { schemaVersion: 1, id, baseline, changes: changed.sort().map(key => ({
|
||||
root: key, previous: history?.tips.get(key)?.id ?? null,
|
||||
@@ -885,7 +875,7 @@ function executeCommand(
|
||||
for (const file of outputs) writeFileSync(resolve(root, file.path), file.content, { flag: recordFiles.includes(file) && !update ? 'wx' : 'w' })
|
||||
const completion = baseline
|
||||
? 'Complete both record documents and refresh their translation pairing.'
|
||||
: `Complete the compatibility and verification prose with --update ${id} --prose FILE.`
|
||||
: `Complete the compatibility and verification prose with --update ${id} --decision ${decision} --prose FILE.`
|
||||
const message = existing === undefined && prose === undefined
|
||||
? `Created ${HISTORY_DIRECTORY}/${id}.md and paired schema files. ${completion}`
|
||||
: `${update ? 'Updated' : 'Created'} ${HISTORY_DIRECTORY}/${id}.md; schema artifacts and bilingual pairing are current.`
|
||||
|
||||
@@ -23,7 +23,7 @@ function toolAdditionSchema() {
|
||||
}
|
||||
|
||||
describe('developer field compatibility', () => {
|
||||
it('requires a version bump before a future declaration can use the rejected inline tool field', () => {
|
||||
it('requires compatibility review before a future declaration can use the rejected inline tool field', () => {
|
||||
const { before, nodes, blockIndex, block, stringIndex } = toolAdditionSchema()
|
||||
nodes[blockIndex] = { ...block, properties: [
|
||||
...block.properties.filter(property => property.name !== 'tool'),
|
||||
@@ -31,7 +31,7 @@ describe('developer field compatibility', () => {
|
||||
] }
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
expect(classifyPersistenceChange(before, { ...before, schema, digest: schemaDigest(schema) }))
|
||||
.toContainEqual(expect.objectContaining({ requiresVersionBump: true }))
|
||||
.toContainEqual(expect.objectContaining({ requiresCompatibilityReview: true }))
|
||||
const reserved = block.properties.find(property => property.name === 'tool')
|
||||
expect(reserved).toMatchObject({ optional: true })
|
||||
expect(before.schema.nodes[reserved!.type]).toEqual({ kind: 'primitive', type: 'never' })
|
||||
@@ -47,7 +47,7 @@ describe('developer field compatibility', () => {
|
||||
const { before, nodes, blockIndex, block, stringIndex } = toolAdditionSchema()
|
||||
nodes[blockIndex] = { ...block, properties: [...block.properties, { name: 'traceId', type: stringIndex, optional: true }] }
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
expect(classifyPersistenceChange(before, { ...before, schema, digest: schemaDigest(schema) })).toMatchObject([{ kind: 'optional-property-added', requiresVersionBump: false }])
|
||||
expect(classifyPersistenceChange(before, { ...before, schema, digest: schemaDigest(schema) })).toMatchObject([{ kind: 'optional-property-added', requiresCompatibilityReview: false }])
|
||||
const header = { version: 4, id: 'optional-developer-field', createdAt: 1, isSeeded: false, delegationDepth: 0 }
|
||||
const message = { id: 'developer', role: 'developer', source: { kind: 'tool-registry', extra: true },
|
||||
content: [{ type: 'tool-addition', toolName: 'search', traceId: 'retained' }], extra: true }
|
||||
|
||||
@@ -15,7 +15,7 @@ function property(nodes: readonly SchemaNode[], index: number, name: string): nu
|
||||
return field.type
|
||||
}
|
||||
|
||||
it('requires a version bump before request headers can carry retired system text', { timeout: 60_000 }, () => {
|
||||
it('requires compatibility review before request headers can carry retired system text', { timeout: 60_000 }, () => {
|
||||
const inventory = extractPersistenceSchema(resolve(import.meta.dirname, '..'))
|
||||
const before = inventory.roots.find(root => root.key === 'event:request/header')
|
||||
if (before === undefined) throw new Error('generated schema omits request/header')
|
||||
@@ -41,7 +41,7 @@ it('requires a version bump before request headers can carry retired system text
|
||||
path: 'event:request/header.data.header.system',
|
||||
kind: 'type-changed',
|
||||
description: 'type changed',
|
||||
requiresVersionBump: true,
|
||||
requiresCompatibilityReview: true,
|
||||
}])
|
||||
expect(() => { assertV4RowAdmission({ type: 'request/header', data: { header: { system: 'retired text' } } }) })
|
||||
.toThrow('format v4 request/header rejects retired header.system')
|
||||
|
||||
@@ -21,10 +21,9 @@ export interface PersistenceFinalizationCheckpoint {
|
||||
readonly acceptedRecords: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** Latest accepted baseline schemas and the record ids locked by retained checkpoints. */
|
||||
/** Latest finalized version and the record ids locked by retained checkpoints. */
|
||||
export interface PersistenceFinalization {
|
||||
readonly version: number
|
||||
readonly roots: ReadonlyMap<string, PersistenceRoot>
|
||||
readonly acceptedRecords: ReadonlySet<string>
|
||||
}
|
||||
|
||||
@@ -105,7 +104,7 @@ function status(root: string, path: string): number | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate retained checkpoints against accepted history and reconstruct the latest accepted baseline.
|
||||
* Validate retained checkpoints against accepted history and collect their locked record ids.
|
||||
* @param root - checkout or isolated fixture root.
|
||||
* @param history - parsed acknowledgement entries, including later successors.
|
||||
* @returns finalization state, or undefined for a checkout with neither status nor checkpoints.
|
||||
@@ -120,7 +119,6 @@ export function loadPersistenceFinalization(root: string, history: Pick<Persiste
|
||||
if (!files.includes(`v${version}.json`)) throw new Error(`missing persistence finalization checkpoint v${version}.json`)
|
||||
const entries = new Map(history.entries.map(entry => [entry.record.id, entry]))
|
||||
const acceptedRecords = new Set<string>()
|
||||
let latestRoots: Map<string, PersistenceRoot> | undefined
|
||||
for (const file of files) {
|
||||
const match = /^v(0|[1-9]\d*)\.json$/u.exec(file)
|
||||
const capturedVersion = match === null ? NaN : Number(match[1])
|
||||
@@ -165,8 +163,6 @@ export function loadPersistenceFinalization(root: string, history: Pick<Persiste
|
||||
|| Object.entries(expected).some(([field, expectedValue]) => value[field] !== expectedValue)
|
||||
})) throw new Error(`${file}: finalized roots do not match complete accepted history`)
|
||||
if (writerVersion([...tips.values()]) !== capturedVersion) throw new Error(`${file}: accepted SessionHeader.version does not match finalized version`)
|
||||
if (capturedVersion === version) latestRoots = tips
|
||||
}
|
||||
if (latestRoots === undefined) throw new Error(`missing finalized schemas for Session format ${version}`)
|
||||
return { version, roots: latestRoots, acceptedRecords }
|
||||
return { version, acceptedRecords }
|
||||
}
|
||||
@@ -35,12 +35,12 @@ function structuralChanges(entry: PersistenceReleaseEntry, language: Language):
|
||||
: '规范化后的根类型及其传递引用摘要与前一 tag 相同。'
|
||||
}
|
||||
const summary = language === 'en'
|
||||
? `Detected ${count(entry.record.changes.length, 'changed root')} and ${count(entry.differences.length, 'structural difference')}. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.`
|
||||
: `检测到 ${entry.record.changes.length} 个根类型变化、${entry.differences.length} 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。`
|
||||
const heading = language === 'en' ? '| Path | Change | Current minimum |' : '| 路径 | 变化 | 当前最低要求 |'
|
||||
? `Detected ${count(entry.record.changes.length, 'changed root')} and ${count(entry.differences.length, 'structural difference')}. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.`
|
||||
: `检测到 ${entry.record.changes.length} 个根类型变化、${entry.differences.length} 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。`
|
||||
const heading = language === 'en' ? '| Path | Change | Review requirement |' : '| 路径 | 变化 | 审查要求 |'
|
||||
const rows = entry.differences.map((change) => {
|
||||
const path = change.path.replaceAll('`', '\\`').replaceAll('|', '\\|')
|
||||
return `| \`${path}\` | \`${change.kind}\` | \`${change.requiresVersionBump ? 'version-bump' : 'same-version'}\` |`
|
||||
return `| \`${path}\` | \`${change.kind}\` | \`${change.requiresCompatibilityReview ? 'review-required' : 'not-required'}\` |`
|
||||
})
|
||||
return [summary, '', heading, '|---|---|---|', ...rows].join('\n')
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('pinned persistence releases', () => {
|
||||
expect(archive.entries).toHaveLength(3)
|
||||
expect(archive.entries[0]!.roots.get('event:example/value')!.digest).toBe(event('string').digest)
|
||||
expect(archive.entries[1]!.roots.get('event:example/value')!.digest).toBe(event('number').digest)
|
||||
expect(archive.entries[1]!.differences).toEqual([expect.objectContaining({ root: 'event:example/value', kind: 'type-changed', requiresVersionBump: true })])
|
||||
expect(archive.entries[1]!.differences).toEqual([expect.objectContaining({ root: 'event:example/value', kind: 'type-changed', requiresCompatibilityReview: true })])
|
||||
expect(archive.entries[2]!.roots).toEqual(archive.entries[1]!.roots)
|
||||
expect(archive.entries[2]!.differences).toEqual([])
|
||||
expect(() => parsePersistenceSnapshot(data.snapshots[1])).toThrow('surface metadata')
|
||||
@@ -249,7 +249,7 @@ describe('pinned persistence releases', () => {
|
||||
saveRecord(data.directory, { ...data.records[2]!, changes: [{ root: 'event:example/value', before: event('number').digest, after: null }] })
|
||||
const result = loadPersistenceReleases(data.root)
|
||||
expect(result.entries[2]!.roots.has('event:example/value')).toBe(false)
|
||||
expect(result.entries[2]!.differences).toEqual([expect.objectContaining({ kind: 'root-removed', requiresVersionBump: true })])
|
||||
expect(result.entries[2]!.differences).toEqual([expect.objectContaining({ kind: 'root-removed', requiresCompatibilityReview: true })])
|
||||
})
|
||||
|
||||
it('rejects missing, duplicate, and unreferenced reachable types', () => {
|
||||
@@ -305,7 +305,9 @@ describe('release facts', () => {
|
||||
expect(after['README.md']).toContain(`| [${TAGS[1]}](${TAGS[1]}.md) | 2026-09-01 | 0 | 4 / 7 | 1 |`)
|
||||
expect(after[TAGS[1] + '.md']).toContain('4 roots / 7 types')
|
||||
expect(after[TAGS[1] + '.md']).toContain('Detected 1 changed root and 1 structural difference.')
|
||||
expect(after[TAGS[1] + '.md']).toContain('| `event:example/value.data` | `type-changed` | `version-bump` |')
|
||||
expect(after[TAGS[1] + '.md']).toContain('| `event:example/value.data` | `type-changed` | `review-required` |')
|
||||
expect(after[TAGS[1] + '.md']).toContain('| Path | Change | Review requirement |')
|
||||
expect(after[TAGS[1] + '.zh.md']).toContain('| 路径 | 变化 | 审查要求 |')
|
||||
for (const [name, source] of Object.entries(before)) {
|
||||
if (name.endsWith('.json')) expect(after[name]).toBe(source)
|
||||
if (name.endsWith('.md')) {
|
||||
@@ -342,7 +344,7 @@ describe('release facts', () => {
|
||||
['difference count', '1 structural difference', '9 structural differences'],
|
||||
['path', '`event:example/value.data`', '`event:example/value.wrong`'],
|
||||
['kind', '`type-changed`', '`optional-property-added`'],
|
||||
['minimum', '`version-bump`', '`same-version`'],
|
||||
['review requirement', '`review-required`', '`not-required`'],
|
||||
])('rejects stale record %s', (_label, original, replacement) => {
|
||||
const data = fixture()
|
||||
runPersistenceReleases(['--write'], data.root)
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('persistence review', () => {
|
||||
{ root: 'event:user/message', path: 'event:user/message.data.source[kind="tool-registry"]' },
|
||||
] })
|
||||
const markdown = renderPersistenceReview(report)
|
||||
expect(markdown).toContain('## Compatibility review requirements')
|
||||
expect(markdown).not.toContain('hooks-codex')
|
||||
expect(markdown).not.toMatch(/source\[\d+\]/u)
|
||||
for (const root of report.roots) expect(root.changes).toEqual(classifyPersistenceChange(
|
||||
@@ -81,7 +82,7 @@ describe('persistence review', () => {
|
||||
const report = reviewPersistenceSchemas(fixture(), fixture({ extraKind: true, changed: true }))
|
||||
expect(report.evidence.map(item => item.kind).sort()).toEqual(['kind-added', 'type-changed'])
|
||||
expect(report.evidence.find(item => item.kind === 'type-changed')?.locations[0]?.path).toBe('event:agent/inbox/spliced.data.inserted[].source[kind="changing"].value')
|
||||
expect(report.roots.every(root => root.changes.some(change => change.requiresVersionBump))).toBe(true)
|
||||
expect(report.roots.every(root => root.changes.some(change => change.requiresCompatibilityReview))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps ambiguous kind matches as additions and removals even when another arm is unchanged', () => {
|
||||
|
||||
@@ -199,10 +199,10 @@ export function renderPersistenceReview(report: PersistenceReview): string {
|
||||
`Before: ${item.before === null ? 'absent' : code(item.before)}; after: ${item.after === null ? 'absent' : code(item.after)}.`, '',
|
||||
...item.locations.map(location => `- ${code(location.path)}`), '', '</details>')
|
||||
}
|
||||
lines.push('', '## Authoritative compatibility results', '')
|
||||
lines.push('', '## Compatibility review requirements', '')
|
||||
for (const root of report.roots) {
|
||||
lines.push(`### ${code(root.root)}`, '', `Decision: **${root.changes.some(change => change.requiresVersionBump) ? 'version-bump' : 'same-version'}**.`, '',
|
||||
...root.changes.map(change => `- ${code(change.path)}: ${change.description} (${code(change.kind)}; requiresVersionBump: ${String(change.requiresVersionBump)}).`), '')
|
||||
lines.push(`### ${code(root.root)}`, '', `Assessment: **${root.changes.some(change => change.requiresCompatibilityReview) ? 'compatibility review required' : 'same-version allowed'}**.`, '',
|
||||
...root.changes.map(change => `- ${code(change.path)}: ${change.description} (${code(change.kind)}; requiresCompatibilityReview: ${String(change.requiresCompatibilityReview)}).`), '')
|
||||
}
|
||||
return lines.join('\n').trimEnd() + '\n'
|
||||
}
|
||||
@@ -210,7 +210,7 @@ export function renderPersistenceReview(report: PersistenceReview): string {
|
||||
/**
|
||||
* Read two explicit inventory files and return Markdown or JSON without mutating either.
|
||||
* @param args - --before and --after paths, and optional --json.
|
||||
* @returns structural evidence and exact compatibility results.
|
||||
* @returns structural evidence and per-root compatibility-review requirements.
|
||||
*/
|
||||
export function runPersistenceReview(args: readonly string[]): string {
|
||||
const { values } = parseArgs({ args: [...args], options: { before: { type: 'string' }, after: { type: 'string' }, json: { type: 'boolean' } }, strict: true, allowPositionals: false })
|
||||
|
||||
@@ -346,7 +346,7 @@ describe('explicitly reserved JSON properties', () => {
|
||||
const before = extractedEvent('export interface Payload { id: string;\n/** @persistenceReserved */\ntool?: never }')
|
||||
const after = extractedEvent('export interface Payload { id: string; tool?: string }')
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({
|
||||
path: 'event:test/record.data.tool', kind: 'type-changed', requiresVersionBump: true,
|
||||
path: 'event:test/record.data.tool', kind: 'type-changed', requiresCompatibilityReview: true,
|
||||
})])
|
||||
})
|
||||
|
||||
@@ -420,7 +420,7 @@ describe('source compatibility authoring', () => {
|
||||
expect(property.compatibility).toEqual({ version: 1, policy: 'session-source-attribution', binding: `session.${role}-message.source`,
|
||||
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: ['attribution'] })
|
||||
expect(classifyPersistenceChange(before.roots.find(root => root.kind === 'event')!, root))
|
||||
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
|
||||
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresCompatibilityReview: false })])
|
||||
expect(parsePersistenceSnapshot({ ...after, types: [] })).toEqual({ ...after, types: [] })
|
||||
})
|
||||
|
||||
@@ -502,18 +502,18 @@ declare module './message.js' {
|
||||
}`)
|
||||
const after = extractPersistenceSchema(root)
|
||||
expect(classifyPersistenceChange(before.roots.find(root => root.kind === 'event')!, after.roots.find(root => root.kind === 'event')!))
|
||||
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
|
||||
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresCompatibilityReview: false })])
|
||||
})
|
||||
|
||||
it('does not qualify unmarked additions or structurally equal unbound fields', () => {
|
||||
const before = sourceFixture('')
|
||||
const after = sourceFixture("unmarked: {kind: 'new'}")
|
||||
expect(classifyPersistenceChange(extractPersistenceSchema(before).roots.find(root => root.kind === 'event')!,
|
||||
extractPersistenceSchema(after).roots.find(root => root.kind === 'event')!).some(change => change.requiresVersionBump)).toBe(true)
|
||||
extractPersistenceSchema(after).roots.find(root => root.kind === 'event')!).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
const left = fixture("export interface Payload { source: {kind: 'old'} }")
|
||||
const right = fixture("export interface Payload { source: {kind: 'old'} | {kind: 'new'} }")
|
||||
expect(classifyPersistenceChange(extractPersistenceSchema(left).roots.find(root => root.kind === 'event')!,
|
||||
extractPersistenceSchema(right).roots.find(root => root.kind === 'event')!).some(change => change.requiresVersionBump)).toBe(true)
|
||||
extractPersistenceSchema(right).roots.find(root => root.kind === 'event')!).some(change => change.requiresCompatibilityReview)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Generate node-gyp's Makefile on the runner, then compile against glibc 2.28.
|
||||
case "$(uname -m)" in
|
||||
x86_64) image=quay.io/pypa/manylinux_2_28_x86_64 ;;
|
||||
aarch64) image=quay.io/pypa/manylinux_2_28_aarch64 ;;
|
||||
*) echo 'node-pty release preparation requires native Linux x64 or arm64' >&2; exit 1 ;;
|
||||
esac
|
||||
addon_dir="$(realpath packages/subprocess/subprocess-local/node_modules/node-pty)"
|
||||
pnpm_setup_root="$(realpath "$(dirname "$(dirname "$PNPM_HOME")")")"
|
||||
(cd "$addon_dir" && npm_config_build_from_source=true pnpm run install)
|
||||
addon="$addon_dir/build/Release/pty.node"
|
||||
test -f "$addon_dir/build/Makefile"
|
||||
docker run --rm \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "$PWD:$PWD" \
|
||||
-v "$HOME/.cache/node-gyp:$HOME/.cache/node-gyp:ro" \
|
||||
-v "$pnpm_setup_root:$pnpm_setup_root:ro" \
|
||||
-w "$addon_dir" "$image" \
|
||||
bash -euxo pipefail -c 'rm -rf build/Release && make -C build -j2 BUILDTYPE=Release'
|
||||
test -f "$addon"
|
||||
readelf --version-info "$addon"
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { zipSync } from 'fflate'
|
||||
@@ -61,7 +61,11 @@ it('extracts a hash-verified cached library without a Python installer or networ
|
||||
const hash = createHash('sha256').update(libraryWheel).digest('hex')
|
||||
const archive = join(root, hash)
|
||||
await writeFile(archive, libraryWheel)
|
||||
const original = await stat(archive)
|
||||
expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive)
|
||||
const reused = await stat(archive)
|
||||
expect(reused.ino).toBe(original.ino)
|
||||
expect(reused.mtimeMs).toBe(original.mtimeMs)
|
||||
await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))
|
||||
expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n')
|
||||
await writeFile(archive, 'corrupt archive')
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Prepare pinned, relocatable script interpreters without installing into the build host. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { cp } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -10,31 +10,101 @@ import { dirname, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import extractZip from 'extract-zip'
|
||||
import { x as extractTar } from 'tar'
|
||||
import { parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../packages/skill/tool-workspace-dependencies/src/index.ts'
|
||||
import { parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies'
|
||||
import lock from './lock.json' with { type: 'json' }
|
||||
import { prunePrimaryRuntimePythonTests } from './prune-python-tests.ts'
|
||||
|
||||
/**
|
||||
* Download or reuse an archive only when its bytes match the release lock.
|
||||
* @param url - Locked archive URL.
|
||||
* @param sha256 - Expected SHA-256 digest.
|
||||
* @param sha256 - Expected hexadecimal digest for the selected algorithm.
|
||||
* @param cache - Download cache directory.
|
||||
* @param algorithm - Digest algorithm; npm archives use SHA-512.
|
||||
* @returns Verified local archive path.
|
||||
*/
|
||||
export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise<string> {
|
||||
export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string, algorithm: 'sha256' | 'sha512' = 'sha256'): Promise<string> {
|
||||
mkdirSync(cache, { recursive: true })
|
||||
const destination = join(cache, sha256)
|
||||
let bytes: Buffer
|
||||
let cached = true
|
||||
try { bytes = readFileSync(destination) } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
const response = await fetch(url)
|
||||
cached = false
|
||||
const timeout = Number(process.env.DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS ?? '300000')
|
||||
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2_147_483_647) throw new Error('DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS must be an integer from 1 to 2147483647')
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(timeout) })
|
||||
if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`)
|
||||
bytes = Buffer.from(await response.arrayBuffer())
|
||||
}
|
||||
if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`)
|
||||
writeFileSync(destination, bytes)
|
||||
if (createHash(algorithm).update(bytes).digest('hex') !== sha256) {
|
||||
rmSync(destination, { force: true })
|
||||
throw new Error(`primary runtime download: checksum mismatch for ${url}`)
|
||||
}
|
||||
if (cached) return destination
|
||||
const temporary = join(cache, `.${randomUUID()}`)
|
||||
try {
|
||||
writeFileSync(temporary, bytes)
|
||||
renameSync(temporary, destination)
|
||||
} finally {
|
||||
rmSync(temporary, { force: true })
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
/** Pinned npm tarball, without dependency resolution or lifecycle scripts. */
|
||||
export interface RuntimeNpmArchive {
|
||||
/** Exact published package name. */
|
||||
readonly name: string
|
||||
/** Exact published version. */
|
||||
readonly version: string
|
||||
/** Registry tarball URL. */
|
||||
readonly url: string
|
||||
/** npm SHA-512 integrity. */
|
||||
readonly integrity: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and unpack one locked npm package without running its scripts.
|
||||
* @param artifact - Published tarball identity and integrity.
|
||||
* @param destination - Empty package directory.
|
||||
* @param cache - Verified archive cache.
|
||||
* @returns Resolves after package identity validation.
|
||||
*/
|
||||
export async function downloadRuntimeNpmPackage(artifact: RuntimeNpmArchive, destination: string, cache: string): Promise<void> {
|
||||
if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(artifact.integrity)) throw new Error(`runtime download: invalid integrity for ${artifact.name}`)
|
||||
const checksum = Buffer.from(artifact.integrity.slice(7), 'base64').toString('hex')
|
||||
const archive = await downloadPrimaryRuntimeAsset(artifact.url, checksum, cache, 'sha512')
|
||||
mkdirSync(destination, { recursive: true })
|
||||
await extractTar({ file: archive, cwd: destination, strip: 1 })
|
||||
const installed = JSON.parse(readFileSync(join(destination, 'package.json'), 'utf8')) as { name?: string; version?: string }
|
||||
if (installed.name !== artifact.name || installed.version !== artifact.version) throw new Error(`runtime download: package identity mismatch for ${artifact.name}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the locked standalone Node independently of Python and pnpm.
|
||||
* @param target - Target interpreter platform.
|
||||
* @param destination - Directory receiving bin/node and LICENSE.
|
||||
* @param cache - Verified archive cache.
|
||||
* @returns Resolves after extracting the interpreter.
|
||||
*/
|
||||
export async function downloadNodeRuntime(target: PrimaryRuntimeTarget, destination: string, cache: string): Promise<void> {
|
||||
const artifact = lock.targets[target]
|
||||
const filename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}`
|
||||
const archive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${filename}`, artifact.nodeSha256, cache)
|
||||
const staging = mkdtempSync(join(tmpdir(), 'dsh-node-'))
|
||||
try {
|
||||
if (target === 'win-x64') await extractZip(archive, { dir: staging })
|
||||
else await extractTar({ file: archive, cwd: staging })
|
||||
const source = join(staging, filename.replace(/\.(?:zip|tar\.gz)$/u, ''))
|
||||
mkdirSync(join(destination, 'bin'), { recursive: true })
|
||||
cpSync(join(source, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])),
|
||||
join(destination, 'bin', target === 'win-x64' ? 'node.exe' : 'node'))
|
||||
cpSync(join(source, 'LICENSE'), join(destination, 'LICENSE'))
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise<string> {
|
||||
const artifact = lock.targets[target]
|
||||
const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz`
|
||||
@@ -92,7 +162,7 @@ export async function prepareOfficeSkillAssets(source: string, destination: stri
|
||||
/** A locked interpreter and wheel target. */
|
||||
export type PrimaryRuntimeTarget = keyof typeof lock.targets
|
||||
|
||||
/** Build-only inputs shared by Desktop and SDK carriers. */
|
||||
/** Resource preparation inputs shared by builds and explicit SDK downloads. */
|
||||
export interface PreparePrimaryRuntimeOptions {
|
||||
/** Target whose archives and wheels are downloaded. */
|
||||
readonly target: PrimaryRuntimeTarget
|
||||
@@ -102,6 +172,10 @@ export interface PreparePrimaryRuntimeOptions {
|
||||
readonly cache: string
|
||||
/** Carrier release recorded in the legacy desktopVersion manifest field. */
|
||||
readonly version: string
|
||||
/** Locked npm pnpm tarball for installed carriers; build hosts otherwise copy their installed pnpm. */
|
||||
readonly pnpmArchive?: RuntimeNpmArchive
|
||||
/** Ordinary filesystem Office assets; build hosts otherwise resolve the workspace package. */
|
||||
readonly skillSource?: string
|
||||
/** Omit Node.js and pnpm for carriers providing only Python. */
|
||||
readonly pythonOnly?: boolean
|
||||
}
|
||||
@@ -114,7 +188,6 @@ export interface PreparePrimaryRuntimeOptions {
|
||||
export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOptions): Promise<void> {
|
||||
const { target } = options
|
||||
const paths = { runtime: resolve(options.output), downloads: resolve(options.cache) }
|
||||
const artifact = lock.targets[target]
|
||||
mkdirSync(paths.runtime, { recursive: true })
|
||||
mkdirSync(paths.downloads, { recursive: true })
|
||||
const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-'))
|
||||
@@ -124,23 +197,18 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption
|
||||
mkdirSync(dependencies, { recursive: true })
|
||||
let pnpmVersion: string | undefined
|
||||
if (!options.pythonOnly) {
|
||||
const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}`
|
||||
const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads)
|
||||
const unpackedNode = join(staging, 'node')
|
||||
mkdirSync(unpackedNode)
|
||||
if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode })
|
||||
else await extractTar({ file: nodeArchive, cwd: unpackedNode })
|
||||
const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, ''))
|
||||
mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true })
|
||||
mkdirSync(join(dependencies, 'node', 'node_modules'))
|
||||
writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n')
|
||||
cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])),
|
||||
join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node'))
|
||||
cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE'))
|
||||
const require = createRequire(import.meta.url)
|
||||
const pnpmManifest = require.resolve('pnpm')
|
||||
pnpmVersion = (JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }).version
|
||||
await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true })
|
||||
const node = join(dependencies, 'node')
|
||||
await downloadNodeRuntime(target, node, paths.downloads)
|
||||
mkdirSync(join(node, 'node_modules'))
|
||||
writeFileSync(join(node, 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n')
|
||||
if (options.pnpmArchive !== undefined) {
|
||||
pnpmVersion = options.pnpmArchive.version
|
||||
await downloadRuntimeNpmPackage(options.pnpmArchive, join(dependencies, 'pnpm'), paths.downloads)
|
||||
} else {
|
||||
const pnpmManifest = createRequire(import.meta.url).resolve('pnpm')
|
||||
pnpmVersion = (JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }).version
|
||||
await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true })
|
||||
}
|
||||
}
|
||||
await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies })
|
||||
const manifest: PrimaryRuntimeManifest = {
|
||||
@@ -153,7 +221,7 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption
|
||||
pythonPackages: lock.pythonPackages,
|
||||
}
|
||||
const entries = workspaceDependencyPaths(output, manifest)
|
||||
for (const wheel of [...artifact.wheels, ...lock.wheels]) {
|
||||
for (const wheel of [...lock.targets[target].wheels, ...lock.wheels]) {
|
||||
await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages)
|
||||
}
|
||||
prunePrimaryRuntimePythonTests(entries.pythonPackages)
|
||||
@@ -164,8 +232,8 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true })
|
||||
}
|
||||
const require = createRequire(import.meta.url)
|
||||
await prepareOfficeSkillAssets(join(dirname(require.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'),
|
||||
const assets = options.skillSource ?? join(dirname(createRequire(import.meta.url).resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets')
|
||||
await prepareOfficeSkillAssets(assets,
|
||||
join(paths.runtime, 'office-skills'))
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export const PRODUCT_PACKAGE_POLICY: Readonly<Record<string, ProductPackagePolic
|
||||
'packages/lsp/lsp-stdio': { category: 'optional', reason: 'Requires an explicit server list and language-server executables.' },
|
||||
'packages/lsp/tool-lsp': { category: 'optional', reason: 'Model tools selected together with a configured LSP provider.' },
|
||||
'packages/ssh/ssh': { category: 'optional', reason: 'Explicit remote connection and helper configuration.' },
|
||||
'packages/ssh/ssh-helper-runtime': { category: 'optional', reason: 'Private executable carrier for explicitly configured SSH connections.' },
|
||||
'packages/ssh/fs-ssh': { category: 'optional', reason: 'Remote filesystem provider selected in an SSH composition.' },
|
||||
'packages/ssh/sandbox-ssh': { category: 'optional', reason: 'Remote confinement provider selected in an SSH composition.' },
|
||||
'packages/ssh/subprocess-ssh': { category: 'optional', reason: 'Remote process provider selected in an SSH composition.' },
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/** Explicit resource acquisition through the Python carrier's private dispatcher. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile, copyFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-resource-download-'))
|
||||
const bootstrap = join(root, 'node', 'runtime-bootstrap.mjs')
|
||||
const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'win' : process.platform
|
||||
const resources = join(root, `${platform}-${process.arch}`)
|
||||
const cache = join(root, 'cache')
|
||||
async function file(path: string, content: string) {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, content)
|
||||
}
|
||||
await file(join(resources, 'downloads.json'), JSON.stringify({ identity: 'a'.repeat(64), office: [], pnpm: {} }))
|
||||
await mkdir(dirname(bootstrap), { recursive: true })
|
||||
await copyFile(new URL('../python/sdk-runtime/runtime-bootstrap.mjs', import.meta.url), bootstrap)
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh-http-proxy/package.json'), '{"type":"module","exports":"./index.mjs"}')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh-http-proxy/index.mjs'), 'export async function installProxyFromEnvironment(env) { if (typeof env.get !== "function") throw new Error("missing environment lookup"); return async () => {} }')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh-sandbox-windows-acl/package.json'), '{"type":"module","exports":{"./runner":"./runner.mjs"}}')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh-sandbox-windows-acl/runner.mjs'), '')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh/package.json'), '{"type":"module"}')
|
||||
await file(join(root, 'node/node_modules/@deepseek-ai/dsh/lib/bin.js'), 'export async function runCli() { if (process.argv[2]) { console.log(JSON.stringify({ argv: process.argv.slice(2) })); return } console.log(JSON.stringify({ primary: process.env.DSH_BUNDLED_PRIMARY_RUNTIME, office: process.env.DSH_BUNDLED_OFFICE_CLI })) }')
|
||||
await file(join(root, 'node/primary-runtime.mjs'), `
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
export async function downloadNodeRuntime(target, destination) {
|
||||
if (process.env.DSH_RUNTIME_DOWNLOAD !== undefined) throw new Error("private selector leaked");
|
||||
await mkdir(join(destination, 'bin'), { recursive: true });
|
||||
await writeFile(join(destination, 'bin', process.platform === 'win32' ? 'node.exe' : 'node'), 'node');
|
||||
}
|
||||
export async function preparePrimaryRuntime({ output }) {
|
||||
if (process.env.DSH_RUNTIME_DOWNLOAD !== undefined) throw new Error("private selector leaked");
|
||||
await mkdir(join(output, 'primary-runtime'), { recursive: true });
|
||||
await writeFile(join(output, 'primary-runtime/runtime.json'), '{}');
|
||||
}
|
||||
`)
|
||||
await file(join(root, 'node/office-sidecar.mjs'), `
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
export async function downloadOfficeSidecar(packages, destination) {
|
||||
const directory = join(destination, 'node_modules/@deepseek-ai/libreoffice-kit/lib');
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, 'cli.js'), 'cli');
|
||||
if (process.env.DSH_FIXTURE_FAIL) throw new Error('fixture download failed');
|
||||
if (process.env.DSH_FIXTURE_BARRIER) {
|
||||
process.stderr.write('ready\\n');
|
||||
await new Promise(resolve => process.stdin.once('data', resolve));
|
||||
process.stdin.pause();
|
||||
}
|
||||
}
|
||||
`)
|
||||
function run(resource?: string, extra: NodeJS.ProcessEnv = {}, args: string[] = []) {
|
||||
const child = spawn(process.execPath, [bootstrap, ...args], {
|
||||
env: { ...process.env, DSH_RESOURCE_CACHE: cache, DSH_RUNTIME_DOWNLOAD: resource, DSH_BUNDLED_PRIMARY_RUNTIME: undefined,
|
||||
DSH_OFFICE_SIDECAR: undefined, DSH_BUNDLED_OFFICE_CLI: undefined, ...extra },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = '', stderr = ''
|
||||
child.stdout.on('data', (data) => { stdout += String(data) })
|
||||
let reportReady: (() => void) | undefined
|
||||
let failReady: ((error: Error) => void) | undefined
|
||||
const ready = extra.DSH_FIXTURE_BARRIER === undefined ? Promise.resolve()
|
||||
: new Promise<void>((resolve, reject) => { reportReady = resolve; failReady = reject })
|
||||
child.stderr.on('data', (data) => { stderr += String(data); if (stderr.includes('ready\n')) reportReady?.() })
|
||||
const done = new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code) => { failReady?.(new Error(`download exited before readiness: ${stderr}`)); resolve({ code, stdout, stderr }) })
|
||||
})
|
||||
return { child, ready, done }
|
||||
}
|
||||
return { root, cache, run }
|
||||
}
|
||||
|
||||
it('starts without downloading, independently downloads either resource, and reuses completed caches', async () => {
|
||||
const { root, cache, run } = await fixture()
|
||||
try {
|
||||
expect(await run().done).toMatchObject({ code: 0, stdout: '{}\n' })
|
||||
await expect(readdir(cache)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
const primary = await run('primary').done
|
||||
expect(primary.code, primary.stderr).toBe(0)
|
||||
expect(JSON.parse(primary.stdout)).toBe(join(cache, 'a'.repeat(64), 'primary', 'primary-runtime'))
|
||||
await expect(readdir(join(cache, 'a'.repeat(64), 'office'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
const office = await run('office').done
|
||||
expect(office.code, office.stderr).toBe(0)
|
||||
const location = JSON.parse(office.stdout) as string
|
||||
expect(await readFile(join(location, 'node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js'), 'utf8')).toBe('cli')
|
||||
expect(await run('office', { DSH_FIXTURE_FAIL: '1' }).done).toMatchObject({ code: 0, stdout: office.stdout })
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it('cleans failed staging and allows an explicit retry', async () => {
|
||||
const { root, cache, run } = await fixture()
|
||||
try {
|
||||
const failed = await run('office', { DSH_FIXTURE_FAIL: '1' }).done
|
||||
expect(failed.code).toBe(1)
|
||||
expect(failed.stderr).toContain('fixture download failed')
|
||||
expect(await readdir(join(cache, 'a'.repeat(64)))).toEqual([])
|
||||
expect((await run('office').done).code).toBe(0)
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
it('publishes one complete cache when two processes download concurrently', async () => {
|
||||
const { root, cache, run } = await fixture()
|
||||
const jobs = [run('office', { DSH_FIXTURE_BARRIER: '1' }), run('office', { DSH_FIXTURE_BARRIER: '1' })]
|
||||
try {
|
||||
await Promise.all(jobs.map(job => job.ready))
|
||||
for (const job of jobs) job.child.stdin.end('continue')
|
||||
const results = await Promise.all(jobs.map(job => job.done))
|
||||
for (const result of results) expect(result.code, result.stderr).toBe(0)
|
||||
expect(results[0]!.stdout).toBe(results[1]!.stdout)
|
||||
expect(await readdir(join(cache, 'a'.repeat(64)))).toEqual(['office'])
|
||||
} finally {
|
||||
for (const job of jobs) if (job.child.exitCode === null && job.child.signalCode === null) job.child.kill()
|
||||
await Promise.allSettled(jobs.map(job => job.done))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
it('leaves ordinary CLI arguments to the CLI and rejects an invalid private download selector', async () => {
|
||||
const { root, cache, run } = await fixture()
|
||||
try {
|
||||
expect(await run(undefined, {}, ['--download-office']).done).toMatchObject({
|
||||
code: 0, stdout: JSON.stringify({ argv: ['--download-office'] }) + '\n',
|
||||
})
|
||||
const rejected = await run('invalid').done
|
||||
expect(rejected.code).toBe(1)
|
||||
expect(rejected.stderr).toContain('DSH_RUNTIME_DOWNLOAD must select office or primary')
|
||||
await expect(readdir(cache)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
@@ -884,10 +884,18 @@ def main() -> None:
|
||||
|
||||
def smoke_sdk_authoring(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
"""Query the bundled Python and switch skills without replacing that environment."""
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-authoring-cache-") as cache_directory:
|
||||
_smoke_sdk_authoring_cached(base_url, executable, update_snapshots, Path(cache_directory))
|
||||
|
||||
|
||||
def _smoke_sdk_authoring_cached(base_url: str, executable: Path, update_snapshots: bool, cache: Path) -> None:
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
resources = executable.with_name(executable.name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe"))
|
||||
manifest = json.loads((resources / "primary-runtime/runtime.json").read_text())
|
||||
cache_environment = {**os.environ, "DSH_RESOURCE_CACHE": str(cache), "DSH_RUNTIME_DOWNLOAD": "primary"}
|
||||
primary = Path(json.loads(subprocess.check_output([str(executable)], encoding="utf-8", env=cache_environment)))
|
||||
resources = primary.parent
|
||||
manifest = json.loads((primary / "runtime.json").read_text())
|
||||
for mode in ("default", "replacement", "disabled"):
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-authoring-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
@@ -903,7 +911,7 @@ def smoke_sdk_authoring(base_url: str, executable: Path, update_snapshots: bool)
|
||||
provider="deepseek-official", model="smoke-model", cwd=str(root),
|
||||
dsh_bin=str(executable), dsh_home=str(home), patches=(str(patch),),
|
||||
api_key="sk-keyless-smoke", base_url=base_url,
|
||||
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"},
|
||||
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1", "DSH_RESOURCE_CACHE": str(cache)},
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
result = harness.run(AUTHORING_PROMPT, session_id="authoring")
|
||||
@@ -958,7 +966,9 @@ def smoke_sdk_office(executable: Path) -> None:
|
||||
shutil.copytree(source, destination)
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
office = root / f"{stem}-office"
|
||||
cache_environment = {**os.environ, "DSH_RESOURCE_CACHE": str(root / "cache")}
|
||||
office = Path(json.loads(subprocess.check_output([str(relocated)], encoding="utf-8", env={**cache_environment, "DSH_RUNTIME_DOWNLOAD": "office"})))
|
||||
assert not (office.parent / "primary").exists(), "Office download unexpectedly prepared the authoring environment"
|
||||
adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json"
|
||||
native = stem.removeprefix("deepseek-harness-sdk-runtime-").replace("win-", "win32-").replace("macos-", "darwin-")
|
||||
declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {})
|
||||
@@ -997,7 +1007,7 @@ def smoke_sdk_office(executable: Path) -> None:
|
||||
patches=(str(patch),),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url="http://127.0.0.1:9",
|
||||
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"},
|
||||
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1", "DSH_RESOURCE_CACHE": str(root / "cache")},
|
||||
# The startup plugin awaits a converter with a 120-second deadline before JSON-RPC is ready.
|
||||
initialize_timeout_seconds=180,
|
||||
request_timeout_seconds=180,
|
||||
@@ -1007,10 +1017,32 @@ def smoke_sdk_office(executable: Path) -> None:
|
||||
if result["backend"] != expected_backend:
|
||||
raise AssertionError(f"Office conversion did not use {expected_backend}: {result}")
|
||||
if not result["moduleUrl"].startswith(office.as_uri() + "/"):
|
||||
raise AssertionError(f"Office module was not loaded from the relocated wheel: {result}")
|
||||
raise AssertionError(f"Office module was not loaded from the downloaded sidecar: {result}")
|
||||
pdf = output.read_bytes()
|
||||
if len(pdf) < 100 or not pdf.startswith(b"%PDF-"):
|
||||
raise AssertionError(f"Office conversion produced an invalid PDF at {output}")
|
||||
workbook = root / "input.xlsx"
|
||||
with zipfile.ZipFile(workbook, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>')
|
||||
archive.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>')
|
||||
archive.writestr("xl/workbook.xml", '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>')
|
||||
archive.writestr("xl/_rels/workbook.xml.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>')
|
||||
archive.writestr("xl/worksheets/sheet1.xml", '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1"><f>1+2</f><v>0</v></c></row></sheetData></worksheet>')
|
||||
subprocess.run([sys.executable, "-c", """
|
||||
import sys, zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree
|
||||
from deepseek_harness.office import convert, render_images, recalculate
|
||||
root = Path(sys.argv[1])
|
||||
assert convert(root / 'document.docx', root / 'python.pdf').backend == sys.argv[2]
|
||||
rendered = render_images(root / 'python.pdf', root / 'images', pages=[1])
|
||||
assert rendered.images and rendered.images[0].path.read_bytes().startswith(b'\\x89PNG')
|
||||
recalculate(root / 'input.xlsx', root / 'calculated.xlsx')
|
||||
with zipfile.ZipFile(root / 'calculated.xlsx') as archive:
|
||||
sheet = ElementTree.fromstring(archive.read('xl/worksheets/sheet1.xml'))
|
||||
assert sheet.find('.//{*}c[@r="A1"]/{*}v').text == '3'
|
||||
""", str(root), expected_backend], env={name: value for name, value in cache_environment.items() if name != "DSH_OFFICE_SIDECAR"}, check=True, timeout=180)
|
||||
assert not (office.parent / "primary").exists(), "Python Office API unexpectedly downloaded authoring resources"
|
||||
print(f"smoke-python-runtime: relocated Office {result['backend']} DOCX produced {len(pdf)} PDF bytes")
|
||||
|
||||
|
||||
@@ -1336,6 +1368,8 @@ def smoke_sdk_dynamic_tools(base_url: str, executable: Path, update_snapshots: b
|
||||
"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1",
|
||||
"HOME": str(user_home), "USERPROFILE": str(user_home),
|
||||
"DSH_AGENTS_HOME": str(user_home / ".agents"),
|
||||
# Pin the tool baseline independently of optional resources in the host cache.
|
||||
"DSH_PRIMARY_RUNTIME": "",
|
||||
},
|
||||
api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60,
|
||||
) as harness:
|
||||
@@ -2307,11 +2341,17 @@ def build_dynamic_tools_snapshot_files(
|
||||
assert changes == [[], [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "snapshot_ping"}}], []], changes
|
||||
assert requests[1]["messages"][:len(requests[0]["messages"])] == requests[0]["messages"], "tool addition changed earlier messages"
|
||||
replacements = [(str(cwd), "{{cwd}}"), (result.session_id, "{{session}}")]
|
||||
header_references = {header["seq"]: f"{{{{header:{index + 1}}}}}" for index, header in enumerate(headers)}
|
||||
snapshot_events = [
|
||||
{**event, "data": {**event["data"], "headerSeq": header_references[event["data"]["headerSeq"]]}}
|
||||
if event["type"] == "developer/message" and "headerSeq" in event["data"] else event
|
||||
for event in events if event["type"] in {"request/header", "request/context", "developer/message"}
|
||||
]
|
||||
evidence = {
|
||||
"finalResponse": result.final_response,
|
||||
"declarations": declarations,
|
||||
"requestToolChanges": changes,
|
||||
"events": [event for event in events if event["type"] in {"request/header", "request/context", "developer/message"}],
|
||||
"events": snapshot_events,
|
||||
"toolCalls": calls,
|
||||
"pingResult": ping,
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"job_list",
|
||||
"job_output",
|
||||
"list_agents",
|
||||
"load_workspace_dependencies",
|
||||
"read",
|
||||
"read_image",
|
||||
"send_message",
|
||||
@@ -87,7 +86,7 @@
|
||||
},
|
||||
{
|
||||
"type": "request/header",
|
||||
"seq": 20,
|
||||
"seq": 19,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"header": {
|
||||
@@ -113,7 +112,6 @@
|
||||
"job_list",
|
||||
"job_output",
|
||||
"list_agents",
|
||||
"load_workspace_dependencies",
|
||||
"read",
|
||||
"read_image",
|
||||
"send_message",
|
||||
@@ -135,7 +133,7 @@
|
||||
},
|
||||
{
|
||||
"type": "developer/message",
|
||||
"seq": 21,
|
||||
"seq": 20,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -153,13 +151,13 @@
|
||||
"role": "developer",
|
||||
"id": "{{messageId}}"
|
||||
},
|
||||
"headerSeq": 20
|
||||
"headerSeq": "{{header:2}}"
|
||||
},
|
||||
"surfaceOp": "append"
|
||||
},
|
||||
{
|
||||
"type": "request/header",
|
||||
"seq": 28,
|
||||
"seq": 26,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"header": {
|
||||
@@ -185,7 +183,6 @@
|
||||
"job_list",
|
||||
"job_output",
|
||||
"list_agents",
|
||||
"load_workspace_dependencies",
|
||||
"read",
|
||||
"read_image",
|
||||
"send_message",
|
||||
@@ -206,7 +203,7 @@
|
||||
},
|
||||
{
|
||||
"type": "developer/message",
|
||||
"seq": 29,
|
||||
"seq": 27,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { create as createTar } from 'tar'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { artifactManifestSchema, fileDigest, payloadFiles, SSH_HELPER_TARGETS, verifyPayload } from './artifact.ts'
|
||||
import { macosDeploymentVersions } from './native.ts'
|
||||
import { extractHelper } from '../verify-ssh-helper-artifact.ts'
|
||||
import { prepareSshRelease } from './release.ts'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-helper-archive-'))
|
||||
onTestFinished(() => rm(root, { recursive: true, force: true }))
|
||||
const payload = join(root, 'dsh-ssh-helper')
|
||||
await mkdir(join(payload, 'native/system'), { recursive: true })
|
||||
await writeFile(join(payload, 'dsh-ssh-helper'), 'fixture executable')
|
||||
await writeFile(join(payload, 'native/system/package.json'), '{}')
|
||||
const metadata = {
|
||||
format: 1, version: '1.0.0', commit: 'a'.repeat(40), dirty: false,
|
||||
target: 'node24-linux-x64', protocol: 2, nodeVersion: 'v24.1.0',
|
||||
files: await Promise.all((await payloadFiles(payload)).map(async path => ({
|
||||
path, sha256: await fileDigest(join(payload, path)), mode: (await lstat(join(payload, path))).mode & 0o777,
|
||||
}))),
|
||||
}
|
||||
const save = async () => { await writeFile(join(payload, 'manifest.json'), JSON.stringify(metadata)) }
|
||||
await save()
|
||||
return { root, payload, metadata, save }
|
||||
}
|
||||
|
||||
it('validates the exact unpacked inventory and rejects altered bytes', async () => {
|
||||
const test = await fixture()
|
||||
expect(await verifyPayload(test.payload)).toEqual(test.metadata)
|
||||
await writeFile(join(test.payload, 'dsh-ssh-helper'), 'changed executable')
|
||||
await expect(verifyPayload(test.payload)).rejects.toThrow('digest differs')
|
||||
})
|
||||
|
||||
it('rejects missing, undeclared and duplicate payload files', async () => {
|
||||
const test = await fixture()
|
||||
await writeFile(join(test.payload, 'undeclared'), '')
|
||||
await expect(verifyPayload(test.payload)).rejects.toThrow('inventory')
|
||||
await rm(join(test.payload, 'undeclared'))
|
||||
test.metadata.files.push(test.metadata.files[0]!)
|
||||
await test.save()
|
||||
await expect(verifyPayload(test.payload)).rejects.toThrow('inventory')
|
||||
test.metadata.files.pop()
|
||||
await test.save()
|
||||
await rm(join(test.payload, 'native/system/package.json'))
|
||||
await expect(verifyPayload(test.payload)).rejects.toThrow('inventory')
|
||||
})
|
||||
|
||||
it.each(['../outside', '/absolute', 'a/../b', 'a\\b', 'a//b'])('rejects unsafe manifest path %s', async (path) => {
|
||||
const test = await fixture()
|
||||
expect(() => artifactManifestSchema.parse({ ...test.metadata, files: [{ ...test.metadata.files[0], path }] })).toThrow('payload path')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects links before extracting an archive', async () => {
|
||||
const test = await fixture()
|
||||
await symlink('/etc/passwd', join(test.payload, 'outside'))
|
||||
await expect(payloadFiles(test.payload)).rejects.toThrow('symbolic link')
|
||||
const archive = join(test.root, 'linked.tar.gz')
|
||||
await createTar({ cwd: test.root, file: archive, gzip: true }, ['dsh-ssh-helper'])
|
||||
const extracted = join(test.root, 'unpack')
|
||||
await mkdir(extracted)
|
||||
await expect(extractHelper(archive, extracted)).rejects.toThrow('Invalid SSH helper archive entry')
|
||||
})
|
||||
|
||||
it('extracts and verifies a portable archive in a new directory', async () => {
|
||||
const test = await fixture()
|
||||
const archive = join(test.root, 'helper.tar.gz')
|
||||
await createTar({ cwd: test.root, file: archive, gzip: true, portable: true }, ['dsh-ssh-helper'])
|
||||
const extracted = join(test.root, 'relocated')
|
||||
await mkdir(extracted)
|
||||
const directory = await extractHelper(archive, extracted)
|
||||
expect(dirname(directory)).toBe(extracted)
|
||||
expect(await verifyPayload(directory)).toEqual(test.metadata)
|
||||
expect(await readFile(join(directory, 'dsh-ssh-helper'), 'utf8')).toBe('fixture executable')
|
||||
})
|
||||
|
||||
it('reads minimum macOS versions without accepting a linked library version as the OS version', () => {
|
||||
expect(macosDeploymentVersions('Load command 0\ncmd LC_VERSION_MIN_MACOSX\nversion 11.0\nLoad command 1\ncmd LC_LOAD_DYLIB\ncurrent version 1311.100.3\nLoad command 2\ncmd LC_BUILD_VERSION\nminos 14.0\nsdk 15.1\n'))
|
||||
.toEqual(['11.0', '14.0'])
|
||||
expect(macosDeploymentVersions('Load command 0\ncmd LC_LOAD_DYLIB\ncurrent version 14.0\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes only a complete clean-source matrix with native and no-Node evidence', async () => {
|
||||
const test = await fixture()
|
||||
const archives = join(test.root, 'release')
|
||||
await mkdir(archives)
|
||||
const checks = ['executable-handshake', 'guarded-files-and-streams', 'managed-process-output', 'process-cancellation', 'native-pty', 'native-flock', 'embedded-ptc-and-deadline', 'sandbox-enforcement']
|
||||
for (const target of SSH_HELPER_TARGETS) {
|
||||
const parent = join(test.root, target)
|
||||
await mkdir(parent)
|
||||
const payload = join(parent, 'dsh-ssh-helper')
|
||||
await cp(test.payload, payload, { recursive: true })
|
||||
const manifest = { ...test.metadata, target }
|
||||
await writeFile(join(payload, 'manifest.json'), JSON.stringify(manifest))
|
||||
const archive = join(archives, `dsh-ssh-helper-1.0.0-${target.slice('node24-'.length)}.tar.gz`)
|
||||
await createTar({ cwd: parent, file: archive, gzip: true, portable: true }, ['dsh-ssh-helper'])
|
||||
await writeFile(`${archive}.sha256`, `${await fileDigest(archive)} archive\n`)
|
||||
const runtime = { checks, nodeVersion: 'v24.1.0', platform: target.includes('linux') ? 'linux' : 'darwin', sandboxLane: 'required' }
|
||||
await writeFile(join(archives, `${target}.json`), JSON.stringify({ manifest, runtime, restrictedWorker: true, readonlyInstall: true, concurrentColdStarts: 2 }))
|
||||
if (target.includes('linux')) {
|
||||
await writeFile(join(archives, `${target}.ssh.json`), JSON.stringify({ manifest, runtime: { ...runtime, checks: [...checks, 'target-has-no-node'], sandboxLane: 'portable' }, glibc: 'glibc 2.28' }))
|
||||
await writeFile(join(archives, `${target}.landlock.json`), JSON.stringify({ manifest, runtime, expectedBackend: 'landlock-run' }))
|
||||
}
|
||||
}
|
||||
expect(await prepareSshRelease(archives, '1.0.0', test.metadata.commit)).toHaveLength(5)
|
||||
expect((await readFile(join(archives, 'SHA256SUMS'), 'utf8')).trim().split('\n')).toHaveLength(4)
|
||||
await expect(prepareSshRelease(archives, '1.0.0', 'b'.repeat(40))).rejects.toThrow()
|
||||
const report = join(archives, 'node24-linux-x64.ssh.json')
|
||||
const evidence = JSON.parse(await readFile(report, 'utf8')) as { runtime: { checks: string[] } }
|
||||
evidence.runtime.checks = evidence.runtime.checks.filter(check => check !== 'target-has-no-node')
|
||||
await writeFile(report, JSON.stringify(evidence))
|
||||
await expect(prepareSshRelease(archives, '1.0.0', test.metadata.commit)).rejects.toThrow('lacks target-has-no-node')
|
||||
await rm(report)
|
||||
await expect(prepareSshRelease(archives, '1.0.0', test.metadata.commit)).rejects.toThrow('ENOENT')
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Versioned archive metadata and file integrity checks for SSH helper releases. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { lstat, readFile, readdir } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Native target identifiers accepted by the builder and release workflow. */
|
||||
export const SSH_HELPER_TARGETS = ['node24-linux-x64', 'node24-linux-arm64', 'node24-macos-x64', 'node24-macos-arm64'] as const
|
||||
/** One qualified OS and architecture for a bundled Node 24 helper. */
|
||||
export type SshHelperTarget = typeof SSH_HELPER_TARGETS[number]
|
||||
|
||||
const payloadPath = z.string().min(1).refine(value =>
|
||||
!value.startsWith('/') && !value.includes('\\') && !value.includes('\0')
|
||||
&& value.split('/').every(part => part !== '' && part !== '.' && part !== '..'), 'invalid payload path')
|
||||
|
||||
/** Parser for metadata supplied beside an executable, including every runtime resource. */
|
||||
export const artifactManifestSchema = z.object({
|
||||
format: z.literal(1),
|
||||
version: z.string().regex(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/),
|
||||
commit: z.string().regex(/^[a-f0-9]{40}$/),
|
||||
dirty: z.boolean(),
|
||||
target: z.enum(SSH_HELPER_TARGETS),
|
||||
protocol: z.number().int().positive(),
|
||||
nodeVersion: z.string().regex(/^v24\./),
|
||||
files: z.array(z.object({
|
||||
path: payloadPath, sha256: z.string().regex(/^[a-f0-9]{64}$/), mode: z.number().int().min(0).max(0o777),
|
||||
}).strict()).min(1),
|
||||
}).strict()
|
||||
|
||||
/** Results written by the isolated Loader consumer after its assertions succeed. */
|
||||
export const runtimeReportSchema = z.object({
|
||||
checks: z.array(z.string()), platform: z.enum(['linux', 'darwin']), nodeVersion: z.string(),
|
||||
sandboxLane: z.enum(['required', 'unavailable', 'portable']),
|
||||
}).strict()
|
||||
|
||||
/** Parsed release metadata; files excludes manifest.json to avoid a self-referential digest. */
|
||||
export type ArtifactManifest = z.infer<typeof artifactManifestSchema>
|
||||
|
||||
/**
|
||||
* Enumerate an owned payload without following links outside its directory.
|
||||
* @param root - unpacked payload directory.
|
||||
* @returns sorted POSIX relative file paths, excluding manifest.json.
|
||||
*/
|
||||
export async function payloadFiles(root: string): Promise<string[]> {
|
||||
const paths: string[] = []
|
||||
const visit = async (directory: string): Promise<void> => {
|
||||
for (const name of await readdir(directory)) {
|
||||
const path = join(directory, name)
|
||||
const metadata = await lstat(path)
|
||||
if (metadata.isSymbolicLink()) throw new Error(`SSH helper payload contains a symbolic link: ${path}`)
|
||||
if (metadata.isDirectory()) await visit(path)
|
||||
else if (metadata.isFile()) paths.push(relative(root, path).split(sep).join('/'))
|
||||
else throw new Error(`SSH helper payload contains a non-regular file: ${path}`)
|
||||
}
|
||||
}
|
||||
await visit(root)
|
||||
return paths.filter(path => path !== 'manifest.json').sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash the exact file bytes used for installation or publication.
|
||||
* @param path - regular file path.
|
||||
* @returns lowercase SHA-256.
|
||||
*/
|
||||
export async function fileDigest(path: string): Promise<string> {
|
||||
return createHash('sha256').update(await readFile(path)).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify all payload bytes and permissions before running an unpacked release.
|
||||
* @param directory - unpacked payload root.
|
||||
* @returns the validated metadata; missing, duplicate, extra, or altered files reject.
|
||||
*/
|
||||
export async function verifyPayload(directory: string): Promise<ArtifactManifest> {
|
||||
const manifest = artifactManifestSchema.parse(JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')))
|
||||
const actual = await payloadFiles(directory)
|
||||
const declared = manifest.files.map(file => file.path).sort()
|
||||
if (new Set(declared).size !== declared.length || JSON.stringify(actual) !== JSON.stringify(declared)) {
|
||||
throw new Error('SSH helper payload file inventory differs from manifest.json')
|
||||
}
|
||||
if (!declared.includes('dsh-ssh-helper') || !declared.includes('native/system/package.json')) throw new Error('SSH helper payload omits required runtime files')
|
||||
for (const file of manifest.files) {
|
||||
const path = join(directory, file.path)
|
||||
if (await fileDigest(path) !== file.sha256) throw new Error(`SSH helper payload digest differs: ${file.path}`)
|
||||
if (((await lstat(path)).mode & 0o777) !== file.mode) throw new Error(`SSH helper payload permissions differ: ${file.path}`)
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** Inspect every native payload, including modules embedded in the executable. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { open } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { payloadFiles, type SshHelperTarget } from './artifact.ts'
|
||||
|
||||
function newer(version: string, ceiling: number[]): boolean {
|
||||
const components = version.split('.').map(Number)
|
||||
for (let index = 0; index < Math.max(components.length, ceiling.length); index += 1) {
|
||||
const difference = (components[index] ?? 0) - (ceiling[index] ?? 0)
|
||||
if (difference !== 0) return difference > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Read deployment versions without confusing linked-library versions with OS requirements.
|
||||
* @param output - otool load-command text, possibly containing multiple architecture slices.
|
||||
* @returns every minimum macOS version declared by the binary.
|
||||
*/
|
||||
export function macosDeploymentVersions(output: string): string[] {
|
||||
let command: string | undefined
|
||||
const versions: string[] = []
|
||||
for (const line of output.split('\n')) {
|
||||
const text = line.trim()
|
||||
if (/^Load command \d+$/.test(text)) command = undefined
|
||||
else if (text.startsWith('cmd ')) command = text.slice(4)
|
||||
else {
|
||||
const match = /^(minos|version)\s+(\d+(?:\.\d+)+)$/.exec(text)
|
||||
if (match !== null && ((command === 'LC_BUILD_VERSION' && match[1] === 'minos') || (command === 'LC_VERSION_MIN_MACOSX' && match[1] === 'version'))) versions.push(match[2] as string)
|
||||
}
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect all ELF or Mach-O files for the requested architecture and deployment baseline.
|
||||
* @param directory - symlink-free staging or payload directory.
|
||||
* @param target - build target whose ABI must match every native file.
|
||||
* @returns relative paths of inspected native files.
|
||||
*/
|
||||
export async function verifyNativePayload(directory: string, target: SshHelperTarget): Promise<string[]> {
|
||||
const inspected: string[] = []
|
||||
const linux = target.includes('-linux-')
|
||||
const arch = target.endsWith('-arm64') ? 'arm64' : 'x64'
|
||||
for (const path of await payloadFiles(directory)) {
|
||||
const filename = join(directory, path)
|
||||
const file = await open(filename, 'r')
|
||||
const header = Buffer.alloc(4)
|
||||
try { await file.read(header, 0, 4, 0) } finally { await file.close() }
|
||||
const magic = header.readUInt32BE()
|
||||
const elf = magic === 0x7f454c46
|
||||
const macho = [0xcffaedfe, 0xcefaedfe, 0xfeedfacf, 0xfeedface, 0xcafebabe, 0xbebafeca, 0xcafebabf].includes(magic)
|
||||
if (!elf && !macho) continue
|
||||
if (linux !== elf) throw new Error(`Native file does not belong to ${target}: ${path}`)
|
||||
if (elf) {
|
||||
const elfHeader = execFileSync('readelf', ['--file-header', filename], { encoding: 'utf8' })
|
||||
const expected = arch === 'arm64' ? /Machine:\s+AArch64/ : /Machine:\s+Advanced Micro Devices X86-64/
|
||||
if (!expected.test(elfHeader)) throw new Error(`ELF architecture differs from ${target}: ${path}`)
|
||||
const versions = execFileSync('readelf', ['--version-info', filename], { encoding: 'utf8' })
|
||||
for (const match of versions.matchAll(/\bGLIBC_(\d+(?:\.\d+)+)/g)) {
|
||||
if (newer(match[1] as string, [2, 28])) throw new Error(`${path} requires GLIBC_${match[1]}, exceeding 2.28`)
|
||||
}
|
||||
} else {
|
||||
execFileSync('lipo', [filename, '-verify_arch', arch === 'arm64' ? 'arm64' : 'x86_64'], { stdio: 'pipe' })
|
||||
const loads = execFileSync('otool', ['-l', filename], { encoding: 'utf8' })
|
||||
const versions = macosDeploymentVersions(loads)
|
||||
if (versions.length === 0) throw new Error(`Mach-O has no deployment version: ${path}`)
|
||||
if (versions.some(version => newer(version, [14, 0]))) throw new Error(`${path} requires macOS newer than 14.0`)
|
||||
}
|
||||
inspected.push(path)
|
||||
}
|
||||
return inspected
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Short handshake against an actual executable, with bounded process ownership. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { z } from 'zod'
|
||||
import { SshRpcPeer, SSH_PROTOCOL_VERSION } from '../../packages/ssh/ssh/src/protocol.ts'
|
||||
import { helloSchema } from '../../packages/ssh/ssh/src/schemas.ts'
|
||||
|
||||
/**
|
||||
* Run a packaged helper and read its runtime version through the private wire protocol.
|
||||
* @param executable - physical executable under test.
|
||||
* @param workspace - existing directory supplied for the handshake.
|
||||
* @returns the validated handshake after the helper has exited.
|
||||
*/
|
||||
export async function probeHelper(executable: string, workspace: string): Promise<z.infer<typeof helloSchema>> {
|
||||
const child = spawn(executable, [], { cwd: workspace, env: { ...process.env, NODE_OPTIONS: '--disable-sigusr1' }, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
let diagnostic = ''
|
||||
child.stderr.on('data', (chunk: Buffer) => { diagnostic = (diagnostic + chunk.toString()).slice(-8192) })
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0 && signal === null) resolve()
|
||||
else reject(new Error(`SSH helper probe exited (${code ?? signal}): ${diagnostic}`))
|
||||
})
|
||||
})
|
||||
void closed.catch(() => {})
|
||||
const rpc = new SshRpcPeer(child.stdout, child.stdin, 64 * 1024 * 1024, 128)
|
||||
child.once('error', (error) => { rpc.close(error) })
|
||||
const force = setTimeout(() => { child.kill('SIGKILL') }, 30_000)
|
||||
try {
|
||||
const hello = await rpc.request('hello', { protocol: SSH_PROTOCOL_VERSION, workspace, leaseMs: 30_000 }, helloSchema, AbortSignal.timeout(20_000))
|
||||
await rpc.request('close', {}, z.null(), AbortSignal.timeout(5000))
|
||||
child.stdin.end()
|
||||
await closed
|
||||
return hello
|
||||
} catch (error) {
|
||||
child.kill('SIGTERM')
|
||||
await closed.catch(() => {})
|
||||
throw new Error(`SSH helper handshake failed: ${diagnostic}`, { cause: error })
|
||||
} finally {
|
||||
clearTimeout(force)
|
||||
rpc.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** Require four complete, same-commit executable reports before publishing release assets. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { z } from 'zod'
|
||||
import { artifactManifestSchema, fileDigest, runtimeReportSchema, SSH_HELPER_TARGETS, verifyPayload, type ArtifactManifest } from './artifact.ts'
|
||||
import { extractHelper } from '../verify-ssh-helper-artifact.ts'
|
||||
|
||||
const reportSchema = z.object({
|
||||
manifest: artifactManifestSchema,
|
||||
runtime: runtimeReportSchema,
|
||||
restrictedWorker: z.boolean().optional(), expectedBackend: z.string().nullable().optional(), glibc: z.string().optional(),
|
||||
readonlyInstall: z.boolean().optional(), concurrentColdStarts: z.number().optional(),
|
||||
}).strict()
|
||||
const coreChecks = ['executable-handshake', 'guarded-files-and-streams', 'managed-process-output', 'process-cancellation', 'native-pty', 'native-flock', 'embedded-ptc-and-deadline']
|
||||
|
||||
async function report(path: string, manifest: ArtifactManifest, checks: readonly string[]) {
|
||||
const evidence = reportSchema.parse(JSON.parse(await readFile(path, 'utf8')))
|
||||
assert.deepEqual(evidence.manifest, manifest, `Report metadata differs: ${path}`)
|
||||
assert.equal(evidence.runtime.nodeVersion, manifest.nodeVersion)
|
||||
assert.equal(evidence.runtime.platform, manifest.target.includes('-linux-') ? 'linux' : 'darwin')
|
||||
for (const check of [...coreChecks, ...checks]) assert(evidence.runtime.checks.includes(check), `Report ${path} lacks ${check}`)
|
||||
return evidence
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate archives and their required native and SSH evidence, then write the release checksum list.
|
||||
* @param directory - downloaded artifacts from one successful four-target workflow run.
|
||||
* @param version - version selected by the release tag.
|
||||
* @param commit - immutable source commit checked out by every build job.
|
||||
* @returns the exact archive and checksum files permitted for upload.
|
||||
*/
|
||||
export async function prepareSshRelease(directory: string, version: string, commit: string): Promise<string[]> {
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'dsh-helper-release-'))
|
||||
const archives: string[] = []
|
||||
const sums: string[] = []
|
||||
try {
|
||||
for (const target of SSH_HELPER_TARGETS) {
|
||||
const archive = join(directory, `dsh-ssh-helper-${version}-${target.slice('node24-'.length)}.tar.gz`)
|
||||
const digest = await fileDigest(archive)
|
||||
assert.equal(digest, (await readFile(`${archive}.sha256`, 'utf8')).split(/\s/)[0])
|
||||
const destination = join(temporary, target)
|
||||
await mkdir(destination)
|
||||
const manifest = await verifyPayload(await extractHelper(archive, destination))
|
||||
assert.equal(manifest.target, target)
|
||||
assert.equal(manifest.version, version)
|
||||
assert.equal(manifest.commit, commit)
|
||||
assert.equal(manifest.dirty, false, 'Release artifacts must come from a clean source checkout')
|
||||
const native = await report(join(directory, `${target}.json`), manifest, ['sandbox-enforcement'])
|
||||
assert.equal(native.runtime.sandboxLane, 'required')
|
||||
assert.equal(native.readonlyInstall, true)
|
||||
assert.equal(native.concurrentColdStarts, 2)
|
||||
if (target.includes('-macos-')) assert.equal(native.restrictedWorker, true)
|
||||
else {
|
||||
const ssh = await report(join(directory, `${target}.ssh.json`), manifest, ['target-has-no-node'])
|
||||
assert.equal(ssh.glibc, 'glibc 2.28')
|
||||
const landlock = await report(join(directory, `${target}.landlock.json`), manifest, ['sandbox-enforcement'])
|
||||
assert.equal(landlock.expectedBackend, 'landlock-run')
|
||||
assert.equal(landlock.runtime.sandboxLane, 'required')
|
||||
}
|
||||
archives.push(archive)
|
||||
sums.push(`${digest} ${basename(archive)}`)
|
||||
}
|
||||
const checksums = join(directory, 'SHA256SUMS')
|
||||
await writeFile(checksums, `${sums.join('\n')}\n`)
|
||||
return [...archives, checksums]
|
||||
} finally { await rm(temporary, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const { values } = parseArgs({ options: { directory: { type: 'string' }, version: { type: 'string' }, commit: { type: 'string' } } })
|
||||
if (values.directory === undefined || values.version === undefined || values.commit === undefined) throw new Error('Release verification requires --directory, --version and --commit')
|
||||
console.log((await prepareSshRelease(resolve(values.directory), values.version, values.commit)).join('\n'))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Run the embedded worker while macOS denies access to the checkout and host Node. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { Duplex, type Readable, type Writable } from 'node:stream'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { JsonChannel } from '../../packages/ptc-runtime/ptc-runtime-node/src/channel.ts'
|
||||
import { decodePtcJsonWire } from '../../packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts'
|
||||
import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '../../packages/subprocess/subprocess/src/control.ts'
|
||||
|
||||
/**
|
||||
* Execute a PTC program with the host runtime and source files inaccessible.
|
||||
* @param executable - relocated helper executable.
|
||||
* @param workspace - isolated program working directory.
|
||||
* @param home - empty helper home and native-resource cache.
|
||||
* @param checkout - real source checkout path denied by Seatbelt.
|
||||
* @param hostNode - real host Node path denied by Seatbelt.
|
||||
*/
|
||||
export async function probeRestrictedWorker(
|
||||
executable: string, workspace: string, home: string, checkout: string, hostNode: string,
|
||||
): Promise<void> {
|
||||
const profile = `(version 1)(allow default)${[checkout, hostNode].map(path => `(deny file-read* (subpath ${JSON.stringify(path)}))(deny process-exec (subpath ${JSON.stringify(path)}))`).join('')}`
|
||||
const child = spawn('/usr/bin/sandbox-exec', ['-p', profile, executable, '1048576'], {
|
||||
cwd: workspace, env: { HOME: home, TMPDIR: home, PATH: '/usr/bin:/bin', DSH_PTC_RUNTIME_NODE: '1', [SUBPROCESS_CONTROL_ENV]: 'pipe', NODE_OPTIONS: '--disable-sigusr1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ignore', 'ignore', 'ignore', 'ignore', 'pipe'],
|
||||
})
|
||||
const completed = Promise.withResolvers<unknown>()
|
||||
let diagnostic = ''
|
||||
assert(child.stderr !== null && child.stdout !== null)
|
||||
child.stderr.on('data', (chunk: Buffer) => { diagnostic = (diagnostic + chunk.toString()).slice(-8192) })
|
||||
child.stdout.resume()
|
||||
const exited = new Promise<void>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0 && signal === null) resolve()
|
||||
else reject(new Error(`Restricted PTC worker exited (${code ?? signal}): ${diagnostic}`))
|
||||
})
|
||||
})
|
||||
void exited.catch((error: unknown) => { completed.reject(error) })
|
||||
const streams: ReadonlyArray<Readable | Writable | null | undefined> = child.stdio
|
||||
const control = streams[SUBPROCESS_CONTROL_FD]
|
||||
assert(control instanceof Duplex)
|
||||
const channel = new JsonChannel(control, 1_048_576, (raw) => {
|
||||
const message = z.object({ type: z.string(), value: z.unknown().optional(), error: z.unknown().optional() }).parse(raw)
|
||||
if (message.type === 'ready') {
|
||||
const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};`
|
||||
void channel.send({ type: 'boot', data: { code, namespaces: [], maxOutputBytes: 65_536 } })
|
||||
.catch((error: unknown) => { completed.reject(error) })
|
||||
} else if (message.type === 'done') {
|
||||
if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error)))
|
||||
else completed.resolve(decodePtcJsonWire(message.value))
|
||||
channel.close()
|
||||
} else completed.reject(new Error(`Unexpected restricted-worker message: ${message.type}`))
|
||||
}, (error) => { completed.reject(error) })
|
||||
const force = setTimeout(() => { child.kill('SIGKILL') }, 20_000)
|
||||
try {
|
||||
const result = z.object({ executable: z.string(), env: z.array(z.string()), denied: z.array(z.enum(['EPERM', 'EACCES'])) }).parse(await completed.promise)
|
||||
assert.equal(result.executable, executable)
|
||||
assert.deepEqual(result.env, [])
|
||||
assert.equal(result.denied.length, 2)
|
||||
await exited
|
||||
} finally {
|
||||
channel.close()
|
||||
child.kill('SIGTERM')
|
||||
try { await exited } finally { clearTimeout(force) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { isIP } from 'node:net'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { probeZonedLoopback } from './test-non-loopback-address.ts'
|
||||
|
||||
const binding = vi.hoisted(() => ({ refusal: undefined as Error | undefined }))
|
||||
|
||||
vi.mock('node:os', async importOriginal => ({
|
||||
...await importOriginal<typeof import('node:os')>(),
|
||||
networkInterfaces: vi.fn(),
|
||||
}))
|
||||
|
||||
// The regression concerns OS interface metadata, not the test host's IPv6 availability.
|
||||
vi.mock('node:http', () => ({
|
||||
createServer: () => {
|
||||
let reject: (error: Error) => void
|
||||
return {
|
||||
once(_event: string, listener: (error: Error) => void) { reject = listener },
|
||||
listen(_options: { host: string; port: number }, ready: () => void) {
|
||||
if (binding.refusal) reject(binding.refusal)
|
||||
else ready()
|
||||
},
|
||||
close(closed: () => void) { closed() },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
binding.refusal = undefined
|
||||
vi.mocked(networkInterfaces).mockReturnValue({
|
||||
'Loopback Pseudo-Interface 1': [{
|
||||
address: '::1',
|
||||
netmask: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff',
|
||||
family: 'IPv6',
|
||||
mac: '00:00:00:00:00:00',
|
||||
internal: true,
|
||||
scopeid: 0,
|
||||
cidr: '::1/128',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns a valid scoped literal when the interface display name contains spaces', async () => {
|
||||
const host = await probeZonedLoopback()
|
||||
expect(isIP(host ?? '')).toBe(6)
|
||||
expect(host).toContain('%')
|
||||
})
|
||||
|
||||
it('skips a scoped loopback whose listener refuses the bind', async () => {
|
||||
binding.refusal = new Error('address unavailable')
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
expect(await probeZonedLoopback()).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Address probes for concrete-bind integration cases. Interface membership
|
||||
* does not guarantee a usable address; hosts without one report why their
|
||||
* network coverage is skipped.
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { networkInterfaces } from 'node:os'
|
||||
|
||||
/**
|
||||
* First candidate whose listener opens and closes successfully.
|
||||
* @param candidates - local IP literals to probe.
|
||||
* @param description - address category reported when no candidate can bind.
|
||||
* @returns a bindable address, or undefined when the scenario must skip.
|
||||
*/
|
||||
async function probeBindableAddress(candidates: string[], description: string): Promise<string | undefined> {
|
||||
const refused: string[] = []
|
||||
for (const address of candidates) {
|
||||
const server = createServer()
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen({ host: address, port: 0 }, resolve)
|
||||
})
|
||||
} catch (error) {
|
||||
// A failed listen owns no socket to close.
|
||||
refused.push(`${address}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
continue
|
||||
}
|
||||
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
|
||||
return address
|
||||
}
|
||||
console.info(
|
||||
`${description} coverage skipped: no bindable ${description} address`
|
||||
+ ` (${candidates.length === 0 ? 'this host reports none' : `refused ${refused.join('; ')}`})`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* First local non-loopback IPv4 address that really accepts a listen.
|
||||
*
|
||||
* IPv4 only: supported CI hosts need not own a non-loopback IPv6 address.
|
||||
* @returns the bindable address, or undefined when the scenario must skip.
|
||||
*/
|
||||
export async function probeNonLoopbackIpv4(): Promise<string | undefined> {
|
||||
const candidates: string[] = []
|
||||
for (const entry of Object.values(networkInterfaces()).flat()) {
|
||||
if (entry !== undefined && entry.family === 'IPv4' && !entry.internal) candidates.push(entry.address)
|
||||
}
|
||||
return probeBindableAddress(candidates, 'non-loopback IPv4')
|
||||
}
|
||||
|
||||
/**
|
||||
* Bindable IPv6 loopback with the numeric scope reported by the OS.
|
||||
* Interface display names are not portable IPv6 zone identifiers.
|
||||
*
|
||||
* Loopback only: a link-local candidate is no substitute, because these
|
||||
* scenarios need the loopback address itself — their advertised root and Host
|
||||
* fence follow from it. A host that reports no bindable `::1` skips them.
|
||||
* @returns the zoned literal after closing its probe listener, or undefined when none can bind.
|
||||
*/
|
||||
export async function probeZonedLoopback(): Promise<string | undefined> {
|
||||
const candidates: string[] = []
|
||||
for (const entry of Object.values(networkInterfaces()).flat()) {
|
||||
if (entry !== undefined && entry.family === 'IPv6' && entry.address === '::1') {
|
||||
candidates.push(`::1%${entry.scopeid}`)
|
||||
}
|
||||
}
|
||||
return probeBindableAddress(candidates, 'zoned IPv6 loopback')
|
||||
}
|
||||
@@ -1916,6 +1916,11 @@
|
||||
"symbol": "Config",
|
||||
"source": "packages/host/webserver/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/web-server.md",
|
||||
"symbol": "TlsConfig",
|
||||
"source": "packages/host/webserver/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/storage.md",
|
||||
"symbol": "StorageForms",
|
||||
@@ -2306,6 +2311,11 @@
|
||||
"symbol": "PtcRunSandbox",
|
||||
"source": "packages/ptc-runtime/ptc-runtime/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/ssh.md",
|
||||
"symbol": "HelperLaunch",
|
||||
"source": "packages/ssh/ssh/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/ssh.md",
|
||||
"symbol": "Config",
|
||||
|
||||
@@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/host/product-telemetry-otel': { kind: 'none', reason: 'The plugin exports explicit analytics records without contributing model context.' },
|
||||
'packages/api/terminal-controller': { kind: 'indirect', reason: 'Working-directory recovery owns directory context; user terminal screens never enter model requests.' },
|
||||
'packages/client/ui-sidebar-terminal': { kind: 'none', reason: 'The browser renders user terminal screens without exposing them to the model.' },
|
||||
'packages/ssh/ssh-helper-runtime': { kind: 'none', reason: 'The private executable carrier registers no model-facing content; its providers and consumers own operation results.' },
|
||||
'packages/experimental/worktree': { kind: 'indirect', reason: 'The worktree tool renders creation results; the working-directory service owns model-visible directory context.' },
|
||||
'packages/ssh/ssh': { kind: 'none', reason: 'The connection owner transports private provider operations; consumers own all model-facing content.' },
|
||||
'packages/ssh/fs-ssh': { kind: 'indirect', reason: 'The remote filesystem delegates model rendering to the existing filesystem consumers.' },
|
||||
|
||||
@@ -243,14 +243,6 @@ describe('product package use', () => {
|
||||
expect(failures(root, declarations())).toContain(`${candidate} must declare dsh.bundle.patch`)
|
||||
})
|
||||
|
||||
it('rejects an on-demand contribution whose preset target is missing', () => {
|
||||
const root = fixture()
|
||||
pkg(root, candidateDir, candidate, { dsh: { bundle: { patch: './cordis.patch.yml' } } })
|
||||
write(root, `${candidateDir}/cordis.patch.yml`, [{ preset: 'missing-preset', insert: [{ id: 'optional', name: core }] }])
|
||||
profiles(root, [], [candidate])
|
||||
expect(() => verifyProductUse(root, {})).toThrow(/missing-preset/)
|
||||
})
|
||||
|
||||
it('rejects a missing workspace source mapping instead of consulting built exports', () => {
|
||||
const root = fixture()
|
||||
pkg(root, candidateDir, candidate)
|
||||
|
||||
@@ -39,6 +39,16 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('verifyRuntimeClosure', () => {
|
||||
it('checks a private worker closure without requiring application presets', async () => {
|
||||
const manifestPath = 'packages/ssh/ssh-helper-runtime/package.json'
|
||||
const root = fixture({ [manifestPath]: { name: 'helper', dependencies: { '@scope/worker': 'workspace:^' } } })
|
||||
workspace(root, '@scope/worker', { peerDependencies: { '@scope/peer': 'workspace:^' } })
|
||||
workspace(root, '@scope/peer', {})
|
||||
expect((await verifyRuntimeClosure(root, manifestPath, false)).failures).toEqual(['helper -> @scope/worker -> @scope/peer'])
|
||||
writeFileSync(join(root, manifestPath), JSON.stringify({ name: 'helper', dependencies: { '@scope/worker': 'workspace:^', '@scope/peer': 'workspace:^' } }))
|
||||
expect(await verifyRuntimeClosure(root, manifestPath, false)).toMatchObject({ failures: [], presetCount: 0 })
|
||||
})
|
||||
|
||||
it('requires only plugins active for each published target', async () => {
|
||||
const root = fixture({
|
||||
'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } },
|
||||
|
||||
@@ -42,18 +42,20 @@ export interface RuntimeClosureResult {
|
||||
* Check that the runtime manifest contains every shipped-preset plugin and workspace peer.
|
||||
* @param root repository root containing the runtime manifest and shipped presets.
|
||||
* @param manifestPath runtime manifest path relative to {@link root}.
|
||||
* @param includePresets whether this carrier includes the shipped application presets.
|
||||
* @returns the discovered preset count, reachable workspace package count, and violations.
|
||||
*/
|
||||
export async function verifyRuntimeClosure(
|
||||
root: string,
|
||||
manifestPath = 'python/sdk-runtime/package.json',
|
||||
includePresets = true,
|
||||
): Promise<RuntimeClosureResult> {
|
||||
const runtimeManifest = await loadManifest(resolve(root, manifestPath))
|
||||
const runtimeName = runtimeManifest.name ?? manifestPath
|
||||
const workspace = await loadWorkspacePackages(root)
|
||||
const runtimeDependencies = runtimeManifest.dependencies ?? {}
|
||||
const platforms = await loadJson<RuntimePlatformManifest>(resolve(root, 'python/sdk-runtime/platforms.json'))
|
||||
const presetPaths = globSync(AGENT_PRESET_GLOB, { cwd: root }).sort()
|
||||
const platforms = includePresets ? await loadJson<RuntimePlatformManifest>(resolve(root, 'python/sdk-runtime/platforms.json')) : {}
|
||||
const presetPaths = includePresets ? globSync(AGENT_PRESET_GLOB, { cwd: root }).sort() : []
|
||||
const targets = Object.keys(platforms).sort()
|
||||
const parents = new Map<string, string | undefined>()
|
||||
const queue: string[] = []
|
||||
@@ -65,8 +67,8 @@ export async function verifyRuntimeClosure(
|
||||
}
|
||||
|
||||
const failures: string[] = []
|
||||
if (presetPaths.length === 0) failures.push(`no agent presets matched ${AGENT_PRESET_GLOB}`)
|
||||
if (targets.length === 0) failures.push('python/sdk-runtime/platforms.json defines no runtime targets')
|
||||
if (includePresets && presetPaths.length === 0) failures.push(`no agent presets matched ${AGENT_PRESET_GLOB}`)
|
||||
if (includePresets && targets.length === 0) failures.push('python/sdk-runtime/platforms.json defines no runtime targets')
|
||||
failures.push(...await missingPresetPlugins(root, runtimeDependencies, presetPaths, targets))
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const packageName = queue[index]
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/** Unpack and run a release archive through the actual Loader-mounted SSH consumers. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { extract, list } from 'tar'
|
||||
import { fileDigest, runtimeReportSchema, verifyPayload, type ArtifactManifest } from './ssh-helper/artifact.ts'
|
||||
import { probeRestrictedWorker } from './ssh-helper/worker-probe.ts'
|
||||
import { verifyNativePayload } from './ssh-helper/native.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const fixtures = join(root, 'packages/ssh/ssh-helper-runtime/tests/fixtures')
|
||||
|
||||
async function directoryPermissions(directory: string, mode: number): Promise<void> {
|
||||
await chmod(directory, mode)
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) await directoryPermissions(join(directory, entry.name), mode)
|
||||
}
|
||||
}
|
||||
|
||||
async function makeReadonly(directory: string, manifest: ArtifactManifest): Promise<void> {
|
||||
for (const file of manifest.files) await chmod(join(directory, file.path), file.mode & ~0o222)
|
||||
await chmod(join(directory, 'manifest.json'), 0o444)
|
||||
await directoryPermissions(directory, 0o555)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject links and escaping paths before extracting a helper's release archive.
|
||||
* @param archive - gzip tar file supplied by the build.
|
||||
* @param directory - empty extraction directory owned by the caller.
|
||||
* @returns the extracted payload root.
|
||||
*/
|
||||
export async function extractHelper(archive: string, directory: string): Promise<string> {
|
||||
const entries: { path: string; type: string }[] = []
|
||||
await list({ file: archive, onReadEntry: (entry) => { entries.push({ path: entry.path, type: entry.type }) } })
|
||||
const seen = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
const path = entry.path.replace(/\/$/, '')
|
||||
if (!['File', 'Directory'].includes(entry.type) || path.split('/').some(part => part === '..' || part === '.' || part === '')
|
||||
|| path.includes('\\') || !path.startsWith('dsh-ssh-helper') || (path !== 'dsh-ssh-helper' && !path.startsWith('dsh-ssh-helper/')) || seen.has(path)) {
|
||||
throw new Error(`Invalid SSH helper archive entry: ${entry.path} (${entry.type})`)
|
||||
}
|
||||
seen.add(path)
|
||||
}
|
||||
if (!seen.has('dsh-ssh-helper/manifest.json')) throw new Error('SSH helper archive has no manifest.json')
|
||||
await extract({ file: archive, cwd: directory, strict: true })
|
||||
return join(directory, 'dsh-ssh-helper')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the isolated consumer process and await all output and process teardown.
|
||||
* @param config - generated Loader configuration.
|
||||
* @param report - output report path.
|
||||
* @param sandbox - expected confinement availability on this lane.
|
||||
* @param environment - isolated client environment, including any test-owned SSH wrapper.
|
||||
*/
|
||||
export async function runArtifactDriver(config: string, report: string, sandbox: string, environment: NodeJS.ProcessEnv): Promise<void> {
|
||||
const child = spawn(process.execPath, ['--import', 'tsx/esm', join(fixtures, 'driver.ts'), config, report, sandbox], {
|
||||
cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: environment,
|
||||
})
|
||||
let output = ''
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', (chunk: Buffer) => {
|
||||
output = (output + chunk.toString()).slice(-32_768)
|
||||
})
|
||||
const force = setTimeout(() => { child.kill('SIGKILL') }, 120_000)
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0 && signal === null) resolve()
|
||||
else reject(new Error(`SSH artifact consumer exited (${code ?? signal}):\n${output}`))
|
||||
})
|
||||
})
|
||||
} finally { clearTimeout(force) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the shared provider composition around a selected helper transport.
|
||||
* @param connection - concrete Loader connection row, direct fixture or OpenSSH.
|
||||
* @param workspace - target-side workspace path.
|
||||
* @returns YAML consumed by the normal application boot path.
|
||||
*/
|
||||
export function artifactComposition(connection: { id: string; name: string; config: object }, workspace: string): string {
|
||||
const rows = [
|
||||
{ id: 'projections', name: '@deepseek-ai/dsh-session-projection' },
|
||||
{ id: 'policy', name: '@deepseek-ai/dsh-sandbox-policy', config: { mode: 'workspace-write', workspaceRoot: workspace } },
|
||||
connection,
|
||||
{ id: 'fs', name: '@deepseek-ai/dsh-fs-ssh' },
|
||||
{ id: 'subprocess', name: '@deepseek-ai/dsh-subprocess-ssh' },
|
||||
{ id: 'sandbox', name: '@deepseek-ai/dsh-sandbox-ssh' },
|
||||
]
|
||||
return rows.map(row => `- ${JSON.stringify(row)}`).join('\n')
|
||||
+ '\n- id: ptc\n name: "@deepseek-ai/dsh-ptc-runtime-node"\n inject: [ssh]\n config:\n launch: !!js ctx.ssh.ptcLaunch\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify archive integrity and all core operations after relocation outside the checkout.
|
||||
* @param argv - verifier flags.
|
||||
*/
|
||||
export async function verifySshHelper(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({ args: argv, options: {
|
||||
archive: { type: 'string' }, sandbox: { type: 'string', default: 'required' },
|
||||
report: { type: 'string' },
|
||||
backend: { type: 'string' },
|
||||
} })
|
||||
if (values.archive === undefined || !['required', 'unavailable'].includes(values.sandbox)) throw new Error('Use --archive=<file> --sandbox=required|unavailable')
|
||||
if (values.backend !== undefined && !['bwrap', 'landlock-run', 'sandbox-exec'].includes(values.backend)) throw new Error('Unknown expected sandbox backend')
|
||||
if (values.backend === 'landlock-run' && process.platform !== 'linux') throw new Error('Landlock verification requires Linux')
|
||||
const archive = resolve(values.archive)
|
||||
const checksum = (await readFile(`${archive}.sha256`, 'utf8')).split(/\s/)[0]
|
||||
if (await fileDigest(archive) !== checksum) throw new Error('SSH helper archive checksum mismatch')
|
||||
const temporary = await realpath(await mkdtemp(join(homedir(), '.dsh-helper-check-')))
|
||||
try {
|
||||
const installed = await extractHelper(archive, temporary)
|
||||
const manifest = await verifyPayload(installed)
|
||||
const host = `node24-${process.platform === 'darwin' ? 'macos' : process.platform}-${process.arch}`
|
||||
if (manifest.target !== host) throw new Error(`Artifact ${manifest.target} cannot be validated on ${host}`)
|
||||
await verifyNativePayload(installed, manifest.target)
|
||||
await makeReadonly(installed, manifest)
|
||||
const workspace = join(temporary, 'workspace')
|
||||
const home = join(temporary, 'home')
|
||||
await mkdir(workspace); await mkdir(home)
|
||||
let path = '/usr/bin:/bin'
|
||||
if (values.backend === 'landlock-run') {
|
||||
const bin = join(temporary, 'bin')
|
||||
await mkdir(bin)
|
||||
await writeFile(join(bin, 'bwrap'), '#!/bin/sh\nexit 127\n')
|
||||
await chmod(join(bin, 'bwrap'), 0o755)
|
||||
path = `${bin}:${path}`
|
||||
}
|
||||
const executable = join(installed, 'dsh-ssh-helper')
|
||||
const helperHash = await fileDigest(executable)
|
||||
const environment = {
|
||||
HOME: home, PATH: process.env.PATH, DSH_HOME: join(home, 'dsh'),
|
||||
TSX_TSCONFIG_PATH: join(root, 'tsconfig.base.json'), DSH_TELEMETRY_DISABLED: '1',
|
||||
...(values.backend === undefined ? {} : { DSH_EXPECT_SANDBOX_RUNNER: values.backend }),
|
||||
}
|
||||
const runs = await Promise.allSettled([0, 1].map(async (index) => {
|
||||
const cwd = join(workspace, String(index))
|
||||
await mkdir(cwd)
|
||||
const config = join(temporary, `cordis-${index}.yml`)
|
||||
const report = join(temporary, `report-${index}.json`)
|
||||
await writeFile(config, artifactComposition({ id: 'ssh', name: join(fixtures, 'direct-helper.ts'), config: {
|
||||
executable, helperHash, workspace: cwd, home, path,
|
||||
} }, cwd))
|
||||
await runArtifactDriver(config, report, values.sandbox, environment)
|
||||
}))
|
||||
const failures = runs.filter(result => result.status === 'rejected').map((result): unknown => result.reason)
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'Concurrent SSH artifact consumers failed')
|
||||
if (process.platform === 'darwin') await probeRestrictedWorker(executable, workspace, home, await realpath(root), await realpath(process.execPath))
|
||||
const evidence = { manifest, runtime: runtimeReportSchema.parse(JSON.parse(await readFile(join(temporary, 'report-0.json'), 'utf8'))),
|
||||
restrictedWorker: process.platform === 'darwin', expectedBackend: values.backend ?? null,
|
||||
readonlyInstall: true, concurrentColdStarts: 2 }
|
||||
if (values.report !== undefined) {
|
||||
const destination = resolve(values.report)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await writeFile(destination, `${JSON.stringify(evidence, null, 2)}\n`)
|
||||
}
|
||||
console.log(`SSH helper ${manifest.target}: ${JSON.stringify(evidence.runtime)}`)
|
||||
} finally {
|
||||
await directoryPermissions(temporary, 0o755)
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await verifySshHelper(process.argv.slice(2))
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Real OpenSSH acceptance against a glibc 2.28 container with no installed Node. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { parseArgs, promisify } from 'node:util'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { fileDigest, runtimeReportSchema, verifyPayload } from './ssh-helper/artifact.ts'
|
||||
import { artifactComposition, extractHelper, runArtifactDriver } from './verify-ssh-helper-artifact.ts'
|
||||
|
||||
const run = promisify(execFile)
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
async function docker(...args: string[]): Promise<string> {
|
||||
return (await run('docker', args, { timeout: 300_000, maxBuffer: 16 * 1024 * 1024 })).stdout.trim()
|
||||
}
|
||||
|
||||
async function sshdReady(container: string): Promise<void> {
|
||||
const logs = spawn('docker', ['logs', '--follow', container], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let text = ''
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const closed = new Promise<void>((resolve) => { logs.once('close', () => { resolve(); ready.reject(new Error(`sshd exited before readiness: ${text}`)) }) })
|
||||
const read = (chunk: Buffer): void => {
|
||||
text = (text + chunk.toString()).slice(-8192)
|
||||
if (text.includes('Server listening on')) ready.resolve()
|
||||
}
|
||||
logs.stdout.on('data', read); logs.stderr.on('data', read)
|
||||
logs.once('error', (error) => { ready.reject(error) })
|
||||
const deadline = setTimeout(() => { ready.reject(new Error(`sshd readiness timed out: ${text}`)) }, 30_000)
|
||||
try { await ready.promise }
|
||||
finally { clearTimeout(deadline); logs.kill('SIGTERM'); await closed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Install only a validated release into an isolated SSH server and exercise production providers.
|
||||
* @param argv - archive and optional report flags.
|
||||
*/
|
||||
export async function verifySshTransport(argv: string[]): Promise<void> {
|
||||
const { values } = parseArgs({ args: argv, options: { archive: { type: 'string' }, report: { type: 'string' } } })
|
||||
if (values.archive === undefined) throw new Error('Use --archive=<Linux helper archive>')
|
||||
const archive = resolve(values.archive)
|
||||
assert.equal(await fileDigest(archive), (await readFile(`${archive}.sha256`, 'utf8')).split(/\s/)[0], 'Archive checksum')
|
||||
const temporary = await mkdtemp(join(tmpdir(), 'dsh-helper-ssh-'))
|
||||
const image = `dsh-helper-test:${randomUUID()}`
|
||||
let imageBuilt = false
|
||||
let container: string | undefined
|
||||
try {
|
||||
const installed = await extractHelper(archive, temporary)
|
||||
const manifest = await verifyPayload(installed)
|
||||
if (!manifest.target.includes('-linux-')) throw new Error('OpenSSH container acceptance requires a Linux artifact')
|
||||
const platform = manifest.target.endsWith('-arm64') ? 'linux/arm64' : 'linux/amd64'
|
||||
await docker('build', '--platform', platform, '--tag', image, join(root, 'packages/ssh/ssh-helper-runtime/tests/fixtures/sshd'))
|
||||
imageBuilt = true
|
||||
const identity = join(temporary, 'identity')
|
||||
await run('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', identity])
|
||||
container = await docker('run', '--detach', '--platform', platform, '--publish', '127.0.0.1::22',
|
||||
'--mount', `type=bind,src=${installed},dst=/opt/dsh-helper,readonly`,
|
||||
'--mount', `type=bind,src=${identity}.pub,dst=/fixture-key.pub,readonly`, image)
|
||||
await sshdReady(container)
|
||||
const published = await docker('port', container, '22/tcp')
|
||||
const port = /^127\.0\.0\.1:(\d+)$/.exec(published)?.[1]
|
||||
if (port === undefined) throw new Error(`Unexpected Docker port mapping: ${published}`)
|
||||
const publicKey = await docker('exec', container, 'cat', '/etc/ssh/ssh_host_ed25519_key.pub')
|
||||
const knownHosts = join(temporary, 'known_hosts')
|
||||
await writeFile(knownHosts, `[127.0.0.1]:${port} ${publicKey}\n`, { mode: 0o600 })
|
||||
const sshConfig = join(temporary, 'ssh_config')
|
||||
await writeFile(sshConfig, `Host artifact\n HostName 127.0.0.1\n Port ${port}\n User dsh\n IdentityFile "${identity}"\n IdentitiesOnly yes\n UserKnownHostsFile "${knownHosts}"\n GlobalKnownHostsFile /dev/null\n`, { mode: 0o600 })
|
||||
const bin = join(temporary, 'bin')
|
||||
await mkdir(bin)
|
||||
const quote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`
|
||||
await writeFile(join(bin, 'ssh'), `#!/bin/sh\nexec /usr/bin/ssh -F ${quote(sshConfig)} "$@"\n`)
|
||||
await chmod(join(bin, 'ssh'), 0o755)
|
||||
const workspace = '/home/dsh/workspace'
|
||||
const config = join(temporary, 'cordis.yml')
|
||||
await writeFile(config, artifactComposition({ id: 'ssh', name: '@deepseek-ai/dsh-ssh', config: {
|
||||
host: 'artifact', launch: { kind: 'executable' }, helper: '/opt/dsh-helper/dsh-ssh-helper',
|
||||
helperHash: await fileDigest(join(installed, 'dsh-ssh-helper')), workspace,
|
||||
} }, workspace))
|
||||
const home = join(temporary, 'client-home')
|
||||
await mkdir(home)
|
||||
const report = join(temporary, 'report.json')
|
||||
await runArtifactDriver(config, report, 'portable', {
|
||||
HOME: home, PATH: `${bin}:${process.env.PATH ?? '/usr/bin:/bin'}`, DSH_HOME: join(home, 'dsh'),
|
||||
TSX_TSCONFIG_PATH: join(root, 'tsconfig.base.json'), DSH_TELEMETRY_DISABLED: '1', DSH_VERIFY_NO_NODE: '1',
|
||||
})
|
||||
const evidence = { manifest, runtime: runtimeReportSchema.parse(JSON.parse(await readFile(report, 'utf8'))), glibc: await docker('exec', container, 'getconf', 'GNU_LIBC_VERSION') }
|
||||
assert.equal(evidence.glibc, 'glibc 2.28')
|
||||
if (values.report !== undefined) {
|
||||
const destination = resolve(values.report)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await writeFile(destination, `${JSON.stringify(evidence, null, 2)}\n`)
|
||||
}
|
||||
console.log(`SSH helper real SSH ${manifest.target}: ${JSON.stringify(evidence.runtime)}`)
|
||||
} catch (error) {
|
||||
if (container === undefined) throw error
|
||||
const logs = await run('docker', ['logs', container], { maxBuffer: 1024 * 1024 })
|
||||
throw new Error(`SSH container diagnostics:\n${logs.stdout}\n${logs.stderr}`, { cause: error })
|
||||
} finally {
|
||||
try { if (container !== undefined) await docker('rm', '--force', container) }
|
||||
finally {
|
||||
try { if (imageBuilt) await docker('image', 'rm', image) }
|
||||
finally { await rm(temporary, { recursive: true, force: true }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await verifySshTransport(process.argv.slice(2))
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { expect, it } from 'vitest'
|
||||
import { desktopTargetBuildPaths } from '../apps/desktop/scripts/desktop-build-paths.mjs'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows-package.yml'), 'utf8')) as {
|
||||
on: Record<string, { inputs: Record<string, { required?: boolean }> }>
|
||||
permissions: Record<string, string>
|
||||
concurrency: { group: string; 'cancel-in-progress': boolean }
|
||||
jobs: Record<string, {
|
||||
'runs-on': string
|
||||
steps: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
uses?: string
|
||||
run?: string
|
||||
if?: string
|
||||
env?: Record<string, string>
|
||||
with?: Record<string, unknown>
|
||||
}>
|
||||
}>
|
||||
}
|
||||
const job = workflow.jobs.package!
|
||||
|
||||
it('requires explicit manual dispatch on the dedicated Windows runner', () => {
|
||||
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
|
||||
expect(job['runs-on']).toBe('dsh-win-package-trial')
|
||||
expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false })
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
expect(workflow.on.workflow_dispatch!.inputs.policy_origin!.required).toBe(true)
|
||||
const checkout = job.steps.find(step => step.id === 'checkout')!
|
||||
expect(checkout.with).toMatchObject({ clean: true, 'persist-credentials': false })
|
||||
expect(job.steps.find(step => step.uses?.startsWith('pnpm/action-setup@'))?.with?.dest)
|
||||
.toBe('${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}')
|
||||
})
|
||||
|
||||
it('passes user input through environment variables and shares the validated version', () => {
|
||||
const settings = job.steps.find(step => step.id === 'settings')!
|
||||
expect(settings.env).toEqual({
|
||||
PACKAGE_BUILD_VERSION: '${{ inputs.build_version }}', PACKAGE_DEPLOYMENT: '${{ inputs.deployment }}',
|
||||
PACKAGE_POLICY_ORIGIN: '${{ inputs.policy_origin }}', PACKAGE_LOGIN_ORIGINS: '${{ inputs.login_origins }}',
|
||||
})
|
||||
const commands = job.steps.filter(step => step.run?.includes('pnpm run package:desktop:win:x64:unsigned'))
|
||||
expect(commands).toHaveLength(2)
|
||||
expect(commands[0]!.run).toContain('--check --build-version "$env:PACKAGE_VERSION"')
|
||||
expect(commands[1]!.run).toContain('unsigned --build-version "$env:PACKAGE_VERSION"')
|
||||
for (const step of commands) {
|
||||
expect(step.env?.PACKAGE_VERSION).toBe('${{ steps.settings.outputs.version }}')
|
||||
expect(step.run).toContain('exit $LASTEXITCODE')
|
||||
}
|
||||
for (const step of job.steps) expect(step.run ?? '').not.toContain('${{ inputs.')
|
||||
expect(job.steps.indexOf(settings)).toBeLessThan(job.steps.indexOf(commands[0]!))
|
||||
})
|
||||
|
||||
it('retains failure diagnostics and only uploads successful installers from the owned output directory', () => {
|
||||
const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))
|
||||
const logs = uploads.find(step => step.if === "always() && steps.checkout.outcome == 'success'")!
|
||||
expect(logs.with).toMatchObject({ path: 'apps/desktop/.desktop-build/packaging-runs/', 'include-hidden-files': true })
|
||||
const installer = uploads.find(step => step !== logs)!
|
||||
expect(installer.if).toBeUndefined()
|
||||
expect(installer.with).toMatchObject({
|
||||
path: `${relative(root, desktopTargetBuildPaths('win-x64').unsignedArtifacts).replaceAll('\\', '/')}/*.exe`,
|
||||
'include-hidden-files': true, 'if-no-files-found': 'error',
|
||||
})
|
||||
const cleanup = job.steps.find(step => step.run?.includes('Remove-Item -LiteralPath apps/desktop/.env.windows'))!
|
||||
expect(cleanup.if).toBe("always() && steps.checkout.outcome == 'success'")
|
||||
expect(cleanup.run).toContain('Test-Path -LiteralPath apps/desktop/.env.windows')
|
||||
})
|
||||
Reference in new issue
Block a user