fix(web): review round 1 — loud opt-out, headless drain, scaffold isolation, docs

- resolveTelemetryPatch: extracted pure switch resolution (unit-tested);
  fails loud when DSH_TELEMETRY_DISABLED is set but the row is absent, and
  documents that ANY non-empty value (including '0'/'false') disables.
- runHeadless: SIGINT/SIGTERM now dispose the tree before exit so the
  telemetry tail and shutdown marker drain (Node's default signal exit
  skips disposal).
- web.cordis.yml: explicit maxQueueSize beside maxExportBatchSize (the
  single-batch drain invariant no longer leans on an SDK default), comment
  covers exportTimeoutMillis's role and links the Agent Note.
- apps/web scaffold: disable telemetry-otel — fixture sessions must never
  leave the process.
- apps/cli README (en/zh + pairing): document the default endpoint, both
  env seams, and the no-redaction disclosure.
This commit is contained in:
imccyu
2026-07-31 00:50:04 +08:00
parent b38e1aa062
commit bd1c69149d
8 changed files with 82 additions and 13 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: f7b5fb09cacbdaea013a8da433c09da74db928ab
README.zh.md: bccd944fb96b270427d59c05c4e04760cd68a90d

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).
The Web/headless composition reports session telemetry by default: 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); the TUI surface does not report.
## 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 界面会忽略该变量(其配置树固定了自身模式)。
Web无头组合默认上报会话遥测每条会话日志事件以 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.zh.md)TUI 界面不上报。
## 安装(开发机)
将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout因此代码更改会在下次启动时生效无需构建

View File

@@ -111,15 +111,21 @@
# Session telemetry: 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.
# 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 opts the process out (AppCLIEntry
# patches the row disabled config cannot disable a row). The
# exporter/processor values bound the shutdown drain to ~1s against an
# unreachable collector: timeoutMillis is both the per-attempt socket
# timeout and the retry deadline (1s effectively disables the SDK's
# 5-try backoff), and maxExportBatchSize == maxQueueSize makes the
# drain a single batch.
# non-empty DSH_TELEMETRY_DISABLED — any value, including '0'/'false' —
# opts the process out (AppCLIEntry patches 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.
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
@@ -129,6 +135,7 @@
timeoutMillis: 1000
processor:
scheduledDelayMillis: 10000
maxQueueSize: 2048
maxExportBatchSize: 2048
exportTimeoutMillis: 1500

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,24 @@ 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 }
}
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -207,9 +228,8 @@ export class AppCLIEntry {
// 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.
if ((process.env.DSH_TELEMETRY_DISABLED ?? '') !== '') {
this.patches.push({ id: 'telemetry-otel', disabled: true })
}
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

@@ -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

@@ -221,6 +221,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// workspace, keeping the composition untouched.
{ id: 'workspace-context', disabled: true },
{ id: 'session-title-llm', disabled: true },
// Fixture sessions must never leave the process: the shipped row defaults
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
// names in the ambient environment).
{ id: 'telemetry-otel', disabled: true },
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
{ id: 'settings', config: { dshHome: harnessHome } },
{ id: 'credentials', config: { dshHome: harnessHome } },