Merge origin/master into feat/recommended-deployment-presets
# Conflicts: # apps/cli/README.i18n.yaml # apps/cli/package.json # 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: 3b2b8808b55e3882de9832029a0d5f1ac457d169
|
||||
README.zh.md: c2f998f1eb2cac737e8822205e0b71b1a951bbb1
|
||||
README.md: 0a684d49b6aba41b39233c5fecb14fb4216b470e
|
||||
README.zh.md: 264326b68fbcb889ec9e85cdd004655edffae872
|
||||
|
||||
@@ -27,6 +27,14 @@ Every surface also registers `web_search` and only `web_search`. Search uses Dee
|
||||
|
||||
`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 and pins `native`.
|
||||
|
||||
[`core-web.cordis.yml`](config/core-web.cordis.yml) is an opt-in `dsh web --config` overlay that keeps the shipped Web host, browser, Workspace, persistence, and permission composition while reducing the default native model surface to owner-scoped persistent `bash` and `str_replace_editor`. The PTY backend and editor consume the existing Web sandbox and filesystem providers. An open persistent shell prevents changing that session's permission mode until the shell closes, so a shell created under wider access cannot survive a downgrade. `DSH_TOOLS_MODE` still controls native/Code Mode presentation for the resulting two-tool registry.
|
||||
|
||||
From a source checkout, start this minimal Web profile with:
|
||||
|
||||
```sh
|
||||
pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
|
||||
```
|
||||
|
||||
Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
|
||||
|
||||
MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout:
|
||||
|
||||
@@ -27,6 +27,14 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用
|
||||
|
||||
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:`native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 线路)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam——Loader 组合是静态的,因此该设置作用于整个进程——待 Web UI 负责逐会话工具模式选择后便会移除;TUI 界面会忽略该变量并固定为 `native`。
|
||||
|
||||
[`core-web.cordis.yml`](config/core-web.cordis.yml) 是一个可选启用的 `dsh web --config` 覆盖层:它保留已交付的 Web 宿主、浏览器、Workspace、持久化与权限组合,同时将默认的原生模型界面精简为以所有者为作用域的持久 `bash` 以及 `str_replace_editor`。PTY 后端和编辑器分别消费现有的 Web 沙箱与文件系统提供方。持久 shell 处于打开状态时,会阻止所属会话更改权限模式;因此,在较宽权限下创建的 shell 无法在降权后继续存活。`DSH_TOOLS_MODE` 仍控制由此得到的双工具注册表采用原生/Code Mode 呈现。
|
||||
|
||||
在源码 checkout 中,用以下命令启动这个精简 Web profile:
|
||||
|
||||
```sh
|
||||
pnpm run dsh web --config apps/cli/config/core-web.cordis.yml
|
||||
```
|
||||
|
||||
每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
|
||||
|
||||
MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml` 或 `--config` 覆盖层挂载你自己的服务器:
|
||||
|
||||
@@ -84,8 +84,7 @@
|
||||
- id: session-persistence-jsonl
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js >-
|
||||
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
|
||||
root: !!js dshHomePath('sessions')
|
||||
|
||||
# TUI consumes this shared session capability. Its launcher supplies a unique
|
||||
# process-local path; other surfaces repoint or disable the row in their
|
||||
|
||||
86
apps/cli/config/core-web.cordis.yml
Normal file
86
apps/cli/config/core-web.cordis.yml
Normal file
@@ -0,0 +1,86 @@
|
||||
# Opt-in two-tool profile over the shipped Web composition. The default native
|
||||
# model surface is exactly persistent `bash` plus `str_replace_editor`; the
|
||||
# Web host, browser shell, workspace, persistence, and permission stack remain.
|
||||
|
||||
# Disable every model-facing consumer in the base/Web tree. plan-mode owns the
|
||||
# always-registered exit_plan_mode tool even while the session is not planning.
|
||||
- id: tool-bash
|
||||
disabled: true
|
||||
|
||||
- id: tool-tasks
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs
|
||||
disabled: true
|
||||
|
||||
- id: tool-fs-search
|
||||
disabled: true
|
||||
|
||||
- id: tool-web
|
||||
disabled: true
|
||||
|
||||
- id: tool-skill
|
||||
disabled: true
|
||||
|
||||
- id: plan-mode
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent
|
||||
disabled: true
|
||||
|
||||
- id: tool-subagent-fork
|
||||
disabled: true
|
||||
|
||||
- id: tool-workflow
|
||||
disabled: true
|
||||
|
||||
- id: tool-todo
|
||||
disabled: true
|
||||
|
||||
# These consumers are shared defaults on the ordinary shipped surfaces, but
|
||||
# this opt-in profile keeps exactly its two named tools.
|
||||
- id: tool-goal
|
||||
disabled: true
|
||||
|
||||
- id: tool-ralph
|
||||
disabled: true
|
||||
|
||||
- id: tool-session-query
|
||||
disabled: true
|
||||
|
||||
- id: tool-str-replace-editor
|
||||
disabled: true
|
||||
|
||||
# The matching browser controls must not offer host tools that this profile
|
||||
# omits. ui-question's host half owns the ask_user_question registration.
|
||||
- id: ui-plan
|
||||
disabled: true
|
||||
|
||||
- id: ui-question
|
||||
disabled: true
|
||||
|
||||
- insert:
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
# This backend consumes the existing Web sandbox and permission policy.
|
||||
# It loads only on Linux/macOS; Windows and other platforms fail at boot.
|
||||
# Its 300s send wait matches the persistent Bash command timeout instead of
|
||||
# pty-local's 30s default. An open persistent shell fences permission-mode
|
||||
# changes until it closes.
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
config:
|
||||
timeoutMs: 300000
|
||||
|
||||
# The editor consumes the Web fs-sandbox provider and therefore retains
|
||||
# the selected session permission mode.
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
config:
|
||||
maxOutputChars: 16000
|
||||
@@ -97,8 +97,7 @@
|
||||
- id: storage-json
|
||||
name: '@deepseek-ai/dsh-storage-json'
|
||||
config:
|
||||
root: !!js >-
|
||||
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'storages') })()
|
||||
root: !!js dshHomePath('storages')
|
||||
|
||||
- id: storage-domain
|
||||
name: '@deepseek-ai/dsh-storage-domain'
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-repeat-tool-guard": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
@@ -106,6 +108,7 @@
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
|
||||
@@ -49,13 +49,13 @@ switch (invocation.mode) {
|
||||
break
|
||||
}
|
||||
case 'meta': {
|
||||
const { runMeta } = await import('./tui.ts')
|
||||
await runMeta()
|
||||
const { runTui, SOURCE_ROOT } = await import('./tui.ts')
|
||||
await runTui(undefined, undefined, SOURCE_ROOT)
|
||||
break
|
||||
}
|
||||
case 'upgrade': {
|
||||
const { runSkillSession } = await import('./tui.ts')
|
||||
await runSkillSession(`dsh-${invocation.mode}`)
|
||||
const { runTui } = await import('./tui.ts')
|
||||
await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`)
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -8,12 +8,10 @@
|
||||
* from it, so `dsh` acts on whatever project it is launched in. Session storage
|
||||
* is the exception — it lives under the Harness home so `/resume` reaches every
|
||||
* workspace, and an in-place resume enters the selected session's own directory.
|
||||
* `dsh meta`
|
||||
* ({@link runMeta}) is the one exception — it makes this harness checkout the
|
||||
* workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh
|
||||
* session whose first turn auto-invokes a bundled skill. After boot, the
|
||||
* agent's system prompt is told the path to this harness checkout so it can
|
||||
* find its own source.
|
||||
* `dsh meta` is the one exception — it makes this harness checkout the
|
||||
* workspace. `dsh upgrade` is a fresh session whose first turn auto-invokes a
|
||||
* bundled skill. After boot, the agent's system prompt is told the path to this
|
||||
* harness checkout so it can find its own source.
|
||||
* @module @deepseek-ai/dsh/tui
|
||||
*/
|
||||
|
||||
@@ -61,30 +59,11 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d
|
||||
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
|
||||
// from this bin's location so it holds however `dsh` is launched (a PATH
|
||||
// symlink, an arbitrary cwd). The agent is told where its own source lives.
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
/** The harness checkout used as the `dsh meta` workspace and source prompt path. */
|
||||
export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
|
||||
the CLI PTY smoke drives this path end to end, personal overlay included */
|
||||
/**
|
||||
* Run the interactive TUI with this harness checkout as the workspace
|
||||
* (`dsh meta`), whatever directory it was launched from.
|
||||
*/
|
||||
export async function runMeta(): Promise<void> {
|
||||
return runTui(undefined, undefined, SOURCE_ROOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive TUI as a guided fresh session whose first turn invokes a
|
||||
* bundled skill (`dsh upgrade` → `dsh-upgrade`).
|
||||
* Always mints a fresh session in the invoking directory; the skill is seeded
|
||||
* only on this first launch, so a later `--resume` of the session is an ordinary
|
||||
* TUI session with no re-injection.
|
||||
* @param skill - the bundled skill name to auto-invoke as the first turn.
|
||||
*/
|
||||
export async function runSkillSession(skill: string): Promise<void> {
|
||||
return runTui(undefined, undefined, undefined, skill)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive TUI from the invoking directory.
|
||||
* @param config - an overlay patch list applied over the shared base and the
|
||||
@@ -99,9 +78,8 @@ export async function runSkillSession(skill: string): Promise<void> {
|
||||
* @param workspace - a directory to make the workspace instead of the invoking
|
||||
* one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
|
||||
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's
|
||||
* first turn, or `undefined`. Set only by {@link runSkillSession} and ignored
|
||||
* on a resume, so it never re-fires; reaches the app through
|
||||
* {@link INITIAL_SKILL_KEY}.
|
||||
* first turn, or `undefined`. Set only by `dsh upgrade` and ignored on a resume,
|
||||
* so it never re-fires; reaches the app through {@link INITIAL_SKILL_KEY}.
|
||||
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
|
||||
* shared base, the TUI overlay, and the personal overlay alike, or `undefined`
|
||||
* to compose them; already parsed from `--config-replace`.
|
||||
|
||||
8
apps/cli/tests/fixtures/memory-mcp-base.cordis.yml
vendored
Normal file
8
apps/cli/tests/fixtures/memory-mcp-base.cordis.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Minimal keyless composition for loading example MCP overlays against the
|
||||
# package-owned fixture server in memory-mcp-configs.spec.ts. Source builtins
|
||||
# keep this unit test independent of prebuilt workspace artifacts.
|
||||
- id: system-prompt
|
||||
name: cordis:memory-test-system-prompt
|
||||
|
||||
- id: tools
|
||||
name: cordis:memory-test-tools
|
||||
132
apps/cli/tests/memory-mcp-configs.spec.ts
Normal file
132
apps/cli/tests/memory-mcp-configs.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The third-party memory examples stay config-only. This suite parses every
|
||||
* checked-in overlay, verifies its pin/transport/secret boundary, then replaces
|
||||
* only the upstream endpoint with the package-owned keyless MCP fixture and
|
||||
* proves the real Cordis Loader discovers a tool through the generic bridge.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client/src/index.ts'
|
||||
|
||||
interface ExampleContract {
|
||||
file: string
|
||||
id: string
|
||||
serverName: string
|
||||
transport: 'stdio' | 'streamable-http'
|
||||
pin: string
|
||||
}
|
||||
|
||||
interface InsertedRow {
|
||||
id?: string
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '../../..')
|
||||
const exampleDir = resolve(root, 'examples/mcp-memory')
|
||||
const baseConfig = resolve(import.meta.dirname, 'fixtures/memory-mcp-base.cordis.yml')
|
||||
const fixtureServer = resolve(root, 'packages/mcp/mcp-client/tests/fixture-server.ts')
|
||||
|
||||
const examples: ExampleContract[] = [
|
||||
{
|
||||
file: 'memorix.cordis.yml',
|
||||
id: 'memory-memorix',
|
||||
serverName: 'memorix',
|
||||
transport: 'stdio',
|
||||
pin: '1.3.0',
|
||||
},
|
||||
{
|
||||
file: 'mcp-reference-memory.cordis.yml',
|
||||
id: 'memory-mcp-reference',
|
||||
serverName: 'reference_memory',
|
||||
transport: 'stdio',
|
||||
pin: '2026.7.4',
|
||||
},
|
||||
{
|
||||
file: 'engram.cordis.yml',
|
||||
id: 'memory-engram',
|
||||
serverName: 'engram',
|
||||
transport: 'stdio',
|
||||
pin: '1.20.0',
|
||||
},
|
||||
]
|
||||
|
||||
const liveContexts = new Set<Context>()
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([...liveContexts].map(async ctx => ctx.fiber.dispose()))
|
||||
liveContexts.clear()
|
||||
})
|
||||
|
||||
function insertedRow(patches: PatchOptions[]): InsertedRow {
|
||||
expect(patches).toHaveLength(1)
|
||||
const insert = patches[0]?.insert
|
||||
expect(insert).toHaveLength(1)
|
||||
return insert?.[0] as InsertedRow
|
||||
}
|
||||
|
||||
async function waitForTool(ctx: Context, name: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!ctx.tools.schemas().some(schema => schema.name === name)) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${name}`)
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, 25))
|
||||
}
|
||||
}
|
||||
|
||||
describe('third-party memory MCP example overlays', () => {
|
||||
it.each(examples)('parses $file with the documented generic boundary', (contract) => {
|
||||
const file = resolve(exampleDir, contract.file)
|
||||
const source = readFileSync(file, 'utf8')
|
||||
const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file))
|
||||
|
||||
expect(row.id).toBe(contract.id)
|
||||
expect(row.name).toBe('@deepseek-ai/dsh-mcp-client')
|
||||
expect(row.config?.serverName).toBe(contract.serverName)
|
||||
expect(row.config?.transport).toBe(contract.transport)
|
||||
expect(source.split('\n', 1)[0]).toContain(contract.pin)
|
||||
expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/)
|
||||
expect(source).not.toContain('DEEPSEEK_API_KEY')
|
||||
})
|
||||
|
||||
it.each(examples)('loads $file and discovers a keyless fixture tool', async (contract) => {
|
||||
const patches = loadOverlayPatches(
|
||||
'memory-mcp-config-test',
|
||||
resolve(exampleDir, contract.file),
|
||||
)
|
||||
// The static config gate verifies the checked-in bare package specifier.
|
||||
// The unit test maps it to the source module so a clean checkout needs no
|
||||
// prebuilt `lib/` artifacts before proving the Loader/MCP behavior.
|
||||
insertedRow(patches).name = 'cordis:memory-test-mcp-client'
|
||||
const fixturePatch: PatchOptions = {
|
||||
id: contract.id,
|
||||
config: {
|
||||
serverName: contract.serverName,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
env: {},
|
||||
cwd: root,
|
||||
toolCallTimeoutMs: 5_000,
|
||||
},
|
||||
}
|
||||
const ctx = await boot(
|
||||
'memory-mcp-config-test',
|
||||
baseConfig,
|
||||
[...patches, fixturePatch],
|
||||
(ctx) => {
|
||||
liveContexts.add(ctx)
|
||||
ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt
|
||||
ctx.loader.builtins['memory-test-tools'] = ToolRegistry
|
||||
ctx.loader.builtins['memory-test-mcp-client'] = McpClient
|
||||
},
|
||||
)
|
||||
await waitForTool(ctx, `mcp__${contract.serverName}__greet`)
|
||||
}, 15_000)
|
||||
})
|
||||
Reference in New Issue
Block a user