From ec236b273edaffafa63219609ac66b194655f2f3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 14:48:02 +0800 Subject: [PATCH] fix(telemetry): freeze launcher consent before running a command dsh-sdk resolved launcher telemetry consent in the finally block, after startSDK had already loaded the project .env into process.env, so a project file or project code could grant reporting of its own cordis.yml and package.json. Freeze the decision from the launching environment before dispatch and pass it to the reporter; an unsupported mode denies instead of throwing because telemetry may never change a command result. Configuration source ownership denies the whole DSH_* namespace to discovered files, so the launcher must not read a mutated environment. --- ...2026-08-10-telemetry-default-off.i18n.yaml | 4 +- .../2026-08-10-telemetry-default-off.md | 2 +- .../2026-08-10-telemetry-default-off.zh.md | 2 +- packages/scaffold/scripts/src/command.ts | 14 +++-- packages/scaffold/scripts/src/telemetry.ts | 31 +++++++++-- .../scaffold/scripts/tests/scripts.spec.ts | 54 ++++++++++++++++++- packages/scaffold/telemetry/README.i18n.yaml | 4 +- packages/scaffold/telemetry/README.md | 2 +- packages/scaffold/telemetry/README.zh.md | 2 +- 9 files changed, 99 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 7274b41848..7c4995a88d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: a56b08820b4bf4f9ffa91944b1d9c961d1f3826f -2026-08-10-telemetry-default-off.zh.md: 90b0ae5a7627dcdddf68c11e59e52b80b9c807f1 +2026-08-10-telemetry-default-off.md: 4bda346c2b05a94106eb5658c3ee558a4b32407f +2026-08-10-telemetry-default-off.zh.md: 706f2c18fbbf226e0357fa99bf3fd61c39fce08a diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index a56b08820b..4bda346c2b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -12,7 +12,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. -The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. +The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 90b0ae5a76..706f2c18fb 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。 -dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 +dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 diff --git a/packages/scaffold/scripts/src/command.ts b/packages/scaffold/scripts/src/command.ts index ebf9b06846..e1685e4102 100644 --- a/packages/scaffold/scripts/src/command.ts +++ b/packages/scaffold/scripts/src/command.ts @@ -9,7 +9,12 @@ import { runProjectBuild } from './build.ts' import { runConfigCommand, type ConfigCommandContext } from './config.ts' import { runCreatePluginCommand } from './create-plugin.ts' import { runSDK } from './runtime.ts' -import { reportCommandTelemetry, type CommandTelemetryEvent } from './telemetry.ts' +import { + freezeTelemetryConsent, + reportCommandTelemetry, + type CommandTelemetryDeps, + type CommandTelemetryEvent, +} from './telemetry.ts' import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts' /** Injectable process and command boundaries used by the dsh-sdk bin. */ @@ -22,7 +27,7 @@ export interface DshSdkCommandContext extends ConfigCommandContext { build?: typeof runProjectBuild config?: typeof runConfigCommand createPlugin?: typeof runCreatePluginCommand - telemetry?: (event: CommandTelemetryEvent) => Promise + telemetry?: (event: CommandTelemetryEvent, deps?: CommandTelemetryDeps) => Promise } /** Run one parsed dsh-sdk command and return its process exit code. */ @@ -36,6 +41,9 @@ export async function runDshSdkCommand( }, ): Promise { const startedAt = Date.now() + // Freeze consent from the launching environment: a command may load a project + // `.env` or mutate `process.env`, and neither may grant launcher reporting. + const consent = freezeTelemetryConsent() let command: string | undefined let success = true try { @@ -70,7 +78,7 @@ export async function runDshSdkCommand( if (command !== undefined) { /* v8 ignore next -- production telemetry wiring is exercised by the built-bin smoke */ const telemetry = context.telemetry ?? reportCommandTelemetry - await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success }) + await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success }, { consent }) } } } diff --git a/packages/scaffold/scripts/src/telemetry.ts b/packages/scaffold/scripts/src/telemetry.ts index 193cdf1fc7..37ad56c72a 100644 --- a/packages/scaffold/scripts/src/telemetry.ts +++ b/packages/scaffold/scripts/src/telemetry.ts @@ -27,14 +27,37 @@ export interface CommandTelemetryEvent { /** Injectable consent and delivery hooks for tests. */ export interface CommandTelemetryDeps { + /** + * Consent frozen from the launching environment before the command ran. When + * present it is authoritative: the environment a command mutated cannot grant + * or revoke reporting. + */ + consent?: ConsentDecision resolve?: () => ConsentDecision | Promise reporter?: Pick } /** - * Resolve the shared telemetry mode and, when allowed, assemble and send one - * telemetry event, draining in-flight sends before returning. Swallows every - * error so telemetry can never change a command's result. + * Freeze launcher telemetry consent from the launching environment before any + * command runs. A command may load a project `.env` or mutate `process.env`, so + * resolving consent afterwards would let project files or project code enable + * reporting of their own configuration. An unsupported mode denies rather than + * throwing, because telemetry may never change a command's result. + * @param env - Environment containing `DSH_TELEMETRY_MODE`; defaults to `process.env`. + * @returns The consent decision to apply after the command finishes. + */ +export function freezeTelemetryConsent(env: NodeJS.ProcessEnv = process.env): ConsentDecision { + try { + return resolveTelemetryConsent(env) + } catch { + return { allowed: false, reason: 'DISABLED' } + } +} + +/** + * Assemble and send one telemetry event when consent allows, draining in-flight + * sends before returning. Swallows every error so telemetry can never change a + * command's result. * @param event - the command lifecycle facts. * @param deps - Consent and delivery hooks; defaults hit the real endpoint. */ @@ -44,7 +67,7 @@ export async function reportCommandTelemetry( ): Promise { try { /* v8 ignore next -- the production resolver is exercised by its owning tests */ - const consent = await (deps.resolve?.() ?? resolveTelemetryConsent()) + const consent = deps.consent ?? await (deps.resolve?.() ?? resolveTelemetryConsent()) if (!consent.allowed) return const payload = await buildTelemetryPayload({ command: event.command, diff --git a/packages/scaffold/scripts/tests/scripts.spec.ts b/packages/scaffold/scripts/tests/scripts.spec.ts index d1f6cf81d1..396b6b7ee7 100644 --- a/packages/scaffold/scripts/tests/scripts.spec.ts +++ b/packages/scaffold/scripts/tests/scripts.spec.ts @@ -32,7 +32,12 @@ import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' import { runConfigCommand } from '../src/config.ts' import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts' import { runCreatePluginCommand } from '../src/create-plugin.ts' -import { reportCommandTelemetry, type CommandTelemetryEvent } from '../src/telemetry.ts' +import { + freezeTelemetryConsent, + reportCommandTelemetry, + type CommandTelemetryDeps, + type CommandTelemetryEvent, +} from '../src/telemetry.ts' import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' const temporary: string[] = [] @@ -626,6 +631,53 @@ describe('command telemetry', () => { expect(sent).toHaveLength(1) }) + it('prefers frozen consent over resolving the mutated environment', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-telemetry-')) + temporary.push(dir) + const sent: unknown[] = [] + const reporter = { report: () => { sent.push(1) }, flush: async () => {} } + let resolved = 0 + await reportCommandTelemetry( + { command: 'start', cwd: dir, durationMs: 5, success: true }, + { + consent: { allowed: false, reason: 'DISABLED' }, + resolve: () => { resolved += 1; return { allowed: true, reason: 'FULL' } }, + reporter, + }, + ) + expect(sent).toHaveLength(0) + expect(resolved).toBe(0) + }) + + it('freezes consent from the launching environment and denies unsupported modes', () => { + expect(freezeTelemetryConsent({ DSH_TELEMETRY_MODE: 'FULL' })).toEqual({ allowed: true, reason: 'FULL' }) + expect(freezeTelemetryConsent({ DSH_TELEMETRY_MODE: 'FEEDBACK_ONLY' })) + .toEqual({ allowed: false, reason: 'FEEDBACK_ONLY' }) + expect(freezeTelemetryConsent({})).toEqual({ allowed: false, reason: 'DISABLED' }) + expect(freezeTelemetryConsent({ DSH_TELEMETRY_MODE: '' })).toEqual({ allowed: false, reason: 'DISABLED' }) + expect(freezeTelemetryConsent({ DSH_TELEMETRY_MODE: 'nonsense' })) + .toEqual({ allowed: false, reason: 'DISABLED' }) + }) + + it('denies reporting when the command itself sets the mode', async () => { + const project = await committedProject() + const previous = process.env.DSH_TELEMETRY_MODE + delete process.env.DSH_TELEMETRY_MODE + const seen: (CommandTelemetryDeps | undefined)[] = [] + const context = commandContext(project.root) + context.telemetry = async (_event, deps) => { seen.push(deps) } + // A project `.env` load or project code mutating the environment mid-command. + context.build = async () => { process.env.DSH_TELEMETRY_MODE = 'FULL' } + try { + await expect(runDshSdkCommand(['build'], context)).resolves.toBe(0) + } finally { + if (previous === undefined) delete process.env.DSH_TELEMETRY_MODE + else process.env.DSH_TELEMETRY_MODE = previous + } + expect(seen).toHaveLength(1) + expect(seen[0]?.consent).toEqual({ allowed: false, reason: 'DISABLED' }) + }) + it('emits a telemetry event carrying each command outcome', async () => { const project = await committedProject() const events: CommandTelemetryEvent[] = [] diff --git a/packages/scaffold/telemetry/README.i18n.yaml b/packages/scaffold/telemetry/README.i18n.yaml index c44209d617..09c7f3732d 100644 --- a/packages/scaffold/telemetry/README.i18n.yaml +++ b/packages/scaffold/telemetry/README.i18n.yaml @@ -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 packages/scaffold/telemetry/README.md -README.md: 670937d70dd2cabc0c77306a6239caa23123ae6f -README.zh.md: 6bbd17a46fe9828b3722c5703c1484eafebb8540 +README.md: e923632d05a941722a1b91740461d818ccaa6bb5 +README.zh.md: 5537a660f42df52616a968746dcbd122c16bec04 diff --git a/packages/scaffold/telemetry/README.md b/packages/scaffold/telemetry/README.md index 670937d70d..e923632d05 100644 --- a/packages/scaffold/telemetry/README.md +++ b/packages/scaffold/telemetry/README.md @@ -12,7 +12,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li | `getOrCreateAnonymousId` | Random UUID persisted in the harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`$DSH_HOME` > `~/.dsh`), scoped to that home rather than the machine, never derived from git. | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | -`DSH_TELEMETRY_MODE` is the single positive consent setting for session and launcher telemetry. `FULL` enables this launcher feed; `FEEDBACK_ONLY` keeps command telemetry off and permits only feedback-triggered Session Log sharing; every other supported state keeps this feed off. +`DSH_TELEMETRY_MODE` is the single positive consent setting for session and launcher telemetry. `FULL` enables this launcher feed; `FEEDBACK_ONLY` keeps command telemetry off and permits only feedback-triggered Session Log sharing; every other supported state keeps this feed off. Callers must resolve consent from the launching environment before running a command, because a command may load a project `.env` or mutate `process.env`; the launcher wiring in `@deepseek-ai/dsh-scripts` freezes the decision up front. The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`). diff --git a/packages/scaffold/telemetry/README.zh.md b/packages/scaffold/telemetry/README.zh.md index 6bbd17a46f..5537a660f4 100644 --- a/packages/scaffold/telemetry/README.zh.md +++ b/packages/scaffold/telemetry/README.zh.md @@ -12,7 +12,7 @@ | `getOrCreateAnonymousId` | 将随机 UUID 持久化到 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析出的 harness home(`$DSH_HOME` > `~/.dsh`);其范围限定为该 home,而不是整台机器,且绝不从 git 派生。 | | `TelemetryReporter` | 即发即弃发送:`report()` 绝不阻塞或抛出;无论经过哪条路径,发送操作最终都会结束;`flush()` 可以在上限内排空进行中的发送。 | -`DSH_TELEMETRY_MODE` 是会话与启动器 telemetry 的唯一正向授权配置。`FULL` 启用该启动器数据流;`FEEDBACK_ONLY` 保持命令 telemetry 关闭,只允许由反馈触发的 Session Log 共享;其他受支持的状态都会保持该数据流关闭。 +`DSH_TELEMETRY_MODE` 是会话与启动器 telemetry 的唯一正向授权配置。`FULL` 启用该启动器数据流;`FEEDBACK_ONLY` 保持命令 telemetry 关闭,只允许由反馈触发的 Session Log 共享;其他受支持的状态都会保持该数据流关闭。调用方必须在执行命令前从启动环境解析授权,因为命令可能加载项目 `.env` 或修改 `process.env`;`@deepseek-ai/dsh-scripts` 中的启动器接线会在命令执行前冻结该决定。 收集端点是固定常量(`DSH_TELEMETRY_ENDPOINT`)。