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:
creatixchu
2026-07-29 14:38:34 +08:00
307 changed files with 10892 additions and 1737 deletions

View File

@@ -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())

View File

@@ -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

View File

@@ -37,6 +37,7 @@ switch (invocation.mode) {
invocation.workspaceRoot,
invocation.provider,
invocation.model,
invocation.trustedHosts,
)
break
}

View File

@@ -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)
},
)

View File

@@ -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) })