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:
@@ -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 apps/cli/README.md
|
||||
README.md: 6da9b0f1af393414f8bdb99802f6c6e39fa5d8be
|
||||
README.zh.md: 8e50733f2ac5c7f53f317fb9c6f614de0c38ada1
|
||||
README.md: a6e266898be18c9b4c061b937084d7b65aea4de8
|
||||
README.zh.md: eea857e1bd91cd62ca2275414e2b251db9b8af38
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
|
||||
|
||||
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot.
|
||||
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
|
||||
|
||||
The TUI surface:
|
||||
|
||||
@@ -16,6 +16,8 @@ The TUI surface:
|
||||
|
||||
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider <name> --model <id>` mounts that pi-ai catalog route with provider-native ambient credentials; the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
|
||||
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
|
||||
|
||||
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
|
||||
|
||||
## Install (developer machine)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。
|
||||
|
||||
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。
|
||||
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
|
||||
|
||||
TUI 界面:
|
||||
|
||||
@@ -16,6 +16,8 @@ TUI 界面:
|
||||
|
||||
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。`dsh web --provider <name> --model <id>` 会挂载对应的 pi-ai catalog 路由,并使用提供方原生的环境凭据;默认 DeepSeek 路由仍仅支持文本。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
|
||||
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
|
||||
|
||||
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
|
||||
|
||||
## 安装(开发机)
|
||||
|
||||
@@ -89,6 +89,19 @@
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
|
||||
# Common pi-ai provider routes read credentials and endpoint overrides from the
|
||||
# boot's layered environment.
|
||||
- id: llm-pi-ai
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai'
|
||||
config:
|
||||
providers:
|
||||
- provider: openai
|
||||
apiKey: !!js process.env.OPENAI_API_KEY
|
||||
baseURL: !!js process.env.OPENAI_BASE_URL
|
||||
- provider: anthropic
|
||||
apiKey: !!js process.env.ANTHROPIC_API_KEY
|
||||
baseURL: !!js process.env.ANTHROPIC_BASE_URL
|
||||
|
||||
# Transient-failure recovery around the loop's model calls (same policy as
|
||||
# the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff).
|
||||
- id: llm-retry
|
||||
@@ -133,8 +146,45 @@
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
- id: bash-local
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
# The sandboxed product path (the acp-agent composition): per-platform
|
||||
# runner provider, the shared policy home, the confined bash executor, and
|
||||
# the approval seam its escalation asks through. The web deployment default
|
||||
# is danger-full-access + never (same behavior as the former bash-local
|
||||
# rows); DSH_PERMISSION_MODE opts a process into a confined default, and
|
||||
# per-session switches ride the /permission command's knob events.
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
- id: bash-sandbox
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
- id: approval
|
||||
name: '@deepseek-ai/dsh-user-approval'
|
||||
config:
|
||||
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'"
|
||||
|
||||
# Presets over the two knobs (requires the confining executor + approval):
|
||||
# the web permission chip's table, served through the permissions projection
|
||||
# and switched through /permission.
|
||||
- id: permission
|
||||
name: '@deepseek-ai/dsh-permission'
|
||||
config:
|
||||
presets:
|
||||
read-only:
|
||||
sandbox: read-only
|
||||
approval: ask
|
||||
workspace-write:
|
||||
sandbox: workspace-write
|
||||
approval: ask
|
||||
danger-full-access:
|
||||
sandbox: danger-full-access
|
||||
approval: never
|
||||
|
||||
- id: tool-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash'
|
||||
@@ -146,9 +196,11 @@
|
||||
name: '@deepseek-ai/dsh-tool-tasks'
|
||||
|
||||
# fs cwd stays the package default (process.cwd()) — the same value the
|
||||
# gateway injects into session.cwd, so paths and sessions agree.
|
||||
- id: fs-local
|
||||
name: '@deepseek-ai/dsh-fs-local'
|
||||
# gateway injects into session.cwd, so paths and sessions agree. The
|
||||
# sandboxed backend rides the SAME policy as bash: write/edit fence by the
|
||||
# effective mode, so read/write/edit stay available under every mode.
|
||||
- id: fs-sandbox
|
||||
name: '@deepseek-ai/dsh-fs-sandbox'
|
||||
|
||||
- id: fs-policy
|
||||
name: '@deepseek-ai/dsh-fs-policy'
|
||||
@@ -265,6 +317,13 @@
|
||||
# The API gateway: the transport-agnostic dispatch face every client shape
|
||||
# shares. provider/model are the host default routing — the profile json's
|
||||
# mapping target (user config overrides these engineering defaults).
|
||||
# Directory-picking package, dual-face: the node half serves the gateway's
|
||||
# host.* picker RPCs, the browser half fills ui-workspace's directory-flow
|
||||
# slots — one row composes the whole interaction. Swap point: mount
|
||||
# '-native' instead for the host-display OS chooser.
|
||||
- id: directory-picker
|
||||
name: '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||
|
||||
- id: api-gateway
|
||||
name: '@deepseek-ai/dsh-host-apiproxy'
|
||||
config:
|
||||
@@ -349,6 +408,10 @@
|
||||
- id: ui-model
|
||||
name: '@deepseek-ai/dsh-client-ui-model'
|
||||
|
||||
# The /permission popup picker (hostBacked over the host /permission command).
|
||||
- id: ui-permission
|
||||
name: '@deepseek-ai/dsh-client-ui-permission'
|
||||
|
||||
# Plan control: the composer plan seat over the plan projection + /plan channel.
|
||||
- id: ui-plan
|
||||
name: '@deepseek-ai/dsh-client-ui-plan'
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
@@ -33,6 +33,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-models": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
@@ -49,18 +50,23 @@
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
@@ -92,6 +98,7 @@
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace": "workspace:^",
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -45,6 +45,9 @@ describe('parseDshArgs', () => {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-opus-4-8',
|
||||
})
|
||||
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
})
|
||||
|
||||
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
|
||||
|
||||
33
apps/cli/tests/trusted-hosts.spec.ts
Normal file
33
apps/cli/tests/trusted-hosts.spec.ts
Normal file
@@ -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/app-cli-entry.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'] })
|
||||
})
|
||||
})
|
||||
@@ -50,6 +50,9 @@
|
||||
{
|
||||
"path": "../../packages/client/ui-models"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-permission"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/locale"
|
||||
},
|
||||
|
||||
@@ -119,6 +119,10 @@ it('projects titles and routes the next turn through the selected model in the b
|
||||
await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) })
|
||||
const revised = titleSurfaces(revisedLabel)
|
||||
|
||||
// fx-alpha carries the fixture's resident answerable approval, so the
|
||||
// approval panel has taken over the composer (the real takeover behavior);
|
||||
// answer it to restore the composer chrome before asserting the model seat.
|
||||
fireEvent.click(await screen.findByRole('button', { name: '允许一次' }))
|
||||
const modelTrigger = await screen.findByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
|
||||
})
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Using ONE run_code program: run" [disabled]'
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- 'button "Think The user wants me to write a single `run_code` program that:"':
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to write a single `run_code` program that:"
|
||||
- button:
|
||||
- img
|
||||
- text: Code Run bash echo and catch missing file read Echo CODE_ROUND_OK
|
||||
- button
|
||||
- text: Read missing.txt
|
||||
- img
|
||||
- text: Code Run bash echo and catch missing file read
|
||||
- img
|
||||
- text: Bash Echo CODE_ROUND_OK Read
|
||||
- button "missing.txt"
|
||||
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The program ran successfully. Let me now reply DONE as instructed.
|
||||
- paragraph: DONE
|
||||
@@ -23,10 +32,12 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use only Cordis tools. First" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -13,14 +12,16 @@
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- button "Think The user wants me to:":
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to:"
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
- text: Inspect temporary
|
||||
- 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."':
|
||||
- img
|
||||
- img
|
||||
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
|
||||
- button [expanded]:
|
||||
@@ -29,12 +30,15 @@
|
||||
- button "复制"
|
||||
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
|
||||
- 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."':
|
||||
- img
|
||||
- img
|
||||
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
- text: Unmount temporary Plugin dyn-1
|
||||
- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.":
|
||||
- img
|
||||
- img
|
||||
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
|
||||
- paragraph: CORDIS_UI_DONE
|
||||
@@ -42,7 +46,12 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the bash tool to" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to run a simple bash command and reply with "DONE".
|
||||
- text: Echo the test string
|
||||
- img
|
||||
- text: Bash Echo the test string
|
||||
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
|
||||
- img
|
||||
- img
|
||||
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
|
||||
- paragraph: DONE
|
||||
@@ -19,10 +27,12 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
- button "New session"
|
||||
- button "Collapse sidebar":
|
||||
- img
|
||||
- button "New session":
|
||||
@@ -27,11 +28,13 @@
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 详情
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with the single word" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with the single word LIGHTHOUSE and stop.
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "Think The user wants me to reply with a single word. Let me comply.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to reply with a single word. Let me comply.
|
||||
- paragraph: LIGHTHOUSE
|
||||
@@ -15,10 +21,12 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop.
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- paragraph: partial
|
||||
- text: 已停止 0 tokens · 1 turns · 1 steps
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop.
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop.
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
@@ -15,10 +21,12 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
@@ -14,12 +13,15 @@
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"
|
||||
- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user answered "Blue". I should now reply with the single word DONE and stop.
|
||||
- paragraph: DONE
|
||||
@@ -27,10 +29,12 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
|
||||
- button
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)"
|
||||
- button "▸ 问题内容"
|
||||
- text: 请在原客户端处理(web 端作答后续里程碑提供) cache hit 98% · 7,946 tokens · 1 turns · 1 steps
|
||||
- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps
|
||||
- region "Ready to continue?":
|
||||
- text: Checkpoint
|
||||
- heading "Ready to continue?" [level=2]
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Use the ask_user_question tool to" [disabled]
|
||||
- text: · 1 turns
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- tab "Waterfall"
|
||||
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- img
|
||||
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
|
||||
- button:
|
||||
- img
|
||||
- img
|
||||
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply."
|
||||
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
|
||||
- paragraph: Great, let's move forward. BANANA!
|
||||
@@ -21,10 +29,12 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read-only" [selected]
|
||||
- option "Read-write"
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
- dialog "选择工作区目录":
|
||||
- heading "选择工作区目录" [level=2]
|
||||
- navigation:
|
||||
- button "主目录"
|
||||
- img
|
||||
- button "browse-golden"
|
||||
- button "编辑路径"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "alpha":
|
||||
- img
|
||||
- text: alpha
|
||||
- img
|
||||
- listitem:
|
||||
- button "beta":
|
||||
- img
|
||||
- text: beta
|
||||
- img
|
||||
- button "新建文件夹":
|
||||
- img
|
||||
- text: 新建文件夹
|
||||
- button "取消"
|
||||
- button "打开"
|
||||
@@ -37,6 +37,15 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
],
|
||||
},
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
// Dual-face host package: its browser half fills the directory-flow holes
|
||||
// (the same composition row apps/cli mounts for the node-side backend).
|
||||
{
|
||||
id: '@deepseek-ai/dsh-host-directory-picker-browse',
|
||||
dir: '../host/directory-picker-browse',
|
||||
url: '/plugins/directory-picker-browse.js',
|
||||
rev: 'fx',
|
||||
inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'],
|
||||
},
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
@@ -174,6 +183,37 @@ it('locks the composer in the New Session view state until a Workspace is chosen
|
||||
`)
|
||||
})
|
||||
|
||||
it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
await findLockedComposer()
|
||||
fireEvent.click(workspaceChip())
|
||||
const menu = await screen.findByRole('menu')
|
||||
// The composed flow package occupies the directory-flow hole, so the
|
||||
// picking affordance is present (no advertised-kind read exists anymore).
|
||||
expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item)))
|
||||
.toEqual(['Open local folder…', 'Create a new workspace'])
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' }))
|
||||
// The browse occupant renders the Select Workspace Directory dialog at the
|
||||
// fixture home; select Documents, advance into project, and adopt it.
|
||||
const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 })
|
||||
// Row targeting goes through the visible label text: listitem accessible-name
|
||||
// computation differs across dom-accessibility-api environments, while the
|
||||
// row's name span is stable (clicks bubble to the row button).
|
||||
fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 }))
|
||||
fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
|
||||
// Open disables while the selection's child listing is in flight; wait for
|
||||
// the enabled state or the click lands on a dead button on slow runners.
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: '打开' }).disabled).toBe(false)
|
||||
}, { timeout: 10_000 })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '打开' }))
|
||||
await findHeroComposer()
|
||||
await waitFor(() => {
|
||||
expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project')
|
||||
})
|
||||
})
|
||||
|
||||
it('selects the recent Workspace and opens its blank Session on first load', async () => {
|
||||
boot('?fixture')
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
|
||||
@@ -23,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', i
|
||||
// spec needs any one cold session row, not new recorded content.
|
||||
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
|
||||
const SEED_ID = 'workspace-management-web-e2e'
|
||||
|
||||
describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
|
||||
@@ -30,14 +31,40 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let pickedDirectory: string | null = null
|
||||
|
||||
/**
|
||||
* Drive the in-app browser to a directory via its path-edit affordance,
|
||||
* confirm it, and wait for the adoption to settle host-side (workspace
|
||||
* registered + the flow's New-Session agent up), so later test steps can't
|
||||
* race the in-flight blank-session attach.
|
||||
*/
|
||||
async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
|
||||
const agentsBefore = scaffold.ctx.agents.list().length
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '编辑路径' }).click()
|
||||
await dialog.getByLabel('编辑路径').fill(path)
|
||||
await dialog.getByLabel('编辑路径').press('Enter')
|
||||
await dialog.getByRole('button', { name: '打开' }).click()
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(path),
|
||||
{ timeout: 10_000 },
|
||||
).not.toBeUndefined()
|
||||
// First adoption births a blank Session+Agent whose workspace attach must
|
||||
// settle before a test may delete the registration; the reuse path (same
|
||||
// canonical cwd already has a blank session) creates no agent, so callers
|
||||
// opt in only where a fresh attach is possible.
|
||||
if (options.waitForAgent === true) {
|
||||
await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
|
||||
.toBeGreaterThan(agentsBefore)
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({
|
||||
rpcId: request.rpcId,
|
||||
result: { ok: true, value: { path: pickedDirectory } },
|
||||
})
|
||||
// Seed one cold session (Ungrouped bucket) for the flat view + hover card.
|
||||
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
|
||||
await mkdir(sessionCwd, { recursive: true })
|
||||
@@ -137,14 +164,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
collect()
|
||||
})
|
||||
// Register the scaffold's existing project directory through the real UI.
|
||||
pickedDirectory = scaffold.workspaceCwd
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
|
||||
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
|
||||
{ timeout: 10_000 },
|
||||
).not.toBeUndefined()
|
||||
await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true })
|
||||
const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
|
||||
if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
|
||||
await workspace.attachSession(SessionId(SEED_ID))
|
||||
@@ -200,9 +220,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
// Re-registering the exact deleted path immediately, without a reload, is
|
||||
// a supported reversible flow. It creates a fresh Workspace id without
|
||||
// re-adopting the retained Session.
|
||||
pickedDirectory = scaffold.workspaceCwd
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
|
||||
await openLocalFolder(scaffold.workspaceCwd)
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
|
||||
{ timeout: 10_000 },
|
||||
@@ -272,9 +290,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
collect()
|
||||
})
|
||||
|
||||
pickedDirectory = oldPath
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
|
||||
await openLocalFolder(oldPath)
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.workspace.resolveByPath(oldPath),
|
||||
{ timeout: 10_000 },
|
||||
@@ -330,6 +346,42 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('matches the directory-browser dialog aria golden at a staged directory', async () => {
|
||||
// A staged subtree under the scaffold cwd keeps the listing deterministic
|
||||
// (normalizeAria scrubs the cwd), and pointing the in-process host's HOME
|
||||
// at the cwd collapses the breadcrumb ancestry into the Home crumb — no
|
||||
// machine-specific path segments or real $HOME contents enter the golden.
|
||||
const staged = join(scaffold.workspaceCwd, 'browse-golden')
|
||||
await mkdir(join(staged, 'alpha'), { recursive: true })
|
||||
await mkdir(join(staged, 'beta'), { recursive: true })
|
||||
// homedir() reads HOME on POSIX and USERPROFILE on Windows: root both
|
||||
// at the scaffold cwd so the golden's ancestry collapses everywhere.
|
||||
const realHome = process.env.HOME
|
||||
const realUserProfile = process.env.USERPROFILE
|
||||
process.env.HOME = scaffold.workspaceCwd
|
||||
process.env.USERPROFILE = scaffold.workspaceCwd
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '编辑路径' }).click()
|
||||
await dialog.getByLabel('编辑路径').fill(staged)
|
||||
await dialog.getByLabel('编辑路径').press('Enter')
|
||||
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
|
||||
await dialog.getByRole('button', { name: '取消' }).click()
|
||||
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
|
||||
} finally {
|
||||
if (realHome === undefined) delete process.env.HOME
|
||||
else process.env.HOME = realHome
|
||||
if (realUserProfile === undefined) delete process.env.USERPROFILE
|
||||
else process.env.USERPROFILE = realUserProfile
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('shows the session hover card after a dwell on the row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
|
||||
// Expand Ungrouped to reveal the seeded session row, then dwell on it
|
||||
@@ -361,8 +413,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
// This spec mints no fixture directory contents of its own; the seed it
|
||||
// reuses is owned (and inventory-guarded) by seeded-history.
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep'])
|
||||
// The directory-browser aria golden is this spec's one owned artifact;
|
||||
// the seed it reuses is owned (and inventory-guarded) by seeded-history.
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user