Files
deepseek-harness/apps/cli/src/web.ts
T
Yichen Jiang 3d68185480 feat(web): move the agent plane behind per-session presets
The Web overlay disables base's 32 agent-plane rows and mounts the preset
roster instead, so each session composes its own tools and prompt rather than
sharing one process-wide set. The TUI keeps base unchanged: it is single-session
and composing its agent process-wide is correct there.

`roots` is patched in by AppCLIEntry, like `distIndex`: the shipped presets sit
beside the composition that names them and the user's live under the Harness
home, neither of which a config author chooses.

A session's preset is fixed at creation. Naming a different one for an existing
identity is `agent-preset-conflict` rather than a switch, because that
session's history was produced under the first preset's tools. The guard sits
after `await creation`, beside the cwd check, so it covers every path that
yields a live agent — freshly created, adopted live, resumed, or recovered by
the concurrent-creation catch. A request naming no preset adopts the session as
it is, keeping reconnect and retry ordinary.

Two bugs the real-composition test caught, both invisible to unit tests:

`PresetTree` now refuses to write. The Loader persists a tree whose plugin
self-disposed, and tearing an agent down disposes its whole subtree — inherited,
that rewrote the shipped composition, truncating a 241-line preset to `[]` the
first time a session ended.

`dsh-tool-skill` compared against a lookup of its own name in the global layer,
so it threw inside any preset: `register()` files into the calling context's
scope. It now compares against the definition it registered, which is what the
identity check meant all along.

The `standard` catalog is asserted exactly, not spot-checked: a row that
registers into the wrong layer mounts cleanly and simply contributes nothing, so
an omission is this design's quietest failure. It matches the shipped TUI
catalog plus `glob`/`grep`, the pair that composition documents as
ripgrep-dependent.

Re-records `cordis-inspect-jsdoc`, whose rendered `SessionHeader` gains the
`agentPreset` field. `fs-glob-sampling` fails identically on pristine master
and is untouched here.

The browser e2e scaffold gains the roster fact AppCLIEntry supplies. `roots` is
resolved and patched in by the CLI entry, like `distIndex` on the webserver row,
and this lane boots the shipped tree without that entry — so it has to supply
the same fact or the roster resolves nothing and every session in the lane
composes an agent with no tools, no persona, and no token meter. Only the
shipped root: a developer's own `~/.dsh/.agent-presets` must not decide a golden. The
`cordis:group` builtin comes with it, exactly as `boot()` registers it, because
a preset resolving package names from its own directory cannot reach
`@cordisjs/plugin-group` by name.

The lane stays red through this layer and the next four for the reason stated
above — the api-proxy injects `subagents`, `workspace`, and `tools`, so
`api-gateway` cannot activate and the browser has no `/api` at all. It goes
green again in the layer that returns those registries to the host plane; this
change is what makes that layer's fix sufficient rather than partial.
2026-08-07 00:35:30 +08:00

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/--workspace-root/
* --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 { dshHomePath } from '@deepseek-ai/dsh-paths'
import type { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import { runProfile } from './profile-boot.ts'
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
/** Harness-home directory holding locally authored agent presets. */
const USER_PRESET_DIR = '.agent-presets'
/** 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
workspaceRoot?: string
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: Map<string, { name?: string; config?: unknown }>,
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)
if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot)
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 an assembly fact, like the values above: the
// shipped set sits beside this app's config and the user's own under the
// Harness home, and neither location is something a patch author chooses.
// Only patched when the composed tree actually mounts the roster.
if (rows.has('agent-presets')) {
put('agent-presets', 'roots', [
{ path: SHIPPED_PRESET_ROOT, trust: 'system' },
{ path: dshHomePath(USER_PRESET_DIR), trust: 'user' },
])
}
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
}
/**
* Serve the browser UI from the web profile. Host/port/workspace-root 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.
*/
export async function runWeb(flags: WebFlags): Promise<void> {
await runProfile({
profile: 'web',
patchFiles: flags.patches,
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),
prepare: (ctx: Context) => {
ctx.inject(['systemPrompt'], (promptCtx) => {
addHarnessSourceSection(promptCtx, SOURCE_ROOT)
})
},
})
}