From e27ec2ba54a2c7fa3c21b7cdb411a61e6ac4f13e Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Fri, 18 Sep 2026 15:03:25 +0800 Subject: [PATCH] [CTX-0071] feat(devtools): implement wire-trace flag per accepted design (#120) --- src/cli.ts | 474 +++++++++++++++++++++++++++++++++++++++++- src/tracing.ts | 4 +- tests/cli.test.ts | 413 +++++++++++++++++++++++++++++++++++- tests/tracing.test.ts | 133 ++++++++++++ 4 files changed, 1017 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 4211662..5bd8b65 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,7 +22,7 @@ * untrusted observation data, never instructions. */ -import { truncateToChars } from "./bounds.js"; +import { BOUNDS, BoundError, truncateToChars } from "./bounds.js"; import { generation } from "./panel-runtime.js"; import { DevtoolsClient } from "./client.js"; import { InspectionError } from "./inspection.js"; @@ -31,6 +31,12 @@ import type { PluginSummary, SubscriptionInfo, } from "./inspection.js"; +import { TracingError } from "./tracing.js"; +import type { + TraceChunk, + TraceStartResult, + TraceStopResult, +} from "./tracing.js"; import { AuthError, peerCredentials, resolveSocketPath } from "./auth.js"; import { IpcTransport, TransportError } from "./transport.js"; import { connectLiveSocket } from "./ipc-socket.js"; @@ -53,6 +59,10 @@ export const MAX_JSON_DEPTH = 8 as const; /** Generation used by `inspect --budgets` when the caller omits one. */ export const DEFAULT_GENERATION = 1 as const; +export const DEFAULT_TRACE_DURATION_MS = 10000 as const; +export const DEFAULT_TRACE_MAX_BYTES = 524288 as const; +export const TRACE_ID_MAX_BYTES = 128 as const; + export type InspectSelector = "plugins" | "budgets" | "subscriptions"; export type InspectOptions = { @@ -64,8 +74,41 @@ export type InspectOptions = { instance: string | null; }; +export type TraceStartOptions = { + durationMs: number; + maxBytes: number; + includeInput: boolean; + json: boolean; + socket: string | null; + instance: string | null; +}; + +export type TraceStopOptions = { + traceId: string; + json: boolean; + socket: string | null; + instance: string | null; +}; + +export type TraceFetchOptions = { + traceId: string; + offset: number; + json: boolean; + socket: string | null; + instance: string | null; +}; + +export type ConnectionOptions = { + socket: string | null; + instance: string | null; +}; + export type CliCommand = - { kind: "help" } | { kind: "inspect"; options: InspectOptions }; + | { kind: "help" } + | { kind: "inspect"; options: InspectOptions } + | { kind: "trace-start"; options: TraceStartOptions } + | { kind: "trace-stop"; options: TraceStopOptions } + | { kind: "trace-fetch"; options: TraceFetchOptions }; export class CliUsageError extends Error { constructor(message: string) { @@ -96,6 +139,9 @@ Usage: bitty-devtools inspect --plugins [--generation ] [options] bitty-devtools inspect --subscriptions --plugin [options] bitty-devtools inspect --budgets --plugin [--generation ] [options] + bitty-devtools trace start --wire-trace [--duration-ms ] [--max-bytes ] [--include-input] [options] + bitty-devtools trace stop --wire-trace --trace-id [options] + bitty-devtools trace fetch-chunk --wire-trace --trace-id --offset [options] bitty-devtools --help Selectors (exactly one): @@ -103,9 +149,20 @@ Selectors (exactly one): --subscriptions List event subscriptions for --plugin. --budgets Show RC-1/RC-2/RC-4/RC-5 budgets for --plugin. +Trace (requires --wire-trace, live socket, debug.trace): + start Start a wire trace with bounded duration and bytes. + stop Stop a wire trace and show redacted previews. + fetch-chunk Fetch one 262144-byte chunk with continuation. + Options: --plugin Plugin id required by --subscriptions and --budgets. --generation Target plugin generation (default ${DEFAULT_GENERATION} for --budgets). + --wire-trace Presence-only opt-in for trace verbs, default off. + --duration-ms Trace duration 1..300000, default 10000. + --max-bytes Trace bytes 1..4194304, default 524288. + --include-input Presence-only input capture opt-in, default off. + --trace-id Trace id for stop and fetch-chunk. + --offset Byte offset for fetch-chunk. --socket Explicit Bitty IPC socket path (advisory). --instance Instance id under $XDG_RUNTIME_DIR/bitty/.sock. --json Emit bounded, pretty-printed JSON instead of a table. @@ -118,8 +175,9 @@ Connection: With no --socket/--instance, the CLI reads BITTY_SOCKET, then BITTY_INSTANCE_ID with XDG_RUNTIME_DIR. It fails closed when no instance is selected. Read-only; requires the debug.inspect scope and never fabricates - data for a server method the core has not implemented. Methods and fields - follow the accepted devtools-rfc v1.`; + data for a server method the core has not implemented. Trace verbs use the + live socket only, require debug.trace, spool 0600, and never read + BITTY_WIRE_TRACE. Methods and fields follow the accepted devtools-rfc v1.`; function takeValue( argv: readonly string[], @@ -230,6 +288,251 @@ function parseInspect(argv: readonly string[]): CliCommand { }; } +function parseTraceDurationMs(raw: string): number { + if (!/^\d+$/.test(raw)) { + throw new CliUsageError( + `--duration-ms must be an integer in 1..${BOUNDS.MAX_TRACE_DURATION_MS} (saw '${raw}')`, + ); + } + const value = Number(raw); + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > BOUNDS.MAX_TRACE_DURATION_MS + ) { + throw new CliUsageError( + `--duration-ms must be an integer in 1..${BOUNDS.MAX_TRACE_DURATION_MS} (saw '${raw}')`, + ); + } + return value; +} + +function parseTraceMaxBytes(raw: string): number { + if (!/^\d+$/.test(raw)) { + throw new CliUsageError( + `--max-bytes must be an integer in 1..${BOUNDS.MAX_TRACE_BYTES} (saw '${raw}')`, + ); + } + const value = Number(raw); + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > BOUNDS.MAX_TRACE_BYTES + ) { + throw new CliUsageError( + `--max-bytes must be an integer in 1..${BOUNDS.MAX_TRACE_BYTES} (saw '${raw}')`, + ); + } + return value; +} + +function parseTraceOffset(raw: string): number { + if (!/^\d+$/.test(raw)) { + throw new CliUsageError( + `--offset must be a nonnegative byte integer (saw '${raw}')`, + ); + } + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 0) { + throw new CliUsageError( + `--offset must be a nonnegative byte integer (saw '${raw}')`, + ); + } + return value; +} + +function parseTraceId(raw: string): string { + const bytes = new TextEncoder().encode(raw).length; + if (raw.length === 0 || bytes < 1 || bytes > TRACE_ID_MAX_BYTES) { + throw new CliUsageError( + `--trace-id must be 1..${TRACE_ID_MAX_BYTES} UTF-8 bytes`, + ); + } + return raw; +} + +function parseTraceStart(argv: readonly string[]): CliCommand { + let wireTrace = false; + let durationRaw: string | null = null; + let maxBytesRaw: string | null = null; + let includeInput = false; + let json = false; + let socket: string | null = null; + let instance: string | null = null; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case "--wire-trace": + wireTrace = true; + break; + case "--duration-ms": + durationRaw = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--max-bytes": + maxBytesRaw = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--include-input": + includeInput = true; + break; + case "--socket": + socket = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--instance": + instance = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--json": + json = true; + break; + case "-h": + case "--help": + return { kind: "help" }; + default: + throw new CliUsageError(`unknown argument '${arg ?? ""}'`); + } + } + if (!wireTrace) { + throw new CliUsageError(`trace start requires --wire-trace`); + } + const durationMs = + durationRaw === null + ? DEFAULT_TRACE_DURATION_MS + : parseTraceDurationMs(durationRaw); + const maxBytes = + maxBytesRaw === null + ? DEFAULT_TRACE_MAX_BYTES + : parseTraceMaxBytes(maxBytesRaw); + return { + kind: "trace-start", + options: { durationMs, maxBytes, includeInput, json, socket, instance }, + }; +} + +function parseTraceStop(argv: readonly string[]): CliCommand { + let wireTrace = false; + let traceIdRaw: string | null = null; + let json = false; + let socket: string | null = null; + let instance: string | null = null; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case "--wire-trace": + wireTrace = true; + break; + case "--trace-id": + traceIdRaw = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--socket": + socket = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--instance": + instance = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--json": + json = true; + break; + case "-h": + case "--help": + return { kind: "help" }; + default: + throw new CliUsageError(`unknown argument '${arg ?? ""}'`); + } + } + if (!wireTrace) { + throw new CliUsageError(`trace stop requires --wire-trace`); + } + if (traceIdRaw === null) { + throw new CliUsageError(`trace stop requires --trace-id `); + } + const traceId = parseTraceId(traceIdRaw); + return { kind: "trace-stop", options: { traceId, json, socket, instance } }; +} + +function parseTraceFetch(argv: readonly string[]): CliCommand { + let wireTrace = false; + let traceIdRaw: string | null = null; + let offsetRaw: string | null = null; + let json = false; + let socket: string | null = null; + let instance: string | null = null; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case "--wire-trace": + wireTrace = true; + break; + case "--trace-id": + traceIdRaw = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--offset": + offsetRaw = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--socket": + socket = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--instance": + instance = takeValue(argv, i + 1, arg); + i += 1; + break; + case "--json": + json = true; + break; + case "-h": + case "--help": + return { kind: "help" }; + default: + throw new CliUsageError(`unknown argument '${arg ?? ""}'`); + } + } + if (!wireTrace) { + throw new CliUsageError(`trace fetch-chunk requires --wire-trace`); + } + if (traceIdRaw === null) { + throw new CliUsageError(`trace fetch-chunk requires --trace-id `); + } + if (offsetRaw === null) { + throw new CliUsageError(`trace fetch-chunk requires --offset `); + } + const traceId = parseTraceId(traceIdRaw); + const offset = parseTraceOffset(offsetRaw); + return { + kind: "trace-fetch", + options: { traceId, offset, json, socket, instance }, + }; +} + +function parseTrace(argv: readonly string[]): CliCommand { + const verb = argv[0]; + if (verb === "-h" || verb === "--help") { + return { kind: "help" }; + } + if (verb === undefined) { + throw new CliUsageError("no trace verb: use start|stop|fetch-chunk"); + } + if (verb === "start") { + return parseTraceStart(argv.slice(1)); + } + if (verb === "stop") { + return parseTraceStop(argv.slice(1)); + } + if (verb === "fetch-chunk") { + return parseTraceFetch(argv.slice(1)); + } + throw new CliUsageError( + `unknown trace verb '${verb}' (want start|stop|fetch-chunk)`, + ); +} + export function parseCliArgs(argv: readonly string[]): CliCommand { const command = argv[0]; if (command === undefined || command === "-h" || command === "--help") { @@ -238,6 +541,9 @@ export function parseCliArgs(argv: readonly string[]): CliCommand { if (command === "inspect") { return parseInspect(argv.slice(1)); } + if (command === "trace") { + return parseTrace(argv.slice(1)); + } throw new CliUsageError(`unknown command '${command}'`); } @@ -271,7 +577,7 @@ export type CliDeps = { * environment) so callers report it instead of crashing with a stack trace. */ function resolveSocket( - options: InspectOptions, + options: ConnectionOptions, runtime: CliRuntime, ): string | null { const optionSocket = options.socket; @@ -408,6 +714,56 @@ function toJson(value: unknown): string { return JSON.stringify(boundJsonValue(value), null, 2); } +function renderTraceStart(result: TraceStartResult, json: boolean): string { + if (json) { + return toJson(result); + } + return renderTable( + ["FIELD", "VALUE"], + [ + ["traceId", result.traceId], + ["spoolPath", result.spoolPath], + ["chunkBytes", String(result.chunkBytes)], + ["startWallClockMs", String(result.startWallClockMs)], + ], + ); +} + +function renderTraceStop(result: TraceStopResult, json: boolean): string { + if (json) { + return toJson(result); + } + return renderTable( + ["FIELD", "VALUE"], + [ + ["traceId", result.traceId], + ["byteCount", String(result.byteCount)], + ["dropCount", String(result.dropCount)], + ["exportBytesEstimate", String(result.exportBytesEstimate)], + ["truncated", String(result.truncated)], + ["spoolMode", result.spoolMode], + ["previews", result.previews.join("|")], + ], + ); +} + +function renderTraceChunk(result: TraceChunk, json: boolean): string { + if (json) { + return toJson(result); + } + return renderTable( + ["FIELD", "VALUE"], + [ + ["traceId", result.traceId], + ["offset", String(result.offset)], + ["continuation", String(result.continuation)], + ["sequence", String(result.sequence)], + ["chunk", result.chunk], + ["preview", result.preview], + ], + ); +} + function requirePlugin(options: InspectOptions): string { // parseCliArgs guarantees a non-empty plugin for non-plugin selectors. if (options.plugin === null || options.plugin.length === 0) { @@ -442,6 +798,34 @@ function dispatch(client: DevtoolsClient, options: InspectOptions): string { } } +function dispatchTraceStart( + client: DevtoolsClient, + options: TraceStartOptions, +): string { + const result = client.startTrace({ + durationMs: options.durationMs, + maxBytes: options.maxBytes, + includeInput: options.includeInput, + }); + return renderTraceStart(result, options.json); +} + +function dispatchTraceStop( + client: DevtoolsClient, + options: TraceStopOptions, +): string { + const result = client.stopTrace(options.traceId); + return renderTraceStop(result, options.json); +} + +function dispatchTraceFetch( + client: DevtoolsClient, + options: TraceFetchOptions, +): string { + const result = client.fetchTraceChunk(options.traceId, options.offset); + return renderTraceChunk(result, options.json); +} + /** * Live dispatch for `runCliLive`: same selectors as `dispatch`, but each * inspection call goes over the socket via `client.requestLive` instead of @@ -543,6 +927,12 @@ export function formatCliError(error: unknown): string { if (error instanceof InspectionError) { return `${error.code}: ${error.message}`; } + if (error instanceof TracingError) { + return `${error.code}: ${error.message}`; + } + if (error instanceof BoundError) { + return `${error.bound}: ${error.message}`; + } if (error instanceof AuthError) return `${error.code}: ${error.message}`; if (error instanceof TransportError) { return `${error.code}: ${error.message}`; @@ -560,6 +950,12 @@ export function exitCodeForError(error: unknown): number { if (error instanceof InspectionError) { return expectedExitForError("Error", error.code); } + if (error instanceof TracingError) { + return expectedExitForError("Error", error.code); + } + if (error instanceof BoundError) { + return EXIT_GENERIC; + } if (error instanceof AuthError) { return expectedExitForError("Denied", error.code); } @@ -595,6 +991,18 @@ export function runCli(argv: readonly string[], deps: CliDeps): number { return EXIT_OK; } + if ( + command.kind === "trace-start" || + command.kind === "trace-stop" || + command.kind === "trace-fetch" + ) { + runtime.stderr( + "bitty-devtools: no connected Bitty instance; trace requires live socket, pass --socket or " + + "--instance via live entry\n", + ); + return EXIT_RUNTIME; + } + const options = command.options; let transport = deps.transport ?? null; if (transport === null) { @@ -672,6 +1080,62 @@ export async function runCliLive( return EXIT_OK; } + if ( + command.kind === "trace-start" || + command.kind === "trace-stop" || + command.kind === "trace-fetch" + ) { + const traceOptions = command.options; + let traceSocketPath: string | null; + try { + traceSocketPath = resolveSocket(traceOptions, runtime); + } catch (error) { + runtime.stderr(`bitty-devtools: ${formatCliError(error)}\n`); + return exitCodeForError(error); + } + if (traceSocketPath === null) { + runtime.stderr( + "bitty-devtools: no connected Bitty instance; pass --socket or " + + "--instance , or set BITTY_SOCKET / BITTY_INSTANCE_ID with " + + "XDG_RUNTIME_DIR\n", + ); + return EXIT_RUNTIME; + } + const injected = deps.transport ?? null; + const traceClient = new DevtoolsClient(); + try { + if (injected !== null) { + traceClient.connectWithTransport(injected); + } else { + await traceClient.connectLiveSocket( + runtime.uid, + peerCredentials(runtime.uid, runtime.gid, runtime.pid), + runtime.env["XDG_RUNTIME_DIR"], + traceOptions.instance ?? undefined, + traceSocketPath, + ); + } + traceClient.grantScope("debug.trace"); + let traceOutput: string; + if (command.kind === "trace-start") { + traceOutput = dispatchTraceStart(traceClient, command.options); + } else if (command.kind === "trace-stop") { + traceOutput = dispatchTraceStop(traceClient, command.options); + } else { + traceOutput = dispatchTraceFetch(traceClient, command.options); + } + runtime.stdout(`${traceOutput}\n`); + return EXIT_OK; + } catch (error) { + runtime.stderr(`bitty-devtools: ${formatCliError(error)}\n`); + return exitCodeForError(error); + } finally { + try { + traceClient.disconnect(); + } catch {} + } + } + const options = command.options; let socketPath: string | null; try { diff --git a/src/tracing.ts b/src/tracing.ts index 06eb011..9fffe22 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -178,8 +178,10 @@ export class TracingClient { maxTraces: opts.retention?.maxTraces ?? DEFAULT_RETENTION.maxTraces, }; assertBounded("durationMs", durationMs, BOUNDS.MAX_TRACE_DURATION_MS); - if (durationMs <= 0) + if (!Number.isSafeInteger(durationMs) || durationMs <= 0) throw new TracingError("InvalidDuration", "durationMs must be >0"); + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) + throw new TracingError("InvalidBytes", "maxBytes must be >0"); assertBounded("maxBytes", maxBytes, BOUNDS.MAX_TRACE_BYTES); assertBounded( "retention.maxBytes", diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 96cf8b3..0feff2e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -3,6 +3,8 @@ import { CliConfigError, CliUsageError, DEFAULT_GENERATION, + DEFAULT_TRACE_DURATION_MS, + DEFAULT_TRACE_MAX_BYTES, MAX_CELL_CHARS, exitCodeForError, parseCliArgs, @@ -10,7 +12,7 @@ import { runCliLive, } from "../src/cli.js"; import type { CliRuntime } from "../src/cli.js"; -import { peerCredentials } from "../src/auth.js"; +import { DIR_MODE, SOCKET_MODE, peerCredentials } from "../src/auth.js"; import { IpcTransport } from "../src/transport.js"; import type { IpcRequest } from "../src/transport.js"; import { @@ -21,6 +23,9 @@ import { EXIT_RUNTIME, EXIT_USAGE, } from "../src/campaign.js"; +import { TracingError } from "../src/tracing.js"; +import { BoundError } from "../src/bounds.js"; +import { DevtoolsClient } from "../src/client.js"; type Harness = { out: string[]; @@ -491,3 +496,409 @@ describe("exitCodeForError", () => { expect(exitCodeForError(new Error("boom"))).toBe(EXIT_GENERIC); }); }); + +describe("wire-trace flag contract N1-N15", () => { + test("N1 trace verbs without --wire-trace exit 2", () => { + expect(() => parseCliArgs(["trace", "start"])).toThrow(CliUsageError); + expect(() => + parseCliArgs(["trace", "stop", "--trace-id", "trace-1"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs([ + "trace", + "fetch-chunk", + "--trace-id", + "trace-1", + "--offset", + "0", + ]), + ).toThrow(CliUsageError); + const h1 = makeHarness(); + expect(runCli(["trace", "start"], h1.deps)).toBe(EXIT_USAGE); + expect(h1.err.join("")).toContain("Usage:"); + expect(h1.out).toEqual([]); + const h2 = makeHarness(); + expect(runCli(["trace", "stop", "--trace-id", "trace-1"], h2.deps)).toBe( + EXIT_USAGE, + ); + expect(h2.err.join("")).toContain("Usage:"); + expect(h2.out).toEqual([]); + }); + + test("N1 live trace without --wire-trace exits 2", async () => { + const h = makeHarness(); + expect( + await runCliLive( + ["trace", "start", "--duration-ms", "100", "--max-bytes", "1024"], + h.deps, + ), + ).toBe(EXIT_USAGE); + expect(h.err.join("")).toContain("Usage:"); + expect(h.out).toEqual([]); + }); + + test("N2 inspect with --wire-trace and trace companions exits 2", () => { + expect(() => + parseCliArgs(["inspect", "--plugins", "--wire-trace"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--duration-ms", "100"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--max-bytes", "1024"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--include-input"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--trace-id", "trace-1"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--offset", "0"]), + ).toThrow(CliUsageError); + const h = makeHarness(); + expect(runCli(["inspect", "--plugins", "--wire-trace"], h.deps)).toBe( + EXIT_USAGE, + ); + expect(h.err.join("")).toContain("Usage:"); + expect(h.out).toEqual([]); + }); + + test("N3 --wire-trace value forms exit 2", () => { + expect(() => + parseCliArgs(["trace", "start", "--wire-trace=false"]), + ).toThrow(CliUsageError); + expect(() => parseCliArgs(["trace", "start", "--wire-trace=0"])).toThrow( + CliUsageError, + ); + expect(() => parseCliArgs(["trace", "start", "--wire-trace=1"])).toThrow( + CliUsageError, + ); + expect(() => + parseCliArgs(["trace", "start", "--wire-trace", "false"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["trace", "stop", "--wire-trace=false", "--trace-id", "t"]), + ).toThrow(CliUsageError); + const h = makeHarness(); + expect(runCli(["trace", "start", "--wire-trace=false"], h.deps)).toBe( + EXIT_USAGE, + ); + expect(h.err.join("")).toContain("Usage:"); + }); + + test("N4 flag-as-value for trace companions exits 2", () => { + expect(() => + parseCliArgs([ + "trace", + "start", + "--wire-trace", + "--duration-ms", + "--json", + ]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["trace", "start", "--wire-trace", "--max-bytes", "--json"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["trace", "stop", "--wire-trace", "--trace-id", "--json"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs([ + "trace", + "fetch-chunk", + "--wire-trace", + "--trace-id", + "t", + "--offset", + "--json", + ]), + ).toThrow(CliUsageError); + }); + + test("N5 inspect scope cannot use trace methods exit 7", () => { + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.inspect"); + expect(() => c.startTrace({})).toThrow("debug.trace scope required"); + let code: number | null = null; + try { + c.startTrace({}); + } catch (e) { + code = exitCodeForError(e); + } + expect(code).toBe(EXIT_PERM); + expect(exitCodeForError(new TracingError("ScopeDenied", "denied"))).toBe( + EXIT_PERM, + ); + expect(exitCodeForError(new BoundError("offset", 10, 5))).toBe( + EXIT_GENERIC, + ); + c.disconnect(); + }); + + test("N6 bad --duration-ms exits 2", () => { + for (const bad of ["0", "1.5", "abc", "300001", "1000000", ""]) { + expect(() => + parseCliArgs(["trace", "start", "--wire-trace", "--duration-ms", bad]), + ).toThrow(CliUsageError); + } + expect(() => + parseCliArgs([ + "trace", + "start", + "--wire-trace", + "--duration-ms", + "--json", + ]), + ).toThrow(CliUsageError); + const h = makeHarness(); + expect( + runCli(["trace", "start", "--wire-trace", "--duration-ms", "0"], h.deps), + ).toBe(EXIT_USAGE); + expect(h.err.join("")).toContain("Usage:"); + }); + + test("N7 bad --max-bytes exits 2", () => { + for (const bad of ["0", "abc", "1.5", "4194305", "10000000"]) { + expect(() => + parseCliArgs(["trace", "start", "--wire-trace", "--max-bytes", bad]), + ).toThrow(CliUsageError); + } + const h = makeHarness(); + expect( + runCli(["trace", "start", "--wire-trace", "--max-bytes", "0"], h.deps), + ).toBe(EXIT_USAGE); + expect(h.err.join("")).toContain("Usage:"); + }); + + test("N8 bad --offset exits 2 at parse", () => { + for (const bad of ["abc", "1.5", "NaN"]) { + expect(() => + parseCliArgs([ + "trace", + "fetch-chunk", + "--wire-trace", + "--trace-id", + "trace-1", + "--offset", + bad, + ]), + ).toThrow(CliUsageError); + } + expect(() => + parseCliArgs([ + "trace", + "fetch-chunk", + "--wire-trace", + "--trace-id", + "trace-1", + "--offset", + "--json", + ]), + ).toThrow(CliUsageError); + const h = makeHarness(); + expect( + runCli( + [ + "trace", + "fetch-chunk", + "--wire-trace", + "--trace-id", + "trace-1", + "--offset", + "abc", + ], + h.deps, + ), + ).toBe(EXIT_USAGE); + }); + + test("N9 includeInput defaults false and redaction still applies", () => { + const parsed = parseCliArgs(["trace", "start", "--wire-trace"]); + expect(parsed).toEqual({ + kind: "trace-start", + options: { + durationMs: DEFAULT_TRACE_DURATION_MS, + maxBytes: DEFAULT_TRACE_MAX_BYTES, + includeInput: false, + json: false, + socket: null, + instance: null, + }, + }); + const withInput = parseCliArgs([ + "trace", + "start", + "--wire-trace", + "--include-input", + ]); + expect(withInput).toEqual({ + kind: "trace-start", + options: { + durationMs: DEFAULT_TRACE_DURATION_MS, + maxBytes: DEFAULT_TRACE_MAX_BYTES, + includeInput: true, + json: false, + socket: null, + instance: null, + }, + }); + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.trace"); + const s = c.startTrace({ maxBytes: 1024, includeInput: true }); + c.appendToTrace(s.traceId, "password=example"); + const chunk = c.fetchTraceChunk(s.traceId, 0); + expect(chunk.chunk).toBe("[REDACTED]"); + expect(chunk.chunk).not.toContain("example"); + c.stopTrace(s.traceId); + c.disconnect(); + }); + + test("N10 secret absent spool 0600 and tampered export fails", async () => { + expect(DIR_MODE).toBe(0o700); + expect(SOCKET_MODE).toBe(0o600); + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.trace"); + const s = c.startTrace({ maxBytes: 4096 }); + c.appendToTrace(s.traceId, "password=example-secret-value"); + const fetched = c.fetchTraceChunk(s.traceId, 0); + expect(fetched.chunk).not.toContain("example-secret-value"); + expect(fetched.preview).not.toContain("example-secret-value"); + const stopped = c.stopTrace(s.traceId); + expect(stopped.spoolMode).toBe("0600"); + expect(stopped.previews.join("")).not.toContain("example-secret-value"); + const s2 = c.startTrace({ maxBytes: 1024 }); + c.appendToTrace(s2.traceId, "hello"); + const preview = c.exportTracePreview(s2.traceId); + expect(preview.spoolMode).toBe("0600"); + let tampered: string | null = null; + try { + const { assertPreviewMatchesExport } = await import("../src/tracing.js"); + assertPreviewMatchesExport(preview.preview, "tampered-bytes"); + } catch (e) { + tampered = e instanceof TracingError ? e.code : "threw"; + } + expect(tampered).toBe("PreviewMismatch"); + c.stopTrace(s2.traceId); + c.disconnect(); + }); + + test("N11 UTF-8 byte bounds stay scalar safe", () => { + const enc = new TextEncoder(); + expect("é".length).toBe(1); + expect(enc.encode("é").length).toBe(2); + expect("中".length).toBe(1); + expect(enc.encode("中").length).toBe(3); + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.trace"); + const s = c.startTrace({ maxBytes: 1024 }); + c.appendToTrace(s.traceId, "aé中"); + expect(enc.encode("aé中").length).toBe(6); + const p0 = c.fetchTraceChunk(s.traceId, 0); + expect(p0.chunk).toBe("aé中"); + const p1 = c.fetchTraceChunk(s.traceId, 1); + expect(p1.chunk).toBe("é中"); + expect(() => c.fetchTraceChunk(s.traceId, 2)).toThrow(); + c.stopTrace(s.traceId); + c.disconnect(); + }); + + test("N12 env and bearer do not enable wire trace", () => { + const h1 = makeHarness(); + h1.deps.runtime.env = { BITTY_WIRE_TRACE: "1" }; + expect(runCli(["trace", "start"], h1.deps)).toBe(EXIT_USAGE); + expect(h1.out).toEqual([]); + const h2 = makeHarness(); + h2.deps.runtime.env = { BITTY_CTL_ELEVATE: "1" }; + expect(runCli(["trace", "start"], h2.deps)).toBe(EXIT_USAGE); + expect(h2.out).toEqual([]); + expect(() => + parseCliArgs(["trace", "start", "--wire-trace", "--bearer", "x"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["inspect", "--plugins", "--wire-trace"]), + ).toThrow(CliUsageError); + }); + + test("N13 trace with no socket exits 6 never mock", () => { + const h1 = makeHarness(); + expect(runCli(["trace", "start", "--wire-trace"], h1.deps)).toBe( + EXIT_RUNTIME, + ); + expect(h1.out).toEqual([]); + expect(h1.err.join("")).toContain("no connected Bitty instance"); + const h2 = makeHarness(); + const t = makeTransport(); + h2.deps.transport = t; + expect( + runCli(["trace", "start", "--wire-trace", "--socket", "/tmp/x.sock"], { + runtime: h2.deps.runtime, + transport: t, + }), + ).toBe(EXIT_RUNTIME); + expect(h2.out).toEqual([]); + }); + + test("N13 live trace with no socket exits 6", async () => { + const h = makeHarness(); + expect(await runCliLive(["trace", "start", "--wire-trace"], h.deps)).toBe( + EXIT_RUNTIME, + ); + expect(h.out).toEqual([]); + expect(h.err.join("")).toContain("no connected Bitty instance"); + }); + + test("N14 rate and frame shedding fail closed with counted drops", () => { + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.trace"); + const s = c.startTrace({ maxBytes: 10 }); + c.appendToTrace(s.traceId, "hello"); + c.appendToTrace(s.traceId, "world!"); + const stopped = c.stopTrace(s.traceId); + expect(stopped.byteCount).toBe(5); + expect(stopped.dropCount).toBe(1); + expect(stopped.truncated).toBe(true); + c.disconnect(); + }); + + test("N15 missing trace-id unknown verb and flag exit 2", () => { + expect(() => parseCliArgs(["trace", "stop", "--wire-trace"])).toThrow( + CliUsageError, + ); + expect(() => + parseCliArgs(["trace", "fetch-chunk", "--wire-trace", "--trace-id", "t"]), + ).toThrow(CliUsageError); + expect(() => parseCliArgs(["trace", "bogus", "--wire-trace"])).toThrow( + CliUsageError, + ); + expect(() => parseCliArgs(["trace"])).toThrow(CliUsageError); + expect(() => + parseCliArgs(["trace", "start", "--wire-trace", "--bogus"]), + ).toThrow(CliUsageError); + expect(() => + parseCliArgs(["trace", "start", "--wire-trace", "--retention-ms", "100"]), + ).toThrow(CliUsageError); + const h = makeHarness(); + expect(runCli(["trace", "stop", "--wire-trace"], h.deps)).toBe(EXIT_USAGE); + expect(h.err.join("")).toContain("Usage:"); + expect(h.out).toEqual([]); + }); + + test("trace start live with fake transport succeeds without mock rows", async () => { + const transport = makeTransport(); + const h = makeHarness(transport); + const code = await runCliLive( + ["trace", "start", "--wire-trace", "--socket", "/tmp/fake-trace.sock"], + h.deps, + ); + expect(code).toBe(EXIT_OK); + expect(h.err).toEqual([]); + expect(h.out.join("")).toContain("trace-"); + expect(h.out.join("")).toContain("262144"); + }); +}); diff --git a/tests/tracing.test.ts b/tests/tracing.test.ts index 4e4a0ef..9899cdc 100644 --- a/tests/tracing.test.ts +++ b/tests/tracing.test.ts @@ -8,6 +8,15 @@ import { type StructuredTraceEvent, } from "../src/tracing.js"; import type { PanelRuntimeSnapshot } from "../src/panel-runtime.js"; +import { BOUNDS } from "../src/bounds.js"; +import { DIR_MODE, SOCKET_MODE } from "../src/auth.js"; +import { + RateLimiter, + checkConnectionCap, + checkPayloadCap, +} from "../src/transport.js"; +import { exitCodeForError } from "../src/cli.js"; +import { EXIT_GENERIC, EXIT_PERM } from "../src/campaign.js"; function snap(): PanelRuntimeSnapshot { return { @@ -556,3 +565,127 @@ describe("tracing (debug.trace, opt-in, bounded)", () => { ).toThrow("cancelled"); }); }); + +describe("wire-trace negatives N5-N14", () => { + test("N5 inspect scope denies stop and fetch chunk exit 7", () => { + const c = new DevtoolsClient(); + c.connect(); + c.grantScope("debug.inspect"); + const t = new TracingClient(); + const started = t.startTrace("debug.trace", { maxBytes: 1024 }); + expect(() => t.stopTrace("debug.inspect", started.traceId)).toThrow( + "debug.trace scope required", + ); + expect(() => + t.fetchTraceChunk("debug.inspect", started.traceId, 0), + ).toThrow("debug.trace scope required"); + expect(() => c.startTrace({})).toThrow("debug.trace scope required"); + expect(exitCodeForError(new TracingError("ScopeDenied", "x"))).toBe( + EXIT_PERM, + ); + c.disconnect(); + }); + + test("N6 direct duration rejects zero float and over max", () => { + const t = new TracingClient(); + expect(() => t.startTrace("debug.trace", { durationMs: 0 })).toThrow(); + expect(() => t.startTrace("debug.trace", { durationMs: 1.5 })).toThrow(); + expect(() => + t.startTrace("debug.trace", { + durationMs: BOUNDS.MAX_TRACE_DURATION_MS + 1, + }), + ).toThrow(); + expect(() => + t.startTrace("debug.trace", { durationMs: Number.NaN }), + ).toThrow(); + }); + + test("N7 direct maxBytes rejects zero float and over max", () => { + const t = new TracingClient(); + expect(() => t.startTrace("debug.trace", { maxBytes: 0 })).toThrow(); + expect(() => t.startTrace("debug.trace", { maxBytes: 1.5 })).toThrow(); + expect(() => + t.startTrace("debug.trace", { maxBytes: BOUNDS.MAX_TRACE_BYTES + 1 }), + ).toThrow(); + }); + + test("N8 direct offset rejects negative noninteger and over bytes", () => { + const t = new TracingClient(); + const s = t.startTrace("debug.trace", { maxBytes: 1024 }); + t.appendToTrace(s.traceId, "hello"); + expect(() => t.fetchTraceChunk("debug.trace", s.traceId, -1)).toThrow(); + expect(() => t.fetchTraceChunk("debug.trace", s.traceId, 1.5)).toThrow(); + expect(() => t.fetchTraceChunk("debug.trace", s.traceId, 6)).toThrow(); + expect(exitCodeForError(new TracingError("InvalidOffset", "x"))).toBe( + EXIT_GENERIC, + ); + const ok = t.fetchTraceChunk("debug.trace", s.traceId, 5); + expect(ok.chunk).toBe(""); + expect(ok.continuation).toBe(false); + t.stopTrace("debug.trace", s.traceId); + }); + + test("N9 input default off with redaction on opt in", () => { + const t = new TracingClient(); + const s = t.startTrace("debug.trace", { maxBytes: 1024 }); + t.appendToTrace(s.traceId, "clipboard=top-secret-value password=hide"); + const page = t.fetchTraceChunk("debug.trace", s.traceId, 0); + expect(page.chunk).not.toContain("hide"); + expect(page.chunk).toBe("[REDACTED]"); + t.stopTrace("debug.trace", s.traceId); + const s2 = t.startTrace("debug.trace", { + maxBytes: 1024, + includeInput: true, + }); + t.appendToTrace(s2.traceId, "password=hide-me"); + const page2 = t.fetchTraceChunk("debug.trace", s2.traceId, 0); + expect(page2.chunk).toBe("[REDACTED]"); + t.stopTrace("debug.trace", s2.traceId); + }); + + test("N10 spool 0600 dir 0700 and preview mismatch", () => { + expect(DIR_MODE).toBe(0o700); + expect(SOCKET_MODE).toBe(0o600); + const t = new TracingClient(); + const s = t.startTrace("debug.trace", { maxBytes: 2048 }); + t.appendToTrace(s.traceId, "token=sk-live-abcdefgh12345678"); + const stopped = t.stopTrace("debug.trace", s.traceId); + expect(stopped.spoolMode).toBe("0600"); + expect(stopped.previews.join("")).not.toContain("sk-live"); + expect(() => assertPreviewMatchesExport("hello", "hello-tampered")).toThrow( + "preview must equal export", + ); + }); + + test("N11 byte counts use UTF-8 not length", () => { + const enc = new TextEncoder(); + expect(enc.encode("aé中").length).toBe(6); + expect("aé中".length).toBe(3); + const t = new TracingClient(); + const s = t.startTrace("debug.trace", { maxBytes: 1024 }); + t.appendToTrace(s.traceId, "é"); + const state = t.listTraces(); + expect(state).toContain(s.traceId); + expect(() => t.fetchTraceChunk("debug.trace", s.traceId, 1)).toThrow(); + const p0 = t.fetchTraceChunk("debug.trace", s.traceId, 0); + expect(enc.encode(p0.chunk).length).toBe(2); + t.stopTrace("debug.trace", s.traceId); + }); + + test("N14 RC-9 payload connection and drop shedding", () => { + const limiter = RateLimiter.rc9Default(); + for (let i = 0; i < 200; i += 1) { + limiter.check(i); + } + expect(() => limiter.check(200)).toThrow("rate limited"); + expect(() => checkPayloadCap(2 * 1024 * 1024)).toThrow(); + expect(() => checkConnectionCap(16)).toThrow("shed newest"); + const t = new TracingClient(); + const s = t.startTrace("debug.trace", { maxBytes: 5 }); + t.appendToTrace(s.traceId, "hello"); + t.appendToTrace(s.traceId, "extra-bytes"); + const stopped = t.stopTrace("debug.trace", s.traceId); + expect(stopped.dropCount).toBe(1); + expect(stopped.truncated).toBe(true); + }); +});