app-cli-entry.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. /**
  2. * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
  3. * (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly).
  4. * Everything here is what must exist before the Loader runs: the patch
  5. * composition over the shipped base and surface overlay (CLI flags + the
  6. * resolved frontend dist), and the fail-loud activation audit after the tree
  7. * settles. The environment is what the bin already loaded (ambient over the
  8. * invoking directory's `.env` over `$DSH_HOME/.env`); credentials live in
  9. * `$DSH_HOME/.credentials.yaml` and are never hoisted into it.
  10. */
  11. import { readFileSync } from 'node:fs'
  12. import { createRequire } from 'node:module'
  13. import { networkInterfaces } from 'node:os'
  14. import { resolve } from 'node:path'
  15. import { Context } from 'cordis'
  16. import type { PatchOptions } from '@cordisjs/plugin-include'
  17. import yaml from 'js-yaml'
  18. import { boot, installFailLoud, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
  19. // Empty type import carries the httpServer Context merge for the port read below.
  20. import type {} from '@deepseek-ai/dsh-host-webserver'
  21. /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
  22. const TELEMETRY_ROW_ID = 'telemetry-otel'
  23. /** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
  24. const ALL_INTERFACES_HOST = '0.0.0.0'
  25. /**
  26. * Non-internal IPv4 interface addresses of this machine — the IP-literal
  27. * authorities an all-interfaces bind is reachable by on the LAN.
  28. * @returns the addresses in interface order (possibly empty).
  29. */
  30. function lanIPv4Addresses(): string[] {
  31. return Object.values(networkInterfaces()).flat()
  32. .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
  33. .map(iface => iface.address)
  34. }
  35. /**
  36. * One LAN-trust resolution for one invocation, sampled exactly once: the
  37. * machine's LAN IP literals when the effective bind is all-interfaces, and
  38. * the `trustedHosts` value built from them plus the explicit extras. The
  39. * single sample is deliberate — display must advertise only addresses the
  40. * fence was configured with, so both read this snapshot. Derived entries are
  41. * port-less IP literals: DNS rebinding needs an attacker-controlled name, so
  42. * an IP-literal Host is safe on any port, and the bound port may be
  43. * OS-assigned, unknowable pre-boot.
  44. * @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
  45. * @param extra - `--trusted-host` values, in argv order.
  46. * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
  47. */
  48. export function resolveLanTrust(
  49. bindHost: string | undefined,
  50. extra: readonly string[],
  51. ): { lanAddresses: string[]; trustedHosts: string[] } {
  52. const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
  53. return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
  54. }
  55. /**
  56. * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
  57. * value (including `'0'`/`'false'`) disables: a privacy switch prefers
  58. * off-by-mistake over on-by-mistake. Throws when the switch is set but the
  59. * row is absent — a silently no-op "disabled" privacy switch would keep
  60. * exporting while the user believes it is off.
  61. * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
  62. * @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
  63. * @returns the disable patch, or `undefined` when telemetry stays enabled.
  64. */
  65. export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
  66. if ((disabledEnv ?? '') === '') return undefined
  67. if (!hasRow) {
  68. throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
  69. }
  70. return { id: TELEMETRY_ROW_ID, disabled: true }
  71. }
  72. /**
  73. * Whether a config file carries the telemetry row, parsed under the same
  74. * `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
  75. * that compose their patch lists outside {@link AppCLIEntry} (the TUI).
  76. * @param file - absolute path of the config or overlay file.
  77. * @returns true when a top-level (or inserted) row has the telemetry id.
  78. */
  79. export function configHasTelemetryRow(file: string): boolean {
  80. const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
  81. if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
  82. return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
  83. row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
  84. }
  85. // The include's YAML dialect: `!!js` scalars become expression nodes the
  86. // Loader evaluates at entry activation. The bypass parse below must accept
  87. // them (and passing one through a patch unchanged is legal).
  88. const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
  89. kind: 'scalar',
  90. resolve: data => typeof data === 'string',
  91. construct: data => ({ __jsExpr: String(data) }),
  92. })
  93. const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
  94. /** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
  95. export interface AppCLIEntryOptions {
  96. /** Absolute path of the shared base config the Loader includes. */
  97. configPath: string
  98. /**
  99. * Absolute path of this surface's overlay: a patch list applied over
  100. * {@link configPath} before this entry's own flag patches. Its rows
  101. * are also merge inputs, so a flag override preserves the overlay's other
  102. * fields on the same row.
  103. */
  104. overlayPath: string
  105. /** Optional `--config` overlay applied after {@link overlayPath} and before this entry's own flag patches. */
  106. extraOverlayPath?: string
  107. /**
  108. * Optional `--config-replace` tree: booted INSTEAD of {@link configPath},
  109. * {@link overlayPath}, {@link extraOverlayPath}, and every generated patch,
  110. * so the caller's file is the whole composition. It must still supply the
  111. * serving rows this entry needs — {@link run} rejects a settled tree with no
  112. * `httpServer`.
  113. */
  114. configReplacePath?: string
  115. /** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
  116. dev: boolean
  117. /** --host when explicitly passed; undefined keeps the yml engineering default. */
  118. host?: string
  119. /**
  120. * Listen port override onto the webserver row. Web passes the --port flag
  121. * value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
  122. * never collide — and the printed URL still opens the live session in a
  123. * browser).
  124. */
  125. port?: number
  126. /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
  127. workspaceRoot?: string
  128. /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
  129. trustedHosts?: string[]
  130. /** Surface setup registered after Loader installation and before any config-tree entry mounts. */
  131. prepare?: (ctx: Context) => Promise<void> | void
  132. }
  133. /**
  134. * Boot driver for the config-tree dsh surfaces (web and headless share the
  135. * one composition; the surfaces differ only in constructor facts): holds only
  136. * what exists independently of (and prior to) cordis — argv facts, the
  137. * composed patch set, and finally the root ctx.
  138. */
  139. export class AppCLIEntry {
  140. /** The root context, set by {@link run}. */
  141. ctx!: Context
  142. /**
  143. * LAN IPv4 addresses sampled once at patch composition — the exact snapshot
  144. * the /api trust fence was configured with. Display reads this instead of
  145. * re-sampling, so the advertised LAN URL can never name an address the
  146. * fence rejects. Empty unless the effective bind is all-interfaces.
  147. */
  148. lanAddresses: readonly string[] = []
  149. private patches: PatchOptions[] = []
  150. constructor(private readonly options: AppCLIEntryOptions) {}
  151. /**
  152. * Run the boot chain: patch composition → Loader installation → surface
  153. * preparation → config-tree boot (dev row before await) → fail-loud triple.
  154. * @returns the settled root context and the listening port.
  155. */
  156. async run(): Promise<{ ctx: Context; port: number }> {
  157. this.composePatches()
  158. await this.bootTree()
  159. this.assertBoot()
  160. const port = this.ctx.get('httpServer')?.port
  161. if (port === undefined) {
  162. // The shipped tree always carries the webserver row, so this is only
  163. // reachable through --config-replace: name the missing contract rather
  164. // than report a bare missing service.
  165. throw new Error(
  166. `dsh: no httpServer after booting ${this.bootConfigPath()}; this surface serves over HTTP, so a`
  167. + ' --config-replace tree must mount a webserver row',
  168. )
  169. }
  170. return { ctx: this.ctx, port }
  171. }
  172. /**
  173. * Compose the patch set from CLI flags and the resolved frontend dist.
  174. * Patches replace a row's config wholesale, so each patched row's yml
  175. * static values are re-read here (bypass parse) and merged under the overrides.
  176. */
  177. private composePatches(): void {
  178. const rows = this.parseYmlRows()
  179. if (this.options.configReplacePath !== undefined) {
  180. // A replacement tree is the caller's whole composition: the generated
  181. // patches target shipped row ids this file cannot assume exist, and a
  182. // patch whose id is absent is a silent no-op rather than a diagnostic.
  183. // Telemetry stays, judged against the tree actually booting, because a
  184. // privacy switch that silently no-ops is worse than a loud one.
  185. const replaceTelemetry = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
  186. this.patches = replaceTelemetry === undefined ? [] : [replaceTelemetry]
  187. return
  188. }
  189. const overrides = new Map<string, Record<string, unknown>>()
  190. const put = (entryId: string, key: string, value: unknown): void => {
  191. const bag = overrides.get(entryId) ?? {}
  192. bag[key] = value
  193. overrides.set(entryId, bag)
  194. }
  195. // Source 1: CLI flags.
  196. if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
  197. if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
  198. if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
  199. // Source 1b: authorities for the /api browser-trust fence (rationale on
  200. // resolveLanTrust).
  201. const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
  202. const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
  203. this.lanAddresses = lanAddresses
  204. if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
  205. // Source 2: the frontend dist — an assembly fact of this app, never yml
  206. // user config. Workspace knowledge stays here.
  207. put('webserver', 'distIndex', this.resolveDistIndex())
  208. const generated = [...overrides.entries()].map(([id, bag]) => {
  209. const yml = rows.get(id)
  210. if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
  211. return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
  212. })
  213. this.patches = generated
  214. // Telemetry opt-out: a row can only be turned off at the patch layer
  215. // (config cannot disable an entry), and the switch must hold BEFORE the
  216. // plugin constructs — its exporter.url validation is load-time fail-loud.
  217. const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
  218. if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
  219. }
  220. /** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */
  221. private async bootTree(): Promise<void> {
  222. // One include of the shared base with every overlay as a sibling patch
  223. // list: patches never cross an include boundary, so nesting them would
  224. // silently stop reaching base rows. The surface overlay applies first, then
  225. // any --config overlay, then this entry's CLI-flag patches, which win.
  226. // --config-replace discards all three and boots the named file alone.
  227. const patches = this.options.configReplacePath !== undefined
  228. ? this.patches
  229. : [
  230. ...loadOverlayPatches('dsh', this.options.overlayPath),
  231. ...this.options.extraOverlayPath === undefined
  232. ? []
  233. : loadOverlayPatches('dsh', this.options.extraOverlayPath),
  234. ...this.patches,
  235. ]
  236. this.ctx = await boot('dsh', resolve(this.bootConfigPath()), patches, async (ctx) => {
  237. await this.options.prepare?.(ctx)
  238. if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
  239. })
  240. }
  241. /** The file the Loader includes: the replacement tree when named, otherwise the shared base. */
  242. private bootConfigPath(): string {
  243. return this.options.configReplacePath ?? this.options.configPath
  244. }
  245. /** Install the diagnostic for plugin rejections that happen after settled boot. */
  246. private assertBoot(): void {
  247. installFailLoud('dsh')
  248. }
  249. /**
  250. * Bypass parse of the base and this surface's overlay (id → row) for
  251. * patch-merge inputs; the Loader still reads both files itself. The overlay
  252. * wins per row, matching the order its patches are applied in, and its
  253. * `insert` rows are indexed too because a flag may target one of them.
  254. */
  255. private parseYmlRows(): Map<string, { config?: unknown }> {
  256. const rows = new Map<string, { config?: unknown }>()
  257. // A replacement tree stands alone, so only its own rows are indexed —
  258. // the telemetry-row check must judge the tree that actually boots.
  259. if (this.options.configReplacePath !== undefined) {
  260. for (const row of this.parseRowList(this.options.configReplacePath)) {
  261. if (typeof row.id === 'string') rows.set(row.id, row)
  262. for (const inserted of row.insert ?? []) {
  263. if (typeof inserted.id === 'string') rows.set(inserted.id, inserted)
  264. }
  265. }
  266. return rows
  267. }
  268. const files = [this.options.configPath, this.options.overlayPath]
  269. if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath)
  270. for (const file of files) {
  271. for (const row of this.parseRowList(file)) {
  272. if (typeof row.id === 'string') rows.set(row.id, row)
  273. for (const inserted of row.insert ?? []) {
  274. if (typeof inserted.id === 'string') rows.set(inserted.id, inserted)
  275. }
  276. }
  277. }
  278. return rows
  279. }
  280. /**
  281. * Parse one entry or patch list, rejecting anything that is not a top-level
  282. * array so a malformed file fails here rather than at row lookup.
  283. * @param file - absolute path of the config or overlay file.
  284. * @returns the parsed top-level entries.
  285. */
  286. private parseRowList(file: string): { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] {
  287. const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
  288. if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
  289. return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[]
  290. }
  291. /** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */
  292. private resolveDistIndex(): string {
  293. const require = createRequire(import.meta.url)
  294. try {
  295. return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
  296. } catch {
  297. throw new Error('dsh: frontend dist not built; run pnpm run build from the repository root first')
  298. }
  299. }
  300. }