The shipped surfaces loaded the harness home's .env into the process environment before cordis booted. credentials-local then saw every stored key as an ambient launch override: describe reported source 'env' with writable false, and set/unset rejected as shadowed — so a key the web page or TUI stored was unrotatable and undeletable from the next run onward, and the adapter kept using the value captured at launch. The home's .env is now the credential provider's own store, read by that provider alone and hot-reloaded by it. The genuine launch environment and the invoking directory's .env (loaded by the bin) remain the read-only ambient layer, so a plain composition without the provider still resolves keys exactly as before. Proven by a real restart in the loader composition: store a key through the seam, dispose the tree, re-boot over the same harness home, and the entry is still file-sourced and writable — rotating it lands on the very next request.
296 lines
13 KiB
TypeScript
296 lines
13 KiB
TypeScript
/**
|
|
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
|
* (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
|
|
* Everything here is what must exist before the Loader runs: the patch
|
|
* composition over the shipped cordis.yml (profile json + CLI flags + the
|
|
* resolved frontend dist) and the fail-loud triple after the tree settles.
|
|
* The environment is what the bin already loaded (ambient plus the invoking
|
|
* directory's `.env`); `$DSH_HOME/.env` belongs to the credential provider.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import { createRequire } from 'node:module'
|
|
import { networkInterfaces } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { Context } from 'cordis'
|
|
import type { FiberState } from 'cordis'
|
|
import Loader from '@cordisjs/plugin-loader'
|
|
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
|
import yaml from 'js-yaml'
|
|
import { assertEntriesLoaded, installFailLoud } from '@deepseek-ai/dsh-app-boot'
|
|
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
|
// Empty type import carries the httpServer Context merge for the port read below.
|
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
|
|
/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */
|
|
const PROFILE_DIR = '.dsh-tmp-profile'
|
|
const PROFILE_FILE = 'config.json'
|
|
|
|
/** 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] }
|
|
}
|
|
|
|
/** One profile-json key mapped onto a yml row's config field. */
|
|
interface ProfileMapping {
|
|
jsonPath: string
|
|
entryId: string
|
|
configKey: string
|
|
}
|
|
|
|
/**
|
|
* The static profile→row mapping table. json is user config and wins over the
|
|
* yml engineering default per field; a json key absent from this table fails
|
|
* loud (a typo silently ignored would read as "setting has no effect").
|
|
* Developers extend deployments by adding rows here.
|
|
*/
|
|
const PROFILE_MAPPINGS: ProfileMapping[] = [
|
|
{ jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' },
|
|
{ jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' },
|
|
{ jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' },
|
|
]
|
|
|
|
// 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)
|
|
|
|
/**
|
|
* Value mirror of cordis's `FiberState` const enum members the sweep needs
|
|
* (a const enum has no runtime object to import; same rationale as the
|
|
* client-side mirror in dsh-client-web).
|
|
*/
|
|
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
|
const FIBER_PENDING = 0 as FiberState.PENDING
|
|
|
|
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
|
|
export interface AppCLIEntryOptions {
|
|
/** Absolute path of the shipped cordis.yml. */
|
|
configPath: string
|
|
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
|
|
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[]
|
|
}
|
|
|
|
/**
|
|
* 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: layered env → patch composition → Loader include
|
|
* 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
|
|
/* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
|
|
if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
|
|
return { ctx: this.ctx, port }
|
|
}
|
|
|
|
/**
|
|
* Compose the patch set from the non-yml config sources: computed
|
|
* engineering defaults (the global session root), profile json (user
|
|
* config, overriding those defaults), 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()
|
|
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 0: computed engineering defaults. The session store defaults to
|
|
// a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history
|
|
// is shared across every cwd, not a project-local ./.sessions. The profile
|
|
// (Source 1) overwrites this same field via last-write-wins in put().
|
|
put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions'))
|
|
|
|
// Source 1: profile json (missing file = empty; unmapped key = loud).
|
|
for (const [key, value] of Object.entries(this.readProfile())) {
|
|
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
|
|
if (mapping === undefined) {
|
|
throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
|
|
}
|
|
put(mapping.entryId, mapping.configKey, value)
|
|
}
|
|
|
|
// Source 2: CLI flags (field set disjoint from the json mappings).
|
|
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 2b: 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 3: the frontend dist — an assembly fact of this app, never yml
|
|
// user config. Workspace knowledge stays here.
|
|
put('webserver', 'distIndex', this.resolveDistIndex())
|
|
|
|
this.patches = [...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 } }
|
|
})
|
|
}
|
|
|
|
/** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */
|
|
private async bootTree(): Promise<void> {
|
|
const ctx = new Context()
|
|
ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/'
|
|
await ctx.plugin(Loader)
|
|
ctx.loader.builtins.include = Include
|
|
await ctx.loader.create({
|
|
name: 'cordis:include',
|
|
config: {
|
|
path: pathToFileURL(resolve(this.options.configPath)).href,
|
|
...this.patches.length > 0 ? { patches: this.patches } : {},
|
|
},
|
|
})
|
|
if (this.options.dev) {
|
|
await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
|
|
}
|
|
this.ctx = ctx
|
|
await ctx.loader.await()
|
|
}
|
|
|
|
/**
|
|
* Fail-loud triple: assertEntriesLoaded catches import failures,
|
|
* installFailLoud catches late apply rejections, and the all-ACTIVE sweep
|
|
* below catches PENDING fibers (cordis inject waiting has no timeout).
|
|
*/
|
|
private assertBoot(): void {
|
|
installFailLoud('dsh')
|
|
assertEntriesLoaded(this.ctx, 'dsh')
|
|
const failures: string[] = []
|
|
for (const entry of this.ctx.loader.entries()) {
|
|
if (entry.fiber === undefined || entry.disabled) continue
|
|
const state = entry.fiber.state
|
|
if (state === FIBER_ACTIVE) continue
|
|
if (state === FIBER_PENDING) {
|
|
const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined)
|
|
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
|
|
} else {
|
|
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
|
|
}
|
|
}
|
|
|
|
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
|
|
private parseYmlRows(): Map<string, { config?: unknown }> {
|
|
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
|
|
if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
|
|
const rows = new Map<string, { config?: unknown }>()
|
|
for (const row of doc as { id?: string; config?: unknown }[]) {
|
|
if (typeof row.id === 'string') rows.set(row.id, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
/** Profile json under cwd; read-only — never created here, absent = no user config. */
|
|
private readProfile(): Record<string, unknown> {
|
|
let raw: string
|
|
try {
|
|
raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8')
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
|
|
throw error
|
|
}
|
|
const parsed: unknown = JSON.parse(raw)
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
|
|
}
|
|
return parsed as Record<string, 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 --filter @deepseek-ai/dsh-frontend build first')
|
|
}
|
|
}
|
|
}
|