Files
deepseek-harness/apps/desktop/src/host-process.ts
T

383 lines
18 KiB
TypeScript

/** Electron Node-mode child lifecycle for the shared Web application. */
import { spawn, type ChildProcess } from 'node:child_process'
import { join } from 'node:path'
import { z } from 'zod'
import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
import { desktopNodeEnvironment } from './node-environment.ts'
import { parsePluginOperationRecord, type PluginOperationRecord, type PluginOperationRunId } from '@deepseek-ai/dsh-plugin-manager/diagnostic'
interface ReadyEvent {
readonly type: 'ready'
readonly url: string
readonly injections?: readonly unknown[] | undefined
}
interface FatalEvent {
readonly type: 'fatal'
readonly message: string
/** The Host's complete inspected error: stack, enumerable properties, cause chain. */
readonly diagnostic?: string
}
interface PlatformSessionEvent {
readonly type: 'platform-session'
readonly session: PlatformSession | null
}
type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | {
readonly type: 'starting'
readonly runId: PluginOperationRunId
} | { readonly type: 'shutdown-complete' } | {
readonly type: 'file-drag-resource'
readonly requestId: number
readonly resource: { readonly path: string; readonly sha256: string; readonly size: number } | null
readonly error?: string
} | {
readonly type: 'update-tasks'
readonly requestId: number
readonly active: boolean
readonly error?: string
} | {
readonly type: 'quit-inspection'
readonly requestId: number
readonly activeTasks: boolean
readonly scheduledTasks: boolean
readonly error?: string
}
/** Correlated answer to one shell control request. */
type DesktopHostControlResponse = Extract<DesktopHostEvent, { readonly requestId: number }>
/** What quitting now would affect, as reported by the Host. */
export interface DesktopQuitInspection {
readonly activeTasks: boolean
readonly scheduledTasks: boolean
}
/** Quit inspection deadline; a slower Host counts as unknown work and the shell asks before quitting. */
export const QUIT_INSPECTION_DEADLINE_MS = 2_000
const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024
const runIdSchema = z.uuid()
function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
if (typeof message !== 'object' || message === null || !('type' in message)) return false
const candidate = message as Record<string, unknown>
switch (candidate.type) {
case 'starting':
return runIdSchema.safeParse(candidate.runId).success
case 'shutdown-complete':
return true
case 'ready':
return typeof candidate.url === 'string'
case 'platform-session': {
const session = candidate.session
if (session === null) return true
if (typeof session !== 'object' || !('origin' in session) || !('token' in session)
|| typeof session.origin !== 'string' || typeof session.token !== 'string' || session.token.length === 0) return false
if (!('userId' in session) || (session.userId !== null
&& (typeof session.userId !== 'string' || session.userId.length === 0))) return false
if ('embeddedPageDist' in session && typeof session.embeddedPageDist !== 'string') return false
if ('requestHeaders' in session && (typeof session.requestHeaders !== 'object' || session.requestHeaders === null
|| Array.isArray(session.requestHeaders)
|| Object.entries(session.requestHeaders).some(([name, value]) => typeof value !== 'string'
|| name !== name.toLowerCase() || /[\r\n]/.test(value)
|| ['authorization', 'x-dsh-auth-token', 'host', 'content-length', 'transfer-encoding', 'connection', 'content-type'].includes(name)))) return false
try {
const url = new URL(session.origin)
return url.origin === session.origin && !url.username && !url.password
&& (url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
} catch { return false }
}
case 'fatal':
return typeof candidate.message === 'string' && (candidate.diagnostic === undefined || typeof candidate.diagnostic === 'string')
case 'update-tasks':
return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean'
&& (candidate.error === undefined || typeof candidate.error === 'string')
case 'file-drag-resource': {
const resource = candidate.resource
return Number.isSafeInteger(candidate.requestId) && (candidate.error === undefined || typeof candidate.error === 'string')
&& (resource === null || (typeof resource === 'object' && 'path' in resource && typeof resource.path === 'string'
&& 'sha256' in resource && typeof resource.sha256 === 'string' && /^[a-f0-9]{64}$/u.test(resource.sha256)
&& 'size' in resource && typeof resource.size === 'number' && Number.isSafeInteger(resource.size) && resource.size >= 0))
}
case 'quit-inspection':
return Number.isSafeInteger(candidate.requestId) && typeof candidate.activeTasks === 'boolean'
&& typeof candidate.scheduledTasks === 'boolean' && (candidate.error === undefined || typeof candidate.error === 'string')
default:
return false
}
}
async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<false>((resolve) => {
timer = setTimeout(() => { resolve(false) }, milliseconds)
timer.unref()
})
try {
return await Promise.race([exit.then(() => true), timeout])
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}
/** Browser authentication URL reported by the running Web application. */
export interface DesktopHostReady {
readonly url: string
readonly injections?: readonly unknown[] | undefined
}
/** The child has exited, but task teardown did not finish successfully. */
export class DesktopHostUncleanExitError extends Error {}
/**
* A Host failure reported over IPC before the process exited. `message` is what
* the Host chose to show; `diagnostic` is its complete inspected error, kept
* separately so a crash report can print it verbatim instead of a string escaped
* inside another error's properties.
*/
export class DesktopHostFatalError extends Error {
readonly #diagnostic: string | undefined
/**
* @param message - The Host's failure message.
* @param diagnostic - The Host's inspected error, when the Host supplied one.
*/
constructor(message: string, diagnostic: string | undefined) {
super(message)
this.#diagnostic = diagnostic
}
/** The Host's inspected error; a getter so `util.inspect` of this error does not repeat it as an escaped property. */
get diagnostic(): string | undefined { return this.#diagnostic }
}
/** One Web backend running under the Electron executable in Node mode. */
export class DesktopHostProcess {
private runId: PluginOperationRunId | undefined
private child: ChildProcess | undefined
private readyResolve!: (ready: DesktopHostReady) => void
private readyReject!: (error: Error) => void
private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
this.readyResolve = resolve
this.readyReject = reject
})
private exitPromise: Promise<void> | undefined
private stderr = ''
private failureReported = false
private stopping = false
private shutdownCompleted = false
private nextControlId = 1
private readonly controlRequests = new Map<number, {
resolve: (response: DesktopHostControlResponse) => void
reject: (error: Error) => void
}>()
/**
* @param node - Absolute Electron executable in Node mode.
* @param runtimeDir - Immutable packages carried by the current application.
* @param projectDir - Desktop plugin profile and child working directory.
* @param inspectPort - Optional loopback inspector port for workspace development.
* @param environment - Environment inherited by the Host and its plugin subprocesses.
* @param onFailure - Receives the first unexpected child failure, including after readiness.
* @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling
* `office-skills` resources fail Host startup.
* @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.
* @param onPlatformSession - Private credential updates for embedded Platform views.
* @param onPluginOperation - Optional diagnostic sink; malformed records are ignored without failing the Host.
*/
constructor(
private readonly node: string,
private readonly runtimeDir: string,
private readonly projectDir: string,
private readonly inspectPort?: number,
private readonly environment: NodeJS.ProcessEnv = process.env,
private readonly onFailure?: (error: Error) => void,
private readonly primaryRuntime?: string,
private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },
private readonly onPlatformSession?: (session: PlatformSession | null) => void,
private readonly onPluginOperation?: (record: PluginOperationRecord) => void,
) {}
/**
* Start this child once and await its Web application URL.
* @returns Ready facts supplied by the child after application startup.
*/
async start(): Promise<DesktopHostReady> {
if (this.child !== undefined) return this.readyPromise
const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
const child = spawn(this.node, [
'--expose-internals',
...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
entry,
this.runtimeDir,
this.projectDir,
this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'),
...this.packageManager === undefined ? [] : [this.packageManager.pnpm, this.packageManager.nodeBin],
], {
cwd: this.projectDir,
env: desktopNodeEnvironment(this.node, undefined, this.environment),
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
})
this.child = child
child.stderr?.setEncoding('utf8')
child.stderr?.on('data', (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS) })
child.stdout?.pipe(process.stdout)
child.on('message', (message: unknown) => {
if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'plugin-operation') {
const record = 'record' in message ? parsePluginOperationRecord(message.record) : undefined
if (record !== undefined && record.runId === this.runId) {
try { this.onPluginOperation?.(record) }
catch (_error) {
// Diagnostic persistence cannot invalidate the Host's business IPC channel.
console.error('desktop plugin operation diagnostic sink failed')
}
}
return
}
if (!isDesktopHostEvent(message)) {
this.fail(new Error('dsh desktop host sent an invalid IPC event'))
child.kill('SIGTERM')
return
}
if (message.type === 'starting') this.runId = message.runId
else if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections })
else if (message.type === 'platform-session') this.onPlatformSession?.(message.session)
else if (message.type === 'shutdown-complete') {
if (this.stopping) this.shutdownCompleted = true
else this.fail(new Error('dsh desktop host acknowledged an unrequested shutdown'))
}
else if (message.type === 'fatal') this.fail(new DesktopHostFatalError(message.message, message.diagnostic))
else {
const request = this.controlRequests.get(message.requestId)
if (message.error === undefined) request?.resolve(message)
else request?.reject(new Error(message.error))
}
})
child.once('error', (error) => { this.fail(error) })
this.exitPromise = new Promise<void>((resolve) => {
child.once('close', (code) => {
const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
else this.fail(new Error(`dsh desktop host stopped${suffix}`))
resolve()
})
})
return this.readyPromise
}
/**
* Inspect active work or lock request admission for update handoff.
* @param action - Read-only inspection, admission lock, or recovery unlock.
* @returns Whether live tasks would be affected. Locking drains admitted API requests before inspecting tasks;
* an unanswered drain fails at the control-request deadline without authorizing installation.
*/
async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise<boolean> {
const response = await this.control({ type: 'update-tasks', action }, 10_000, 'desktop update: task inspection timed out', 'desktop update')
if (response.type !== 'update-tasks') throw new Error('desktop update: Host answered with a different control response')
return response.active
}
/**
* Ask the Host what quitting now would interrupt.
* @returns Active tasks and armed scheduled reminders; rejects when the Host is unavailable or misses
* {@link QUIT_INSPECTION_DEADLINE_MS}, and the shell then asks before quitting.
*/
async inspectQuit(): Promise<DesktopQuitInspection> {
const response = await this.control({ type: 'quit-inspection' }, QUIT_INSPECTION_DEADLINE_MS, 'desktop quit: inspection timed out', 'desktop quit')
if (response.type !== 'quit-inspection') throw new Error('desktop quit: Host answered with a different control response')
return { activeTasks: response.activeTasks, scheduledTasks: response.scheduledTasks }
}
/**
* @param address - Session-scoped file reference.
* @param signal - preparation cancellation.
* @returns privately authorized local resource, or null when nonlocal.
*/
async resolveDragResource(address: string, signal?: AbortSignal): Promise<
{ readonly path: string; readonly sha256: string; readonly size: number } | null
> {
const response = await this.control({ type: 'file-drag-resource', address }, 12_000, 'File drag authorization timed out', 'file drag', signal)
if (response.type !== 'file-drag-resource') throw new Error('Host returned a different control response')
return response.resource
}
private async control(
request: { readonly type: 'update-tasks'; readonly action: 'inspect' | 'lock' | 'unlock' } | { readonly type: 'quit-inspection' }
| { readonly type: 'file-drag-resource'; readonly address: string },
deadlineMs: number, deadlineMessage: string, operation: string, signal?: AbortSignal,
): Promise<DesktopHostControlResponse> {
const child = this.child
if (child === undefined || !child.connected || this.failureReported || this.stopping) {
throw new Error(`${operation}: Host is unavailable`)
}
signal?.throwIfAborted()
const requestId = this.nextControlId++
let timer: ReturnType<typeof setTimeout> | undefined
const abort = (): void => {
if (request.type === 'file-drag-resource' && child.connected) child.send({ type: 'file-drag-cancel', requestId }, (_error) => {
// The request still rejects locally if the Host has disconnected.
})
const reason: unknown = signal?.reason
this.controlRequests.get(requestId)?.reject(reason instanceof Error ? reason : new Error(deadlineMessage))
}
try {
return await new Promise<DesktopHostControlResponse>((resolve, reject) => {
this.controlRequests.set(requestId, { resolve, reject })
timer = setTimeout(abort, deadlineMs)
signal?.addEventListener('abort', abort, { once: true })
child.send({ ...request, requestId }, (error) => { if (error !== null) reject(error) })
})
} finally {
clearTimeout(timer)
signal?.removeEventListener('abort', abort)
this.controlRequests.delete(requestId)
}
}
/**
* Request teardown and await child exit, escalating termination when needed.
* @param requireGraceful - Reject update handoff after forced termination or unsuccessful child exit.
* @returns Completion of owned process teardown. DesktopHostUncleanExitError confirms exit but refuses installation;
* other failures do not confirm exit.
*/
async stop(requireGraceful = false): Promise<void> {
const child = this.child
if (child === undefined) return
this.stopping = true
this.onPlatformSession?.(null)
if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) })
const exited = this.exitPromise ?? Promise.resolve()
const graceful = await exitsWithin(exited, 10_000)
if (!graceful) child.kill('SIGTERM')
if (!await exitsWithin(exited, 5_000)) {
child.kill('SIGKILL')
if (!await exitsWithin(exited, 5_000)) {
throw new Error('dsh desktop host did not exit after SIGKILL')
}
}
this.child = undefined
if (requireGraceful && (!graceful || child.exitCode !== 0 || !this.shutdownCompleted)) {
// This diagnostic reaches expandable UI; arbitrary plugin stderr can contain credentials.
throw new DesktopHostUncleanExitError(`desktop update: Host did not complete graceful task teardown (exit ${String(child.exitCode)}, signal ${String(child.signalCode)}, shutdown acknowledged ${String(this.shutdownCompleted)}, graceful deadline exceeded ${String(!graceful)})`)
}
}
private fail(error: Error): void {
if (this.runId !== undefined) Object.assign(error, { hostRunId: this.runId })
this.onPlatformSession?.(null)
this.readyReject(error)
for (const request of this.controlRequests.values()) request.reject(error)
this.controlRequests.clear()
if (!this.failureReported && !this.stopping) {
this.failureReported = true
try { this.onFailure?.(error) } catch (listenerError) {
console.error('desktop host failure listener failed', listenerError)
}
}
}
}