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.
This commit is contained in:
Chinesezjc
2026-08-11 14:48:02 +08:00
parent e92cfc770a
commit ec236b273e
9 changed files with 99 additions and 16 deletions

View File

@@ -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<void>
telemetry?: (event: CommandTelemetryEvent, deps?: CommandTelemetryDeps) => Promise<void>
}
/** Run one parsed dsh-sdk command and return its process exit code. */
@@ -36,6 +41,9 @@ export async function runDshSdkCommand(
},
): Promise<number> {
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 })
}
}
}

View File

@@ -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<ConsentDecision>
reporter?: Pick<TelemetryReporter, 'report' | 'flush'>
}
/**
* 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<void> {
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,

View File

@@ -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[] = []

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 packages/scaffold/telemetry/README.md
README.md: 670937d70dd2cabc0c77306a6239caa23123ae6f
README.zh.md: 6bbd17a46fe9828b3722c5703c1484eafebb8540
README.md: e923632d05a941722a1b91740461d818ccaa6bb5
README.zh.md: 5537a660f42df52616a968746dcbd122c16bec04

View File

@@ -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`).

View File

@@ -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`)。