web.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. /**
  2. * `dsh web` — the browser-surface alias over the profile boot: `--profile web`
  3. * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag
  4. * becoming a patch over the composed profile
  5. * tree. All web runtime glue (dist serving, prompt section, URL line) lives
  6. * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives
  7. * flag patches and the LAN-trust snapshot.
  8. * @module @deepseek-ai/dsh/web
  9. */
  10. import { networkInterfaces } from 'node:os'
  11. import { fileURLToPath } from 'node:url'
  12. import type { Context } from '@deepseek-ai/cordis'
  13. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  14. import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
  15. import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
  16. import { runProfile, type ProfileRows } from './profile-boot.ts'
  17. const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
  18. /** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
  19. const ALL_INTERFACES_HOST = '0.0.0.0'
  20. /**
  21. * Non-internal IPv4 interface addresses of this machine — the IP-literal
  22. * authorities an all-interfaces bind is reachable by on the LAN.
  23. * @returns the addresses in interface order (possibly empty).
  24. */
  25. function lanIPv4Addresses(): string[] {
  26. return Object.values(networkInterfaces()).flat()
  27. .filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
  28. .map(iface => iface.address)
  29. }
  30. /**
  31. * One LAN-trust resolution for one invocation, sampled exactly once: the
  32. * machine's LAN IP literals when the effective bind is all-interfaces, and
  33. * the `trustedHosts` value built from them plus the explicit extras. The
  34. * single sample is deliberate — display must advertise only addresses the
  35. * fence was configured with, so the web-app row receives this same snapshot.
  36. * Derived entries are port-less IP literals: DNS rebinding needs an
  37. * attacker-controlled name, so an IP-literal Host is safe on any port, and
  38. * the bound port may be OS-assigned, unknowable pre-boot.
  39. * @param bindHost - the effective webserver bind host (CLI flag, else the composed row value).
  40. * @param extra - `--trusted-host` values, in argv order.
  41. * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
  42. */
  43. export function resolveLanTrust(
  44. bindHost: string | undefined,
  45. extra: readonly string[],
  46. ): { lanAddresses: string[]; trustedHosts: string[] } {
  47. const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
  48. return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
  49. }
  50. /** The `dsh web` flag family, already parsed by the argument adapter. */
  51. export interface WebFlags {
  52. patches: string[]
  53. host?: string
  54. port?: number
  55. dev: boolean
  56. trustedHosts?: string[]
  57. }
  58. /**
  59. * Derive the web alias's flag patches over an already-composed profile tree.
  60. * Patches replace a row's whole config, so each patched row's composed values
  61. * are re-read and merged under the overrides.
  62. * @param rows - the composed row index from {@link composeProfile}.
  63. * @param flags - the parsed flag family.
  64. * @returns the flag patch list, in application order.
  65. */
  66. function deriveWebFlagPatches(
  67. rows: ProfileRows,
  68. flags: WebFlags,
  69. ): PatchOptions[] {
  70. const overrides = new Map<string, Record<string, unknown>>()
  71. const put = (entryId: string, key: string, value: unknown): void => {
  72. const bag = overrides.get(entryId) ?? {}
  73. bag[key] = value
  74. overrides.set(entryId, bag)
  75. }
  76. if (flags.host !== undefined) put('webserver', 'host', flags.host)
  77. if (flags.port !== undefined) put('webserver', 'port', flags.port)
  78. const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
  79. const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
  80. if (trustedHosts.length > 0) {
  81. // Additive over the composed value: a cordis.patch.yml-configured fence
  82. // authority must survive the derived LAN literals and flag extras — a
  83. // silent drop of security-relevant fence configuration.
  84. const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? []
  85. put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts])
  86. }
  87. // mode and lanAddresses are launcher-derived on every boot (--dev also
  88. // inserts the client-hmr row), never pass-throughs of composed values.
  89. put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
  90. put('web-runtime', 'lanAddresses', lanAddresses)
  91. // The agent-preset roots are patched by the shared profile boot: they are
  92. // an assembly fact of every dsh launcher, and `dsh run` composes agents
  93. // from the same roster this alias offers.
  94. const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
  95. const composed = rows.get(id)
  96. if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)
  97. return { id, config: { ...(composed.config ?? {}) as Record<string, unknown>, ...bag } }
  98. })
  99. if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] })
  100. return patches
  101. }
  102. /**
  103. * Whether the composed Web runtime keeps its model- and shell-visible surface
  104. * context. The bundle schema defaults the field to true, so only an explicit
  105. * false suppresses both the bundle contributions and the launcher-owned
  106. * source-checkout section.
  107. * @param rows - the composed Web profile rows before launcher flag patches.
  108. * @returns true unless the web-runtime row explicitly disables surface context.
  109. */
  110. export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
  111. return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false
  112. }
  113. /**
  114. * Serve the browser UI from the web profile. Host/port flags are passed
  115. * through only when given (absent, the composed profile values
  116. * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
  117. * every boot. The URL line is printed by the web-app bundle's runtime row
  118. * after Loader settlement.
  119. * @param flags - the parsed `dsh web` flag family.
  120. * @param environment - this run's frozen environment snapshot.
  121. */
  122. export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
  123. await runProfile({
  124. environment,
  125. profile: 'web',
  126. patchFiles: flags.patches,
  127. deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
  128. prepare: (ctx: Context, rows: ProfileRows) => {
  129. if (!webSurfaceContextEnabled(rows)) return
  130. ctx.inject(['systemPrompt'], (promptCtx) => {
  131. addHarnessSourceSection(promptCtx, SOURCE_ROOT)
  132. })
  133. },
  134. })
  135. }