# Conflicts: # .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md # apps/cli/reference/README.i18n.yaml # docs/config-catalog.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-approval.spec.ts # packages/host/apiproxy/tests/api-proxy-blank.spec.ts # packages/host/apiproxy/tests/api-proxy-cold.spec.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-config.spec.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/api-proxy-question.spec.ts # packages/host/apiproxy/tests/api-proxy-rename.spec.ts # packages/host/apiproxy/tests/api-proxy-search.spec.ts # packages/host/apiproxy/tests/api-proxy-subagents.spec.ts # packages/host/apiproxy/tests/api-proxy-view.spec.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/todo/tool-todo/tests/projection.spec.ts # scripts/hero-composer-dom-continuity.mjs
145 lines
6.6 KiB
TypeScript
145 lines
6.6 KiB
TypeScript
/**
|
|
* `dsh web` — the browser-surface alias over the profile boot: `--profile web`
|
|
* plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag
|
|
* becoming a patch over the composed profile
|
|
* tree. All web runtime glue (dist serving, prompt section, URL line) lives
|
|
* in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives
|
|
* flag patches and the LAN-trust snapshot.
|
|
* @module @deepseek-ai/dsh/web
|
|
*/
|
|
|
|
import { networkInterfaces } from 'node:os'
|
|
import { fileURLToPath } from 'node:url'
|
|
import type { Context } from 'cordis'
|
|
import type { PatchOptions } from '@cordisjs/plugin-include'
|
|
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
|
|
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
|
import { runProfile, type ProfileRows } from './profile-boot.ts'
|
|
|
|
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
|
|
|
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */
|
|
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 the web-app row receives this same 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 composed row value).
|
|
* @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] }
|
|
}
|
|
|
|
/** The `dsh web` flag family, already parsed by the argument adapter. */
|
|
export interface WebFlags {
|
|
patches: string[]
|
|
host?: string
|
|
port?: number
|
|
dev: boolean
|
|
trustedHosts?: string[]
|
|
}
|
|
|
|
/**
|
|
* Derive the web alias's flag patches over an already-composed profile tree.
|
|
* Patches replace a row's whole config, so each patched row's composed values
|
|
* are re-read and merged under the overrides.
|
|
* @param rows - the composed row index from {@link composeProfile}.
|
|
* @param flags - the parsed flag family.
|
|
* @returns the flag patch list, in application order.
|
|
*/
|
|
function deriveWebFlagPatches(
|
|
rows: ProfileRows,
|
|
flags: WebFlags,
|
|
): PatchOptions[] {
|
|
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)
|
|
}
|
|
if (flags.host !== undefined) put('webserver', 'host', flags.host)
|
|
if (flags.port !== undefined) put('webserver', 'port', flags.port)
|
|
const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
|
|
const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? [])
|
|
if (trustedHosts.length > 0) {
|
|
// Additive over the composed value: a cordis.patch.yml-configured fence
|
|
// authority must survive the derived LAN literals and flag extras — a
|
|
// silent drop of security-relevant fence configuration.
|
|
const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? []
|
|
put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts])
|
|
}
|
|
// mode and lanAddresses are launcher-derived on every boot (--dev also
|
|
// inserts the client-hmr row), never pass-throughs of composed values.
|
|
put('web-runtime', 'mode', flags.dev ? 'development' : 'production')
|
|
put('web-runtime', 'lanAddresses', lanAddresses)
|
|
// The agent-preset roots are patched by the shared profile boot: they are
|
|
// an assembly fact of every dsh launcher, and `dsh run` composes agents
|
|
// from the same roster this alias offers.
|
|
const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => {
|
|
const composed = rows.get(id)
|
|
if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`)
|
|
return { id, config: { ...(composed.config ?? {}) as Record<string, unknown>, ...bag } }
|
|
})
|
|
if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] })
|
|
return patches
|
|
}
|
|
|
|
/**
|
|
* Whether the composed Web runtime keeps its model- and shell-visible surface
|
|
* context. The bundle schema defaults the field to true, so only an explicit
|
|
* false suppresses both the bundle contributions and the launcher-owned
|
|
* source-checkout section.
|
|
* @param rows - the composed Web profile rows before launcher flag patches.
|
|
* @returns true unless the web-runtime row explicitly disables surface context.
|
|
*/
|
|
export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
|
|
return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false
|
|
}
|
|
|
|
/**
|
|
* Serve the browser UI from the web profile. Host/port flags are passed
|
|
* through only when given (absent, the composed profile values
|
|
* stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on
|
|
* every boot. The URL line is printed by the web-app bundle's runtime row
|
|
* after Loader settlement.
|
|
* @param flags - the parsed `dsh web` flag family.
|
|
* @param environment - this run's frozen environment snapshot.
|
|
*/
|
|
export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
|
|
await runProfile({
|
|
environment,
|
|
profile: 'web',
|
|
patchFiles: flags.patches,
|
|
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
|
|
prepare: (ctx: Context, rows: ProfileRows) => {
|
|
if (!webSurfaceContextEnabled(rows)) return
|
|
ctx.inject(['systemPrompt'], (promptCtx) => {
|
|
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
|
|
})
|
|
},
|
|
})
|
|
}
|