mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
$DSH_HOME/.env had just become an ordinary environment layer, which left the harness resolving user-facing values from a flattened process.env that could no longer say where a value came from. A key stored through the web page stayed shadowed by an older key in the user's own .env. An endpoint could be redirected by the project: the invoking directory's .env is materialized like every other layer, and a base URL decides where a resolved API key is sent, so a DEEPSEEK_BASE_URL written into a model-editable workspace would send the user's credential — and the prompts carrying their code — to whatever host that file named. Give every user-facing value one ordering, with four kinds of source: explicit for this run per-operation override, CLI argument > authored by deployment --config / --config-replace > this launch's shell inherited process environment > product-managed store settings.yaml, .credentials.yaml > discovered file $DSH_HOME/.env > defaults schema default, shipped base, public default The domains differ only in which tiers exist. The earlier split — credentials ranking the environment over the managed file while settings ranked over the environment — was inconsistent: the distinguishing fact is who authored the source, not the domain. packages/util/environment owns an immutable snapshot with per-layer provenance. getFrom(name, sources) searches only the layers a caller names, and omitting one is a refusal rather than a demotion: the adapters ask for ['process', 'user-env'], so no reordering can let a project file back into a decision it was excluded from. isBootstrapOnly rejects, before anything is materialized, any .env setting a variable that governs how a process launches (PATH, SHELL, NODE_OPTIONS, LD_PRELOAD), where code or model-visible instructions load from (the whole DSH_* namespace, HOME, XDG_*), or how the network is reached (proxy and CA variables). The namespace is denied wholesale so a switch added later cannot become settable by being forgotten, and there is no opt-out. verify-config-source-ownership keeps both rules: no unregistered process.env read under packages/*/*/src (26 allowlisted with reasons), and no apiKey, baseURL, or headers inlined from the environment in shipped Cordis config — removing those inlines is what makes the deployment tier meaningful.
333 lines
16 KiB
TypeScript
333 lines
16 KiB
TypeScript
/**
|
|
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
|
* (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly).
|
|
* Everything here is what must exist before the Loader runs: the patch
|
|
* composition over the shipped base and surface overlay (CLI flags + the
|
|
* resolved frontend dist), and the fail-loud activation audit after the tree
|
|
* settles. The environment is what the bin already loaded (ambient over the
|
|
* invoking directory's `.env` over `$DSH_HOME/.env`); credentials live in
|
|
* `$DSH_HOME/.credentials.yaml` and are never hoisted into it.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { networkInterfaces } from 'node:os'
|
|
import { resolve } from 'node:path'
|
|
import { Context } from 'cordis'
|
|
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
|
import type { PatchOptions } from '@cordisjs/plugin-include'
|
|
import yaml from 'js-yaml'
|
|
import { boot, installFailLoud, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
|
// Empty type import carries the httpServer Context merge for the port read below.
|
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
|
|
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
|
|
const TELEMETRY_ROW_ID = 'telemetry-otel'
|
|
|
|
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
|
|
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] }
|
|
}
|
|
|
|
/**
|
|
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
|
|
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
|
|
* off-by-mistake over on-by-mistake. Throws when the switch is set but the
|
|
* row is absent — a silently no-op "disabled" privacy switch would keep
|
|
* exporting while the user believes it is off.
|
|
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
|
|
* @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
|
|
* @returns the disable patch, or `undefined` when telemetry stays enabled.
|
|
*/
|
|
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
|
|
if ((disabledEnv ?? '') === '') return undefined
|
|
if (!hasRow) {
|
|
throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
|
|
}
|
|
return { id: TELEMETRY_ROW_ID, disabled: true }
|
|
}
|
|
|
|
/**
|
|
* Whether a config file carries the telemetry row, parsed under the same
|
|
* `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
|
|
* that compose their patch lists outside {@link AppCLIEntry} (the TUI).
|
|
* @param file - absolute path of the config or overlay file.
|
|
* @returns true when a top-level (or inserted) row has the telemetry id.
|
|
*/
|
|
export function configHasTelemetryRow(file: string): boolean {
|
|
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
|
|
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
|
|
return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
|
|
row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
|
|
}
|
|
|
|
// 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)
|
|
|
|
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
|
|
export interface AppCLIEntryOptions {
|
|
/** This run's frozen environment, provided to the tree before any config entry mounts. */
|
|
environment: EnvironmentSnapshot
|
|
/** Absolute path of the shared base config the Loader includes. */
|
|
configPath: string
|
|
/**
|
|
* Absolute path of this surface's overlay: a patch list applied over
|
|
* {@link configPath} before this entry's own flag patches. Its rows
|
|
* are also merge inputs, so a flag override preserves the overlay's other
|
|
* fields on the same row.
|
|
*/
|
|
overlayPath: string
|
|
/** Optional `--config` overlay applied after {@link overlayPath} and before this entry's own flag patches. */
|
|
extraOverlayPath?: string
|
|
/**
|
|
* Optional `--config-replace` tree: booted INSTEAD of {@link configPath},
|
|
* {@link overlayPath}, {@link extraOverlayPath}, and every generated patch,
|
|
* so the caller's file is the whole composition. It must still supply the
|
|
* serving rows this entry needs — {@link run} rejects a settled tree with no
|
|
* `httpServer`.
|
|
*/
|
|
configReplacePath?: string
|
|
/** Whether to append client-bundle HMR (the Web surface's prod/dev difference). */
|
|
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[]
|
|
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
|
|
prepare?: (ctx: Context) => Promise<void> | void
|
|
}
|
|
|
|
/**
|
|
* 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: patch composition → Loader installation → surface
|
|
* preparation → config-tree 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.composePatches()
|
|
await this.bootTree()
|
|
this.assertBoot()
|
|
const port = this.ctx.get('httpServer')?.port
|
|
if (port === undefined) {
|
|
// The shipped tree always carries the webserver row, so this is only
|
|
// reachable through --config-replace: name the missing contract rather
|
|
// than report a bare missing service.
|
|
throw new Error(
|
|
`dsh: no httpServer after booting ${this.bootConfigPath()}; this surface serves over HTTP, so a`
|
|
+ ' --config-replace tree must mount a webserver row',
|
|
)
|
|
}
|
|
return { ctx: this.ctx, port }
|
|
}
|
|
|
|
/**
|
|
* Compose the patch set from 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()
|
|
if (this.options.configReplacePath !== undefined) {
|
|
// A replacement tree is the caller's whole composition: the generated
|
|
// patches target shipped row ids this file cannot assume exist, and a
|
|
// patch whose id is absent is a silent no-op rather than a diagnostic.
|
|
// Telemetry stays, judged against the tree actually booting, because a
|
|
// privacy switch that silently no-ops is worse than a loud one.
|
|
const replaceTelemetry = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
|
this.patches = replaceTelemetry === undefined ? [] : [replaceTelemetry]
|
|
return
|
|
}
|
|
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 1: CLI flags.
|
|
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 1b: 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 2: the frontend dist — an assembly fact of this app, never yml
|
|
// user config. Workspace knowledge stays here.
|
|
put('webserver', 'distIndex', this.resolveDistIndex())
|
|
|
|
const generated = [...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 } }
|
|
})
|
|
this.patches = generated
|
|
|
|
// Telemetry opt-out: a row can only be turned off at the patch layer
|
|
// (config cannot disable an entry), and the switch must hold BEFORE the
|
|
// plugin constructs — its exporter.url validation is load-time fail-loud.
|
|
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
|
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
|
|
}
|
|
|
|
/** Shared Loader boot; surface preparation precedes the tree, and the dev HMR row precedes the activation audit. */
|
|
private async bootTree(): Promise<void> {
|
|
// One include of the shared base with every overlay as a sibling patch
|
|
// list: patches never cross an include boundary, so nesting them would
|
|
// silently stop reaching base rows. The surface overlay applies first, then
|
|
// any --config overlay, then this entry's CLI-flag patches, which win.
|
|
// --config-replace discards all three and boots the named file alone.
|
|
const patches = this.options.configReplacePath !== undefined
|
|
? this.patches
|
|
: [
|
|
...loadOverlayPatches('dsh', this.options.overlayPath),
|
|
...this.options.extraOverlayPath === undefined
|
|
? []
|
|
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
|
|
...this.patches,
|
|
]
|
|
this.ctx = await boot('dsh', resolve(this.bootConfigPath()), patches, async (ctx) => {
|
|
// Before any config-tree entry mounts, so a plugin that resolves a
|
|
// user-facing value at construction already sees this run's layers.
|
|
ctx.provide(DSH_ENVIRONMENT_KEY, this.options.environment)
|
|
await this.options.prepare?.(ctx)
|
|
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
|
|
})
|
|
}
|
|
|
|
/** The file the Loader includes: the replacement tree when named, otherwise the shared base. */
|
|
private bootConfigPath(): string {
|
|
return this.options.configReplacePath ?? this.options.configPath
|
|
}
|
|
|
|
/** Install the diagnostic for plugin rejections that happen after settled boot. */
|
|
private assertBoot(): void {
|
|
installFailLoud('dsh')
|
|
}
|
|
|
|
/**
|
|
* Bypass parse of the base and this surface's overlay (id → row) for
|
|
* patch-merge inputs; the Loader still reads both files itself. The overlay
|
|
* wins per row, matching the order its patches are applied in, and its
|
|
* `insert` rows are indexed too because a flag may target one of them.
|
|
*/
|
|
private parseYmlRows(): Map<string, { config?: unknown }> {
|
|
const rows = new Map<string, { config?: unknown }>()
|
|
// A replacement tree stands alone, so only its own rows are indexed —
|
|
// the telemetry-row check must judge the tree that actually boots.
|
|
if (this.options.configReplacePath !== undefined) {
|
|
for (const row of this.parseRowList(this.options.configReplacePath)) {
|
|
if (typeof row.id === 'string') rows.set(row.id, row)
|
|
for (const inserted of row.insert ?? []) {
|
|
if (typeof inserted.id === 'string') rows.set(inserted.id, inserted)
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
const files = [this.options.configPath, this.options.overlayPath]
|
|
if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath)
|
|
for (const file of files) {
|
|
for (const row of this.parseRowList(file)) {
|
|
if (typeof row.id === 'string') rows.set(row.id, row)
|
|
for (const inserted of row.insert ?? []) {
|
|
if (typeof inserted.id === 'string') rows.set(inserted.id, inserted)
|
|
}
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
/**
|
|
* Parse one entry or patch list, rejecting anything that is not a top-level
|
|
* array so a malformed file fails here rather than at row lookup.
|
|
* @param file - absolute path of the config or overlay file.
|
|
* @returns the parsed top-level entries.
|
|
*/
|
|
private parseRowList(file: string): { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] {
|
|
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
|
|
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
|
|
return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: 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 run build from the repository root first')
|
|
}
|
|
}
|
|
}
|