Files
deepseek-harness/packages/boot/app-boot/src/probe.ts
T
Yichen Jiang a8f2e84946 fix(app-boot): keep the probe's stderr tail across reads of any size
The tail dropped a whole kept chunk whenever a later chunk pushed the total
over the limit, so a flood that arrived as one read (Linux) left only the
bytes written after it. `StreamTail` trims the oldest kept chunk instead and
is unit-tested with both read patterns.
2026-09-08 17:50:29 +08:00

476 lines
21 KiB
TypeScript

/**
* The install-time probe: what an installed package is and whether this
* harness can load it, answered in a child process so a package that throws,
* hangs, or brings its own copy of cordis never runs inside the host. The
* manifest facts (kind, rows, declared modules) are read here; the child
* (`probe-child.ts`) only imports and reports over IPC, and the report and
* the cached record are validated as the process and file boundaries they
* cross.
* @module @deepseek-ai/dsh-app-boot/probe
*/
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { withoutSensitiveEnv } from '@deepseek-ai/dsh-launch-environment'
import { loadOverlayPatches } from './index.ts'
import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts'
import { visitPatchRows } from './patch-rows.ts'
import { isRecord, parseChildReport, type ChildReport } from './probe-report.ts'
/** Directory under a profile holding one probe record per package. */
export const PLUGIN_PROBE_DIR = '.dsh-plugins'
/**
* The probe record format. Bumped when the probe's verdicts change meaning,
* so a record an older probe wrote is re-probed instead of trusted: 2 made a
* bare function export a `library` rather than a `plugin`.
*/
export const PLUGIN_PROBE_FORMAT = 2
/** One row a bundle's patch inserts, as its own patch declares it. */
export interface PluginProbeRow {
/** The row id the bundle declares, or undefined when the row leaves it to the Loader. */
readonly id?: string
/** The module specifier the row names. */
readonly name: string
/** Whether the row carries a `disabled` gate. */
readonly gated: boolean
}
/** One agent-plane module a package declares addable to a composition. */
export interface PluginProbeAddable {
/** The subpath or bare specifier, as declared in `dsh.plugins[].name`. */
readonly name: string
/** Display title, when declared. */
readonly title?: string
/** Default row config, when declared. */
readonly config?: unknown
/** Whether the child process imported the module and found a plugin. */
readonly ok: boolean
/** The import failure, when `ok` is false. */
readonly error?: string
/** The module's `Config` schema envelope, when it declares one. */
readonly configSchema?: unknown
}
/** What the probe learned about one installed package. */
export interface PluginProbe {
/** The package name. */
readonly packageName: string
/** The package version as installed. */
readonly version?: string
/** Free text from the manifest. */
readonly description?: string
/** Display title from `dsh.title`, when declared. */
readonly title?: string
/**
* `bundle` (declares `dsh.bundle`); `plugin` (declares itself to dsh — a
* `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main
* export is a cordis plugin); else `library`. A function export alone is
* not a plugin: `lodash` exports one too.
*/
readonly kind: 'bundle' | 'plugin' | 'library'
/**
* Whether the main export imported and cordis is not a second copy. True
* for a `library`, for a package whose cordis resolution is unknown, and
* for one whose addable modules failed: `kind` and `addable[].ok` decide
* what can be enabled or added.
*/
readonly ok: boolean
/** Why the import or the cordis check failed, when `ok` is false. */
readonly reason?: string
/** Whether the package resolves `@deepseek-ai/cordis` to the harness's own copy; null when unknown. */
readonly cordisSameCopy: boolean | null
/** The harness version range the package declares in `engines.dsh`. */
readonly enginesDsh?: string
/** Rows the bundle's patch introduces, inserted or set as a group's config, empty for a non-bundle. */
readonly rows: readonly PluginProbeRow[]
/** Ids of rows outside the bundle that its patch overrides. */
readonly overrides: readonly string[]
/** Agent-plane modules the package declares addable. */
readonly addable: readonly PluginProbeAddable[]
/** The main export's `Config` schema envelope, for a `plugin` kind. */
readonly configSchema?: unknown
/** ISO time of the probe. */
readonly checkedAt: string
}
/** Inputs of {@link probePackage}. */
export interface ProbeOptions {
/** The diagnostic prefix on thrown errors. */
binName: string
/** The profile directory the package is installed in. */
profileDir: string
/** Absolute path of the dsh app's package.json (first resolution anchor). */
installAnchor: string
/** The package to probe. */
packageName: string
/** Bound on the child process; default 20 seconds. */
timeoutMs?: number
/** The Node executable; defaults to the running one. */
nodeExecutable?: string
}
/** The manifest slice the probe reads beyond the profile launcher's. */
interface ProbedManifest extends ProfileManifest {
main?: string
exports?: unknown
engines?: Record<string, string>
}
/**
* The child entry beside this module: the TypeScript source under a source
* launch, run through tsx; the bundled `lib/probe-child.js` otherwise.
*/
function childEntryArgs(): string[] {
/* v8 ignore next 3 -- the built-output arm: tests run from src */
if (!import.meta.url.endsWith('.ts')) {
return [fileURLToPath(new URL('./probe-child.js', import.meta.url))]
}
return ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./probe-child.ts', import.meta.url))]
}
/**
* Run the child and take its report from the IPC channel. The report is all
* the probe needs, so the child is killed once it arrived: a package that
* keeps a timer alive after import costs nothing more. stdout is not read
* at all, so whatever the imported modules print cannot corrupt the report.
* The child gets the parent environment minus credential-shaped names, plus
* a per-run token it echoes in its report — a message without the token is
* the package's own, not the report — and the probe settles only once the
* child closed, so its pipes and channel are gone when the caller continues.
*/
function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
return new Promise((resolve, reject) => {
const token = randomUUID()
const child = spawn(
options.nodeExecutable ?? process.execPath,
['--experimental-import-meta-resolve', ...childEntryArgs(), packageDir, mainSpecifier, JSON.stringify(addable)],
{
cwd: options.profileDir,
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: { ...withoutSensitiveEnv(process.env), NODE_NO_WARNINGS: '1', [PROBE_REPORT_VARIABLE]: token },
},
)
// The tail of stderr, for the failure text: a package that floods stderr at
// import must not grow this process's heap by as much.
const err = new StreamTail(STDERR_TAIL_BYTES)
child.stderr?.on('data', (chunk: Buffer) => { err.push(chunk) })
// The outcome lands on `close`. A kill — after the report, or at the
// timeout — records its outcome and waits for the close it causes, so the
// child's pipes and channel are gone when the caller continues; a spawn
// failure emits `error` with no process to wait for.
let outcome: (() => void) | undefined
const finish = (next: () => void): void => {
/* v8 ignore next -- a report the kill still let through after the timeout decided, or the reverse: the first outcome stands */
if (outcome !== undefined) return
outcome = next
clearTimeout(timer)
child.kill('SIGKILL')
}
const timer = setTimeout(() => {
finish(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
}, timeoutMs)
child.on('message', (message) => {
const report = parseChildReport(message)
/* v8 ignore next -- the imported code finds no process.send; a message through the raw channel would still lack the token */
if (report === undefined || report.token !== token) return
finish(() => { resolve(report) })
})
child.on('error', (error) => {
clearTimeout(timer)
reject(error)
})
child.on('close', (code) => {
clearTimeout(timer)
if (outcome !== undefined) {
outcome()
return
}
const tail = err.text().trim()
reject(new Error(`${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${tail}`))
})
})
}
const DEFAULT_TIMEOUT_MS = 20_000
/** How much of the child's stderr the failure text keeps: the end, where the cause usually is. */
const STDERR_TAIL_BYTES = 16 * 1024
/**
* The last `limit` bytes a stream delivered, kept as the chunks it arrived in:
* as more arrives, whole chunks leave the front and the oldest kept chunk is
* trimmed, so one chunk larger than the limit keeps its end and a small chunk
* after it never drops what was kept.
*/
export class StreamTail {
private readonly chunks: Buffer[] = []
private bytes = 0
/** @param limit - how many bytes of the end to keep. */
constructor(private readonly limit: number) {}
/**
* Take one more chunk from the stream.
* @param chunk - the bytes as delivered.
*/
push(chunk: Buffer): void {
this.chunks.push(chunk)
this.bytes += chunk.length
while (this.bytes > this.limit) {
const excess = this.bytes - this.limit
const first = this.chunks[0] as Buffer
if (first.length <= excess) {
this.chunks.shift()
this.bytes -= first.length
} else {
this.chunks[0] = first.subarray(excess)
this.bytes = this.limit
}
}
}
/**
* The kept end of the stream.
* @returns the last bytes delivered, at most the limit, as UTF-8 text.
*/
text(): string {
return Buffer.concat(this.chunks).toString('utf8')
}
}
/** The environment name carrying the run's report token; the child removes it before importing anything. */
const PROBE_REPORT_VARIABLE = 'DSH_PROBE_REPORT'
/** The rows a bundle patch introduces, flattened from nested groups, with the ids of overrides on other rows. */
function describeBundlePatch(binName: string, patchPath: string): { rows: PluginProbeRow[]; overrides: string[] } {
const rows: PluginProbeRow[] = []
const own = new Set<string>()
const patches = loadOverlayPatches(binName, patchPath)
visitPatchRows(patches, (row) => {
if (typeof row.id === 'string') own.add(row.id)
rows.push({ ...typeof row.id === 'string' ? { id: row.id } : {}, name: row.name, gated: row.disabled !== undefined })
})
const overrides = patches
.filter(patch => patch.insert === undefined && typeof patch.id === 'string' && !own.has(patch.id))
.map(patch => patch.id as string)
return { rows, overrides }
}
/**
* Whether a package declares itself to dsh: a `dsh` section in its manifest,
* or a dependency on `@deepseek-ai/cordis`. A cordis plugin needs at least
* cordis's types, and the harness's plugin conventions put display and
* composition metadata under `dsh`; a package with neither that happens to
* export a function (`lodash`, `debug`) is a library.
* @param manifest - the package manifest.
* @returns true for a package that declares itself to dsh.
*/
function declaresDsh(manifest: ProbedManifest): boolean {
if (manifest.dsh !== undefined) return true
return '@deepseek-ai/cordis' in (manifest.peerDependencies ?? {}) || '@deepseek-ai/cordis' in (manifest.dependencies ?? {})
}
/**
* Probe one installed package: read its manifest, then import it in a child
* process. The child never runs inside the host, so a package that throws at
* import, exits, or hangs costs one child process and yields a `ok: false`
* record with the reason.
* @param options - where the package lives and how to run the child.
* @returns the probe record.
* @throws when the package is not resolvable from the profile, or the child produced no report.
*/
export async function probePackage(options: ProbeOptions): Promise<PluginProbe> {
const packageDir = resolveBundleDir(options.binName, options.packageName, options.installAnchor, options.profileDir)
const manifest = readProfileManifest(options.binName, packageDir) as ProbedManifest
const declaredBundle = manifest.dsh?.bundle?.patch
const declaredAddable = manifest.dsh?.plugins ?? []
const { rows, overrides } = declaredBundle === undefined
? { rows: [], overrides: [] }
: describeBundlePatch(options.binName, join(packageDir, declaredBundle))
const hasMain = manifest.main !== undefined || manifest.exports !== undefined
const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name))
const cordisSameCopy = sameCordisCopy(report.cordis, resolveHarnessCordis())
const kind: PluginProbe['kind'] = declaredBundle !== undefined
? 'bundle'
: report.main.isPlugin && declaresDsh(manifest) ? 'plugin' : 'library'
const addable: PluginProbeAddable[] = declaredAddable.map((entry) => {
// The child reports every declared name; a missing report is a child
// that did not run the loop, which the report parse already rejected.
/* v8 ignore next */
const inspected = report.addable[entry.name] ?? { ok: false, isPlugin: false, configSchema: null, error: 'no report' }
return {
name: entry.name,
...entry.title === undefined ? {} : { title: entry.title },
...entry.config === undefined ? {} : { config: entry.config },
ok: inspected.ok && inspected.isPlugin,
...inspected.error === undefined ? {} : { error: inspected.error },
...inspected.configSchema === null ? {} : { configSchema: inspected.configSchema },
}
})
let reason: string | undefined
// The child attaches the thrown value to every failed import; the fallback keeps the type total.
/* v8 ignore next */
if (hasMain && !report.main.ok) reason = `the package failed to import: ${report.main.error ?? 'unknown error'}`
else if (cordisSameCopy === false) reason = 'the package resolves its own copy of @deepseek-ai/cordis instead of the harness\'s; declare cordis as a peer dependency'
return {
packageName: options.packageName,
...manifest.version === undefined ? {} : { version: manifest.version },
...manifest.description === undefined ? {} : { description: manifest.description },
...manifest.dsh?.title === undefined ? {} : { title: manifest.dsh.title },
kind,
ok: reason === undefined,
...reason === undefined ? {} : { reason },
cordisSameCopy,
...manifest.engines?.dsh === undefined ? {} : { enginesDsh: manifest.engines.dsh },
rows,
overrides,
addable,
...report.main.configSchema === null || report.main.configSchema === undefined ? {} : { configSchema: report.main.configSchema },
checkedAt: new Date().toISOString(),
}
}
/**
* The package directory of the `@deepseek-ai/cordis` copy this harness runs:
* the one this module resolves, which every built-in plugin shares. Undefined
* only in an environment that cannot resolve it at all.
*/
function resolveHarnessCordis(): string | undefined {
try {
return cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis'))
} catch {
// Only an embedder without cordis on its module path lands here; the
// harness itself always resolves its own peer.
/* v8 ignore next */
return undefined
}
}
/**
* Whether the package's cordis is the harness's: the two package directories
* compare equal. Null when the package resolves no cordis, when the harness
* cannot resolve its own, or when no cordis manifest encloses what the
* package resolved, since none of those says the package brought a copy.
*/
function sameCordisCopy(reported: string | null, harness: string | undefined): boolean | null {
if (reported === null) return null
/* v8 ignore next -- the harness resolves its own peer; only an embedder without cordis lands here */
if (harness === undefined) return null
const dir = cordisPackageDir(reported)
return dir === undefined ? null : dir === harness
}
/** The manifest fields that tell a dsh module proxy from the package it stands for. */
interface ProxyAwareManifest {
name?: unknown
dsh?: { moduleFallback?: { targets?: Record<string, string> } }
}
/**
* The directory of the `@deepseek-ai/cordis` package a resolved module URL
* belongs to, with symlinks resolved and a dsh module proxy followed to the
* package it re-exports. Two resolutions of one installed package compare
* equal this way whether they landed on `src` or `lib`, on a link or its
* target, or on the proxy a packaged executable writes for its peers; the
* URLs themselves differ in every one of those cases.
* @param resolved - the URL `import.meta.resolve('@deepseek-ai/cordis')` gave.
* @returns the real package directory, or undefined when no cordis manifest encloses the URL.
*/
export function cordisPackageDir(resolved: string): string | undefined {
let dir = dirname(fileURLToPath(resolved))
for (;;) {
const manifestPath = join(dir, 'package.json')
if (existsSync(manifestPath)) {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ProxyAwareManifest
if (manifest.name === '@deepseek-ai/cordis') {
const target = Object.values(manifest.dsh?.moduleFallback?.targets ?? {})[0]
return target === undefined ? realpathSync(dir) : cordisPackageDir(target)
}
}
const parent = dirname(dir)
if (parent === dir) return undefined
dir = parent
}
}
/** The cache file for one package under a profile. */
function probeCachePath(profileDir: string, packageName: string): string {
return join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`)
}
/**
* Read one package's cached probe record.
* @param profileDir - the profile directory.
* @param packageName - the package.
* @param version - when given, a record for a different version is treated as absent.
* @returns the record, or undefined when none is cached, the cached one was written by another probe format, or it is not a record.
*/
export function readProbeCache(profileDir: string, packageName: string, version?: string): PluginProbe | undefined {
const path = probeCachePath(profileDir, packageName)
if (!existsSync(path)) return undefined
let stored: unknown
try {
stored = JSON.parse(readFileSync(path, 'utf8'))
} catch {
return undefined // not JSON: a truncated or hand-edited file is probed again
}
if (!isRecord(stored) || stored.format !== PLUGIN_PROBE_FORMAT) return undefined
const { format: _format, ...fields } = stored
const record = parseProbeRecord(fields)
if (record === undefined) return undefined
if (version !== undefined && record.version !== version) return undefined
return record
}
const PROBE_KINDS: ReadonlySet<string> = new Set<PluginProbe['kind']>(['bundle', 'plugin', 'library'])
/** Whether a value is absent or a string. */
function optionalString(value: unknown): boolean {
return value === undefined || typeof value === 'string'
}
/** Whether a value has the fields of one probed row. */
function isProbeRow(value: unknown): value is PluginProbeRow {
return isRecord(value) && optionalString(value.id) && typeof value.name === 'string' && typeof value.gated === 'boolean'
}
/** Whether a value has the fields of one addable module. */
function isProbeAddable(value: unknown): value is PluginProbeAddable {
return isRecord(value) && typeof value.name === 'string' && optionalString(value.title) && typeof value.ok === 'boolean' && optionalString(value.error)
}
/**
* Validate a stored probe record, as the cache file is a boundary this
* process does not control.
* @param value - the parsed file without its `format` field.
* @returns the record, or undefined when a field is missing or mistyped.
*/
export function parseProbeRecord(value: unknown): PluginProbe | undefined {
if (!isRecord(value)) return undefined
if (typeof value.packageName !== 'string' || typeof value.checkedAt !== 'string') return undefined
if (![value.version, value.description, value.title, value.reason, value.enginesDsh].every(optionalString)) return undefined
if (typeof value.kind !== 'string' || !PROBE_KINDS.has(value.kind) || typeof value.ok !== 'boolean') return undefined
if (value.cordisSameCopy !== null && typeof value.cordisSameCopy !== 'boolean') return undefined
if (!Array.isArray(value.rows) || !value.rows.every(isProbeRow)) return undefined
if (!Array.isArray(value.overrides) || !value.overrides.every(item => typeof item === 'string')) return undefined
if (!Array.isArray(value.addable) || !value.addable.every(isProbeAddable)) return undefined
return value as unknown as PluginProbe
}
/**
* Persist one package's probe record under the profile.
* @param profileDir - the profile directory.
* @param probe - the record to persist.
*/
export function writeProbeCache(profileDir: string, probe: PluginProbe): void {
const path = probeCachePath(profileDir, probe.packageName)
mkdirSync(join(profileDir, PLUGIN_PROBE_DIR), { recursive: true })
writeFileSync(path, JSON.stringify({ format: PLUGIN_PROBE_FORMAT, ...probe }, undefined, 2) + '\n')
}