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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user