Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # apps/cli/README.i18n.yaml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/args.ts # apps/cli/src/bin.ts # apps/cli/src/web.ts # apps/cli/tests/args.spec.ts # docs/architecture.i18n.yaml # docs/config-catalog.md # docs/module-graph.md # packages/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/src/client/api.ts # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/host/apiproxy/src/api/host.schema.ts # packages/host/apiproxy/src/index.ts # packages/ui/tui/README.i18n.yaml # pnpm-lock.yaml
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
|
||||
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'
|
||||
@@ -25,6 +26,41 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
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
|
||||
@@ -83,6 +119,8 @@ export interface AppCLIEntryOptions {
|
||||
provider?: string
|
||||
/** Host default model override. */
|
||||
model?: string
|
||||
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +133,14 @@ 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[] = []
|
||||
private piAiProvider: string | undefined
|
||||
|
||||
@@ -171,6 +217,13 @@ export class AppCLIEntry {
|
||||
}
|
||||
this.piAiProvider = provider === 'deepseek' ? undefined : provider
|
||||
|
||||
// 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())
|
||||
|
||||
@@ -43,6 +43,8 @@ interface WebInvocation {
|
||||
workspaceRoot?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
|
||||
@@ -56,6 +58,7 @@ interface WebOptions {
|
||||
workspaceRoot?: string
|
||||
provider?: string
|
||||
model?: string
|
||||
trustedHost?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,6 +76,7 @@ function resolveWeb(options: WebOptions): WebInvocation {
|
||||
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
|
||||
...options.provider !== undefined && { provider: options.provider },
|
||||
...options.model !== undefined && { model: options.model },
|
||||
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +130,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
|
||||
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
|
||||
.option('--provider <name>', 'override the host default provider')
|
||||
.option('--model <id>', 'override the host default model')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.action((options: WebOptions) => {
|
||||
// Commander parses the parent (default-surface) options on either side of
|
||||
// the subcommand into `program.opts()`. `web` shares none of them, so a
|
||||
|
||||
@@ -37,6 +37,7 @@ switch (invocation.mode) {
|
||||
invocation.workspaceRoot,
|
||||
invocation.provider,
|
||||
invocation.model,
|
||||
invocation.trustedHosts,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { Context } from 'cordis'
|
||||
import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
|
||||
import {
|
||||
TUI_GOODBYE_MESSAGE_KEY,
|
||||
type TuiResumeHost,
|
||||
} from '@deepseek-ai/dsh-tui'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
@@ -70,8 +73,10 @@ export async function runTui(config: string | undefined, resumeSessionId: string
|
||||
const entry = process.argv[1]
|
||||
const execve = process.execve?.bind(process)
|
||||
const app: { current?: Context } = {}
|
||||
const resumeCommand = (sessionId: string): string =>
|
||||
`${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}`
|
||||
const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : {
|
||||
async handoff(sessionId): Promise<never> {
|
||||
async handoff(sessionId, cwd): Promise<never> {
|
||||
const current = app.current
|
||||
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
|
||||
// Rebuild argv from the parsed config plus the selected id: TUI mode's
|
||||
@@ -83,6 +88,11 @@ export async function runTui(config: string | undefined, resumeSessionId: string
|
||||
`--resume=${sessionId}`,
|
||||
...config !== undefined ? ['--config', config] : [],
|
||||
]
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
} catch (error) {
|
||||
throw new Error(`${NAME}: cannot resume in "${cwd}": ${String(error)}`)
|
||||
}
|
||||
try {
|
||||
await current.fiber.dispose()
|
||||
execve(process.execPath, nextArgv, process.env)
|
||||
@@ -101,6 +111,9 @@ export async function runTui(config: string | undefined, resumeSessionId: string
|
||||
// Inject the resume id (or undefined) so the shipped config's `!!js`
|
||||
// reads it as a bare identifier; then offer the in-place handoff host.
|
||||
hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
|
||||
if (resumeSessionId !== undefined) {
|
||||
hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`)
|
||||
}
|
||||
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,17 +6,14 @@
|
||||
* gates them at boot.
|
||||
*/
|
||||
|
||||
import { networkInterfaces } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
|
||||
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
||||
|
||||
// Display-only mirrors of the webserver schema's allowed hosts: the loopback
|
||||
// address the local URL always prints, and the all-interfaces value that gates
|
||||
// LAN-address discovery. Not a source of truth — the schema is.
|
||||
// Display-only mirror of the webserver schema's loopback host: the address the
|
||||
// local URL always prints. Not a source of truth — the schema is.
|
||||
const LOOPBACK_HOST = '127.0.0.1'
|
||||
const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
|
||||
/**
|
||||
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
|
||||
@@ -27,6 +24,7 @@ const ALL_INTERFACES_HOST = '0.0.0.0'
|
||||
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
|
||||
* @param provider - provider override, or `undefined` to keep the profile/config route.
|
||||
* @param model - model override, or `undefined` to keep the profile/config route.
|
||||
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
|
||||
*/
|
||||
export async function runWeb(
|
||||
host: string | undefined,
|
||||
@@ -35,6 +33,7 @@ export async function runWeb(
|
||||
workspaceRoot: string | undefined,
|
||||
provider: string | undefined,
|
||||
model: string | undefined,
|
||||
trustedHosts: string[] | undefined,
|
||||
): Promise<void> {
|
||||
const entry = new AppCLIEntry({
|
||||
configPath: CONFIG_PATH,
|
||||
@@ -44,6 +43,7 @@ export async function runWeb(
|
||||
...workspaceRoot !== undefined && { workspaceRoot },
|
||||
...provider !== undefined && { provider },
|
||||
...model !== undefined && { model },
|
||||
...trustedHosts !== undefined && { trustedHosts },
|
||||
})
|
||||
const { ctx, port: boundPort } = await entry.run()
|
||||
|
||||
@@ -54,12 +54,11 @@ export async function runWeb(
|
||||
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
|
||||
}
|
||||
|
||||
const lanCandidate = host === ALL_INTERFACES_HOST
|
||||
? Object.values(networkInterfaces()).flat()
|
||||
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
|
||||
: undefined
|
||||
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
|
||||
// must name an address the /api trust fence was configured with.
|
||||
const lanCandidate = entry.lanAddresses[0]
|
||||
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
|
||||
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
|
||||
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
|
||||
|
||||
process.on('SIGTERM', () => { shutdown(0) })
|
||||
process.on('SIGINT', () => { shutdown(130) })
|
||||
|
||||
Reference in New Issue
Block a user