web.ts re-sampled interfaces after boot, so an address change during entry.run() could advertise a LAN URL absent from the trustedHosts snapshot composePatches captured, answering 403 on arrival. resolveLanTrust now returns the single sample and AppCLIEntry exposes it for display.
301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
/**
|
|
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
|
* (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
|
|
* Everything here is what must exist before the Loader runs: layered env,
|
|
* the patch composition over the shipped cordis.yml (profile json + CLI
|
|
* flags + the resolved frontend dist), and the fail-loud triple after the
|
|
* tree settles.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { networkInterfaces } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { Context } from 'cordis'
|
|
import type { FiberState } from 'cordis'
|
|
import Loader from '@cordisjs/plugin-loader'
|
|
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
|
import yaml from 'js-yaml'
|
|
import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
|
|
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
|
// Empty type import carries the httpServer Context merge for the port read below.
|
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
|
|
/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */
|
|
const PROFILE_DIR = '.dsh-tmp-profile'
|
|
const PROFILE_FILE = 'config.json'
|
|
|
|
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
|
|
export const ALL_INTERFACES_HOST = '0.0.0.0'
|
|
|
|
/**
|
|
* Non-internal IPv4 interface addresses of this machine — the IP-literal
|
|
* authorities an all-interfaces bind is reachable by on the LAN.
|
|
* @returns the addresses in interface order (possibly empty).
|
|
*/
|
|
function lanIPv4Addresses(): string[] {
|
|
return Object.values(networkInterfaces()).flat()
|
|
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
|
.map(iface => iface.address)
|
|
}
|
|
|
|
/**
|
|
* One LAN-trust resolution for one invocation, sampled exactly once: the
|
|
* machine's LAN IP literals when the effective bind is all-interfaces, and
|
|
* the `trustedHosts` value built from them plus the explicit extras. The
|
|
* single sample is deliberate — display must advertise only addresses the
|
|
* fence was configured with, so both read this snapshot. Derived entries are
|
|
* port-less IP literals: DNS rebinding needs an attacker-controlled name, so
|
|
* an IP-literal Host is safe on any port, and the bound port may be
|
|
* OS-assigned, unknowable pre-boot.
|
|
* @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
|
|
* @param extra - `--trusted-host` values, in argv order.
|
|
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
|
|
*/
|
|
export function resolveLanTrust(
|
|
bindHost: string | undefined,
|
|
extra: readonly string[],
|
|
): { lanAddresses: string[]; trustedHosts: string[] } {
|
|
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
|
|
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
|
|
}
|
|
|
|
/** One profile-json key mapped onto a yml row's config field. */
|
|
interface ProfileMapping {
|
|
jsonPath: string
|
|
entryId: string
|
|
configKey: string
|
|
}
|
|
|
|
/**
|
|
* The static profile→row mapping table. json is user config and wins over the
|
|
* yml engineering default per field; a json key absent from this table fails
|
|
* loud (a typo silently ignored would read as "setting has no effect").
|
|
* Developers extend deployments by adding rows here.
|
|
*/
|
|
const PROFILE_MAPPINGS: ProfileMapping[] = [
|
|
{ jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' },
|
|
{ jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' },
|
|
{ jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' },
|
|
]
|
|
|
|
// The include's YAML dialect: `!!js` scalars become expression nodes the
|
|
// Loader evaluates at entry activation. The bypass parse below must accept
|
|
// them (and passing one through a patch unchanged is legal).
|
|
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
|
kind: 'scalar',
|
|
resolve: data => typeof data === 'string',
|
|
construct: data => ({ __jsExpr: String(data) }),
|
|
})
|
|
const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
|
|
|
|
/**
|
|
* Value mirror of cordis's `FiberState` const enum members the sweep needs
|
|
* (a const enum has no runtime object to import; same rationale as the
|
|
* client-side mirror in dsh-client-web).
|
|
*/
|
|
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
|
const FIBER_PENDING = 0 as FiberState.PENDING
|
|
|
|
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
|
|
export interface AppCLIEntryOptions {
|
|
/** Absolute path of the shipped cordis.yml. */
|
|
configPath: string
|
|
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
|
|
dev: boolean
|
|
/** --host when explicitly passed; undefined keeps the yml engineering default. */
|
|
host?: string
|
|
/**
|
|
* Listen port override onto the webserver row. Web passes the --port flag
|
|
* value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
|
|
* never collide — and the printed URL still opens the live session in a
|
|
* browser).
|
|
*/
|
|
port?: number
|
|
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
|
|
workspaceRoot?: string
|
|
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
|
|
trustedHosts?: string[]
|
|
}
|
|
|
|
/**
|
|
* Boot driver for the config-tree dsh surfaces (web and headless share the
|
|
* one composition; the surfaces differ only in constructor facts): holds only
|
|
* what exists independently of (and prior to) cordis — argv facts, the
|
|
* composed patch set, and finally the root ctx.
|
|
*/
|
|
export class AppCLIEntry {
|
|
/** The root context, set by {@link run}. */
|
|
ctx!: Context
|
|
|
|
/**
|
|
* LAN IPv4 addresses sampled once at patch composition — the exact snapshot
|
|
* the /api trust fence was configured with. Display reads this instead of
|
|
* re-sampling, so the advertised LAN URL can never name an address the
|
|
* fence rejects. Empty unless the effective bind is all-interfaces.
|
|
*/
|
|
lanAddresses: readonly string[] = []
|
|
|
|
private patches: PatchOptions[] = []
|
|
|
|
constructor(private readonly options: AppCLIEntryOptions) {}
|
|
|
|
/**
|
|
* Run the boot chain: layered env → patch composition → Loader include
|
|
* boot (dev row before await) → fail-loud triple.
|
|
* @returns the settled root context and the listening port.
|
|
*/
|
|
async run(): Promise<{ ctx: Context; port: number }> {
|
|
this.loadEnvLayers()
|
|
this.composePatches()
|
|
await this.bootTree()
|
|
this.assertBoot()
|
|
const port = this.ctx.get('httpServer')?.port
|
|
/* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
|
|
if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
|
|
return { ctx: this.ctx, port }
|
|
}
|
|
|
|
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
|
|
private loadEnvLayers(): void {
|
|
loadEnv('dsh', resolveDshHome())
|
|
}
|
|
|
|
/**
|
|
* Compose the patch set from the non-yml config sources: computed
|
|
* engineering defaults (the global session root), profile json (user
|
|
* config, overriding those defaults), CLI flags, and the resolved frontend
|
|
* dist. Patches replace a row's config wholesale, so each patched row's yml
|
|
* static values are re-read here (bypass parse) and merged under the overrides.
|
|
*/
|
|
private composePatches(): void {
|
|
const rows = this.parseYmlRows()
|
|
const overrides = new Map<string, Record<string, unknown>>()
|
|
const put = (entryId: string, key: string, value: unknown): void => {
|
|
const bag = overrides.get(entryId) ?? {}
|
|
bag[key] = value
|
|
overrides.set(entryId, bag)
|
|
}
|
|
|
|
// Source 0: computed engineering defaults. The session store defaults to
|
|
// a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history
|
|
// is shared across every cwd, not a project-local ./.sessions. The profile
|
|
// (Source 1) overwrites this same field via last-write-wins in put().
|
|
put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions'))
|
|
|
|
// Source 1: profile json (missing file = empty; unmapped key = loud).
|
|
for (const [key, value] of Object.entries(this.readProfile())) {
|
|
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
|
|
if (mapping === undefined) {
|
|
throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
|
|
}
|
|
put(mapping.entryId, mapping.configKey, value)
|
|
}
|
|
|
|
// Source 2: CLI flags (field set disjoint from the json mappings).
|
|
if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
|
|
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
|
|
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
|
|
|
|
// Source 2b: authorities for the /api browser-trust fence (rationale on
|
|
// resolveLanTrust).
|
|
const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
|
|
const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
|
|
this.lanAddresses = lanAddresses
|
|
if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
|
|
|
|
// Source 3: the frontend dist — an assembly fact of this app, never yml
|
|
// user config. Workspace knowledge stays here.
|
|
put('webserver', 'distIndex', this.resolveDistIndex())
|
|
|
|
this.patches = [...overrides.entries()].map(([id, bag]) => {
|
|
const yml = rows.get(id)
|
|
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
|
|
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
|
|
})
|
|
}
|
|
|
|
/** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */
|
|
private async bootTree(): Promise<void> {
|
|
const ctx = new Context()
|
|
ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/'
|
|
await ctx.plugin(Loader)
|
|
ctx.loader.builtins.include = Include
|
|
await ctx.loader.create({
|
|
name: 'cordis:include',
|
|
config: {
|
|
path: pathToFileURL(resolve(this.options.configPath)).href,
|
|
...this.patches.length > 0 ? { patches: this.patches } : {},
|
|
},
|
|
})
|
|
if (this.options.dev) {
|
|
await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
|
|
}
|
|
this.ctx = ctx
|
|
await ctx.loader.await()
|
|
}
|
|
|
|
/**
|
|
* Fail-loud triple: assertEntriesLoaded catches import failures,
|
|
* installFailLoud catches late apply rejections, and the all-ACTIVE sweep
|
|
* below catches PENDING fibers (cordis inject waiting has no timeout).
|
|
*/
|
|
private assertBoot(): void {
|
|
installFailLoud('dsh')
|
|
assertEntriesLoaded(this.ctx, 'dsh')
|
|
const failures: string[] = []
|
|
for (const entry of this.ctx.loader.entries()) {
|
|
if (entry.fiber === undefined || entry.disabled) continue
|
|
const state = entry.fiber.state
|
|
if (state === FIBER_ACTIVE) continue
|
|
if (state === FIBER_PENDING) {
|
|
const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined)
|
|
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
|
} else {
|
|
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
|
}
|
|
}
|
|
|
|
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
|
|
private parseYmlRows(): Map<string, { config?: unknown }> {
|
|
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
|
|
if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
|
|
const rows = new Map<string, { config?: unknown }>()
|
|
for (const row of doc as { id?: string; config?: unknown }[]) {
|
|
if (typeof row.id === 'string') rows.set(row.id, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
/** Profile json under cwd; read-only — never created here, absent = no user config. */
|
|
private readProfile(): Record<string, unknown> {
|
|
let raw: string
|
|
try {
|
|
raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8')
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
|
|
throw error
|
|
}
|
|
const parsed: unknown = JSON.parse(raw)
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
|
|
}
|
|
return parsed as Record<string, unknown>
|
|
}
|
|
|
|
/** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */
|
|
private resolveDistIndex(): string {
|
|
const require = createRequire(import.meta.url)
|
|
try {
|
|
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
|
|
} catch {
|
|
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
|
|
}
|
|
}
|
|
}
|