Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

This commit is contained in:
creatixchu
2026-07-31 18:23:02 +08:00
31 changed files with 803 additions and 15 deletions

View File

@@ -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: cf038ad19c631721c7b3182ffe83e75e3837d9ba
README.zh.md: c790f973a9ab0071253ab161bd8bc7835ebb2e02
README.md: aee08225febe34bb10ee1001c798db9d7daa8905
README.zh.md: 8f38fd342a0433ca5f7645f896b81aa5392033ae

View File

@@ -27,6 +27,14 @@ The Web/headless composition also registers only `web_search`. Search uses DeepS
`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).
[`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).
## Install (developer machine)

View File

@@ -27,6 +27,14 @@ Web无头组合还只会注册 `web_search`。搜索使用 DeepSeek 的 Anthr
`DSH_TOOLS_MODE` 为整个 Web无头进程选择工具呈现模式可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seamLoader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
[`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)。
## 安装(开发机)

View File

@@ -0,0 +1,72 @@
# 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
# 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

View File

@@ -69,9 +69,12 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "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:^",
@@ -104,12 +107,14 @@
"@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:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
"@deepseek-ai/dsh-tool-ralph": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",

View 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

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

View File

@@ -0,0 +1,84 @@
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url))
describe('core Web profile', () => {
let scaffold: WebScaffold
let agentHandle: AgentHandle
beforeAll(async () => {
scaffold = await launchWebScaffold({
extraOverlayPath: CORE_WEB_OVERLAY,
toolsMode: 'native',
})
agentHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('core-web-profile-smoke'),
meta: { cwd: scaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
})
})
afterAll(async () => {
const failures: unknown[] = []
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed')
})
it('boots and executes both tools through the shipped Web composition', async () => {
const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt')
await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n')
const signal = new AbortController().signal
const bash = await scaffold.ctx.tools.execute({
signal,
callId: CallId('core-web-bash-smoke'),
name: 'bash',
arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" },
agent: agentHandle.agent,
})
const editor = await scaffold.ctx.tools.execute({
signal,
callId: CallId('core-web-editor-smoke'),
name: 'str_replace_editor',
arguments: { command: 'view', path: seedPath },
agent: agentHandle.agent,
})
const text = (result: typeof bash): string => result.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
.trimEnd()
expect({
tools: scaffold.ctx.tools.schemas().map(tool => tool.name),
bash: text(bash),
editor: text(editor),
}).toMatchInlineSnapshot(`
{
"bash": "CORE_WEB_BASH_OK",
"editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines):
1 CORE_WEB_EDITOR_OK
2",
"tools": [
"bash",
"str_replace_editor",
],
}
`)
const entries = [...scaffold.ctx.loader.entries()]
expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined()
expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined()
expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined()
})
})

View File

@@ -106,6 +106,12 @@ export interface WebScaffold {
/** Options for {@link launchWebScaffold}. */
export interface LaunchOptions {
/**
* Optional product overlay applied after the shipped Web surface and before
* the scaffold's hermetic test patches, matching AppCLIEntry's `--config`
* ordering.
*/
extraOverlayPath?: string
/**
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
* in replay/refresh modes; ignored in record mode (the real adapter
@@ -215,8 +221,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
// stops matching a row fails the boot sweep loudly instead of drifting).
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_OVERLAY_PATH)
const extraOverlayPatches = options.extraOverlayPath === undefined
? []
: loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
const patches: PatchOptions[] = [
...surfacePatches,
...extraOverlayPatches,
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
// storage-json's yml root is anchored to the real $DSH_HOME; pin the row

View File

@@ -24,6 +24,7 @@
"exclude": [
"tests/scaffold.ts",
"tests/scaffold-hermetic.e2e.ts",
"tests/core-web-profile.snapshot.ts",
"tests/live-interactions.e2e.ts",
"tests/question-composer.e2e.ts",
"tests/approval-composer.e2e.ts",