Merge origin/master into worktree/pr1006-merge-20260731

This commit is contained in:
Tianyi Cui
2026-07-31 01:56:19 +08:00
26 changed files with 333 additions and 70 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: d783d75cc9747d13887386fcf7609a6778e5dfb5
README.zh.md: 3f5ce7e7a3a302fd9e255c1042ccb7b03deb59d9
README.md: e56b726029c5bba9ba769c6dd3493d913f0129d7
README.zh.md: 24ff9a6e8d48016d213e877e23768332d86cccde

View File

@@ -24,6 +24,8 @@ The shipped TUI and Web compositions register the native DeepSeek adapter plus p
`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).
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)
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:

View File

@@ -24,6 +24,8 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用
`DSH_TOOLS_MODE` 为整个 Web无头进程选择工具呈现模式可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seamLoader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
每个 `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)。
## 安装(开发机)
将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout因此代码更改会在下次启动时生效无需构建

View File

@@ -38,6 +38,8 @@ flowchart LR
cfg --> plugin_tui_session_persistence_jsonl
plugin_tui_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_tui_session_query_sqlite
plugin_tui_telemetry_otel["telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
cfg --> plugin_tui_telemetry_otel
plugin_tui_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
cfg --> plugin_tui_subprocess
plugin_tui_bash_local["bash-local<br/>@deepseek-ai/dsh-bash-local"]
@@ -123,6 +125,7 @@ flowchart LR
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `bash-local` | `@deepseek-ai/dsh-bash-local` |
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` |

View File

@@ -94,6 +94,38 @@
config:
path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db'
# Session telemetry, on for every dsh surface: mirrors every session-log
# event (assistant/chunk projected to first-of-step) plus ops markers onto
# OTLP/HTTP log records, streaming on the batch processor's cadence
# (10s/batch here) — not at exit; a crash loses at most the last unexported
# interval. No telemetry/record redaction rule is mounted yet, so exports
# are the raw captured copy; the deployment stance, env seams, and
# follow-ups are pinned in the web-telemetry-default-mount Agent Note.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
# process out (the launchers patch the row disabled; config cannot disable
# a row). The exporter/processor values bound the shutdown drain to ~1s
# against an unreachable collector: exporter.timeoutMillis is both the
# per-attempt socket timeout and the retry deadline (1s effectively
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
# (both explicit) makes the drain a single batch, and exportTimeoutMillis
# is the processor's own cap on that one export cycle — the second bound
# when the exporter's clock alone does not fire. Every surface's exit path
# drains it: web/headless dispose on SIGINT/SIGTERM, and the TUI's normal
# exit and /resume handoff both dispose the root.
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
compression: gzip
timeoutMillis: 1000
processor:
scheduledDelayMillis: 10000
maxQueueSize: 2048
maxExportBatchSize: 2048
exportTimeoutMillis: 1500
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'

View File

@@ -81,6 +81,7 @@
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-settings-local": "workspace:^",
@@ -123,7 +124,9 @@
"js-yaml": "^4.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@types/js-yaml": "^4.0.9",
"execa": "^10.0.0",
"node-pty": "1.1.0"
}
}

View File

@@ -24,6 +24,9 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
const TELEMETRY_ROW_ID = 'telemetry-otel'
/** 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'
@@ -59,6 +62,38 @@ export function resolveLanTrust(
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/**
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
* off-by-mistake over on-by-mistake. Throws when the switch is set but the
* row is absent — a silently no-op "disabled" privacy switch would keep
* exporting while the user believes it is off.
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
* @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
* @returns the disable patch, or `undefined` when telemetry stays enabled.
*/
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '') return undefined
if (!hasRow) {
throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
}
return { id: TELEMETRY_ROW_ID, disabled: true }
}
/**
* Whether a config file carries the telemetry row, parsed under the same
* `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
* that compose their patch lists outside {@link AppCLIEntry} (the TUI).
* @param file - absolute path of the config or overlay file.
* @returns true when a top-level (or inserted) row has the telemetry id.
*/
export function configHasTelemetryRow(file: string): boolean {
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
}
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -203,6 +238,12 @@ export class AppCLIEntry {
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
// Telemetry opt-out: a row can only be turned off at the patch layer
// (config cannot disable an entry), and the switch must hold BEFORE the
// plugin constructs — its exporter.url validation is load-time fail-loud.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
}
/** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */

View File

@@ -82,6 +82,17 @@ export async function runHeadless(task: string): Promise<void> {
})
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// Signal exits must still dispose the tree: the composition mounts
// exit-drained plugins (telemetry's queued tail and shutdown marker would
// otherwise be lost), and Node's default signal exit skips disposal.
let signalled = false
const disposeAndExit = (code: number): void => {
if (signalled) return
signalled = true
void dispose().finally(() => { process.exit(code) })
}
process.on('SIGTERM', () => { disposeAndExit(143) })
process.on('SIGINT', () => { disposeAndExit(130) })
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))

View File

@@ -31,6 +31,7 @@ import {
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { SessionId } from '@deepseek-ai/dsh-session'
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
import type { Context } from 'cordis'
@@ -196,16 +197,26 @@ export async function runTui(
// demo or test config would silently run on the user's provider and model.
// `--config-replace` additionally discards the base and the surface overlay.
const replaceTree = configReplace !== undefined
const patches = replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? loadPersonalPatches(NAME) ?? []
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined)
// Same opt-out semantics as the web surface (resolveTelemetryPatch: any
// non-empty value disables; setting the switch against a tree without the
// row fails loud rather than silently no-opping a privacy switch). The row
// presence is checked against the tree actually booting, so a
// --config-replace tree is judged on its own rows, not the shipped base's.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
const patches = [
...replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? loadPersonalPatches(NAME) ?? []
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
],
...telemetryPatch === undefined ? [] : [telemetryPatch],
]
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined),
bootConfig,
patches,
(hostCtx) => {
// The launcher owns session identity and the exit line: a config-mounted

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { resolveTelemetryPatch } from '../src/app-cli-entry.ts'
describe('resolveTelemetryPatch', () => {
it('keeps telemetry enabled when the switch is unset or empty', () => {
expect(resolveTelemetryPatch(undefined, true)).toBeUndefined()
expect(resolveTelemetryPatch('', true)).toBeUndefined()
})
it('disables on ANY non-empty value, including falsy-looking ones', () => {
for (const value of ['1', '0', 'false', 'no']) {
expect(resolveTelemetryPatch(value, true)).toEqual({ id: 'telemetry-otel', disabled: true })
}
})
it('fails loud when the switch is set but the row is absent', () => {
expect(() => resolveTelemetryPatch('1', false)).toThrow('DSH_TELEMETRY_DISABLED is set but row "telemetry-otel" is not in this composition')
})
it('ignores a missing row while the switch is unset', () => {
expect(resolveTelemetryPatch(undefined, false)).toBeUndefined()
})
})

View File

@@ -130,7 +130,9 @@ function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Prom
tempDirPrefix: 'dsh-tui-smoke-',
binScript: dshBinScript,
tsconfigPath,
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
// Telemetry now mounts in the shared base: keep fixture sessions from
// POSTing to the production endpoint when run outside CI's workflow env.
env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call', DSH_TELEMETRY_DISABLED: '1' },
// Artifact CI builds and smokes concurrently on a contended runner.
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
...overrides,