refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Per-harness-home anonymous telemetry id.
|
||||
*
|
||||
* The id is a random UUID persisted directly in the harness home resolved by
|
||||
* {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`), and never derived from the
|
||||
* git remote, repository URL, or any other identifying source (a derived id
|
||||
* would make "anonymous" a fiction). The id is scoped to the harness home, not
|
||||
* the machine: every command sharing one `$DSH_HOME` reuses the same id, so the
|
||||
* default `~/.dsh` counts per-OS-user home directories, while a relocated
|
||||
* `$DSH_HOME` moves the id with the rest of the harness data — the single-root
|
||||
* convention this package shares, not a telemetry-specific policy.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-telemetry/anonymous-id
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
/** A harness-home-scoped anonymous telemetry id (random UUID v4). */
|
||||
export type AnonymousId = Branded<'AnonymousId'>
|
||||
|
||||
/** Default file, inside the harness home, storing the anonymous id. */
|
||||
export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json'
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/** Ambient seams for locating and generating the id; every field has a default. */
|
||||
export interface AnonymousIdOptions {
|
||||
/** Environment consulted for `DSH_HOME`; defaults to `process.env`. */
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** UUID generator; defaults to `crypto.randomUUID` (test seam). */
|
||||
randomUUID?: () => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the single-root harness home that stores the anonymous id.
|
||||
* Delegates to {@link resolveDshHome} so telemetry shares the harness's one
|
||||
* home-resolution policy (`DSH_HOME` > `~/.dsh`) instead of maintaining a
|
||||
* second config-directory convention.
|
||||
* @param options - environment seam.
|
||||
* @returns absolute harness home path.
|
||||
*/
|
||||
export function globalConfigDir(options: AnonymousIdOptions = {}): string {
|
||||
return resolveDshHome(undefined, options.env ?? process.env)
|
||||
}
|
||||
|
||||
/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */
|
||||
async function readPersistedId(file: string): Promise<AnonymousId | undefined> {
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(file, 'utf8')
|
||||
} catch {
|
||||
// Absent or unreadable: the caller mints and persists a fresh id.
|
||||
return undefined
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
// Corrupt JSON: the caller overwrites the store with a fresh id.
|
||||
return undefined
|
||||
}
|
||||
if (parsed !== null && typeof parsed === 'object') {
|
||||
const value = (parsed as Record<string, unknown>).anonymousId
|
||||
if (typeof value === 'string' && UUID_PATTERN.test(value)) return value as AnonymousId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the harness home's anonymous id, creating and persisting one on first use.
|
||||
* Persistence is best-effort: a write failure still returns a usable id for the
|
||||
* current run so telemetry is never blocked by config-dir permissions.
|
||||
* @param options - config-location and UUID-generation seams.
|
||||
* @returns the stable per-harness-home anonymous id.
|
||||
*/
|
||||
export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise<AnonymousId> {
|
||||
const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME)
|
||||
const existing = await readPersistedId(file)
|
||||
if (existing !== undefined) return existing
|
||||
const generate = options.randomUUID ?? randomUUID
|
||||
const created = generate() as AnonymousId
|
||||
try {
|
||||
await mkdir(dirname(file), { recursive: true })
|
||||
await writeFile(file, `${JSON.stringify({ anonymousId: created }, null, 2)}\n`, 'utf8')
|
||||
} catch {
|
||||
// Best-effort persistence: return the fresh id even when the store is unwritable.
|
||||
}
|
||||
return created
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Consent resolution for dsh-sdk telemetry.
|
||||
*
|
||||
* Telemetry is OFF only when `cordis.yml` contains a telemetry entry that is
|
||||
* explicitly `disabled`; every other file state reports (no `cordis.yml`, an
|
||||
* enabled entry, or no telemetry entry at all). The resolver PARSES `cordis.yml`
|
||||
* — it never boots a Cordis application — because several launcher commands
|
||||
* (`build`, `create`) never boot Cordis at all. `DO_NOT_TRACK` and CI
|
||||
* environment signals force a denial regardless of file state.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-telemetry/consent-resolver
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { parseDocument, type ScalarTag } from 'yaml'
|
||||
|
||||
/** Default `cordis.yml` entry name that carries telemetry consent. */
|
||||
export const DEFAULT_TELEMETRY_PLUGIN_NAME = '@deepseek-ai/dsh-telemetry'
|
||||
|
||||
/**
|
||||
* Passthrough for Cordis' `!!js` expression tag so parsing consent never fails
|
||||
* on projects that inline JavaScript expressions; the resolver only reads plain
|
||||
* `name`/`disabled` scalars and does not evaluate expressions.
|
||||
*/
|
||||
const JS_EXPRESSION_TAG: ScalarTag = {
|
||||
tag: 'tag:yaml.org,2002:js',
|
||||
resolve: value => value,
|
||||
}
|
||||
|
||||
/** Why telemetry is or is not permitted for one command. */
|
||||
export type ConsentReason =
|
||||
| 'enabled'
|
||||
| 'disabled'
|
||||
| 'absent'
|
||||
| 'no-config'
|
||||
| 'do-not-track'
|
||||
| 'ci'
|
||||
| 'unreadable'
|
||||
|
||||
/** Resolved telemetry consent for one command invocation. */
|
||||
export interface ConsentDecision {
|
||||
/** Whether telemetry may be sent. */
|
||||
allowed: boolean
|
||||
/** The signal that determined {@link allowed}. */
|
||||
reason: ConsentReason
|
||||
}
|
||||
|
||||
/** Tuning for {@link ConsentResolver}; every field defaults to a documented value. */
|
||||
export interface ConsentResolverOptions {
|
||||
/** `cordis.yml` entry name whose enabled state carries consent. */
|
||||
telemetryPluginName?: string
|
||||
/** Environment used for `DO_NOT_TRACK`/CI checks; defaults to `process.env`. */
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Honor `DO_NOT_TRACK`/CI env signals as a hard opt-out. Defaults to `true`. */
|
||||
honorEnvOptOut?: boolean
|
||||
/** Consent when `cordis.yml` does not exist yet (first `create`). Defaults to `true` (telemetry is default-on). */
|
||||
allowWhenNoConfig?: boolean
|
||||
/** Consent when `cordis.yml` exists but has no telemetry entry. Defaults to `true` (report unless a present entry is disabled). */
|
||||
allowWhenEntryAbsent?: boolean
|
||||
}
|
||||
|
||||
/** Whether an environment variable is set to a non-empty, non-"0"/"false" value. */
|
||||
function envEnabled(value: string | undefined): boolean {
|
||||
if (value === undefined) return false
|
||||
const normalized = value.trim().toLowerCase()
|
||||
return normalized.length > 0 && normalized !== '0' && normalized !== 'false'
|
||||
}
|
||||
|
||||
/** Read a `cordis.yml` entry's `name`/`disabled` scalars, tolerating `!!js` tags. */
|
||||
function readTelemetryEntry(text: string, pluginName: string): { present: boolean; disabled: boolean } {
|
||||
const document = parseDocument(text, { customTags: [JS_EXPRESSION_TAG] })
|
||||
const contents: unknown = document.toJS({ maxAliasCount: -1 })
|
||||
if (!Array.isArray(contents)) return { present: false, disabled: false }
|
||||
for (const entry of contents) {
|
||||
if (entry === null || typeof entry !== 'object') continue
|
||||
const record = entry as Record<string, unknown>
|
||||
if (record.name === pluginName) return { present: true, disabled: record.disabled === true }
|
||||
}
|
||||
return { present: false, disabled: false }
|
||||
}
|
||||
|
||||
/** Resolve telemetry consent by parsing a project's `cordis.yml` and the environment. */
|
||||
export class ConsentResolver {
|
||||
readonly #pluginName: string
|
||||
readonly #env: NodeJS.ProcessEnv
|
||||
readonly #honorEnvOptOut: boolean
|
||||
readonly #allowWhenNoConfig: boolean
|
||||
readonly #allowWhenEntryAbsent: boolean
|
||||
|
||||
/** @param options - plugin name, environment, and default-decision knobs. */
|
||||
constructor(options: ConsentResolverOptions = {}) {
|
||||
this.#pluginName = options.telemetryPluginName ?? DEFAULT_TELEMETRY_PLUGIN_NAME
|
||||
this.#env = options.env ?? process.env
|
||||
this.#honorEnvOptOut = options.honorEnvOptOut ?? true
|
||||
this.#allowWhenNoConfig = options.allowWhenNoConfig ?? true
|
||||
this.#allowWhenEntryAbsent = options.allowWhenEntryAbsent ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve consent for a command run in the given project directory.
|
||||
* @param projectDir - absolute or relative project root containing `cordis.yml`.
|
||||
* @returns the consent decision and the signal that produced it.
|
||||
*/
|
||||
async resolve(projectDir: string): Promise<ConsentDecision> {
|
||||
if (this.#honorEnvOptOut) {
|
||||
if (envEnabled(this.#env.DO_NOT_TRACK)) return { allowed: false, reason: 'do-not-track' }
|
||||
if (envEnabled(this.#env.CI)) return { allowed: false, reason: 'ci' }
|
||||
}
|
||||
let text: string
|
||||
try {
|
||||
text = await readFile(join(projectDir, 'cordis.yml'), 'utf8')
|
||||
} catch (error) {
|
||||
// Missing cordis.yml is the first-init (`create`) path; any other read
|
||||
// fault is treated conservatively as its own reason.
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { allowed: this.#allowWhenNoConfig, reason: 'no-config' }
|
||||
}
|
||||
return { allowed: false, reason: 'unreadable' }
|
||||
}
|
||||
const entry = readTelemetryEntry(text, this.#pluginName)
|
||||
if (!entry.present) return { allowed: this.#allowWhenEntryAbsent, reason: 'absent' }
|
||||
return entry.disabled ? { allowed: false, reason: 'disabled' } : { allowed: true, reason: 'enabled' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Launcher-side telemetry for the dsh-sdk toolchain: secret redaction, consent
|
||||
* resolution, anonymous id, payload assembly, and a fire-and-forget reporter.
|
||||
*
|
||||
* This package is a plain library the launcher imports around each command — it
|
||||
* is NOT a Cordis plugin (several commands never boot Cordis). Wiring it into
|
||||
* the launcher command dispatch and the helper feature catalog lives outside
|
||||
* this package.
|
||||
*
|
||||
* FIXME: rename to `@deepseek-ai/dsh-sdk-telemetry` before the first tagged release —
|
||||
* the current name collides with the `dsh-session-telemetry` family; this is
|
||||
* launcher-side SDK telemetry ([regrouping Agent Note](../../../../.agents/notes/proposed/architecture/2026-07-29-package-regrouping.md)).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-telemetry
|
||||
*/
|
||||
|
||||
export {
|
||||
DEFAULT_ENTROPY_THRESHOLD,
|
||||
DEFAULT_MIN_TOKEN_LENGTH,
|
||||
DEFAULT_REDACTION_PLACEHOLDER,
|
||||
SecretRedactor,
|
||||
keyLooksSecret,
|
||||
} from './secret-redactor.ts'
|
||||
export type { SecretRedactorOptions } from './secret-redactor.ts'
|
||||
export {
|
||||
ConsentResolver,
|
||||
DEFAULT_TELEMETRY_PLUGIN_NAME,
|
||||
} from './consent-resolver.ts'
|
||||
export type {
|
||||
ConsentDecision,
|
||||
ConsentReason,
|
||||
ConsentResolverOptions,
|
||||
} from './consent-resolver.ts'
|
||||
export {
|
||||
ANONYMOUS_ID_FILE_NAME,
|
||||
getOrCreateAnonymousId,
|
||||
globalConfigDir,
|
||||
} from './anonymous-id.ts'
|
||||
export type { AnonymousId, AnonymousIdOptions } from './anonymous-id.ts'
|
||||
export { buildTelemetryPayload } from './payload.ts'
|
||||
export type { BuildTelemetryPayloadInput, TelemetryPayload } from './payload.ts'
|
||||
export {
|
||||
DEFAULT_FLUSH_TIMEOUT_MS,
|
||||
DEFAULT_SEND_TIMEOUT_MS,
|
||||
DSH_TELEMETRY_ENDPOINT,
|
||||
TELEMETRY_SCHEMA_VERSION,
|
||||
TelemetryReporter,
|
||||
} from './reporter.ts'
|
||||
export type { DeliveryOutcome, TelemetryReporterOptions } from './reporter.ts'
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-telemetry`.
|
||||
* @module @deepseek-ai/dsh-telemetry/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-telemetry'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'telemetry-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
|
||||
* generated output and consumer tests cover its contract.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Telemetry payload assembly.
|
||||
*
|
||||
* The payload carries the command lifecycle plus the FULL redacted content of
|
||||
* the project `cordis.yml` and `package.json`. It NEVER reads or includes `.env`
|
||||
* — secrets live only in `.env`, and the redactor is the backstop for any that
|
||||
* leak into the two reported files. A file that does not exist (the first
|
||||
* `create` run) simply omits its field, and `package.json` ships only when
|
||||
* `cordis.yml` is present: without it the directory is not an SDK project, and
|
||||
* its manifest belongs to whatever unrelated project the command ran in.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-telemetry/payload
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { SecretRedactor } from './secret-redactor.ts'
|
||||
|
||||
/** Project files whose full (redacted) content ships with the payload. */
|
||||
const REPORTED_FILES = ['cordis.yml', 'package.json'] as const
|
||||
|
||||
/** One command's telemetry payload. */
|
||||
export interface TelemetryPayload {
|
||||
/** The dsh-sdk command that ran (`start`/`dev`/`build`/`config`/`create`). */
|
||||
command: string
|
||||
/** Wall-clock duration of the command in milliseconds. */
|
||||
durationMs: number
|
||||
/** Whether the command completed without error. */
|
||||
success: boolean
|
||||
/** Redacted full text of the project `cordis.yml`, absent when the file does not exist. */
|
||||
cordisYmlContent?: string
|
||||
/** Redacted full text of the project `package.json`, absent when it or `cordis.yml` does not exist. */
|
||||
packageJsonContent?: string
|
||||
}
|
||||
|
||||
/** Inputs for {@link buildTelemetryPayload}. */
|
||||
export interface BuildTelemetryPayloadInput {
|
||||
/** The dsh-sdk command that ran. */
|
||||
command: string
|
||||
/** Wall-clock duration of the command in milliseconds. */
|
||||
durationMs: number
|
||||
/** Whether the command completed without error. */
|
||||
success: boolean
|
||||
/** Project root whose `cordis.yml` and `package.json` are read. */
|
||||
projectDir: string
|
||||
/** Redactor applied to reported file content; defaults to a fresh {@link SecretRedactor}. */
|
||||
redactor?: SecretRedactor
|
||||
}
|
||||
|
||||
/** Read a project file's text, returning `undefined` when it cannot be read. */
|
||||
async function readReportedFile(projectDir: string, name: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(join(projectDir, name), 'utf8')
|
||||
} catch {
|
||||
// Missing/unreadable reported file: telemetry omits the field rather than fail.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a redacted telemetry payload for one command invocation.
|
||||
* @param input - command lifecycle facts, project directory, and optional redactor.
|
||||
* @returns the payload with redacted `cordis.yml`/`package.json` content.
|
||||
*/
|
||||
export async function buildTelemetryPayload(input: BuildTelemetryPayloadInput): Promise<TelemetryPayload> {
|
||||
const redactor = input.redactor ?? new SecretRedactor()
|
||||
const [cordisYml, packageJson] = await Promise.all(
|
||||
REPORTED_FILES.map(name => readReportedFile(input.projectDir, name)),
|
||||
)
|
||||
return {
|
||||
command: input.command,
|
||||
durationMs: input.durationMs,
|
||||
success: input.success,
|
||||
...cordisYml !== undefined ? { cordisYmlContent: redactor.redactText(cordisYml) } : {},
|
||||
// package.json is an SDK-project manifest only alongside cordis.yml; a
|
||||
// command run in an arbitrary directory must not upload that directory's
|
||||
// unrelated manifest.
|
||||
...cordisYml !== undefined && packageJson !== undefined
|
||||
? { packageJsonContent: redactor.redactText(packageJson) }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Fire-and-forget telemetry reporter for the dsh-sdk launcher.
|
||||
*
|
||||
* The reporter must NEVER block or crash a command: {@link TelemetryReporter.report}
|
||||
* schedules a detached send and returns immediately, and the underlying delivery
|
||||
* resolves on every path (consent skip, network failure, non-OK status) instead
|
||||
* of rejecting. {@link TelemetryReporter.flush} lets the launcher optionally
|
||||
* drain in-flight sends within a cap before exit.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-telemetry/reporter
|
||||
*/
|
||||
|
||||
import type { ConsentDecision } from './consent-resolver.ts'
|
||||
import type { TelemetryPayload } from './payload.ts'
|
||||
import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts'
|
||||
import { SecretRedactor } from './secret-redactor.ts'
|
||||
|
||||
/**
|
||||
* Fail-safe placeholder collection endpoint. The `.invalid` TLD guarantees
|
||||
* delivery fails harmlessly until a collector is deployed. This is a fixed
|
||||
* protocol constant, not a deployment tunable.
|
||||
*/
|
||||
// TODO(telemetry-endpoint): Replace the placeholder before release.
|
||||
export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk'
|
||||
|
||||
/** Wire-envelope schema version; bump on any incompatible body change. */
|
||||
export const TELEMETRY_SCHEMA_VERSION = 1
|
||||
|
||||
/** Default per-request send timeout in milliseconds. */
|
||||
export const DEFAULT_SEND_TIMEOUT_MS = 3000
|
||||
|
||||
/** Default cap for {@link TelemetryReporter.flush} in milliseconds. */
|
||||
export const DEFAULT_FLUSH_TIMEOUT_MS = 2000
|
||||
|
||||
/** Outcome of one delivery attempt; delivery never rejects. */
|
||||
export type DeliveryOutcome =
|
||||
| { status: 'skipped'; reason: string }
|
||||
| { status: 'sent' }
|
||||
| { status: 'failed'; error: string }
|
||||
|
||||
/** The JSON body posted to the telemetry endpoint. */
|
||||
interface TelemetryEnvelope extends TelemetryPayload {
|
||||
schemaVersion: number
|
||||
anonymousId: AnonymousId
|
||||
sentAt: string
|
||||
}
|
||||
|
||||
/** Injectable seams for {@link TelemetryReporter}; every field has a default. */
|
||||
export interface TelemetryReporterOptions {
|
||||
/** Collection endpoint; defaults to {@link DSH_TELEMETRY_ENDPOINT}. */
|
||||
endpoint?: string
|
||||
/** `fetch` implementation; defaults to the global `fetch`. */
|
||||
fetch?: typeof globalThis.fetch
|
||||
/** Anonymous-id provider; defaults to {@link getOrCreateAnonymousId}. */
|
||||
anonymousId?: () => Promise<AnonymousId>
|
||||
/** Redactor applied to the assembled envelope as a final backstop; defaults to a fresh {@link SecretRedactor}. */
|
||||
redactor?: SecretRedactor
|
||||
/** Per-request send timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Clock for the envelope timestamp; defaults to `Date.now`. */
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/** Sends telemetry payloads fire-and-forget, swallowing every failure. */
|
||||
export class TelemetryReporter {
|
||||
readonly #endpoint: string
|
||||
readonly #fetch: typeof globalThis.fetch
|
||||
readonly #anonymousId: () => Promise<AnonymousId>
|
||||
readonly #redactor: SecretRedactor
|
||||
readonly #timeoutMs: number
|
||||
readonly #now: () => number
|
||||
readonly #inflight = new Set<Promise<DeliveryOutcome>>()
|
||||
|
||||
/** @param options - endpoint, transport, id provider, and timing seams. */
|
||||
constructor(options: TelemetryReporterOptions = {}) {
|
||||
this.#endpoint = options.endpoint ?? DSH_TELEMETRY_ENDPOINT
|
||||
this.#fetch = options.fetch ?? globalThis.fetch
|
||||
this.#anonymousId = options.anonymousId ?? getOrCreateAnonymousId
|
||||
this.#redactor = options.redactor ?? new SecretRedactor()
|
||||
this.#timeoutMs = options.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS
|
||||
this.#now = options.now ?? Date.now
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a detached, non-blocking send. Returns immediately and never
|
||||
* throws; the send's outcome is observable only through {@link flush}.
|
||||
* @param payload - the command payload to report.
|
||||
* @param consent - resolved consent; a denial short-circuits to a skip.
|
||||
*/
|
||||
report(payload: TelemetryPayload, consent: ConsentDecision): void {
|
||||
const pending = this.#deliver(payload, consent)
|
||||
this.#inflight.add(pending)
|
||||
void pending.finally(() => this.#inflight.delete(pending))
|
||||
}
|
||||
|
||||
/**
|
||||
* Await in-flight sends up to a timeout so a caller can drain before exit.
|
||||
* Resolves on the cap regardless of send progress; never rejects.
|
||||
* @param timeoutMs - maximum time to wait; defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}.
|
||||
*/
|
||||
async flush(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise<void> {
|
||||
if (this.#inflight.size === 0) return
|
||||
const drained = Promise.allSettled([...this.#inflight]).then(() => undefined)
|
||||
let timer!: ReturnType<typeof setTimeout>
|
||||
const capped = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, timeoutMs)
|
||||
})
|
||||
try {
|
||||
await Promise.race([drained, capped])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver one payload, resolving to an outcome on every path (never rejects). */
|
||||
async #deliver(payload: TelemetryPayload, consent: ConsentDecision): Promise<DeliveryOutcome> {
|
||||
if (!consent.allowed) return { status: 'skipped', reason: consent.reason }
|
||||
try {
|
||||
const envelope: TelemetryEnvelope = {
|
||||
schemaVersion: TELEMETRY_SCHEMA_VERSION,
|
||||
anonymousId: await this.#anonymousId(),
|
||||
sentAt: new Date(this.#now()).toISOString(),
|
||||
...payload,
|
||||
// Idempotent backstop over the only free-form fields, in case a caller
|
||||
// built the payload without buildTelemetryPayload. Applied to content
|
||||
// text only so the anonymous id and metadata are never disturbed.
|
||||
...payload.cordisYmlContent !== undefined
|
||||
? { cordisYmlContent: this.#redactor.redactText(payload.cordisYmlContent) }
|
||||
: {},
|
||||
...payload.packageJsonContent !== undefined
|
||||
? { packageJsonContent: this.#redactor.redactText(payload.packageJsonContent) }
|
||||
: {},
|
||||
}
|
||||
const response = await this.#fetch(this.#endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(envelope),
|
||||
signal: AbortSignal.timeout(this.#timeoutMs),
|
||||
})
|
||||
if (!response.ok) return { status: 'failed', error: `HTTP ${response.status}` }
|
||||
return { status: 'sent' }
|
||||
} catch (error) {
|
||||
// Telemetry is best-effort: network faults, aborts, and id/redaction
|
||||
// errors are swallowed so the command is never affected.
|
||||
return { status: 'failed', error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Conservative secret redactor: the safety backstop that scrubs credential-like
|
||||
* values from telemetry content before it leaves the machine.
|
||||
*
|
||||
* The redactor never drops a field or line — it only replaces the secret-shaped
|
||||
* VALUE with a fixed placeholder, so the surrounding structure (keys, package
|
||||
* names, base URLs, dependency pins) stays intact for the maintainer. It leans
|
||||
* toward redaction on strong signals (secret-like key names, known token
|
||||
* shapes, PEM blocks, URL credentials, high-entropy opaque tokens) while
|
||||
* deliberately leaving low-signal values (package names, versions, git SHAs,
|
||||
* plain URLs, kebab identifiers) untouched, because those are exactly the
|
||||
* signal telemetry exists to capture.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-telemetry/secret-redactor
|
||||
*/
|
||||
|
||||
/** Default text substituted for a detected secret. */
|
||||
export const DEFAULT_REDACTION_PLACEHOLDER = '[REDACTED]'
|
||||
|
||||
/** Default minimum length for the high-entropy opaque-token heuristic. */
|
||||
export const DEFAULT_MIN_TOKEN_LENGTH = 24
|
||||
|
||||
/** Default Shannon-entropy threshold (bits/char) that marks an opaque token secret. */
|
||||
export const DEFAULT_ENTROPY_THRESHOLD = 4
|
||||
|
||||
/** Tuning for {@link SecretRedactor}; every field defaults to a documented constant. */
|
||||
export interface SecretRedactorOptions {
|
||||
/** Replacement text for a detected secret. */
|
||||
placeholder?: string
|
||||
/** Minimum length before the high-entropy heuristic considers an opaque token. */
|
||||
minTokenLength?: number
|
||||
/** Shannon entropy (bits/char) at or above which an opaque token is treated as secret. */
|
||||
entropyThreshold?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Regexes for well-known credential shapes. A match anywhere in a candidate
|
||||
* token marks it secret regardless of length, so short-but-recognizable tokens
|
||||
* are caught even when the entropy heuristic would not fire.
|
||||
*/
|
||||
const KNOWN_SECRET_PATTERNS: readonly RegExp[] = [
|
||||
/sk-(?:ant-)?[A-Za-z0-9_-]{10,}/, // OpenAI / DeepSeek / Anthropic style
|
||||
/gh[pousr]_[A-Za-z0-9]{16,}/, // GitHub personal/oauth/server/refresh tokens
|
||||
/github_pat_[A-Za-z0-9_]{20,}/, // GitHub fine-grained PAT
|
||||
/xox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens
|
||||
/AKIA[0-9A-Z]{16}/, // AWS access key id
|
||||
/AIza[0-9A-Za-z_-]{35}/, // Google API key
|
||||
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JWT
|
||||
]
|
||||
|
||||
/**
|
||||
* Key names (normalized to lowercase, separators stripped) whose value is a
|
||||
* secret. Split by match strategy so short/ambiguous words do not over-match:
|
||||
* `author` must not trip the `auth` rule.
|
||||
*/
|
||||
const KEY_SUBSTRING_INDICATORS: readonly string[] = [
|
||||
'password', 'passwd', 'passphrase', 'secret', 'apikey', 'apisecret',
|
||||
'clientsecret', 'privatekey', 'secretkey', 'accesskey', 'credential',
|
||||
'connectionstring', 'sastoken', 'xapikey', 'authtoken', 'accesstoken',
|
||||
'refreshtoken', 'idtoken', 'sessiontoken', 'bearertoken',
|
||||
]
|
||||
const KEY_SUFFIX_INDICATORS: readonly string[] = ['token']
|
||||
const KEY_EXACT_INDICATORS: readonly string[] = [
|
||||
'auth', 'authorization', 'cookie', 'bearer', 'dsn', 'signature',
|
||||
]
|
||||
|
||||
/**
|
||||
* Whether a key name marks its value as a secret.
|
||||
* @param key - raw object key or assignment name.
|
||||
* @returns whether the value under this key must be redacted.
|
||||
*/
|
||||
export function keyLooksSecret(key: string): boolean {
|
||||
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
if (normalized.length === 0) return false
|
||||
if (KEY_SUBSTRING_INDICATORS.some(indicator => normalized.includes(indicator))) return true
|
||||
if (KEY_SUFFIX_INDICATORS.some(indicator => normalized.endsWith(indicator))) return true
|
||||
return KEY_EXACT_INDICATORS.includes(normalized)
|
||||
}
|
||||
|
||||
/** Shannon entropy in bits per character. */
|
||||
function shannonEntropy(value: string): number {
|
||||
const counts = new Map<string, number>()
|
||||
for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1)
|
||||
let entropy = 0
|
||||
for (const count of counts.values()) {
|
||||
const probability = count / value.length
|
||||
entropy -= probability * Math.log2(probability)
|
||||
}
|
||||
return entropy
|
||||
}
|
||||
|
||||
/** Opaque-token character set (base64/base64url plus common token punctuation). */
|
||||
const OPAQUE_TOKEN = /^[A-Za-z0-9+/=_.-]+$/
|
||||
/** Version-like leader kept visible (dependency pins, semver). */
|
||||
const VERSION_LIKE = /^v?\d+(?:\.\d+)+/
|
||||
|
||||
/**
|
||||
* Conservative secret detector and redactor for telemetry content.
|
||||
* Detection is a pure function of the input; construction only fixes tunables.
|
||||
*/
|
||||
export class SecretRedactor {
|
||||
readonly #placeholder: string
|
||||
readonly #minTokenLength: number
|
||||
readonly #entropyThreshold: number
|
||||
|
||||
/** @param options - placeholder text and heuristic thresholds. */
|
||||
constructor(options: SecretRedactorOptions = {}) {
|
||||
this.#placeholder = options.placeholder ?? DEFAULT_REDACTION_PLACEHOLDER
|
||||
this.#minTokenLength = options.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH
|
||||
this.#entropyThreshold = options.entropyThreshold ?? DEFAULT_ENTROPY_THRESHOLD
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a standalone token value looks like a secret.
|
||||
* @param value - candidate token, already trimmed of surrounding quotes.
|
||||
* @returns whether the value should be redacted on its own merits.
|
||||
*/
|
||||
isSecretValue(value: string): boolean {
|
||||
if (KNOWN_SECRET_PATTERNS.some(pattern => pattern.test(value))) return true
|
||||
if (value.length < this.#minTokenLength) return false
|
||||
if (!OPAQUE_TOKEN.test(value)) return false
|
||||
// Git SHAs and integrity digests are hex and public — never a secret we hide.
|
||||
if (/^[0-9a-fA-F]+$/.test(value)) return false
|
||||
if (VERSION_LIKE.test(value)) return false
|
||||
const classes = (/[a-z]/.test(value) ? 1 : 0) + (/[A-Z]/.test(value) ? 1 : 0) + (/[0-9]/.test(value) ? 1 : 0)
|
||||
return classes >= 3 || shannonEntropy(value) >= this.#entropyThreshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-redact a parsed value in place-safe fashion, returning a new structure.
|
||||
* A secret-named key redacts its string value outright; every other string is
|
||||
* judged on its own shape. Non-string leaves pass through untouched.
|
||||
* @param value - parsed JSON-like value (object, array, or primitive).
|
||||
* @returns a structurally identical value with secret strings replaced.
|
||||
*/
|
||||
redactValue<T>(value: T): T {
|
||||
return this.#redactNode(value, false) as T
|
||||
}
|
||||
|
||||
#redactNode(value: unknown, keyIsSecret: boolean): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return keyIsSecret || this.isSecretValue(value) ? this.#placeholder : value
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(item => this.#redactNode(item, false))
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [key, this.#redactNode(child, keyLooksSecret(key))]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact secrets embedded in raw text (YAML, JSON, or `.env`-style content),
|
||||
* preserving every line and key while replacing only secret-shaped values.
|
||||
* @param text - raw file or message text.
|
||||
* @returns text with detected secrets replaced by the placeholder.
|
||||
*/
|
||||
redactText(text: string): string {
|
||||
let output = this.#redactPemBlocks(text)
|
||||
output = this.#redactAssignments(output)
|
||||
output = this.#redactUrlCredentials(output)
|
||||
output = this.#redactBearerTokens(output)
|
||||
return this.#redactStandaloneTokens(output)
|
||||
}
|
||||
|
||||
#redactPemBlocks(text: string): string {
|
||||
return text.replace(
|
||||
/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z ]+ )?PRIVATE KEY-----/g,
|
||||
this.#placeholder,
|
||||
)
|
||||
}
|
||||
|
||||
#redactAssignments(text: string): string {
|
||||
// `key: value`, `key = value`, or `"key": "value"` across YAML/JSON/.env.
|
||||
return text.replace(
|
||||
/("?)([A-Za-z0-9_.-]+)\1(\s*[:=]\s*)(["']?)([^\n\r"']+)\4/g,
|
||||
(match, keyQuote: string, key: string, separator: string, valueQuote: string, value: string) =>
|
||||
keyLooksSecret(key) && value.trim().length > 0
|
||||
? `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${this.#placeholder}${valueQuote}`
|
||||
: match,
|
||||
)
|
||||
}
|
||||
|
||||
#redactUrlCredentials(text: string): string {
|
||||
// Redact only the password in `scheme://user:password@host`, keeping host visible.
|
||||
return text.replace(
|
||||
/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi,
|
||||
(_match, prefix: string, _password: string, at: string) => `${prefix}${this.#placeholder}${at}`,
|
||||
)
|
||||
}
|
||||
|
||||
#redactBearerTokens(text: string): string {
|
||||
// The candidate must contain a digit: real bearer credentials are never
|
||||
// letters-only, while prose like "bearer authentication" is.
|
||||
return text.replace(
|
||||
/(bearer\s+)((?=[a-z._-]*[0-9])[a-z0-9._-]{8,})/gi,
|
||||
(_match, prefix: string) => `${prefix}${this.#placeholder}`,
|
||||
)
|
||||
}
|
||||
|
||||
#redactStandaloneTokens(text: string): string {
|
||||
// `/` is excluded so package names, file paths, and URLs are never split or
|
||||
// redacted; a secret containing `/` is still scrubbed piecewise.
|
||||
return text.replace(/[A-Za-z0-9][A-Za-z0-9+=_.-]{7,}/g, token =>
|
||||
this.isSecretValue(token) ? this.#placeholder : token)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user