diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 20543965dc..85c5227bd4 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' @@ -100,7 +101,9 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - return await boot('dsh-test', rootConfig, patches) + return await boot('dsh-test', rootConfig, patches, (bootCtx) => { + provideCmdline(bootCtx, { args: [], exit: () => {} }) + }) } const toolNames = (ctx: Context, agent?: Agent): string[] => diff --git a/apps/web/package.json b/apps/web/package.json index 6f4bce8dc2..1bf97cb17c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 0104a7bee7..a93828282e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -35,7 +35,6 @@ import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { - addHarnessSourceSection, assertEntriesLoaded, composeEntries, healProfilesModuleFallback, @@ -65,6 +64,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' // Empty type imports carry the httpServer/agents/sessionPersistence Context merges. import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-agent' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */ @@ -459,6 +459,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`) + }, + }) await ctx.plugin(Loader) ctx.loader.builtins.include = Include // `cordis:group` beside it, exactly as `boot()` registers it: a group row is @@ -469,9 +479,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { addHarnessSourceSection(promptCtx, REPO_ROOT) }) - } await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(rootConfig).href, patches }, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index b0acf074b4..ae5805dc8a 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -485,10 +485,13 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke child = spawn( process.execPath, [ - '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port), + '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', + // Launcher flags come first: the first token the launcher does not own + // starts the web app's own arguments. // Pin the in-browser picker: the shipped `-auto` row would resolve to // the native OS chooser on this bind, and no page can drive that. '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + '--port', String(port), ], { cwd: sessionsDir, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 7ac7f488d9..4e3953bd1b 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -28,15 +28,15 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-include": "^1.0.4", + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 2ded85f25b..2ce1b72942 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: f8b76b77f2beb22f501a49f0fc4cf5cd72223765 -README.zh.md: aae8ab5bea663b8909de942f72615f5ef9b16c84 +README.md: 45c87f0c85cbb68ad0366ea5f2c86e55fc307309 +README.zh.md: 22322692450fa85a87e9faf903abee0d38968f91 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index f8b76b77f2..45c87f0c85 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`). It mounts no Host, HTTP server, Web runtime, or browser plugin. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, shipped disabled until the startup row supplies the task). It mounts no Host, HTTP server, Web runtime, or browser plugin. -After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row. +After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the `headless-startup` row ([`src/startup.ts`](src/startup.ts)) reads it as the positional argument of `dsh --profile headless "task"` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), prints the app's `--help`, and rejects an invocation with no task instead of letting the runner's schema fail. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index aae8ab5bea..2232269245 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,在启动行供给任务之前以禁用状态交付)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 +Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:`headless-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))把它读作 `dsh --profile headless "task"` 的位置参数,打印应用自己的 `--help`,并拒绝没有任务的调用,而不是让 runner 的 schema 失败。 ## 模型体验 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 8b147714be..6904931acc 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,7 +1,8 @@ # The dsh-headless bundle patch: one-shot task mode directly over dsh-base. -# It mounts no Host, HTTP server, Web runtime, or browser plugin. The launcher -# patches the runner's `task`; the direct driver creates an Agent through the -# core registry and prints the final durable assistant message. +# It mounts no Host, HTTP server, Web runtime, or browser plugin. The startup +# row owns the task positional (`dsh --profile headless ""`) and this +# app's --help; the direct driver creates an Agent through the core registry +# and prints the final durable assistant message. - id: system-prompt config: @@ -22,5 +23,12 @@ - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' + - id: headless-startup + name: '@deepseek-ai/dsh-headless/startup' + + # Shipped off, not merely waiting: the runner's schema requires the task. + # The startup row enables it with the task after parsing this app's argv. - id: headless-runner name: '@deepseek-ai/dsh-headless' + inject: [headlessStartup] + disabled: true diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index c5090d4b93..5216aa3048 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +26,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -32,15 +37,19 @@ } }, "dependencies": { + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0", + "commander": "^15.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-default-model": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-web-app": "^0.0.1", "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -50,6 +59,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-web-app": "workspace:^", "@deepseek-ai/cordis": "^4.0.0-rc.7" } } diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts new file mode 100644 index 0000000000..eb9907a6b7 --- /dev/null +++ b/packages/bundle/headless/src/startup.ts @@ -0,0 +1,70 @@ +/** + * The one-shot app's startup row: it owns the `dsh --profile headless` command + * line — the task text is this command's positional argument — and its + * `--help` text, then provides {@link HEADLESS_STARTUP_SERVICE} with the task + * the user asked for. The runner waits for it, so a missing task is a usage + * error printed by this command instead of a schema failure inside the runner. + * + * This app layers over the web app, and a composition has exactly one + * command-line owner: the bundle patch disables the web startup row, and this + * one also provides {@link WEB_STARTUP_SERVICE} so the web rows start on their + * composed (one-shot) values. + * @module @deepseek-ai/dsh-headless/startup + */ + +import { Command } from 'commander' +import type { Context } from 'cordis' +import type { EntryOptions } from '@cordisjs/plugin-loader' +import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' +import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' + +/** Stable Cordis plugin name. */ +export const name = 'headless-startup' + +/** Services required before the task can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** The startup service the one-shot runner row injects. */ +export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' + +/** The runner row this app configures. */ +const RUNNER_ROW_ID = 'headless-runner' + +/** + * This app's command: the task positional, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function headlessCommand(): Command { + return new Command() + .name('dsh --profile headless') + .description('Answer one task, print the final assistant message, and exit.') + .helpOption('-h, --help', 'show this help') + .argument('[task...]', 'the task text; multiple words are joined by spaces') + .addHelpText('after', ` +Examples: + dsh --profile headless "run the tests" answer one task and exit +`) +} + +/** + * Turn the parsed command line into the runner row's task. + * @param program - the parsed headless command. + * @param rows - the waiting rows' composed options, in tree order. + * @returns row id → changes. + */ +function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): Map { + const task = program.args.join(' ') + if (task === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + const runner = rows.find(row => row.id === RUNNER_ROW_ID) + if (runner === undefined) throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) + return new Map([[RUNNER_ROW_ID, overrideConfig(runner, { task })]]) +} + +/** + * Resolve the task and start the rows waiting for it. + * @param ctx - plugin context carrying the command line and the Loader. + * @returns nothing once the runner is released, or once `--help` or a missing task requested exit. + */ +export function apply(ctx: Context): Promise { + return runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) +} diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts new file mode 100644 index 0000000000..1b4d6f1430 --- /dev/null +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -0,0 +1,146 @@ +/** + * The one-shot app's startup row over a REAL Loader tree: the task + * positional reaches the runner row, a missing task is a usage error, and the + * web startup service this app absorbs releases its rows on the composed values. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import z from 'schemastery' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' +import { afterEach, describe, expect, it } from 'vitest' +import { apply, HEADLESS_STARTUP_SERVICE } from '../src/startup.ts' + +/** What one boot of the fixture tree observed. */ +interface Observed { + started: Record> + exits: number[] + out: string + /** Patches the startup row handed the launcher for later compositions. */ + contributed: unknown[] +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** + * Boot the real headless startup row over stand-ins for the runner row and one + * web row it absorbs. + * @param args - the invocation's inner arguments. + * @returns what the boot observed. + */ +async function bootStartup(args: string[], options: { withoutRunner?: boolean } = {}): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) + const observed: Observed = { started: {}, exits: [], out: '', contributed: [] } + // The runner's real schema requires the task, which is exactly what makes a + // waiting-but-enabled row fail at fiber creation; the stand-in keeps that. + writeFileSync(join(dir, 'row.mjs'), ` +export const Config = globalThis.__headlessRunnerConfigSchema +export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } +`) + writeFileSync(join(dir, 'plain-row.mjs'), ` +export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } +`) + // The Loader imports a row through Node's own resolver, which cannot resolve + // this workspace's sources; the row delegates to the real plugin the test + // imported through the source-plane path mapping. + writeFileSync(join(dir, 'startup-row.mjs'), ` +export const name = 'headless-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__headlessStartupApply(ctx) +`) + const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href + const plainRowUrl = pathToFileURL(join(dir, 'plain-row.mjs')).href + writeFileSync(join(dir, 'cordis.yml'), [ + // A composition that lost the runner still injects the startup service, so + // the startup row reaches its own row check rather than the generic one. + options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner', + ` name: ${rowUrl}`, + ` inject: [${HEADLESS_STARTUP_SERVICE}]`, + // Shipped off, like the bundle patch: the schema below requires the task, + // which only the startup row can supply. + ' disabled: true', + '- id: webserver', + ` name: ${plainRowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' config:', + ' port: 0', + '- id: headless-startup', + ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`, + '', + ].join('\n')) + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { + __headlessStartupObserved: Observed + __headlessStartupApply: typeof apply + __headlessRunnerConfigSchema: unknown + } + globals.__headlessStartupObserved = observed + globals.__headlessStartupApply = apply + globals.__headlessRunnerConfigSchema = z.object({ task: z.string().required() }) + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { + args, + exit: code => void observed.exits.push(code), + contribute: patches => void observed.contributed.push(...patches), + }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return observed +} + +describe('headless startup', () => { + it('joins the task positional and starts the runner with it', async () => { + const observed = await bootStartup(['run', 'the', 'tests']) + expect(observed.started['headless-runner']).toEqual({ task: 'run the tests' }) + expect(observed.exits).toEqual([]) + }) + + it('hands the task to the launcher as a patch, so a recomposition keeps it', async () => { + const observed = await bootStartup(['run', 'the', 'tests']) + expect(observed.contributed).toEqual([ + { id: 'headless-runner', disabled: false, config: { task: 'run the tests' } }, + ]) + }) + + it('starts the web rows it absorbed on the composed one-shot values', async () => { + const observed = await bootStartup(['task']) + expect(observed.started.webserver).toEqual({ port: 0 }) + }) + + it('rejects an invocation with no task instead of failing inside the runner schema', async () => { + const observed = await bootStartup([]) + expect(observed.out).toContain('a task is required') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([1]) + }) + + it('fails the boot when the composition has no runner row to give the task to', async () => { + await expect(bootStartup(['task'], { withoutRunner: true })) + .rejects.toThrow('the composition has no waiting "headless-runner" row') + }) + + it('prints its own help and starts nothing', async () => { + const observed = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile headless') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([0]) + }) +}) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 9ae3212f6b..17d11ed3ff 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -31,6 +31,12 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../ui/cmdline" + }, + { + "path": "../web-app" } ] } diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 6594cdb6d1..e702feca98 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: d89ae4a7e28506166498caf0032f864bbb109cc5 -README.zh.md: 746ec2e8b6748a0d72f697d0aea5f3809e7106ee +README.md: 1b54e6d29ad49c62b7862bf7fffcd6d24831c643 +README.zh.md: 82b7c4c2574aa93697e8483c362cf4ec75630f34 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index d89ae4a7e2..1b54e6d29a 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. This bundle also owns the app command line: the `web-startup` row ([`src/startup.ts`](src/startup.ts)) parses `--host`, `--port`, `--dev`, `--workspace-root`, and repeatable `--trusted-host` from `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)) and prints the app's `--help`. Every row it configures injects `webStartup`, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. `mode` and `lanAddresses` resolve on every boot because they describe the invocation. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 746ec2e8b6..82b7c4c257 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。本组合包还持有应用命令行:`web-startup` 行([`src/startup.ts`](src/startup.ts))从 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md))解析 `--host`、`--port`、`--dev`、`--workspace-root` 以及可重复的 `--trusted-host`,并打印应用自己的 `--help`。它所配置的每一行都注入 `webStartup`,因此在参数解析完成之前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。`mode` 与 `lanAddresses` 在每次 boot 时解析,因为它们描述的是本次调用。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 69335c90ac..7825d68383 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -3,9 +3,13 @@ # the profile's own cordis.patch.yml and any --patch overlays still to come. # # A patch replaces the targeted row's whole `config`, so each row below -# restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--trusted-host into further patches over these rows -# (`--dev` inserts the dsh-client-hmr row). +# restates every key it owns. +# +# Rows this app configures from flags declare `inject: [webStartup]`: they wait +# until the web-startup row has parsed --host/--port/--dev/--workspace-root/ +# --trusted-host and provided that service with the resolved values. +# `dsh --profile web --help` therefore prints this app's own help and exits +# without ever binding a port. # ── surface-specific values the base deliberately omits ───────────────────── @@ -76,6 +80,11 @@ - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' + # Owns the web flag family and its --help; provides webStartup with the + # values this invocation resolved. Nothing waiting on it starts first. + - id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' + # ── layer 2: transport/service ────────────────────────────────────────────── # Plain route-registration carrier; host and port arrive as `dsh web` @@ -83,6 +92,7 @@ # row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] config: host: 127.0.0.1 port: 3080 @@ -96,12 +106,19 @@ # these host-owned shell variables. - id: web-runtime name: '@deepseek-ai/dsh-web-app' + inject: [webStartup] config: mode: production printUrl: true surfaceContext: true - # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── + # The client-plugin HMR receiver ships disabled; `--dev` enables it. + - id: client-hmr + name: '@deepseek-ai/dsh-client-hmr' + inject: [webStartup] + disabled: true + + # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── # Dual-face: node half scans this very tree for dsh.client rows, composes # window.__DSH_BOOT__, serves /plugins//client.js; browser half is the @@ -114,6 +131,7 @@ # webserver under /api; browser half is the fetch/SSE client. - id: connection name: '@deepseek-ai/dsh-client-connection' + inject: [webStartup] - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index f8cd162a11..e8240e1b63 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -11,6 +11,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +26,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -61,6 +66,7 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-frontend-static": "workspace:^", @@ -74,15 +80,18 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "^3.18.0", + "commander": "^15.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts new file mode 100644 index 0000000000..1b1969b0d6 --- /dev/null +++ b/packages/bundle/web-app/src/startup.ts @@ -0,0 +1,152 @@ +/** + * The web app's startup row: it owns the `dsh --profile web` flag family + * (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and its + * `--help` text, turns those flags into changes on the rows that inject + * {@link WEB_STARTUP_SERVICE}, and then provides it. Until it does, no web row + * starts, so `dsh --profile web --help` prints this command's help and the + * server never binds. + * @module @deepseek-ai/dsh-web-app/startup + */ + +import { networkInterfaces } from 'node:os' +import { Command } from 'commander' +import type { Context } from 'cordis' +import type { EntryOptions } from '@cordisjs/plugin-loader' +import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' + +/** Stable Cordis plugin name. */ +export const name = 'web-startup' + +/** Services required before the flags can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** + * The startup service every flag-configured web row injects. The rows are + * listed in this bundle's `cordis.patch.yml`; a row this startup plans changes + * for without injecting the service fails loud. + */ +export const WEB_STARTUP_SERVICE = 'webStartup' + +/** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ +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 => 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-runtime` 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 before the server binds. + * @param bindHost - the effective webserver bind host (the 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 web flag family, as commander parsed it. */ +interface WebOptions { + host?: string + port?: string + dev?: boolean + workspaceRoot?: string + trustedHost?: string[] +} + +/** + * This app's command: its flags, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function webCommand(): Command { + return new Command() + .name('dsh --profile web') + .description('Serve the DeepSeek Harness browser UI.') + .helpOption('-h, --help', 'show this help') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') + .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') + .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') + .addHelpText('after', ` +Examples: + dsh web serve on the composed host and port + dsh web --port 8080 serve on another port + dsh web --host 0.0.0.0 reach it from another machine on the LAN + dsh web --dev mount the client-plugin HMR receiver +`) +} + +/** + * Turn the parsed flags into the changes each waiting row needs. + * @param program - the parsed web command. + * @param rows - the waiting rows' composed options, in tree order. + * @returns row id → changes; rows absent from the map start on their composed values. + */ +function planWebStartup(program: Command, rows: readonly EntryOptions[]): Map { + const options = program.opts() + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + const row = (id: string): EntryOptions => { + const found = rows.find(candidate => candidate.id === id) + if (found === undefined) throw new Error(`web-startup: the web composition has no waiting "${id}" row to configure`) + return found + } + const plan = new Map() + const webserver = row('webserver') + const composedHost = (webserver.config as { host?: string } | undefined)?.host + plan.set('webserver', overrideConfig(webserver, { + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + })) + if (options.workspaceRoot !== undefined) { + plan.set('api-gateway', overrideConfig(row('api-gateway'), { workspaceRoot: options.workspaceRoot })) + } + const { lanAddresses, trustedHosts } = resolveLanTrust(options.host ?? composedHost, options.trustedHost ?? []) + if (trustedHosts.length > 0) { + // Additive over the composed value: a cordis.patch.yml-configured fence + // authority must survive the derived LAN literals and the flag extras — + // dropping it silently would weaken security-relevant configuration. + const connection = row('connection') + const composedTrusted = (connection.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] + plan.set('connection', overrideConfig(connection, { trustedHosts: [...composedTrusted, ...trustedHosts] })) + } + // mode and lanAddresses are resolved on every boot, never pass-throughs of + // composed values: they describe this invocation, not the deployment. + plan.set('web-runtime', overrideConfig(row('web-runtime'), { + mode: options.dev === true ? 'development' : 'production', + lanAddresses, + })) + // The receiver ships disabled so `--dev` is a row toggle rather than a + // runtime insert (the Loader cannot resolve a row inserted from inside a + // mounting plugin). + if (options.dev === true) plan.set('client-hmr', { disabled: false }) + return plan +} + +/** + * Resolve the web flag family and start the rows waiting for it. + * @param ctx - plugin context carrying the command line and the Loader. + * @returns nothing once the waiting rows are released, or once `--help` requested exit. + */ +export function apply(ctx: Context): Promise { + return runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) +} diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts new file mode 100644 index 0000000000..c3cdfa08dc --- /dev/null +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -0,0 +1,163 @@ +/** + * The web app's startup row over a REAL Loader tree carrying this bundle's + * waiting row ids: flags reach the rows they configure, absent flags leave the + * composed values standing, `--dev` enables the shipped-disabled HMR receiver, + * and `--help` leaves the app unstarted. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply, WEB_STARTUP_SERVICE } from '../src/startup.ts' + +vi.mock('node:os', async importOriginal => ({ + ...await importOriginal(), + networkInterfaces: () => ({ + lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], + }), +})) + +/** What one boot of the fixture tree observed. */ +interface Observed { + /** Config each waiting row started with, by row id; absent means it never started. */ + started: Record> + exits: number[] + out: string +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** One stand-in for a row this bundle's patch makes wait for the web startup. */ +interface WaitingRow { + id: string + config?: Record + disabled?: boolean +} + +/** The waiting rows this bundle's patch declares, with the composed values they ship. */ +const WAITING_ROWS: WaitingRow[] = [ + { id: 'webserver', config: { host: '127.0.0.1', port: 3080 } }, + { id: 'api-gateway', config: { provider: 'deepseek-official' } }, + { id: 'connection', config: { trustedHosts: ['configured.internal'] } }, + { id: 'web-runtime', config: { mode: 'production', printUrl: true } }, + { id: 'client-hmr', disabled: true }, +] + +/** + * Boot the real startup row over stand-ins for this bundle's waiting rows. + * @param args - the invocation's inner arguments. + * @returns what the boot observed. + */ +async function bootStartup(args: string[], rows: readonly WaitingRow[] = WAITING_ROWS): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) + const observed: Observed = { started: {}, exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), ` +export function apply(ctx, config) { globalThis.__webStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } +`) + // The Loader imports a row through Node's own resolver, which cannot resolve + // this workspace's sources; the row delegates to the real plugin the test + // imported through the source-plane path mapping. + writeFileSync(join(dir, 'startup-row.mjs'), ` +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__webStartupApply(ctx) +`) + const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href + const lines = rows.flatMap(row => [ + `- id: ${row.id}`, + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ...row.disabled === true ? [' disabled: true'] : [], + ...row.config === undefined ? [] : [' config:', ...Object.entries(row.config).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)], + ]) + lines.push('- id: web-startup', ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`) + writeFileSync(join(dir, 'cordis.yml'), lines.join('\n') + '\n') + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { __webStartupObserved: Observed; __webStartupApply: typeof apply } + globals.__webStartupObserved = observed + globals.__webStartupApply = apply + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return observed +} + +describe('web startup', () => { + it('applies each flag to the row that owns it and leaves the rest composed', async () => { + const observed = await bootStartup(['--port', '8080', '--workspace-root', '/w']) + expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 8080 }) + expect(observed.started['api-gateway']).toEqual({ provider: 'deepseek-official', workspaceRoot: '/w' }) + expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: [] }) + expect(observed.started['client-hmr']).toBeUndefined() + expect(observed.exits).toEqual([]) + }) + + it('starts every row on its composed values when the invocation carries no flags', async () => { + const observed = await bootStartup([]) + expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 3080 }) + expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal'] }) + }) + + it('adds the LAN literals over the configured fence authorities for an all-interfaces bind', async () => { + const observed = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) + expect(observed.started.webserver).toEqual({ host: '0.0.0.0', port: 3080 }) + expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal', '192.168.1.5', 'lab.internal'] }) + // Display gets the same single sample the fence was configured with. + expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: ['192.168.1.5'] }) + }) + + it('enables the shipped-disabled HMR receiver for --dev', async () => { + const observed = await bootStartup(['--dev']) + expect(observed.started['client-hmr']).toEqual({}) + expect(observed.started['web-runtime']).toEqual({ mode: 'development', printUrl: true, lanAddresses: [] }) + }) + + it('prints its own help and starts nothing', async () => { + const observed = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile web') + expect(observed.out).toContain('--trusted-host') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([0]) + }) + + it('fails the boot when the composition lost a row this app configures', async () => { + // The bundle patch and this startup plugin must agree on the row set; a + // missing row would otherwise silently drop the flag that targets it. + const withoutWebserver = WAITING_ROWS.filter(row => row.id !== 'webserver') + await expect(bootStartup([], withoutWebserver)) + .rejects.toThrow('the web composition has no waiting "webserver" row') + }) + + it('derives the fence authorities alone when the composition configured none', async () => { + const withoutTrust = WAITING_ROWS.map(row => row.id === 'connection' ? { id: 'connection' } : row) + const observed = await bootStartup(['--host', '0.0.0.0'], withoutTrust) + expect(observed.started.connection).toEqual({ trustedHosts: ['192.168.1.5'] }) + }) + + it('rejects a non-numeric port before anything binds', async () => { + const observed = await bootStartup(['--port', 'abc']) + expect(observed.out).toContain('--port must be a number') + expect(observed.started).toEqual({}) + expect(observed.exits).toEqual([1]) + }) +}) diff --git a/packages/bundle/web-app/tests/trusted-hosts.spec.ts b/packages/bundle/web-app/tests/trusted-hosts.spec.ts new file mode 100644 index 0000000000..110aaeae61 --- /dev/null +++ b/packages/bundle/web-app/tests/trusted-hosts.spec.ts @@ -0,0 +1,33 @@ +/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ + +import { describe, expect, it, vi } from 'vitest' +import { resolveLanTrust } from '../src/startup.ts' + +vi.mock('node:os', () => ({ + networkInterfaces: () => ({ + lo0: [ + { family: 'IPv4', internal: true, address: '127.0.0.1' }, + ], + en0: [ + { family: 'IPv6', internal: false, address: 'fe80::1' }, + { family: 'IPv4', internal: false, address: '192.168.1.5' }, + ], + en1: [ + { family: 'IPv4', internal: false, address: '10.0.0.7' }, + ], + utun0: undefined, + }), +})) + +describe('resolveLanTrust', () => { + it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { + const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) + expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) + expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) + }) + + it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) + expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) + }) +}) diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index 6aadb534cb..b15ebb1664 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -14,6 +14,12 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../ui/cmdline" + }, { "path": "../../host/frontend-static" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d890741ac..a05f336f3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: '@deepseek-ai/dsh-client-web-react': specifier: workspace:^ version: link:../../packages/client/web-react + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/bash/pwsh-local @@ -1181,18 +1184,18 @@ importers: specifier: ^15.0.0 version: 15.0.0 devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bundle/base: dependencies: @@ -1443,12 +1446,18 @@ importers: packages/bundle/headless: dependencies: + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 @@ -1471,6 +1480,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-web-app': + specifier: workspace:^ + version: link:../web-app packages/bundle/web-app: dependencies: @@ -1558,6 +1570,9 @@ importers: '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../../client/ui-workspace + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker @@ -1600,10 +1615,16 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env