From 08cec553f0ffab70f379421beea18b76d1a9c319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Sun, 31 May 2026 02:34:23 -0400 Subject: [PATCH] Improve agent-oriented CLI workflows Add structured agent-facing CLI contracts, canonical debug workflows, remote diagnostics, triage helpers, breakpoint planning, action logging, and expanded CI smoke coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/smoke-tests.yml | 153 ++ Cargo.lock | 1 + README.md | 16 +- crates/windbg-tool/Cargo.toml | 1 + crates/windbg-tool/src/cli.rs | 1941 ++++++++++++++++++++++-- crates/windbg-tool/src/cli/dispatch.rs | 51 +- crates/windbg-tool/src/cli/output.rs | 311 +++- crates/windbg-tool/src/cli/remote.rs | 244 ++- crates/windbg-tool/src/main.rs | 4 +- crates/windbg-tool/tests/daemon_cli.rs | 285 ++++ crates/windbg-ttd/src/server.rs | 14 +- crates/windbg-ttd/src/tools.rs | 83 +- docs/cli.md | 101 +- docs/mcp.md | 3 +- 14 files changed, 3065 insertions(+), 143 deletions(-) diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index fab0c65..186049e 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -27,6 +27,78 @@ jobs: shell: pwsh run: cargo build -p windbg-tool + - name: Agent command smoke tests + shell: pwsh + timeout-minutes: 5 + run: | + $ErrorActionPreference = 'Stop' + + $tool = Join-Path $PWD 'target\debug\windbg-tool.exe' + + Write-Host 'Checking canonical debug capability matrix' + $debugCapabilities = & $tool --compact debug capabilities | ConvertFrom-Json + if ($debugCapabilities.canonical_command -ne 'debug capabilities') { + throw "Unexpected canonical command: $($debugCapabilities.canonical_command)" + } + $matrixBackends = @($debugCapabilities.backend_matrix | ForEach-Object { $_.backend }) + foreach ($backend in @('ttd_cursor', 'dbgeng_live', 'dbgeng_dump', 'dbgeng_remote_plan')) { + if ($matrixBackends -notcontains $backend) { + throw "debug capabilities missing backend '$backend'" + } + } + + Write-Host 'Checking remote doctor/status/plan' + $remoteDoctor = & $tool --compact remote doctor --transport tcp:port=0 | ConvertFrom-Json + if ($remoteDoctor.schema_version -ne 1 -or $remoteDoctor.status.parsed_tcp_port -ne 0) { + throw "remote doctor returned unexpected payload: $($remoteDoctor | ConvertTo-Json -Compress -Depth 20)" + } + if (-not (@($remoteDoctor.diagnostics) | Where-Object { $_.id -eq 'remote.probe.opt_in' })) { + throw 'remote doctor should report that TCP probing is opt-in' + } + + $remoteStatus = & $tool --compact remote status --transport tcp:port=0 | ConvertFrom-Json + if ($remoteStatus.schema_version -ne 1 -or $null -ne $remoteStatus.probe) { + throw "remote status should not probe unless requested: $($remoteStatus | ConvertTo-Json -Compress -Depth 20)" + } + + $remotePlan = & $tool --compact remote plan --server target01 | ConvertFrom-Json + $stepIds = @($remotePlan.steps | ForEach-Object { $_.id }) + foreach ($step in @('target_start_server', 'host_connect', 'verify')) { + if ($stepIds -notcontains $step) { + throw "remote plan missing step '$step'" + } + } + + Write-Host 'Checking breakpoint planner and CLI schema' + $breakpointPlan = & $tool --compact breakpoint plan --target 1 --address 0x1000 | ConvertFrom-Json + if (-not $breakpointPlan.supported -or $breakpointPlan.safety -ne 'mutating') { + throw "breakpoint plan returned unexpected payload: $($breakpointPlan | ConvertTo-Json -Compress -Depth 20)" + } + + $debugSchema = & $tool --compact cli-schema debug snapshot | ConvertFrom-Json + if ($debugSchema.command.path -ne 'debug snapshot' -or $debugSchema.command.metadata.command -ne 'debug snapshot') { + throw "cli-schema did not include debug snapshot metadata: $($debugSchema | ConvertTo-Json -Compress -Depth 20)" + } + + Write-Host 'Checking optional action log' + $logPath = Join-Path $env:RUNNER_TEMP 'windbg-tool-action-log.jsonl' + Remove-Item -Path $logPath -Force -ErrorAction SilentlyContinue + $env:WINDBG_TOOL_ACTION_LOG = $logPath + try { + & $tool --compact debug capabilities | Out-Null + $logSummary = & $tool --compact debug log summarize --path $logPath | ConvertFrom-Json + } + finally { + Remove-Item Env:WINDBG_TOOL_ACTION_LOG -ErrorAction SilentlyContinue + } + if ($logSummary.total_entries -lt 1 -or $logSummary.failed_entries -ne 0) { + throw "action log summary returned unexpected payload: $($logSummary | ConvertTo-Json -Compress -Depth 20)" + } + $loggedPath = @($logSummary.recent[0].command_path) + if ($loggedPath.Count -ne 2 -or $loggedPath[0] -ne 'debug' -or $loggedPath[1] -ne 'capabilities') { + throw "action log did not redact to the expected command path: $($logSummary | ConvertTo-Json -Compress -Depth 20)" + } + - name: Create and inspect ping dump shell: pwsh timeout-minutes: 10 @@ -108,6 +180,87 @@ jobs: throw "Expected at least one stack frame in the dump" } + Write-Host 'Checking daemon-backed agent commands on the dump target' + $pipe = "\\.\pipe\windbg-tool-ci-$PID" + $daemon = Start-Process ` + -FilePath $tool ` + -ArgumentList @('--pipe', $pipe, 'daemon', 'start') ` + -PassThru + $daemonReady = $false + for ($attempt = 0; $attempt -lt 50; $attempt++) { + Start-Sleep -Milliseconds 100 + $statusOutput = & $tool --pipe $pipe daemon status 2>$null + if ($LASTEXITCODE -eq 0) { + $statusOutput | ConvertFrom-Json | Out-Null + $daemonReady = $true + break + } + if ($daemon.HasExited) { + throw "Daemon exited before becoming ready with code $($daemon.ExitCode)" + } + } + if (-not $daemonReady) { + throw "Daemon did not become ready on pipe $pipe" + } + + $targetId = $null + try { + $opened = & $tool --pipe $pipe dump open $dumpPath | ConvertFrom-Json + $targetId = $opened.target_id + if (-not $targetId) { + throw "dump open did not return a target_id: $($opened | ConvertTo-Json -Compress -Depth 20)" + } + + $debugCapabilities = & $tool --pipe $pipe --compact debug capabilities --target $targetId | ConvertFrom-Json + if ($debugCapabilities.selected.subject.kind -ne 'target' -or -not $debugCapabilities.selected.matrix.can_stack) { + throw "debug capabilities did not describe the dump target: $($debugCapabilities | ConvertTo-Json -Compress -Depth 20)" + } + + $snapshot = & $tool --pipe $pipe --compact debug snapshot ` + --target $targetId ` + --include status ` + --include stack ` + --include modules ` + --max-frames 8 ` + --max-modules 32 | ConvertFrom-Json + if ( + $snapshot.canonical_command -ne 'debug snapshot' -or + $snapshot.subject.kind -ne 'target' -or + -not $snapshot.sections.status.status -or + -not $snapshot.sections.stack.status -or + -not $snapshot.sections.modules.status + ) { + throw "debug snapshot returned unexpected dump-target sections: $($snapshot | ConvertTo-Json -Compress -Depth 20)" + } + + $symbolsDoctor = & $tool --pipe $pipe --compact symbols doctor --target $targetId | ConvertFrom-Json + if ($symbolsDoctor.schema_version -ne 1 -or $symbolsDoctor.subject.kind -ne 'target' -or $symbolsDoctor.doctor.status -ne 'ok') { + throw "symbols doctor returned unexpected dump-target payload: $($symbolsDoctor | ConvertTo-Json -Compress -Depth 20)" + } + + $triage = & $tool --pipe $pipe --compact triage symbol-health --target $targetId | ConvertFrom-Json + if ($triage.schema_version -ne 1 -or $triage.kind -ne 'symbol_health' -or $triage.evidence.snapshot.canonical_command -ne 'debug snapshot') { + throw "triage did not include debug snapshot evidence: $($triage | ConvertTo-Json -Compress -Depth 20)" + } + + $breakpointPlan = & $tool --pipe $pipe --compact breakpoint plan --target $targetId --address 0x1000 | ConvertFrom-Json + if (-not $breakpointPlan.supported -or $breakpointPlan.safety -ne 'mutating') { + throw "breakpoint plan returned unexpected dump-target payload: $($breakpointPlan | ConvertTo-Json -Compress -Depth 20)" + } + } + finally { + if ($targetId) { + & $tool --pipe $pipe target close --target $targetId | Out-Null + } + & $tool --pipe $pipe daemon shutdown | Out-Null + if ($daemon -and -not $daemon.HasExited) { + if (-not $daemon.WaitForExit(5000)) { + Stop-Process -Id $daemon.Id -Force + $daemon.WaitForExit() + } + } + } + - name: Upload dump on failure if: failure() uses: actions/upload-artifact@v6 diff --git a/Cargo.lock b/Cargo.lock index 48663dc..e40905e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1814,6 +1814,7 @@ dependencies = [ "clap", "iced-x86", "rmcp", + "serde", "serde_json", "tokio", "tracing-subscriber", diff --git a/README.md b/README.md index 3d11f69..16f5fc2 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ - Run a stdio MCP server for TTD replay workflows. - Keep long-lived replay sessions in a local daemon and drive them from the CLI. - Open traces, create cursors, seek positions, inspect threads/modules/registers/memory, and replay to watchpoints. -- Use higher-level helpers such as `discover`, `recipes`, `context snapshot`, `symbols diagnose`, `disasm`, `stack backtrace`, and `memory chase`. -- Start a DbgEng process server and install, update, or launch WinDbg. +- Use higher-level helpers such as `discover`, `recipes`, `debug capabilities`, `debug snapshot`, `triage`, `symbols doctor`, `disasm`, `stack backtrace`, and `memory chase`. +- Plan and diagnose local/remote DbgEng workflows with `remote doctor`, `remote plan`, process-server commands, and WinDbg install/update/launch helpers. ## Repository layout @@ -51,6 +51,7 @@ Some commands work without loading a trace or starting the daemon: ```powershell target\debug\windbg-tool.exe discover +target\debug\windbg-tool.exe cli-schema target\debug\windbg-tool.exe recipes target\debug\windbg-tool.exe tools ``` @@ -61,18 +62,23 @@ For trace-driven work, the common flow is: target\debug\windbg-tool.exe daemon ensure target\debug\windbg-tool.exe open C:\path\to\trace.run --binary-path C:\path\to\binary.exe target\debug\windbg-tool.exe sessions -target\debug\windbg-tool.exe context snapshot --session 1 --cursor 1 +target\debug\windbg-tool.exe debug capabilities --session 1 --cursor 1 +target\debug\windbg-tool.exe debug snapshot --session 1 --cursor 1 target\debug\windbg-tool.exe disasm --session 1 --cursor 1 ``` `open` is the best starting command for the CLI because it loads the trace, creates a cursor, and returns both `session_id` and `cursor_id`. Most replay commands then use `--session` and `--cursor` (or the short forms `-s` and `-c`). +For AI agents and robust scripts, add `--envelope` or set `WINDBG_TOOL_ENVELOPE=1` to get stable `{ schema_version, ok, data|error }` JSON responses and structured error codes. +Set `WINDBG_TOOL_ACTION_LOG=` to append redacted JSONL command outcomes for handoff/resume workflows, then inspect them with `debug log summarize`. + Representative command areas: -- Discovery: `discover`, `recipes`, `tools`, `schema` +- Discovery: `discover`, `cli-schema`, `recipes`, `tools`, `schema` - Session and replay: `open`, `load`, `sessions`, `info`, `position set`, `step`, `replay to` +- Agent workflow: `debug capabilities`, `debug snapshot`, `triage crash`, `symbols doctor`, `breakpoint plan`, `debug log summarize` - Analysis: `symbols diagnose`, `disasm`, `memory dump`, `memory strings`, `memory chase`, `stack recover`, `stack backtrace` -- Platform helpers: `remote explain`, `dbgeng server`, `live launch`, `dump create`, `dump inspect`, `windbg status` +- Platform helpers: `remote explain`, `remote doctor`, `remote plan`, `dbgeng server`, `live launch`, `dump create`, `dump inspect`, `windbg status` For a fuller CLI walkthrough, output-shaping flags, and command map, see [the CLI guide](docs/cli.md). diff --git a/crates/windbg-tool/Cargo.toml b/crates/windbg-tool/Cargo.toml index a47aea1..89d69a5 100644 --- a/crates/windbg-tool/Cargo.toml +++ b/crates/windbg-tool/Cargo.toml @@ -9,6 +9,7 @@ anyhow.workspace = true clap.workspace = true iced-x86 = "1.21" rmcp.workspace = true +serde.workspace = true serde_json.workspace = true tokio.workspace = true tracing-subscriber.workspace = true diff --git a/crates/windbg-tool/src/cli.rs b/crates/windbg-tool/src/cli.rs index ab6fd11..3a3f7e5 100644 --- a/crates/windbg-tool/src/cli.rs +++ b/crates/windbg-tool/src/cli.rs @@ -1,13 +1,15 @@ use crate::pe_symbols::{diagnose_pe, export_symbol_value, read_export_symbols, ExportSymbol}; use anyhow::{bail, ensure, Context}; -use clap::{Args, Parser, Subcommand, ValueEnum}; +use clap::{Arg, Args, Command, CommandFactory, Parser, Subcommand, ValueEnum}; use iced_x86::{ Decoder, DecoderOptions, FlowControl, Formatter, Instruction, NasmFormatter, OpKind, Register, }; use rmcp::{transport::stdio, ServiceExt}; use serde_json::{json, Map, Value}; -use std::fs; +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use windbg_ttd::daemon::{default_pipe_name, run_daemon, DaemonClient}; use windbg_ttd::server::TtdMcpServer; use windbg_ttd::tools::{self, ToolCall}; @@ -18,7 +20,7 @@ mod output; mod platform; mod remote; -use output::{print_value, OutputOptions}; +use output::{classify_error, print_failure, print_value, OutputOptions}; #[derive(Debug, Parser)] #[command(about = "WinDbg Time Travel Debugging MCP server, daemon, and CLI")] @@ -39,6 +41,12 @@ struct Cli { help = "Print selected scalar values without JSON quoting" )] raw: bool, + #[arg( + long, + global = true, + help = "Wrap command results in a stable {schema_version, ok, data|error} JSON envelope" + )] + envelope: bool, #[command(subcommand)] command: Option, } @@ -56,6 +64,11 @@ enum Commands { Recipes(RecipeArgs), #[command(about = "Show the JSON schema for one MCP tool without contacting the daemon")] Schema(SchemaArgs), + #[command( + name = "cli-schema", + about = "Show machine-readable CLI command and argument schemas without contacting the daemon" + )] + CliSchema(CliSchemaArgs), Trace { #[command(subcommand)] command: TraceCommand, @@ -92,6 +105,14 @@ enum Commands { #[command(subcommand)] command: RemoteCommand, }, + Debug { + #[command(subcommand)] + command: DebugCommand, + }, + Triage { + #[command(subcommand)] + command: TriageCommand, + }, Windbg { #[command(subcommand)] command: WindbgCommand, @@ -274,6 +295,53 @@ enum RemoteCommand { ServerCommand(RemoteServerCommandArgs), #[command(about = "Generate a host-side WinDbg connection command")] ConnectCommand(RemoteConnectCommandArgs), + #[command(about = "Diagnose local readiness and command lines for remote debugging")] + Doctor(RemoteDoctorArgs), + #[command(about = "Show remote-debugging prerequisite status and optional reachability")] + Status(RemoteStatusArgs), + #[command( + about = "Generate a lifecycle plan for starting, connecting, verifying, and cleanup" + )] + Plan(RemotePlanArgs), +} + +#[derive(Debug, Subcommand)] +enum DebugCommand { + #[command(about = "Show canonical per-backend debugging capability matrix")] + Capabilities(DebugCapabilitiesArgs), + #[command(about = "Capture a bounded cross-backend AI-agent debugging snapshot")] + Snapshot(DebugSnapshotArgs), + #[command(about = "Read optional agent action logs")] + Log { + #[command(subcommand)] + command: DebugLogCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum DebugLogCommand { + #[command(about = "Summarize recent WINDBG_TOOL_ACTION_LOG JSONL entries")] + Summarize(DebugLogSummarizeArgs), +} + +#[derive(Debug, Subcommand)] +enum TriageCommand { + #[command(about = "Triage crash evidence from a TTD cursor or daemon-owned target")] + Crash(TriageArgs), + #[command(about = "Triage hang evidence from a TTD cursor or daemon-owned target")] + Hang(TriageArgs), + #[command(about = "Triage access-violation evidence from a TTD cursor or daemon-owned target")] + AccessViolation(TriageArgs), + #[command( + about = "Triage memory-corruption evidence from available stack/module/memory facts" + )] + MemoryCorruption(TriageArgs), + #[command(about = "Triage loader and suspicious-module evidence")] + Loader(TriageArgs), + #[command(about = "Triage symbol and source readiness")] + SymbolHealth(TriageArgs), + #[command(about = "Triage deadlock evidence where backend data is available")] + Deadlock(TriageArgs), } #[derive(Debug, Subcommand)] @@ -306,6 +374,8 @@ enum SymbolsCommand { Exports(SymbolExportsArgs), #[command(about = "Find the nearest exported symbol for a TTD address")] Nearest(SymbolNearestArgs), + #[command(about = "Diagnose symbol/source readiness for a TTD cursor or daemon-owned target")] + Doctor(SymbolDoctorArgs), } #[derive(Debug, Subcommand)] @@ -340,6 +410,8 @@ enum BreakpointCommand { Set(BreakpointSetArgs), #[command(about = "Remove a breakpoint from a daemon-owned live target")] Remove(BreakpointRemoveArgs), + #[command(about = "Plan a breakpoint or watchpoint without mutating the target")] + Plan(BreakpointPlanArgs), } #[derive(Debug, Subcommand)] @@ -499,6 +571,67 @@ struct DbgEngServerArgs { transport: String, } +#[derive(Debug, Args)] +struct RemoteDoctorArgs { + #[arg(long, value_enum, default_value_t = RemoteKind::Dbgsrv)] + kind: RemoteKind, + #[arg( + long, + help = "Target machine name or address for generated host commands" + )] + server: Option, + #[arg(short = 't', long, default_value = "tcp:port=5005")] + transport: String, + #[arg(long, help = "Target process id for NTSD/CDB -server attach recipes")] + pid: Option, + #[arg( + long, + help = "Target executable or command line for NTSD/CDB -server launch recipes" + )] + executable: Option, + #[arg(long, help = "Run an opt-in bounded TCP connect probe to --server")] + probe_connect: bool, + #[arg(long, default_value_t = 1000)] + timeout_ms: u64, +} + +#[derive(Debug, Args)] +struct RemoteStatusArgs { + #[arg(long, value_enum, default_value_t = RemoteKind::Dbgsrv)] + kind: RemoteKind, + #[arg( + long, + help = "Target machine name or address for optional connect probe" + )] + server: Option, + #[arg(short = 't', long, default_value = "tcp:port=5005")] + transport: String, + #[arg(long, help = "Run an opt-in bounded TCP connect probe to --server")] + probe_connect: bool, + #[arg(long, default_value_t = 1000)] + timeout_ms: u64, +} + +#[derive(Debug, Args)] +struct RemotePlanArgs { + #[arg(long, value_enum, default_value_t = RemoteKind::Dbgsrv)] + kind: RemoteKind, + #[arg( + long, + help = "Target machine name or address for generated host commands" + )] + server: Option, + #[arg(short = 't', long, default_value = "tcp:port=5005")] + transport: String, + #[arg(long, help = "Target process id for NTSD/CDB -server attach recipes")] + pid: Option, + #[arg( + long, + help = "Target executable or command line for NTSD/CDB -server launch recipes" + )] + executable: Option, +} + #[derive(Debug, Args)] struct LiveLaunchArgs { #[arg(long, help = "Full command line to launch under DbgEng")] @@ -647,6 +780,69 @@ struct ContextSnapshotArgs { cursor: Option, } +#[derive(Debug, Args, Clone)] +struct DebugSubjectArgs { + #[arg(short = 's', long, help = "TTD session id")] + session: Option, + #[arg(short = 'c', long, help = "TTD cursor id")] + cursor: Option, + #[arg( + short = 't', + long = "target", + help = "Daemon-owned live or dump target id" + )] + target: Option, +} + +#[derive(Debug, Args)] +struct DebugCapabilitiesArgs { + #[command(flatten)] + subject: DebugSubjectArgs, +} + +#[derive(Debug, Args)] +struct DebugSnapshotArgs { + #[command(flatten)] + subject: DebugSubjectArgs, + #[arg(long, default_value_t = 16)] + max_frames: u32, + #[arg(long, default_value_t = 64)] + max_modules: usize, + #[arg(long, default_value_t = 64)] + max_threads: usize, + #[arg(long, default_value_t = 8)] + disasm_count: u32, + #[arg(long, default_value_t = 2000)] + section_timeout_ms: u64, + #[arg(long, help = "Only include these snapshot sections; can be repeated")] + include: Vec, + #[arg(long, help = "Exclude these snapshot sections; can be repeated")] + exclude: Vec, +} + +#[derive(Debug, Args)] +struct DebugLogSummarizeArgs { + #[arg( + long, + help = "JSONL action log path; defaults to WINDBG_TOOL_ACTION_LOG" + )] + path: Option, + #[arg(long, default_value_t = 20)] + max: usize, +} + +#[derive(Debug, Args)] +struct TriageArgs { + #[command(flatten)] + subject: DebugSubjectArgs, + #[arg(long, default_value_t = 16)] + max_frames: u32, + #[arg(long, default_value_t = 32)] + max_modules: usize, + #[arg(long, default_value_t = 32)] + max_threads: usize, +} + #[derive(Debug, Args)] struct SymbolDiagnoseArgs { #[arg(short = 's', long)] @@ -689,6 +885,14 @@ struct SymbolNearestArgs { include_exports: bool, } +#[derive(Debug, Args)] +struct SymbolDoctorArgs { + #[command(flatten)] + subject: DebugSubjectArgs, + #[arg(long, help = "Optional address for nearest symbol/source checks")] + address: Option, +} + #[derive(Debug, Args)] struct SourceResolveArgs { #[arg( @@ -813,6 +1017,26 @@ struct BreakpointRemoveArgs { breakpoint_id: u32, } +#[derive(Debug, Args)] +struct BreakpointPlanArgs { + #[command(flatten)] + subject: DebugSubjectArgs, + #[arg(long, help = "Address for the planned breakpoint/watchpoint")] + address: Option, + #[arg(long, help = "Symbol expression for future symbol-breakpoint support")] + symbol: Option, + #[arg(long, help = "Module constraint for the plan")] + module: Option, + #[arg(long, default_value = "code", value_parser = ["code", "read", "write", "execute", "read_write"])] + kind: String, + #[arg(long)] + size: Option, + #[arg(long, value_parser = ["previous", "next"])] + direction: Option, + #[arg(long)] + thread_unique_id: Option, +} + #[derive(Debug, Args)] struct DataModelEvalArgs { #[arg(short = 't', long = "target")] @@ -925,6 +1149,17 @@ struct SchemaArgs { tool: String, } +#[derive(Debug, Args)] +struct CliSchemaArgs { + #[arg( + value_name = "COMMAND", + num_args = 0.., + trailing_var_arg = true, + help = "Optional command path to describe, for example: memory read" + )] + command: Vec, +} + #[derive(Debug, Args)] struct ToolArgs { name: String, @@ -1320,8 +1555,25 @@ struct SweepWatchMemoryArgs { background: bool, } -pub async fn run() -> anyhow::Result<()> { - dispatch::run_cli().await +pub async fn run() -> i32 { + let started = Instant::now(); + let result = dispatch::run_cli().await; + let exit_code = match result { + Ok(()) => 0, + Err(error) => { + let output = OutputOptions::from_env_and_args(); + let failure = classify_error(error); + if let Err(print_error) = print_failure(&failure, &output) { + eprintln!("Error: {}", failure.message); + eprintln!("Caused by: {print_error}"); + } + failure.exit_code() + } + }; + if let Err(error) = append_action_log(exit_code, started) { + eprintln!("Warning: failed to append action log: {error}"); + } + exit_code } async fn target_capabilities_and_print( @@ -1439,6 +1691,372 @@ async fn target_capabilities_and_print( ) } +async fn debug_capabilities_and_print( + pipe: String, + args: DebugCapabilitiesArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + let client = DaemonClient::new(pipe); + let subject = resolve_debug_subject(&args.subject, false)?; + let selected = match subject { + Some(DebugSubject::Ttd { session, cursor }) => { + let capabilities = call_status_value( + client + .call_tool(session_call("ttd_capabilities", SessionArgs { session })) + .await, + ); + let architecture = if let Some(cursor) = cursor { + Some(call_status_value( + architecture_state_value( + &client, + ArchitectureStateArgs { + session, + cursor, + thread_id: None, + }, + ) + .await, + )) + } else { + None + }; + Some(json!({ + "subject": debug_subject_value(&DebugSubject::Ttd { session, cursor }), + "capabilities": capabilities, + "architecture": architecture, + "matrix": backend_capability("ttd_cursor") + })) + } + Some(DebugSubject::Target { target }) => Some(json!({ + "subject": debug_subject_value(&DebugSubject::Target { target }), + "status": call_status_value(client.call_tool(target_call("target_status", target)).await), + "matrix": backend_capability("dbgeng_target") + })), + None => None, + }; + print_value( + json!({ + "schema_version": 1, + "canonical_command": "debug capabilities", + "selected": selected, + "backend_matrix": [ + backend_capability("ttd_cursor"), + backend_capability("dbgeng_live"), + backend_capability("dbgeng_dump"), + backend_capability("dbgeng_remote_plan") + ], + "safe_command_taxonomy": safe_command_taxonomy() + }), + output, + ) +} + +async fn debug_snapshot_and_print( + pipe: String, + args: DebugSnapshotArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + print_value(debug_snapshot_value(pipe, args).await?, output) +} + +fn debug_log_summarize_and_print( + args: DebugLogSummarizeArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + print_value(debug_log_summary_value(args)?, output) +} + +async fn debug_snapshot_value(pipe: String, args: DebugSnapshotArgs) -> anyhow::Result { + let subject = resolve_debug_subject(&args.subject, true)? + .context("debug snapshot requires either --target or --session plus --cursor")?; + match subject { + DebugSubject::Ttd { session, cursor } => { + let cursor = cursor.context("debug snapshot requires --cursor with --session")?; + debug_ttd_snapshot_value(pipe, session, cursor, args).await + } + DebugSubject::Target { target } => debug_target_snapshot_value(pipe, target, args).await, + } +} + +async fn debug_ttd_snapshot_value( + pipe: String, + session: u64, + cursor: u64, + args: DebugSnapshotArgs, +) -> anyhow::Result { + let legacy = context_snapshot_value( + pipe, + ContextSnapshotArgs { + session: Some(session), + cursor: Some(cursor), + }, + ) + .await?; + let mut sections = Map::new(); + add_legacy_section(&mut sections, "trace_info", &legacy, "info"); + add_legacy_section(&mut sections, "capabilities", &legacy, "debug capabilities"); + add_legacy_section(&mut sections, "position", &legacy, "position get"); + add_legacy_section(&mut sections, "active_threads", &legacy, "active-threads"); + add_legacy_section(&mut sections, "stack", &legacy, "stack info"); + add_legacy_section( + &mut sections, + "architecture_state", + &legacy, + "architecture state", + ); + add_legacy_section( + &mut sections, + "current_disassembly", + &legacy, + "disasm --session --cursor ", + ); + add_legacy_section(&mut sections, "nearest_symbol", &legacy, "symbols nearest"); + add_legacy_section(&mut sections, "command_line", &legacy, "command-line"); + add_legacy_section( + &mut sections, + "timeline_summary", + &legacy, + "timeline events", + ); + filter_sections(&mut sections, &args.include, &args.exclude); + Ok(json!({ + "schema_version": 1, + "canonical_command": "debug snapshot", + "subject": debug_subject_value(&DebugSubject::Ttd { session, cursor: Some(cursor) }), + "stability": "replayable_cursor", + "section_timeout_ms": args.section_timeout_ms, + "sections": sections, + "diagnostics": [], + "next_recommended_safe_commands": [ + format!("windbg-tool debug capabilities --session {session} --cursor {cursor}"), + format!("windbg-tool timeline events --session {session}"), + format!("windbg-tool stack backtrace --session {session} --cursor {cursor}") + ], + "legacy_snapshot": legacy + })) +} + +async fn debug_target_snapshot_value( + pipe: String, + target: u64, + args: DebugSnapshotArgs, +) -> anyhow::Result { + let client = DaemonClient::new(pipe); + let mut sections = Map::new(); + let subject = DebugSubject::Target { target }; + if snapshot_section_enabled(&args, "status") { + let started = Instant::now(); + sections.insert( + "status".to_string(), + composite_section( + started, + call_status_value(client.call_tool(target_call("target_status", target)).await), + Some("target status --target "), + None, + ), + ); + } + if snapshot_section_enabled(&args, "capabilities") { + sections.insert( + "capabilities".to_string(), + json!({ + "status": "ok", + "duration_ms": 0, + "truncated": false, + "value": backend_capability("dbgeng_target"), + "diagnostics": [], + "command": "debug capabilities --target " + }), + ); + } + if snapshot_section_enabled(&args, "registers") { + let started = Instant::now(); + sections.insert( + "registers".to_string(), + composite_section( + started, + call_status_value( + client + .call_tool(target_call("target_core_registers", target)) + .await, + ), + Some("target registers --target "), + None, + ), + ); + } + if snapshot_section_enabled(&args, "threads") { + let started = Instant::now(); + sections.insert( + "threads".to_string(), + composite_section( + started, + call_status_value( + client + .call_tool(target_call("target_list_threads", target)) + .await, + ), + Some("target threads --target "), + Some(args.max_threads), + ), + ); + } + if snapshot_section_enabled(&args, "modules") { + let started = Instant::now(); + sections.insert( + "modules".to_string(), + composite_section( + started, + call_status_value( + client + .call_tool(target_call("target_list_modules", target)) + .await, + ), + Some("target modules --target "), + Some(args.max_modules), + ), + ); + } + if snapshot_section_enabled(&args, "stack") { + let started = Instant::now(); + sections.insert( + "stack".to_string(), + composite_section( + started, + call_status_value( + client + .call_tool(target_stack_call(TargetStackTraceArgs { + target, + max_frames: args.max_frames, + })) + .await, + ), + Some("target stack --target "), + Some(args.max_frames as usize), + ), + ); + } + if snapshot_section_enabled(&args, "disassembly") { + let started = Instant::now(); + sections.insert( + "disassembly".to_string(), + composite_section( + started, + call_status_value( + client + .call_tool(target_disasm_call(TargetDisasmArgs { + target, + address: None, + count: args.disasm_count, + })?) + .await, + ), + Some("target disasm --target "), + Some(args.disasm_count as usize), + ), + ); + } + if snapshot_section_enabled(&args, "breakpoints") { + let started = Instant::now(); + sections.insert( + "breakpoints".to_string(), + composite_section( + started, + call_status_value( + client + .call_tool(target_call("target_list_breakpoints", target)) + .await, + ), + Some("breakpoint list --target "), + None, + ), + ); + } + if snapshot_section_enabled(&args, "symbol_source") { + sections.insert( + "symbol_source".to_string(), + symbol_source_doctor_value(&client, &subject, None).await, + ); + } + Ok(json!({ + "schema_version": 1, + "canonical_command": "debug snapshot", + "subject": debug_subject_value(&subject), + "stability": "stopped_or_best_effort_live_state", + "section_timeout_ms": args.section_timeout_ms, + "sections": sections, + "diagnostics": [ + diagnostic_item( + "debug.snapshot.live_consistency", + "info", + "Live target sections are best-effort.", + "If another actor continues or steps the target while the snapshot is collected, sections may describe adjacent target states.", + "high", + None, + ) + ], + "next_recommended_safe_commands": [ + format!("windbg-tool debug capabilities --target {target}"), + format!("windbg-tool target stack --target {target}"), + format!("windbg-tool breakpoint plan --target {target} --address ") + ] + })) +} + +async fn symbols_doctor_and_print( + pipe: String, + args: SymbolDoctorArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + let client = DaemonClient::new(pipe); + let subject = resolve_debug_subject(&args.subject, true)? + .context("symbols doctor requires either --target or --session plus --cursor")?; + let address = args + .address + .as_deref() + .map(parse_u64_argument) + .transpose()?; + print_value( + json!({ + "schema_version": 1, + "subject": debug_subject_value(&subject), + "doctor": symbol_source_doctor_value(&client, &subject, address).await + }), + output, + ) +} + +async fn triage_and_print( + pipe: String, + kind: &'static str, + args: TriageArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + let snapshot = debug_snapshot_value( + pipe, + DebugSnapshotArgs { + subject: args.subject, + max_frames: args.max_frames, + max_modules: args.max_modules, + max_threads: args.max_threads, + disasm_count: 8, + section_timeout_ms: 2000, + include: Vec::new(), + exclude: Vec::new(), + }, + ) + .await?; + print_value(triage_value(kind, snapshot), output) +} + +async fn breakpoint_plan_and_print( + _pipe: String, + args: BreakpointPlanArgs, + output: &OutputOptions, +) -> anyhow::Result<()> { + print_value(breakpoint_plan_value(args)?, output) +} + async fn run_mcp_stdio() -> anyhow::Result<()> { let server = TtdMcpServer::default(); let service = server @@ -1515,6 +2133,10 @@ async fn context_snapshot_and_print( args: ContextSnapshotArgs, output: &OutputOptions, ) -> anyhow::Result<()> { + print_value(context_snapshot_value(pipe, args).await?, output) +} + +async fn context_snapshot_value(pipe: String, args: ContextSnapshotArgs) -> anyhow::Result { let client = DaemonClient::new(pipe.clone()); let sessions = client.sessions().await?; let selected = select_snapshot_handles(&sessions, args)?; @@ -1630,7 +2252,7 @@ async fn context_snapshot_and_print( ); } - print_value(snapshot, output) + Ok(snapshot) } async fn symbols_diagnose_and_print( @@ -1766,6 +2388,8 @@ fn symbols_exports_and_print( "total_exports": exports.len(), "filtered_exports": filtered.len(), "max": args.max, + "returned": values.len(), + "limit": args.max, "truncated": filtered.len() > args.max, "exports": values, }), @@ -2457,111 +3081,814 @@ fn collect_source_matches( } return Ok(()); } - if !metadata.is_dir() { - return Ok(()); - } - - let entries = match fs::read_dir(root) { - Ok(entries) => entries, - Err(error) => { - matches.push(json!({ - "path": root, - "error": error.to_string() - })); - return Ok(()); + if !metadata.is_dir() { + return Ok(()); + } + + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(error) => { + matches.push(json!({ + "path": root, + "error": error.to_string() + })); + return Ok(()); + } + }; + for entry in entries { + if matches.len() >= max_candidates { + break; + } + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + matches.push(json!({ "error": error.to_string() })); + continue; + } + }; + collect_source_matches( + &entry.path(), + recorded_components, + max_candidates, + max_depth, + depth + 1, + matches, + )?; + } + Ok(()) +} + +fn source_match_value(path: &PathBuf, recorded_components: &[String], direct: bool) -> Value { + let candidate_components = normalized_components(path); + let matched_components = matching_suffix_len(&candidate_components, recorded_components); + json!({ + "path": path, + "direct": direct, + "matched_components": matched_components, + "candidate_components": candidate_components, + }) +} + +fn matching_suffix_len(candidate: &[String], recorded: &[String]) -> usize { + candidate + .iter() + .rev() + .zip(recorded.iter().rev()) + .take_while(|(candidate, recorded)| candidate == recorded) + .count() +} + +fn normalized_components(path: &Path) -> Vec { + path.components() + .filter_map(|component| match component { + std::path::Component::Normal(value) => { + Some(value.to_string_lossy().to_ascii_lowercase()) + } + _ => None, + }) + .collect() +} + +fn select_snapshot_handles(sessions: &Value, args: ContextSnapshotArgs) -> anyhow::Result { + if args.cursor.is_some() && args.session.is_none() { + bail!("context snapshot requires --session when --cursor is supplied") + } + let session_id = args.session.or_else(|| { + sessions["sessions"] + .as_array() + .and_then(|items| items.first()) + .and_then(|session| session["session_id"].as_u64()) + }); + let cursor_id = args.cursor.or_else(|| { + let session_id = session_id?; + sessions["sessions"].as_array()?.iter().find_map(|session| { + (session["session_id"].as_u64() == Some(session_id)) + .then(|| { + session["cursors"] + .as_array() + .and_then(|cursors| cursors.first()) + .and_then(|cursor| cursor["cursor_id"].as_u64()) + }) + .flatten() + }) + }); + Ok(json!({ + "session_id": session_id, + "cursor_id": cursor_id, + "selection": if args.session.is_some() || args.cursor.is_some() { "explicit" } else { "first_available" } + })) +} + +fn call_status_value(result: anyhow::Result) -> Value { + match result { + Ok(value) => json!({ "ok": true, "value": value }), + Err(error) => json!({ "ok": false, "error": error.to_string() }), + } +} + +#[derive(Debug, Clone)] +enum DebugSubject { + Ttd { session: u64, cursor: Option }, + Target { target: u64 }, +} + +fn resolve_debug_subject( + args: &DebugSubjectArgs, + require_cursor: bool, +) -> anyhow::Result> { + if args.target.is_some() && (args.session.is_some() || args.cursor.is_some()) { + bail!("choose either --target or --session/--cursor, not both"); + } + if let Some(target) = args.target { + return Ok(Some(DebugSubject::Target { target })); + } + match (args.session, args.cursor) { + (Some(session), Some(cursor)) => Ok(Some(DebugSubject::Ttd { + session, + cursor: Some(cursor), + })), + (Some(session), None) if !require_cursor => Ok(Some(DebugSubject::Ttd { + session, + cursor: None, + })), + (Some(_), None) => bail!("--cursor is required with --session for this command"), + (None, Some(_)) => bail!("--session is required with --cursor"), + (None, None) => Ok(None), + } +} + +fn debug_subject_value(subject: &DebugSubject) -> Value { + match subject { + DebugSubject::Ttd { session, cursor } => json!({ + "kind": if cursor.is_some() { "ttd_cursor" } else { "ttd_session" }, + "backend": "ttd_replay", + "ids": { + "session_id": session, + "cursor_id": cursor + }, + "stability": "replayable" + }), + DebugSubject::Target { target } => json!({ + "kind": "target", + "backend": "dbgeng_target", + "ids": { + "target_id": target + }, + "stability": "target_state" + }), + } +} + +pub(super) fn fix_item( + description: impl Into, + command: Option>, +) -> Value { + json!({ + "description": description.into(), + "command": command.map(Into::into) + }) +} + +pub(super) fn diagnostic_item( + id: impl Into, + severity: impl Into, + summary: impl Into, + detail: impl Into, + confidence: impl Into, + fix: Option, +) -> Value { + json!({ + "id": id.into(), + "severity": severity.into(), + "summary": summary.into(), + "detail": detail.into(), + "confidence": confidence.into(), + "fix": fix + }) +} + +fn composite_section( + started: Instant, + result: Value, + command: Option<&str>, + limit: Option, +) -> Value { + let ok = result["ok"].as_bool().unwrap_or(false); + let value = result.get("value").cloned().unwrap_or(Value::Null); + let returned = estimate_returned(&value); + let diagnostics = if ok { + Vec::new() + } else { + vec![diagnostic_item( + "debug.section.error", + "warning", + "Snapshot section failed.", + result["error"].as_str().unwrap_or("unknown error"), + "high", + None, + )] + }; + json!({ + "status": if ok { "ok" } else { "error" }, + "duration_ms": started.elapsed().as_millis(), + "truncated": limit.zip(returned).is_some_and(|(limit, returned)| returned >= limit), + "returned": returned, + "limit": limit, + "value": if ok { value } else { Value::Null }, + "error": if ok { Value::Null } else { result["error"].clone() }, + "diagnostics": diagnostics, + "command": command + }) +} + +fn add_legacy_section( + sections: &mut Map, + name: &str, + legacy: &Value, + command: &str, +) { + if let Some(value) = legacy.get(name) { + let ok = value["ok"].as_bool().unwrap_or(!value.is_null()); + sections.insert( + name.to_string(), + json!({ + "status": if ok { "ok" } else { "error" }, + "duration_ms": Value::Null, + "truncated": value["value"]["truncated"].as_bool().unwrap_or(false), + "returned": estimate_returned(value.get("value").unwrap_or(value)), + "limit": Value::Null, + "value": value.get("value").cloned().unwrap_or_else(|| value.clone()), + "error": if ok { Value::Null } else { value["error"].clone() }, + "diagnostics": [], + "command": command + }), + ); + } +} + +fn estimate_returned(value: &Value) -> Option { + if let Some(returned) = value.get("returned").and_then(Value::as_u64) { + return Some(returned as usize); + } + if let Some(array) = value.as_array() { + return Some(array.len()); + } + if let Some(object) = value.as_object() { + for key in [ + "frames", + "modules", + "threads", + "breakpoints", + "events", + "instructions", + "exports", + "strings", + ] { + if let Some(array) = object.get(key).and_then(Value::as_array) { + return Some(array.len()); + } + } + } + None +} + +fn snapshot_section_enabled(args: &DebugSnapshotArgs, name: &str) -> bool { + (args.include.is_empty() || args.include.iter().any(|item| item == name)) + && !args.exclude.iter().any(|item| item == name) +} + +fn filter_sections(sections: &mut Map, include: &[String], exclude: &[String]) { + if !include.is_empty() { + sections.retain(|name, _| include.iter().any(|item| item == name)); + } + if !exclude.is_empty() { + sections.retain(|name, _| !exclude.iter().any(|item| item == name)); + } +} + +fn backend_capability(kind: &str) -> Value { + match kind { + "ttd_cursor" => json!({ + "backend": "ttd_cursor", + "can_read_memory": true, + "can_disassemble": true, + "can_stack": true, + "can_query_symbols": true, + "can_query_source": true, + "can_step": true, + "can_continue": false, + "can_set_breakpoint": false, + "can_set_data_breakpoint": true, + "can_write_dump": false, + "can_time_travel": true, + "supports_jobs": true, + "supports_timeline": true, + "required_identifiers": ["session_id", "cursor_id"], + "safe_commands": ["debug snapshot", "timeline events", "memory read", "stack backtrace", "symbols nearest"], + "mutating_commands": ["position set", "step"], + "destructive_commands": ["close"], + "unsupported_operations": [ + { "operation": "live_continue", "reason": "TTD cursors replay; they do not continue a live process." } + ] + }), + "dbgeng_live" | "dbgeng_target" => json!({ + "backend": "dbgeng_live", + "can_read_memory": true, + "can_disassemble": true, + "can_stack": true, + "can_query_symbols": true, + "can_query_source": true, + "can_step": true, + "can_continue": true, + "can_set_breakpoint": true, + "can_set_data_breakpoint": false, + "can_write_dump": true, + "can_time_travel": false, + "supports_jobs": false, + "supports_timeline": false, + "required_identifiers": ["target_id"], + "safe_commands": ["debug snapshot", "target status", "target stack", "target disasm", "breakpoint plan"], + "mutating_commands": ["target continue", "target step", "breakpoint set", "target dump"], + "destructive_commands": ["target terminate", "target close"], + "unsupported_operations": [ + { "operation": "time_travel", "reason": "Live DbgEng targets are not TTD replay cursors." } + ] + }), + "dbgeng_dump" => json!({ + "backend": "dbgeng_dump", + "can_read_memory": true, + "can_disassemble": true, + "can_stack": true, + "can_query_symbols": true, + "can_query_source": true, + "can_step": false, + "can_continue": false, + "can_set_breakpoint": false, + "can_set_data_breakpoint": false, + "can_write_dump": false, + "can_time_travel": false, + "supports_jobs": false, + "supports_timeline": false, + "required_identifiers": ["target_id"], + "safe_commands": ["debug snapshot", "target status", "target stack", "target disasm"], + "mutating_commands": [], + "destructive_commands": ["target close"], + "unsupported_operations": [ + { "operation": "execution_control", "reason": "Dump targets are immutable snapshots." } + ] + }), + "dbgeng_remote_plan" => json!({ + "backend": "dbgeng_remote_plan", + "can_read_memory": false, + "can_disassemble": false, + "can_stack": false, + "can_query_symbols": false, + "can_query_source": false, + "can_step": false, + "can_continue": false, + "can_set_breakpoint": false, + "can_set_data_breakpoint": false, + "can_write_dump": false, + "can_time_travel": false, + "supports_jobs": false, + "supports_timeline": false, + "required_identifiers": ["transport"], + "safe_commands": ["remote doctor", "remote status", "remote plan", "remote server-command", "remote connect-command"], + "mutating_commands": [], + "destructive_commands": [], + "unsupported_operations": [ + { "operation": "debugger_actions", "reason": "Remote plan commands generate connection instructions; attach/launch happens after connecting." } + ] + }), + _ => json!({ "backend": kind, "status": "unknown" }), + } +} + +fn safe_command_taxonomy() -> Value { + json!({ + "safe": "Read-only commands that inspect local state, debugger state, or generate command lines.", + "mutating": "Commands that alter cursor position, target execution, breakpoints, or write dumps.", + "destructive": "Commands that terminate, close, cancel, or otherwise end target/debugger resources." + }) +} + +async fn symbol_source_doctor_value( + client: &DaemonClient, + subject: &DebugSubject, + address: Option, +) -> Value { + match subject { + DebugSubject::Ttd { session, cursor } => { + let nearest = if let (Some(cursor), Some(address)) = (cursor, address) { + call_status_value( + nearest_symbol_value( + client, + &SymbolNearestArgs { + session: *session, + cursor: *cursor, + address: format!("0x{address:X}"), + include_exports: false, + }, + ) + .await, + ) + } else { + json!({ + "ok": false, + "error": "Pass --cursor and --address for nearest-symbol/source quality checks." + }) + }; + json!({ + "status": "ok", + "duration_ms": Value::Null, + "truncated": false, + "value": { + "trace_info": call_status_value(client.call_tool(session_call("ttd_trace_info", SessionArgs { session: *session })).await), + "nearest_symbol": nearest + }, + "diagnostics": [ + diagnostic_item( + "symbols.source.follow_up", + "info", + "Use focused symbol/source commands for deeper diagnosis.", + "This doctor composes currently available trace and nearest-symbol checks.", + "high", + Some(fix_item( + "Run symbols diagnose or symbols nearest with the current address.", + Some("windbg-tool symbols diagnose --session ") + )) + ) + ], + "command": "symbols doctor" + }) + } + DebugSubject::Target { target } => { + let symbol = if let Some(address) = address { + call_status_value( + client + .call_tool( + target_address_call( + "target_symbol_by_offset", + TargetAddressArgs { + target: *target, + address: format!("0x{address:X}"), + }, + ) + .expect("address was formatted as hex"), + ) + .await, + ) + } else { + json!({ "ok": false, "error": "Pass --address for target symbol/source checks." }) + }; + let source = if let Some(address) = address { + call_status_value( + client + .call_tool( + target_address_call( + "target_source_by_offset", + TargetAddressArgs { + target: *target, + address: format!("0x{address:X}"), + }, + ) + .expect("address was formatted as hex"), + ) + .await, + ) + } else { + json!({ "ok": false, "error": "Pass --address for target source checks." }) + }; + json!({ + "status": "ok", + "duration_ms": Value::Null, + "truncated": false, + "value": { + "symbol": symbol, + "source": source + }, + "diagnostics": [ + diagnostic_item( + "symbols.source.address_optional", + "info", + "Current-PC symbol/source checks need an address when registers do not expose one.", + "Pass --address or use debug snapshot disassembly to identify a program counter.", + "medium", + Some(fix_item( + "Run target registers or target disasm, then pass --address.", + Some("windbg-tool symbols doctor --target --address ") + )) + ) + ], + "command": "symbols doctor" + }) + } + } +} + +fn triage_value(kind: &'static str, snapshot: Value) -> Value { + let mut hypotheses = Vec::new(); + let diagnostics = snapshot["diagnostics"].clone(); + match kind { + "symbol_health" => hypotheses.push(json!({ + "id": "symbol_health.requires_review", + "confidence": "medium", + "summary": "Review symbol_source and nearest_symbol evidence before trusting names or source paths.", + "supporting_sections": ["symbol_source", "nearest_symbol"] + })), + "loader" => hypotheses.push(json!({ + "id": "loader.module_review", + "confidence": "medium", + "summary": "Review module paths, duplicate module names, and recently loaded modules for loader anomalies.", + "supporting_sections": ["modules", "timeline_summary"] + })), + "deadlock" | "hang" => hypotheses.push(json!({ + "id": "hang.stack_threads_review", + "confidence": "low", + "summary": "Thread and stack evidence can identify waits, but this command does not yet prove lock ownership.", + "supporting_sections": ["threads", "stack"] + })), + "access_violation" | "crash" => hypotheses.push(json!({ + "id": "crash.exception_stack_review", + "confidence": "medium", + "summary": "Inspect exception/timeline, current disassembly, stack, and symbol quality before assigning root cause.", + "supporting_sections": ["timeline_summary", "current_disassembly", "stack", "symbol_source"] + })), + "memory_corruption" => hypotheses.push(json!({ + "id": "memory_corruption.needs_watchpoint", + "confidence": "low", + "summary": "Snapshot evidence can identify suspicious pointers or stacks; use watchpoint planning before replaying or mutating.", + "supporting_sections": ["stack", "modules", "disassembly"] + })), + _ => {} + } + json!({ + "schema_version": 1, + "kind": kind, + "evidence": { + "snapshot": snapshot + }, + "hypotheses": hypotheses, + "diagnostics": diagnostics, + "next_actions": [ + { "safety": "safe", "command": "windbg-tool debug snapshot", "reason": "Refresh bounded evidence." }, + { "safety": "safe", "command": "windbg-tool symbols doctor", "reason": "Validate symbol/source quality." }, + { "safety": "safe", "command": "windbg-tool breakpoint plan", "reason": "Plan breakpoints/watchpoints before mutating the target." } + ], + "limitations": [ + "Triage commands report evidence and hypotheses, not final root-cause verdicts.", + "Backend support varies; inspect debug capabilities for unsupported operations." + ] + }) +} + +fn breakpoint_plan_value(args: BreakpointPlanArgs) -> anyhow::Result { + let subject = resolve_debug_subject(&args.subject, false)? + .context("breakpoint plan requires --target or --session/--cursor")?; + if args.address.is_none() && args.symbol.is_none() { + bail!("breakpoint plan requires --address or --symbol"); + } + let address = args + .address + .as_deref() + .map(parse_u64_argument) + .transpose()?; + let kind = args.kind.as_str(); + let subject_value = debug_subject_value(&subject); + let (supported, command, reason, safety) = match (&subject, kind) { + (DebugSubject::Target { target }, "code") => ( + args.address.is_some(), + json!(["windbg-tool", "breakpoint", "set", "--target", target, "--address", args.address.clone().unwrap_or_else(|| "
".to_string())]), + if args.symbol.is_some() { + "Symbol breakpoint setting is not first-class yet; resolve the symbol to an address first." + } else { + "DbgEng live targets support code breakpoints by address." + }, + "mutating", + ), + (DebugSubject::Target { .. }, _) => ( + false, + Value::Null, + "Data watchpoints are not currently exposed for daemon-owned live targets.", + "unsupported", + ), + (DebugSubject::Ttd { session, cursor }, "write" | "read" | "read_write") => ( + args.address.is_some() && cursor.is_some(), + json!(["windbg-tool", "replay", "watch-memory", "--session", session, "--cursor", cursor, "--address", args.address.clone().unwrap_or_else(|| "
".to_string()), "--size", args.size.unwrap_or(1), "--access", kind, "--direction", args.direction.clone().unwrap_or_else(|| "previous".to_string())]), + "TTD memory watchpoints replay to the next or previous matching access.", + "bounded_replay", + ), + (DebugSubject::Ttd { .. }, "code" | "execute") => ( + false, + Value::Null, + "TTD code breakpoints are not exposed as persistent breakpoints; use position/disassembly/replay commands instead.", + "unsupported", + ), + _ => ( + false, + Value::Null, + "Requested breakpoint/watchpoint kind is not supported on this subject.", + "unsupported", + ), + }; + Ok(json!({ + "schema_version": 1, + "subject": subject_value, + "request": { + "address": address.map(|value| format!("0x{value:X}")), + "symbol": args.symbol, + "module": args.module, + "kind": kind, + "size": args.size, + "direction": args.direction, + "thread_unique_id": args.thread_unique_id + }, + "supported": supported, + "safety": safety, + "reason": reason, + "command": command, + "diagnostics": if supported { + Vec::::new() + } else { + vec![diagnostic_item( + "breakpoint.plan.unsupported", + "warning", + "Requested plan is not directly supported.", + reason, + "high", + None, + )] + } + })) +} + +fn action_log_path_from_env() -> Option { + std::env::var_os("WINDBG_TOOL_ACTION_LOG") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn append_action_log(exit_code: i32, started: Instant) -> anyhow::Result<()> { + let Some(path) = action_log_path_from_env() else { + return Ok(()); + }; + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .with_context(|| format!("creating action log directory {}", parent.display()))?; + } + let full_args = std::env::var_os("WINDBG_TOOL_ACTION_LOG_FULL").is_some(); + let entry = json!({ + "schema_version": 1, + "timestamp_unix_ms": SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(), + "command_path": command_path_from_args(std::env::args().skip(1)), + "args": if full_args { + std::env::args().skip(1).collect::>() + } else { + Vec::::new() + }, + "args_redacted": !full_args, + "ok": exit_code == 0, + "exit_code": exit_code, + "duration_ms": started.elapsed().as_millis() + }); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("opening action log {}", path.display()))?; + writeln!(file, "{}", serde_json::to_string(&entry)?) + .with_context(|| format!("writing action log {}", path.display())) +} + +fn command_path_from_args(args: impl IntoIterator) -> Vec { + let mut path = Vec::new(); + let mut skip_next = false; + for arg in args { + if skip_next { + skip_next = false; + continue; } - }; - for entry in entries { - if matches.len() >= max_candidates { + if arg == "--" { break; } - let entry = match entry { - Ok(entry) => entry, - Err(error) => { - matches.push(json!({ "error": error.to_string() })); - continue; + if arg.starts_with("--") { + if !path.is_empty() { + break; } - }; - collect_source_matches( - &entry.path(), - recorded_components, - max_candidates, - max_depth, - depth + 1, - matches, - )?; + if !arg.contains('=') && option_takes_value(&arg) { + skip_next = true; + } + continue; + } + if arg.starts_with('-') { + if !path.is_empty() { + break; + } + continue; + } + path.push(arg); + if path.len() >= expected_command_path_depth(&path) { + break; + } } - Ok(()) + path } -fn source_match_value(path: &PathBuf, recorded_components: &[String], direct: bool) -> Value { - let candidate_components = normalized_components(path); - let matched_components = matching_suffix_len(&candidate_components, recorded_components); - json!({ - "path": path, - "direct": direct, - "matched_components": matched_components, - "candidate_components": candidate_components, - }) +fn expected_command_path_depth(path: &[String]) -> usize { + match path { + [] => 1, + [command] if command_requires_subcommand(command) => 2, + [command, subcommand] if command == "debug" && subcommand == "log" => 3, + [command, ..] if command_requires_subcommand(command) => 2, + _ => 1, + } } -fn matching_suffix_len(candidate: &[String], recorded: &[String]) -> usize { - candidate - .iter() - .rev() - .zip(recorded.iter().rev()) - .take_while(|(candidate, recorded)| candidate == recorded) - .count() +fn command_requires_subcommand(command: &str) -> bool { + matches!( + command, + "trace" + | "daemon" + | "dbgeng" + | "live" + | "dump" + | "remote" + | "debug" + | "triage" + | "windbg" + | "context" + | "symbols" + | "source" + | "architecture" + | "arch" + | "index" + | "events" + | "timeline" + | "module" + | "cursor" + | "position" + | "replay" + | "sweep" + | "job" + | "breakpoint" + | "datamodel" + | "target" + | "stack" + | "memory" + | "object" + ) } -fn normalized_components(path: &Path) -> Vec { - path.components() - .filter_map(|component| match component { - std::path::Component::Normal(value) => { - Some(value.to_string_lossy().to_ascii_lowercase()) - } - _ => None, - }) - .collect() +fn option_takes_value(arg: &str) -> bool { + !matches!( + arg, + "--compact" | "--raw" | "--envelope" | "--probe-connect" | "--background" | "--overwrite" + ) } -fn select_snapshot_handles(sessions: &Value, args: ContextSnapshotArgs) -> anyhow::Result { - if args.cursor.is_some() && args.session.is_none() { - bail!("context snapshot requires --session when --cursor is supplied") +fn debug_log_summary_value(args: DebugLogSummarizeArgs) -> anyhow::Result { + let path = args + .path + .or_else(action_log_path_from_env) + .context("debug log summarize requires --path or WINDBG_TOOL_ACTION_LOG")?; + let file = + fs::File::open(&path).with_context(|| format!("opening action log {}", path.display()))?; + let reader = BufReader::new(file); + let mut entries = Vec::new(); + let mut malformed = 0_usize; + for line in reader.lines() { + let line = line.with_context(|| format!("reading action log {}", path.display()))?; + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(value) => entries.push(value), + Err(_) => malformed += 1, + } } - let session_id = args.session.or_else(|| { - sessions["sessions"] - .as_array() - .and_then(|items| items.first()) - .and_then(|session| session["session_id"].as_u64()) - }); - let cursor_id = args.cursor.or_else(|| { - let session_id = session_id?; - sessions["sessions"].as_array()?.iter().find_map(|session| { - (session["session_id"].as_u64() == Some(session_id)) - .then(|| { - session["cursors"] - .as_array() - .and_then(|cursors| cursors.first()) - .and_then(|cursor| cursor["cursor_id"].as_u64()) - }) - .flatten() - }) - }); + let total = entries.len(); + let failed = entries + .iter() + .filter(|entry| !entry["ok"].as_bool().unwrap_or(false)) + .count(); + let mut recent = entries.into_iter().rev().take(args.max).collect::>(); + recent.reverse(); Ok(json!({ - "session_id": session_id, - "cursor_id": cursor_id, - "selection": if args.session.is_some() || args.cursor.is_some() { "explicit" } else { "first_available" } + "schema_version": 1, + "path": path, + "total_entries": total, + "malformed_entries": malformed, + "failed_entries": failed, + "returned": recent.len(), + "limit": args.max, + "truncated": total > recent.len(), + "recent": recent })) } -fn call_status_value(result: anyhow::Result) -> Value { - match result { - Ok(value) => json!({ "ok": true, "value": value }), - Err(error) => json!({ "ok": false, "error": error.to_string() }), - } -} - async fn call_and_print( pipe: String, call: ToolCall, @@ -2685,6 +4012,195 @@ fn tool_schema(name: &str) -> anyhow::Result { .with_context(|| format!("unknown MCP tool: {name}")) } +fn cli_schema(args: CliSchemaArgs) -> anyhow::Result { + let command = Cli::command(); + let metadata = command_metadata(); + if args.command.is_empty() { + let mut commands = Vec::new(); + collect_leaf_command_schemas(&command, Vec::new(), &metadata, &mut commands); + let documented = metadata + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item["command"].as_str()) + .count() + }) + .unwrap_or_default(); + Ok(json!({ + "schema_version": 1, + "binary": command.get_name(), + "commands": commands, + "metadata_coverage": { + "leaf_commands": commands.len(), + "documented_commands": documented + } + })) + } else { + let (selected, path) = resolve_command_path(&command, &args.command)?; + Ok(json!({ + "schema_version": 1, + "binary": command.get_name(), + "command": command_schema(selected, &path, &metadata) + })) + } +} + +fn collect_leaf_command_schemas( + command: &Command, + prefix: Vec, + metadata: &Value, + commands: &mut Vec, +) { + for subcommand in command.get_subcommands() { + let mut path = prefix.clone(); + path.push(subcommand.get_name().to_string()); + if subcommand.has_subcommands() { + collect_leaf_command_schemas(subcommand, path, metadata, commands); + } else { + commands.push(command_schema(subcommand, &path, metadata)); + } + } +} + +fn resolve_command_path<'a>( + command: &'a Command, + path: &[String], +) -> anyhow::Result<(&'a Command, Vec)> { + let mut current = command; + let mut canonical = Vec::new(); + for segment in path { + let next = current + .get_subcommands() + .find(|subcommand| { + subcommand.get_name() == segment + || subcommand.get_all_aliases().any(|alias| alias == segment) + }) + .with_context(|| format!("unknown CLI command path: {}", path.join(" ")))?; + canonical.push(next.get_name().to_string()); + current = next; + } + Ok((current, canonical)) +} + +fn command_schema(command: &Command, path: &[String], metadata: &Value) -> Value { + let path_string = path.join(" "); + json!({ + "path": path_string, + "aliases": command.get_visible_aliases().collect::>(), + "about": command.get_about().map(ToString::to_string), + "long_about": command.get_long_about().map(ToString::to_string), + "arguments": command + .get_arguments() + .filter(|arg| !arg.is_hide_set()) + .map(|arg| argument_schema(command, arg)) + .collect::>(), + "subcommands": command + .get_subcommands() + .map(|subcommand| subcommand.get_name()) + .collect::>(), + "metadata": command_metadata_for(metadata, &path_string) + .unwrap_or_else(|| inferred_command_metadata(command, path)) + }) +} + +fn argument_schema(command: &Command, arg: &Arg) -> Value { + let possible_values = arg + .get_possible_values() + .into_iter() + .filter(|value| !value.is_hide_set()) + .map(|value| { + json!({ + "name": value.get_name(), + "help": value.get_help().map(ToString::to_string), + "aliases": value.get_name_and_aliases().skip(1).collect::>() + }) + }) + .collect::>(); + json!({ + "id": arg.get_id().as_str(), + "kind": if arg.is_positional() { "positional" } else if arg.get_long().is_some() || arg.get_short().is_some() { "option_or_flag" } else { "internal" }, + "long": arg.get_long(), + "short": arg.get_short().map(|value| value.to_string()), + "aliases": arg.get_aliases().unwrap_or_default(), + "help": arg.get_help().map(ToString::to_string), + "required": arg.is_required_set(), + "global": arg.is_global_set(), + "action": format!("{:?}", arg.get_action()), + "num_args": arg.get_num_args().map(|range| format!("{range:?}")), + "value_names": arg + .get_value_names() + .map(|names| names.iter().map(ToString::to_string).collect::>()) + .unwrap_or_default(), + "default_values": arg + .get_default_values() + .iter() + .map(|value| value.to_string_lossy().into_owned()) + .collect::>(), + "possible_values": possible_values, + "conflicts_with": command + .get_arg_conflicts_with(arg) + .into_iter() + .map(|arg| arg.get_id().as_str()) + .collect::>() + }) +} + +fn command_metadata_for(metadata: &Value, path: &str) -> Option { + metadata + .as_array() + .and_then(|items| { + items + .iter() + .find(|item| item["command"].as_str() == Some(path)) + }) + .cloned() +} + +fn inferred_command_metadata(command: &Command, path: &[String]) -> Value { + let path_string = path.join(" "); + let first = path.first().map(String::as_str).unwrap_or_default(); + let no_daemon = matches!( + first, + "discover" | "cli-schema" | "recipes" | "schema" | "tools" | "remote" | "source" + ) || matches!( + path_string.as_str(), + "symbols inspect" + | "symbols exports" + | "dbgeng server" + | "dbgsrv" + | "windbg status" + | "windbg install" + | "windbg update" + | "windbg path" + | "windbg run" + | "dump inspect" + | "dump create" + | "live capabilities" + | "live launch" + | "breakpoint capabilities" + | "datamodel capabilities" + ); + let has_session = command.get_arguments().any(|arg| arg.get_id() == "session"); + let has_cursor = command.get_arguments().any(|arg| arg.get_id() == "cursor"); + json!({ + "command": path_string, + "requires_daemon": !no_daemon, + "requires_native_ttd": has_session || has_cursor, + "session_required": has_session, + "cursor_required": has_cursor, + "cost": if has_session || has_cursor { "depends_on_trace_and_bounds" } else { "low" }, + "safety": if path_string.contains("terminate") || path_string.contains("remove") || path_string.contains("cancel") { + "destructive" + } else if path_string.contains("write") || path_string.contains("dump") || path_string.contains("set") || path_string.contains("continue") || path_string.contains("step") { + "mutating_or_side_effecting" + } else { + "read_only" + }, + "source": "inferred_from_cli_shape" + }) +} + fn discover_manifest() -> Value { json!({ "name": "windbg-tool", @@ -2698,9 +4214,23 @@ fn discover_manifest() -> Value { }, "output_controls": { "default": "pretty JSON", + "envelope": "--envelope or WINDBG_TOOL_ENVELOPE=1 wraps success and error output in a stable agent contract", "compact": "--compact emits single-line JSON", - "field": "--field path.to.value extracts a JSON field", - "raw": "--raw prints selected scalar fields without JSON quoting" + "field": "--field path.to.value extracts a JSON field; with --envelope it selects from data", + "raw": "--raw prints selected scalar fields without JSON quoting; error envelopes remain JSON" + }, + "error_contract": { + "envelope": { "schema_version": 1, "ok": false, "error": { "code": "daemon_unavailable", "kind": "daemon_unavailable", "message": "...", "retryable": true, "hint": "..." } }, + "exit_codes": { + "invalid_argument": 2, + "daemon_unavailable": 3, + "daemon_error": 4, + "session_not_found": 5, + "cursor_not_found": 6, + "timeout": 7, + "tool_error": 8, + "internal": 1 + } }, "recommended_flow": [ "windbg-tool daemon ensure", @@ -2710,10 +4240,12 @@ fn discover_manifest() -> Value { "windbg-tool registers --session --cursor " ], "command_groups": { - "discovery": ["discover", "recipes [topic]", "advise [topic]", "tools", "schema "], + "discovery": ["discover", "cli-schema [command...]", "recipes [topic]", "advise [topic]", "tools", "schema "], "daemon": ["daemon ensure", "daemon status", "daemon shutdown", "sessions"], + "debug": ["debug capabilities", "debug capabilities --session --cursor ", "debug capabilities --target ", "debug snapshot --session --cursor ", "debug snapshot --target ", "debug log summarize"], "context": ["context snapshot", "context snapshot --session --cursor "], - "remote": ["remote explain", "remote server-command", "remote connect-command"], + "triage": ["triage crash", "triage hang", "triage access-violation", "triage memory-corruption", "triage loader", "triage symbol-health", "triage deadlock"], + "remote": ["remote explain", "remote doctor", "remote status", "remote plan", "remote server-command", "remote connect-command"], "live": [ "live capabilities", "live launch --command-line --end detach|terminate", @@ -2733,6 +4265,8 @@ fn discover_manifest() -> Value { "breakpoint list --target ", "breakpoint set --target --address ", "breakpoint remove --target --breakpoint-id ", + "breakpoint plan --target --address ", + "breakpoint plan --session --cursor --address --kind write", "memory watchpoint", "sweep watch-memory" ], @@ -2757,7 +4291,7 @@ fn discover_manifest() -> Value { "target symbol --target --address ", "target source --target --address " ], - "symbols": ["symbols diagnose --session ", "symbols diagnose --session --name ", "symbols diagnose --session --address ", "symbols inspect ", "symbols exports ", "symbols nearest --session --cursor --address "], + "symbols": ["symbols diagnose --session ", "symbols doctor --session --cursor ", "symbols doctor --target --address ", "symbols diagnose --session --name ", "symbols diagnose --session --address ", "symbols inspect ", "symbols exports ", "symbols nearest --session --cursor --address "], "source": ["source resolve --search-path "], "architecture": ["architecture state --session --cursor ", "arch state --session --cursor "], "dbgeng": ["dbgeng server --transport ", "dbgsrv --transport "], @@ -2774,6 +4308,12 @@ fn discover_manifest() -> Value { }, "tool_command_map": tool_command_map(), "command_metadata": command_metadata(), + "action_log": { + "enable": "Set WINDBG_TOOL_ACTION_LOG to a JSONL path.", + "privacy_default": "Logs command path, ok/exit status, and duration; raw arguments are redacted by default.", + "include_full_args": "Set WINDBG_TOOL_ACTION_LOG_FULL=1 only when full command-line logging is safe.", + "summarize": "windbg-tool debug log summarize --path " + }, "recipes": recipes_manifest(), "diagnostic_guidance": diagnostic_guidance(), "ttd_api_coverage": ttd_api_coverage_manifest(), @@ -2784,7 +4324,19 @@ fn discover_manifest() -> Value { }, { "goal": "Capture a one-shot agent context summary", - "command": "windbg-tool context snapshot --session 1 --cursor 1" + "command": "windbg-tool debug snapshot --session 1 --cursor 1" + }, + { + "goal": "Discover backend-safe debugging operations", + "command": "windbg-tool debug capabilities" + }, + { + "goal": "Diagnose remote-debugging readiness without mutating the remote machine", + "command": "windbg-tool remote doctor --transport tcp:port=5005" + }, + { + "goal": "Plan a live breakpoint or TTD watchpoint before changing debugger state", + "command": "windbg-tool breakpoint plan --target 1 --address 0x7ff600001000" }, { "goal": "Start a DbgEng TCP process server", @@ -3160,8 +4712,87 @@ fn command_metadata() -> Value { "requires_native_ttd": false, "session_required": "optional_but_recommended", "cost": "medium", + "safety": "read_only", + "canonical_command": "debug snapshot" + }, + { + "command": "debug capabilities", + "requires_daemon": "only when selecting a live, dump, or TTD subject", + "requires_native_ttd": false, + "session_required": false, + "cost": "low", + "safety": "read_only_discovery", + "canonical_command": "debug capabilities" + }, + { + "command": "debug snapshot", + "requires_daemon": true, + "requires_native_ttd": "TTD subjects require native replay; live/dump subjects use DbgEng target primitives", + "session_required": "TTD subjects require --session and --cursor; live/dump subjects require --target", + "cost": "bounded_composite", + "safety": "read_only", + "bounds": ["--max-frames", "--max-modules", "--max-threads", "--disasm-count", "--include", "--exclude"] + }, + { + "command": "triage", + "requires_daemon": true, + "requires_native_ttd": "depends on selected subject", + "session_required": "TTD subjects require --session and --cursor; live/dump subjects require --target", + "cost": "bounded_composite", + "safety": "read_only_hypothesis_generation", + "canonical_command": "triage " + }, + { + "command": "remote doctor", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "low", + "safety": "read_only_local_diagnostics", + "bounds": ["--probe-connect opt-in", "--timeout-ms"] + }, + { + "command": "remote status", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "low", + "safety": "read_only_local_diagnostics", + "bounds": ["--probe-connect opt-in", "--timeout-ms"] + }, + { + "command": "remote plan", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "low", + "safety": "read_only_command_generation" + }, + { + "command": "symbols doctor", + "requires_daemon": true, + "requires_native_ttd": "depends on selected subject", + "session_required": "TTD subjects require --session and --cursor; live/dump subjects require --target", + "cost": "low_to_medium", "safety": "read_only" }, + { + "command": "breakpoint plan", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": "requires --target or --session/--cursor identifiers", + "cost": "low", + "safety": "read_only_planner" + }, + { + "command": "debug log summarize", + "requires_daemon": false, + "requires_native_ttd": false, + "session_required": false, + "cost": "local_file_read", + "safety": "read_only_log_summary", + "privacy": "Action logging is opt-in through WINDBG_TOOL_ACTION_LOG; full argv logging requires WINDBG_TOOL_ACTION_LOG_FULL=1." + }, { "command": "timeline events", "requires_daemon": true, @@ -3870,6 +5501,8 @@ async fn timeline_events_value( "kind": args.kind, "total_events": total_events, "max_events": args.max_events, + "returned": events.len(), + "limit": args.max_events, "truncated": total_events > args.max_events, "events": events, "sources": Value::Object(sources), @@ -4935,6 +6568,8 @@ async fn memory_strings_and_print( "min_len": args.min_len, "total_strings": total_strings, "max_strings": args.max_strings, + "returned": strings.len(), + "limit": args.max_strings, "truncated": total_strings > args.max_strings, "strings": strings, "unavailable_bytes": if read["complete"].as_bool() == Some(false) { read["requested_size"].as_u64().unwrap_or_default().saturating_sub(read["bytes_read"].as_u64().unwrap_or_default()) } else { 0 } @@ -5461,6 +7096,114 @@ fn query_policy_values() -> [&'static str; 5] { mod tests { use super::*; + #[test] + fn resolves_debug_subject_exclusively() -> anyhow::Result<()> { + let subject = resolve_debug_subject( + &DebugSubjectArgs { + session: Some(7), + cursor: Some(9), + target: None, + }, + true, + )?; + assert!(matches!( + subject, + Some(DebugSubject::Ttd { + session: 7, + cursor: Some(9) + }) + )); + + let conflict = resolve_debug_subject( + &DebugSubjectArgs { + session: Some(7), + cursor: None, + target: Some(3), + }, + false, + ); + assert!(conflict.is_err()); + Ok(()) + } + + #[test] + fn builds_standard_diagnostic_shape() { + let diagnostic = diagnostic_item( + "daemon.unavailable", + "blocker", + "Daemon is unavailable.", + "The daemon pipe could not be reached.", + "high", + Some(fix_item( + "Start the daemon.", + Some("windbg-tool daemon ensure"), + )), + ); + assert_eq!(diagnostic["id"], "daemon.unavailable"); + assert_eq!(diagnostic["severity"], "blocker"); + assert_eq!(diagnostic["fix"]["command"], "windbg-tool daemon ensure"); + } + + #[test] + fn breakpoint_plan_supports_ttd_write_watchpoint() -> anyhow::Result<()> { + let plan = breakpoint_plan_value(BreakpointPlanArgs { + subject: DebugSubjectArgs { + session: Some(1), + cursor: Some(2), + target: None, + }, + address: Some("0x1000".to_string()), + symbol: None, + module: None, + kind: "write".to_string(), + size: Some(8), + direction: Some("previous".to_string()), + thread_unique_id: None, + })?; + assert_eq!(plan["supported"], true); + assert_eq!(plan["safety"], "bounded_replay"); + assert_eq!(plan["request"]["address"], "0x1000"); + Ok(()) + } + + #[test] + fn action_log_command_path_redacts_option_values() { + let path = command_path_from_args( + [ + "--compact", + "debug", + "snapshot", + "--session", + "7", + "--cursor", + "9", + ] + .into_iter() + .map(str::to_string), + ); + assert_eq!(path, vec!["debug", "snapshot"]); + + let path = command_path_from_args( + ["--compact", "open", "C:\\sensitive\\trace.run"] + .into_iter() + .map(str::to_string), + ); + assert_eq!(path, vec!["open"]); + + let path = command_path_from_args( + [ + "debug", + "log", + "summarize", + "--path", + "C:\\logs\\actions.jsonl", + ] + .into_iter() + .map(str::to_string), + ); + assert_eq!(path, vec!["debug", "log", "summarize"]); + } + #[test] fn classifies_strings_fill_and_pointers() { let bytes = [ diff --git a/crates/windbg-tool/src/cli/dispatch.rs b/crates/windbg-tool/src/cli/dispatch.rs index c3c7590..74156a7 100644 --- a/crates/windbg-tool/src/cli/dispatch.rs +++ b/crates/windbg-tool/src/cli/dispatch.rs @@ -1,19 +1,27 @@ -use clap::Parser; +use clap::{error::ErrorKind, Parser}; use serde_json::json; use super::daemon_mode; -use super::output::{print_value, OutputOptions}; +use super::output::{invalid_argument, print_value, OutputOptions}; use super::platform; use super::remote; use super::*; pub(super) async fn run_cli() -> anyhow::Result<()> { - let cli = Cli::parse(); - let output = OutputOptions { - compact: cli.compact, - field: cli.field, - raw: cli.raw, + let cli = match Cli::try_parse() { + Ok(cli) => cli, + Err(error) + if matches!( + error.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + ) => + { + error.print()?; + return Ok(()); + } + Err(error) => return Err(invalid_argument(error.to_string()).into()), }; + let output = OutputOptions::new(cli.compact, cli.field, cli.raw, cli.envelope); let pipe = cli.pipe.unwrap_or_else(default_pipe_name); match cli.command { @@ -21,6 +29,7 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { Some(Commands::Discover) => print_value(discover_manifest(), &output), Some(Commands::Recipes(args)) => print_value(recipes_value(args)?, &output), Some(Commands::Schema(args)) => print_value(tool_schema(&args.tool)?, &output), + Some(Commands::CliSchema(args)) => print_value(cli_schema(args)?, &output), Some(Commands::Trace { command }) => match command { TraceCommand::List(args) => call_and_print(pipe, trace_list_call(args), &output).await, }, @@ -48,6 +57,32 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { Some(Commands::Remote { command }) => { print_value(remote::remote_command_value(command)?, &output) } + Some(Commands::Debug { command }) => match command { + DebugCommand::Capabilities(args) => { + debug_capabilities_and_print(pipe, args, &output).await + } + DebugCommand::Snapshot(args) => debug_snapshot_and_print(pipe, args, &output).await, + DebugCommand::Log { command } => match command { + DebugLogCommand::Summarize(args) => debug_log_summarize_and_print(args, &output), + }, + }, + Some(Commands::Triage { command }) => match command { + TriageCommand::Crash(args) => triage_and_print(pipe, "crash", args, &output).await, + TriageCommand::Hang(args) => triage_and_print(pipe, "hang", args, &output).await, + TriageCommand::AccessViolation(args) => { + triage_and_print(pipe, "access_violation", args, &output).await + } + TriageCommand::MemoryCorruption(args) => { + triage_and_print(pipe, "memory_corruption", args, &output).await + } + TriageCommand::Loader(args) => triage_and_print(pipe, "loader", args, &output).await, + TriageCommand::SymbolHealth(args) => { + triage_and_print(pipe, "symbol_health", args, &output).await + } + TriageCommand::Deadlock(args) => { + triage_and_print(pipe, "deadlock", args, &output).await + } + }, Some(Commands::Windbg { command }) => platform::run_windbg_command(command, &output), Some(Commands::Open(args)) => open_and_print(pipe, args, &output).await, Some(Commands::Load(args)) => call_and_print(pipe, load_call(args), &output).await, @@ -69,6 +104,7 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { SymbolsCommand::Inspect(args) => print_value(diagnose_pe(&args.path)?, &output), SymbolsCommand::Exports(args) => symbols_exports_and_print(args, &output), SymbolsCommand::Nearest(args) => symbols_nearest_and_print(pipe, args, &output).await, + SymbolsCommand::Doctor(args) => symbols_doctor_and_print(pipe, args, &output).await, }, Some(Commands::Source { command }) => match command { SourceCommand::Resolve(args) => print_value(source_resolve(args)?, &output), @@ -209,6 +245,7 @@ pub(super) async fn run_cli() -> anyhow::Result<()> { BreakpointCommand::Remove(args) => { call_and_print(pipe, breakpoint_remove_call(args), &output).await } + BreakpointCommand::Plan(args) => breakpoint_plan_and_print(pipe, args, &output).await, }, Some(Commands::Datamodel { command }) => match command { DataModelCommand::Capabilities => { diff --git a/crates/windbg-tool/src/cli/output.rs b/crates/windbg-tool/src/cli/output.rs index 886bfa1..ed05652 100644 --- a/crates/windbg-tool/src/cli/output.rs +++ b/crates/windbg-tool/src/cli/output.rs @@ -1,11 +1,47 @@ -use anyhow::{bail, Context}; -use serde_json::Value; +use serde::Serialize; +use serde_json::{json, Value}; +use std::borrow::Cow; +use std::error::Error; +use std::fmt; + +const SCHEMA_VERSION: u32 = 1; +const ENVELOPE_ENV: &str = "WINDBG_TOOL_ENVELOPE"; #[derive(Debug, Clone)] pub(super) struct OutputOptions { pub(super) compact: bool, pub(super) field: Option, pub(super) raw: bool, + pub(super) envelope: bool, +} + +impl OutputOptions { + pub(super) fn new(compact: bool, field: Option, raw: bool, envelope: bool) -> Self { + Self { + compact, + field, + raw, + envelope: envelope || env_envelope_enabled(), + } + } + + pub(super) fn from_env_and_args() -> Self { + let mut compact = false; + let mut envelope = env_envelope_enabled(); + for arg in std::env::args_os().skip(1) { + if arg == "--compact" { + compact = true; + } else if arg == "--envelope" { + envelope = true; + } + } + Self { + compact, + field: None, + raw: false, + envelope, + } + } } pub(super) fn print_value(mut value: Value, output: &OutputOptions) -> anyhow::Result<()> { @@ -13,41 +49,263 @@ pub(super) fn print_value(mut value: Value, output: &OutputOptions) -> anyhow::R value = select_field(&value, path)?; } - if output.raw { + if output.envelope && !(output.raw && output.field.is_some()) { + print_json( + json!({ + "schema_version": SCHEMA_VERSION, + "ok": true, + "data": value, + }), + output.compact, + ) + } else if output.raw { print_raw(value) - } else if output.compact { - println!("{}", serde_json::to_string(&value)?); - Ok(()) } else { - println!("{}", serde_json::to_string_pretty(&value)?); + print_json(value, output.compact) + } +} + +pub(super) fn print_failure(error: &CliFailure, output: &OutputOptions) -> anyhow::Result<()> { + if output.envelope { + print_json( + json!({ + "schema_version": SCHEMA_VERSION, + "ok": false, + "error": error, + }), + output.compact, + ) + } else { + eprintln!("Error: {}", error.message); + for cause in &error.causes { + eprintln!("Caused by: {cause}"); + } + if let Some(hint) = &error.hint { + eprintln!("Hint: {hint}"); + } Ok(()) } } +pub(super) fn classify_error(error: anyhow::Error) -> CliFailure { + if let Some(failure) = error.downcast_ref::() { + return failure.clone(); + } + + let causes = error + .chain() + .skip(1) + .map(ToString::to_string) + .collect::>(); + let message = error.to_string(); + let joined = std::iter::once(message.as_str()) + .chain(causes.iter().map(String::as_str)) + .collect::>() + .join("\n"); + let combined = joined.to_ascii_lowercase(); + + if combined.contains("connecting to daemon pipe") + || combined.contains("daemon pipe") + && (combined.contains("os error 2") + || combined.contains("os error 3") + || combined.contains("os error 231") + || combined.contains("not found") + || combined.contains("busy")) + { + let mut failure = CliFailure::new( + CliErrorCode::DaemonUnavailable, + message, + "daemon_unavailable", + true, + ) + .with_hint("Start or repair the local daemon with `windbg-tool daemon ensure`."); + if let Some(pipe) = extract_pipe_hint(&joined) { + failure = failure.with_detail("pipe", pipe); + } + failure.causes = causes; + return failure; + } + + if combined.contains("session") && combined.contains("not found") { + return CliFailure::with_causes( + CliErrorCode::SessionNotFound, + message, + "session_not_found", + false, + causes, + ); + } + if combined.contains("cursor") && combined.contains("not found") { + return CliFailure::with_causes( + CliErrorCode::CursorNotFound, + message, + "cursor_not_found", + false, + causes, + ); + } + if combined.contains("timed out") || combined.contains("timeout") { + return CliFailure::with_causes(CliErrorCode::Timeout, message, "timeout", true, causes); + } + if combined.contains("daemon http") || combined.contains("daemon response") { + return CliFailure::with_causes( + CliErrorCode::DaemonError, + message, + "daemon_error", + false, + causes, + ); + } + if combined.contains("unknown tool") || combined.contains("invalid tool arguments") { + return CliFailure::with_causes( + CliErrorCode::ToolError, + message, + "tool_error", + false, + causes, + ); + } + + CliFailure::with_causes(CliErrorCode::Internal, message, "internal", false, causes) +} + +pub(super) fn invalid_argument(message: impl Into) -> CliFailure { + CliFailure::new( + CliErrorCode::InvalidArgument, + message, + "invalid_argument", + false, + ) +} + +#[derive(Debug, Clone, Serialize)] +pub(super) struct CliFailure { + pub(super) code: CliErrorCode, + pub(super) kind: Cow<'static, str>, + pub(super) message: String, + pub(super) retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) hint: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(super) causes: Vec, + #[serde(skip_serializing_if = "serde_json::Map::is_empty")] + pub(super) details: serde_json::Map, +} + +impl CliFailure { + fn new( + code: CliErrorCode, + message: impl Into, + kind: &'static str, + retryable: bool, + ) -> Self { + Self { + code, + kind: Cow::Borrowed(kind), + message: message.into(), + retryable, + hint: None, + causes: Vec::new(), + details: serde_json::Map::new(), + } + } + + fn with_causes( + code: CliErrorCode, + message: impl Into, + kind: &'static str, + retryable: bool, + causes: Vec, + ) -> Self { + Self { + causes, + ..Self::new(code, message, kind, retryable) + } + } + + fn with_hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + fn with_detail(mut self, key: &'static str, value: impl Into) -> Self { + self.details.insert(key.to_string(), value.into()); + self + } + + pub(super) fn exit_code(&self) -> i32 { + match self.code { + CliErrorCode::InvalidArgument => 2, + CliErrorCode::DaemonUnavailable => 3, + CliErrorCode::DaemonError => 4, + CliErrorCode::SessionNotFound => 5, + CliErrorCode::CursorNotFound => 6, + CliErrorCode::Timeout => 7, + CliErrorCode::ToolError => 8, + CliErrorCode::Internal => 1, + } + } +} + +impl fmt::Display for CliFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl Error for CliFailure {} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum CliErrorCode { + InvalidArgument, + DaemonUnavailable, + DaemonError, + SessionNotFound, + CursorNotFound, + Timeout, + ToolError, + Internal, +} + fn select_field(value: &Value, path: &str) -> anyhow::Result { let mut current = value; for segment in path.split('.') { if segment.is_empty() { - bail!("field path contains an empty segment") + return Err(invalid_argument("field path contains an empty segment").into()); } current = match current { Value::Object(object) => object .get(segment) - .with_context(|| format!("field '{segment}' was not found"))?, + .ok_or_else(|| invalid_argument(format!("field '{segment}' was not found")))?, Value::Array(items) => { - let index = segment - .parse::() - .with_context(|| format!("array field segment '{segment}' is not an index"))?; - items - .get(index) - .with_context(|| format!("array index {index} is out of range"))? + let index = segment.parse::().map_err(|_| { + invalid_argument(format!("array field segment '{segment}' is not an index")) + })?; + items.get(index).ok_or_else(|| { + invalid_argument(format!("array index {index} is out of range")) + })? + } + _ => { + return Err(invalid_argument(format!( + "field '{segment}' cannot be selected from a scalar value" + )) + .into()) } - _ => bail!("field '{segment}' cannot be selected from a scalar value"), }; } Ok(current.clone()) } +fn print_json(value: Value, compact: bool) -> anyhow::Result<()> { + if compact { + println!("{}", serde_json::to_string(&value)?); + } else { + println!("{}", serde_json::to_string_pretty(&value)?); + } + Ok(()) +} + fn print_raw(value: Value) -> anyhow::Result<()> { match value { Value::Null => Ok(()), @@ -69,3 +327,24 @@ fn print_raw(value: Value) -> anyhow::Result<()> { } } } + +fn env_envelope_enabled() -> bool { + std::env::var_os(ENVELOPE_ENV).is_some_and(|value| { + let value = value.to_string_lossy(); + value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") + }) +} + +fn extract_pipe_hint(message: &str) -> Option { + let marker = "daemon pipe "; + let start = message.find(marker)? + marker.len(); + let rest = message[start..].trim_start(); + let pipe = rest + .trim_start_matches('`') + .trim_start_matches('\'') + .trim_start_matches('"'); + let end = pipe + .find(|ch: char| ch == '`' || ch == '\'' || ch == '"' || ch.is_whitespace()) + .unwrap_or(pipe.len()); + (!pipe[..end].is_empty()).then(|| pipe[..end].to_string()) +} diff --git a/crates/windbg-tool/src/cli/remote.rs b/crates/windbg-tool/src/cli/remote.rs index ce807a9..6e8dce7 100644 --- a/crates/windbg-tool/src/cli/remote.rs +++ b/crates/windbg-tool/src/cli/remote.rs @@ -1,7 +1,12 @@ -use anyhow::bail; +use anyhow::{bail, Context}; use serde_json::{json, Value}; +use std::net::{TcpListener, TcpStream, ToSocketAddrs}; +use std::time::Duration; -use super::{RemoteCommand, RemoteConnectCommandArgs, RemoteKind, RemoteServerCommandArgs}; +use super::{ + diagnostic_item, fix_item, RemoteCommand, RemoteConnectCommandArgs, RemoteDoctorArgs, + RemoteKind, RemotePlanArgs, RemoteServerCommandArgs, RemoteStatusArgs, +}; pub(super) fn remote_command_value(command: RemoteCommand) -> anyhow::Result { match command { @@ -41,7 +46,192 @@ pub(super) fn remote_command_value(command: RemoteCommand) -> anyhow::Result doctor(args), + RemoteCommand::Status(args) => status(RemoteStatusArgs { + kind: args.kind, + server: args.server, + transport: args.transport, + probe_connect: args.probe_connect, + timeout_ms: args.timeout_ms, + }), + RemoteCommand::Plan(args) => plan(args), + } +} + +pub(super) fn doctor(args: RemoteDoctorArgs) -> anyhow::Result { + if matches!(args.kind, RemoteKind::Ntsd) && args.pid.is_some() && args.executable.is_some() { + bail!("remote doctor --kind ntsd accepts either --pid or --executable, not both"); + } + let status = status(RemoteStatusArgs { + kind: args.kind, + server: args.server.clone(), + transport: args.transport.clone(), + probe_connect: args.probe_connect, + timeout_ms: args.timeout_ms, + })?; + let plan = plan(RemotePlanArgs { + kind: args.kind, + server: args.server, + transport: args.transport, + pid: args.pid, + executable: args.executable, + })?; + Ok(json!({ + "schema_version": 1, + "kind": remote_kind_name(args.kind), + "status": status, + "plan": plan, + "diagnostics": status["diagnostics"], + "next_safe_commands": [ + "windbg-tool remote plan", + "windbg-tool remote status --probe-connect" + ] + })) +} + +pub(super) fn status(args: RemoteStatusArgs) -> anyhow::Result { + let mut diagnostics = Vec::new(); + let parsed_transport = parse_tcp_transport(&args.transport); + if parsed_transport.is_none() { + diagnostics.push(diagnostic_item( + "remote.transport.unsupported", + "blocker", + "Only tcp:port= transports can be locally diagnosed today.", + format!("Transport '{}' can still be emitted in generated commands, but port checks and connect probes are unavailable.", args.transport), + "high", + Some(fix_item( + "Use a TCP transport for agent-diagnosable remote workflows.", + Some("windbg-tool remote doctor --transport tcp:port=5005"), + )), + )); + } + + if let Some(port) = parsed_transport { + diagnostics.push(match TcpListener::bind(("127.0.0.1", port)) { + Ok(_) => diagnostic_item( + "remote.local_port.available", + "info", + format!("Local TCP port {port} is available."), + "This only checks the current machine; remote target availability still depends on firewall, account, and server state.", + "medium", + None, + ), + Err(error) => diagnostic_item( + "remote.local_port.unavailable", + "warning", + format!("Local TCP port {port} is not available."), + error.to_string(), + "medium", + Some(fix_item( + "Choose a different transport port or stop the process currently using it.", + Some("windbg-tool remote doctor --transport tcp:port=5006"), + )), + ), + }); + } + + let probe = if args.probe_connect { + if let (Some(server), Some(port)) = (args.server.as_deref(), parsed_transport) { + Some(connect_probe(server, port, args.timeout_ms)?) + } else { + diagnostics.push(diagnostic_item( + "remote.probe.skipped", + "warning", + "Connect probe skipped.", + "A TCP probe requires both --server and a tcp:port= transport.", + "high", + None, + )); + None + } + } else { + diagnostics.push(diagnostic_item( + "remote.probe.opt_in", + "info", + "Remote reachability was not probed.", + "Use --probe-connect for a bounded TCP connect check; this may be visible to remote network monitoring.", + "high", + None, + )); + None + }; + + diagnostics.push(diagnostic_item( + "remote.long_running.server", + "info", + "Target-side remote server commands are long-running.", + "Run them in a terminal or supervised process; this command only generates and diagnoses command lines.", + "high", + None, + )); + + Ok(json!({ + "schema_version": 1, + "kind": remote_kind_name(args.kind), + "transport": args.transport, + "server": args.server, + "parsed_tcp_port": parsed_transport, + "probe": probe, + "diagnostics": diagnostics + })) +} + +pub(super) fn plan(args: RemotePlanArgs) -> anyhow::Result { + if matches!(args.kind, RemoteKind::Ntsd) && args.pid.is_some() && args.executable.is_some() { + bail!("remote plan --kind ntsd accepts either --pid or --executable, not both"); } + let server_args = RemoteServerCommandArgs { + kind: args.kind, + transport: args.transport.clone(), + pid: args.pid, + executable: args.executable.clone(), + }; + let connect = args.server.as_ref().map(|server| { + remote_connect_command(&RemoteConnectCommandArgs { + kind: args.kind, + server: server.clone(), + transport: args.transport.clone(), + }) + }); + Ok(json!({ + "schema_version": 1, + "kind": remote_kind_name(args.kind), + "workflow": remote_workflow(args.kind), + "steps": [ + { + "id": "target_start_server", + "side": "target", + "long_running": true, + "command": remote_server_command(&server_args), + "notes": remote_server_notes(&server_args) + }, + { + "id": "host_connect", + "side": "host", + "requires": ["target_start_server"], + "command": connect, + "notes": if args.server.is_some() { + remote_connect_notes(&RemoteConnectCommandArgs { + kind: args.kind, + server: args.server.clone().unwrap_or_default(), + transport: args.transport.clone(), + }) + } else { + json!(["Pass --server to emit the exact host-side command."]) + } + }, + { + "id": "verify", + "side": "host", + "command": ["windbg-tool", "remote", "status", "--probe-connect"], + "notes": ["Run after the target-side server is listening."] + } + ], + "cleanup": [ + "Close the host debugger connection.", + "Stop the target-side server process or terminal." + ] + })) } fn remote_workflows() -> Value { @@ -78,6 +268,56 @@ fn remote_workflow(kind: RemoteKind) -> Value { } } +fn remote_kind_name(kind: RemoteKind) -> &'static str { + match kind { + RemoteKind::Dbgsrv => "dbgsrv", + RemoteKind::Ntsd => "ntsd", + } +} + +fn parse_tcp_transport(transport: &str) -> Option { + let rest = transport.strip_prefix("tcp:")?; + rest.split(',') + .find_map(|part| part.strip_prefix("port=")) + .and_then(|port| port.parse::().ok()) +} + +fn connect_probe(server: &str, port: u16, timeout_ms: u64) -> anyhow::Result { + let timeout = Duration::from_millis(timeout_ms); + let mut addrs = (server, port) + .to_socket_addrs() + .with_context(|| format!("resolving {server}:{port}"))?; + let Some(addr) = addrs.next() else { + return Ok(json!({ + "status": "error", + "summary": "No socket addresses resolved.", + "server": server, + "port": port, + "timeout_ms": timeout_ms + })); + }; + let result = TcpStream::connect_timeout(&addr, timeout); + Ok(match result { + Ok(_) => json!({ + "status": "ok", + "summary": "TCP connect probe succeeded.", + "server": server, + "port": port, + "address": addr.to_string(), + "timeout_ms": timeout_ms + }), + Err(error) => json!({ + "status": "error", + "summary": "TCP connect probe failed.", + "server": server, + "port": port, + "address": addr.to_string(), + "timeout_ms": timeout_ms, + "error": error.to_string() + }), + }) +} + fn remote_server_command(args: &RemoteServerCommandArgs) -> Vec { match args.kind { RemoteKind::Dbgsrv => vec![ diff --git a/crates/windbg-tool/src/main.rs b/crates/windbg-tool/src/main.rs index 23f7786..74ee196 100644 --- a/crates/windbg-tool/src/main.rs +++ b/crates/windbg-tool/src/main.rs @@ -1,5 +1,5 @@ #[tokio::main] -async fn main() -> anyhow::Result<()> { +async fn main() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -8,5 +8,5 @@ async fn main() -> anyhow::Result<()> { .with_writer(std::io::stderr) .init(); - windbg_tool::cli::run().await + std::process::exit(windbg_tool::cli::run().await); } diff --git a/crates/windbg-tool/tests/daemon_cli.rs b/crates/windbg-tool/tests/daemon_cli.rs index 5d543e6..bb1f50c 100644 --- a/crates/windbg-tool/tests/daemon_cli.rs +++ b/crates/windbg-tool/tests/daemon_cli.rs @@ -147,7 +147,44 @@ fn local_discovery_is_complete_without_daemon() -> anyhow::Result<()> { &ttd, ["recipes".to_string(), "remote-debugging".to_string()], )?; + let debug_capabilities = + run_local_json(&ttd, ["debug".to_string(), "capabilities".to_string()])?; + let debug_schema = run_local_json( + &ttd, + [ + "cli-schema".to_string(), + "debug".to_string(), + "snapshot".to_string(), + ], + )?; let remote_explain = run_local_json(&ttd, ["remote".to_string(), "explain".to_string()])?; + let remote_doctor = run_local_json( + &ttd, + [ + "remote".to_string(), + "doctor".to_string(), + "--transport".to_string(), + "tcp:port=0".to_string(), + ], + )?; + let remote_status = run_local_json( + &ttd, + [ + "remote".to_string(), + "status".to_string(), + "--transport".to_string(), + "tcp:port=0".to_string(), + ], + )?; + let remote_plan = run_local_json( + &ttd, + [ + "remote".to_string(), + "plan".to_string(), + "--server".to_string(), + "target01".to_string(), + ], + )?; let remote_connect = run_local_json( &ttd, [ @@ -162,6 +199,17 @@ fn local_discovery_is_complete_without_daemon() -> anyhow::Result<()> { let live_capabilities = run_local_json(&ttd, ["live".to_string(), "capabilities".to_string()])?; let breakpoint_capabilities = run_local_json(&ttd, ["breakpoint".to_string(), "capabilities".to_string()])?; + let breakpoint_plan = run_local_json( + &ttd, + [ + "breakpoint".to_string(), + "plan".to_string(), + "--target".to_string(), + "1".to_string(), + "--address".to_string(), + "0x1000".to_string(), + ], + )?; let datamodel_capabilities = run_local_json(&ttd, ["datamodel".to_string(), "capabilities".to_string()])?; let target_capabilities = @@ -247,6 +295,8 @@ fn local_discovery_is_complete_without_daemon() -> anyhow::Result<()> { ensure!( discover["command_groups"]["dbgeng"].is_array() && discover["command_groups"]["windbg"].is_array() + && discover["command_groups"]["debug"].is_array() + && discover["command_groups"]["triage"].is_array() && discover["command_groups"]["context"].is_array() && discover["command_groups"]["remote"].is_array() && discover["command_groups"]["live"].is_array() @@ -265,6 +315,37 @@ fn local_discovery_is_complete_without_daemon() -> anyhow::Result<()> { && discover["command_groups"]["object"].is_array(), "discover manifest should advertise broader WinDbg command groups: {discover}" ); + ensure!( + discover["action_log"]["enable"].is_string() + && discover["command_metadata"] + .as_array() + .is_some_and( + |items| items.iter().any(|item| item["command"] == "debug snapshot" + && item["cost"] == "bounded_composite") + && items.iter().any(|item| item["command"] == "remote doctor") + && items + .iter() + .any(|item| item["command"] == "debug log summarize") + ), + "discover manifest should advertise the agent debug and action-log contracts: {discover}" + ); + ensure!( + debug_capabilities["canonical_command"] == "debug capabilities" + && debug_capabilities["backend_matrix"] + .as_array() + .is_some_and( + |items| items.iter().any(|item| item["backend"] == "ttd_cursor") + && items + .iter() + .any(|item| item["backend"] == "dbgeng_remote_plan") + ), + "debug capabilities should expose the cross-backend matrix: {debug_capabilities}" + ); + ensure!( + debug_schema["command"]["path"] == "debug snapshot" + && debug_schema["command"]["metadata"]["command"] == "debug snapshot", + "cli-schema should include debug snapshot metadata: {debug_schema}" + ); let recipe_items = discover["recipes"] .as_array() .context("discover should include recipes")?; @@ -301,6 +382,29 @@ fn local_discovery_is_complete_without_daemon() -> anyhow::Result<()> { ), "remote explain should compare DbgSrv and NTSD/CDB workflows: {remote_explain}" ); + ensure!( + remote_doctor["schema_version"] == 1 + && remote_doctor["status"]["parsed_tcp_port"].as_u64() == Some(0) + && remote_doctor["diagnostics"].as_array().is_some_and(|items| items + .iter() + .any(|item| item["id"] == "remote.probe.opt_in")), + "remote doctor should return structured diagnostics without probing by default: {remote_doctor}" + ); + ensure!( + remote_status["schema_version"] == 1 + && remote_status["probe"].is_null() + && remote_status["diagnostics"] + .as_array() + .is_some_and(|items| items.iter().any(|item| item["id"] == "remote.probe.opt_in")), + "remote status should be read-only unless --probe-connect is set: {remote_status}" + ); + ensure!( + remote_plan["steps"].as_array().is_some_and(|items| items + .iter() + .any(|step| step["id"] == "target_start_server") + && items.iter().any(|step| step["id"] == "host_connect")), + "remote plan should generate target and host steps: {remote_plan}" + ); ensure!( remote_connect["command"] .as_array() @@ -322,6 +426,15 @@ fn local_discovery_is_complete_without_daemon() -> anyhow::Result<()> { .is_some_and(|items| items.iter().any(|item| item == "sweep watch-memory")), "breakpoint capabilities should mention sweep watch-memory: {breakpoint_capabilities}" ); + ensure!( + breakpoint_plan["supported"] == true + && breakpoint_plan["safety"] == "mutating" + && breakpoint_plan["command"] + .as_array() + .is_some_and(|items| items.iter().any(|item| item == "breakpoint") + && items.iter().any(|item| item == "set")), + "breakpoint plan should dry-run a live target code breakpoint: {breakpoint_plan}" + ); ensure!( datamodel_capabilities["gaps"] .as_array() @@ -431,6 +544,40 @@ fn local_discovery_is_complete_without_daemon() -> anyhow::Result<()> { ); } + let log_path = std::env::temp_dir().join(format!( + "windbg-tool-action-log-test-{}.jsonl", + std::process::id() + )); + let _ = std::fs::remove_file(&log_path); + let _ = run_local_json_with_env( + &ttd, + [ + ("WINDBG_TOOL_ACTION_LOG", path_string(&log_path)), + ("WINDBG_TOOL_ENVELOPE", "1".to_string()), + ], + ["debug".to_string(), "capabilities".to_string()], + )?; + let action_log_summary = run_local_json( + &ttd, + [ + "debug".to_string(), + "log".to_string(), + "summarize".to_string(), + "--path".to_string(), + path_string(&log_path), + ], + )?; + let _ = std::fs::remove_file(&log_path); + ensure!( + action_log_summary["total_entries"].as_u64() == Some(1) + && action_log_summary["recent"][0]["command_path"] + .as_array() + .is_some_and(|items| items.len() == 2 + && items[0] == "debug" + && items[1] == "capabilities"), + "action log summary should expose redacted command outcomes: {action_log_summary}" + ); + Ok(()) } @@ -509,6 +656,121 @@ fn ping_trace_agent_cli_scenario_uses_long_lived_daemon_session() -> anyhow::Res capabilities["features"]["trace_info"] == true, "capabilities should report trace_info support: {capabilities}" ); + let debug_capabilities = run_json_vec( + &ttd, + &pipe, + vec![ + "debug".to_string(), + "capabilities".to_string(), + "--session".to_string(), + session_id.to_string(), + "--cursor".to_string(), + cursor_id.to_string(), + ], + )?; + ensure!( + debug_capabilities["canonical_command"] == "debug capabilities" + && debug_capabilities["selected"]["subject"]["kind"] == "ttd_cursor" + && debug_capabilities["selected"]["matrix"]["can_time_travel"] == true, + "debug capabilities should describe the selected TTD cursor: {debug_capabilities}" + ); + let debug_snapshot = run_json_vec( + &ttd, + &pipe, + vec![ + "debug".to_string(), + "snapshot".to_string(), + "--session".to_string(), + session_id.to_string(), + "--cursor".to_string(), + cursor_id.to_string(), + "--include".to_string(), + "trace_info".to_string(), + "--include".to_string(), + "capabilities".to_string(), + "--include".to_string(), + "position".to_string(), + ], + )?; + ensure!( + debug_snapshot["canonical_command"] == "debug snapshot" + && debug_snapshot["subject"]["kind"] == "ttd_cursor" + && debug_snapshot["sections"]["trace_info"]["status"].is_string() + && debug_snapshot["sections"]["capabilities"]["status"].is_string() + && debug_snapshot["sections"]["position"]["status"].is_string(), + "debug snapshot should return sectioned TTD evidence: {debug_snapshot}" + ); + let symbols_doctor = run_json_vec( + &ttd, + &pipe, + vec![ + "symbols".to_string(), + "doctor".to_string(), + "--session".to_string(), + session_id.to_string(), + "--cursor".to_string(), + cursor_id.to_string(), + ], + )?; + ensure!( + symbols_doctor["schema_version"] == 1 + && symbols_doctor["subject"]["kind"] == "ttd_cursor" + && symbols_doctor["doctor"]["status"] == "ok" + && symbols_doctor["doctor"]["value"]["trace_info"]["ok"].is_boolean() + && symbols_doctor["doctor"]["diagnostics"] + .as_array() + .is_some_and(|items| items + .iter() + .any(|item| item["id"] == "symbols.source.follow_up")), + "symbols doctor should compose trace and symbol/source diagnostics: {symbols_doctor}" + ); + let triage = run_json_vec( + &ttd, + &pipe, + vec![ + "triage".to_string(), + "symbol-health".to_string(), + "--session".to_string(), + session_id.to_string(), + "--cursor".to_string(), + cursor_id.to_string(), + ], + )?; + ensure!( + triage["schema_version"] == 1 + && triage["kind"] == "symbol_health" + && triage["evidence"]["snapshot"]["canonical_command"] == "debug snapshot" + && triage["hypotheses"] + .as_array() + .is_some_and(|items| !items.is_empty()), + "triage symbol-health should include snapshot evidence and hypotheses: {triage}" + ); + let watchpoint_plan = run_json_vec( + &ttd, + &pipe, + vec![ + "breakpoint".to_string(), + "plan".to_string(), + "--session".to_string(), + session_id.to_string(), + "--cursor".to_string(), + cursor_id.to_string(), + "--address".to_string(), + "0x1000".to_string(), + "--kind".to_string(), + "write".to_string(), + "--size".to_string(), + "8".to_string(), + "--direction".to_string(), + "previous".to_string(), + ], + )?; + ensure!( + watchpoint_plan["supported"] == true + && watchpoint_plan["safety"] == "bounded_replay" + && watchpoint_plan["subject"]["kind"] == "ttd_cursor", + "breakpoint plan should dry-run TTD watchpoints: {watchpoint_plan}" + ); run_json_vec( &ttd, @@ -805,6 +1067,29 @@ fn run_local_json(ttd: &PathBuf, args: [String; N]) -> anyhow::R serde_json::from_slice(&output.stdout).context("parsing local ttd JSON stdout") } +#[cfg(windows)] +fn run_local_json_with_env( + ttd: &PathBuf, + envs: [(&str, String); M], + args: [String; N], +) -> anyhow::Result { + let mut command = Command::new(ttd); + command.args(args); + for (key, value) in envs { + command.env(key, value); + } + let output = command.output().context("running local ttd CLI command")?; + if !output.status.success() { + bail!( + "ttd command failed with status {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + serde_json::from_slice(&output.stdout).context("parsing local ttd JSON stdout") +} + #[cfg(windows)] fn assert_native_ping_cli_aliases( ttd: &PathBuf, diff --git a/crates/windbg-ttd/src/server.rs b/crates/windbg-ttd/src/server.rs index 0970c8a..fc411aa 100644 --- a/crates/windbg-ttd/src/server.rs +++ b/crates/windbg-ttd/src/server.rs @@ -69,11 +69,21 @@ impl ServerHandler for TtdMcpServer { } fn tool_text(value: Value) -> CallToolResult { - CallToolResult::success(vec![Content::text(pretty_json(value))]) + CallToolResult { + content: vec![Content::text(pretty_json(value.clone()))], + structured_content: Some(value), + is_error: Some(false), + meta: None, + } } fn tool_error(value: Value) -> CallToolResult { - CallToolResult::error(vec![Content::text(pretty_json(value))]) + CallToolResult { + content: vec![Content::text(pretty_json(value.clone()))], + structured_content: Some(value), + is_error: Some(true), + meta: None, + } } fn pretty_json(value: Value) -> String { diff --git a/crates/windbg-ttd/src/tools.rs b/crates/windbg-ttd/src/tools.rs index 833c1d5..521db8d 100644 --- a/crates/windbg-ttd/src/tools.rs +++ b/crates/windbg-ttd/src/tools.rs @@ -13,7 +13,7 @@ use crate::ttd_replay::{ RegisterContextRequest, SessionId, StackReadRequest, StepRequest, TraceListRequest, }; use anyhow::{bail, Context}; -use rmcp::model::{JsonObject, Tool}; +use rmcp::model::{JsonObject, Tool, ToolAnnotations}; use schemars::{schema_for, JsonSchema}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -561,6 +561,87 @@ fn tool(name: &str, description: &str) -> Tool { description.to_string(), Arc::new(input_schema), ) + .annotate(tool_annotations(name)) +} + +fn tool_annotations(name: &str) -> ToolAnnotations { + let mut annotations = ToolAnnotations::new() + .read_only(tool_is_read_only(name)) + .destructive(tool_is_destructive(name)) + .idempotent(tool_is_idempotent(name)) + .open_world(tool_is_open_world(name)); + annotations.title = Some(name.replace('_', " ")); + annotations +} + +fn tool_is_read_only(name: &str) -> bool { + !matches!( + name, + "ttd_load_trace" + | "ttd_close_trace" + | "ttd_index_build" + | "ttd_position_set" + | "ttd_step" + | "job_cancel" + | "target_write_dump" + | "target_close" + | "target_terminate" + | "target_continue" + | "target_step" + | "target_breakpoint_set" + | "target_breakpoint_remove" + | "live_launch" + | "live_attach" + | "dump_open" + | "sweep_watch_memory_start" + ) +} + +fn tool_is_destructive(name: &str) -> bool { + matches!( + name, + "ttd_close_trace" + | "job_cancel" + | "target_close" + | "target_terminate" + | "target_continue" + | "target_step" + | "target_breakpoint_remove" + ) +} + +fn tool_is_idempotent(name: &str) -> bool { + matches!( + name, + "ttd_trace_info" + | "ttd_capabilities" + | "ttd_index_status" + | "ttd_index_stats" + | "ttd_list_threads" + | "ttd_list_modules" + | "ttd_list_keyframes" + | "ttd_list_exceptions" + | "ttd_position_get" + | "ttd_read_memory" + | "job_list" + | "job_status" + | "job_result" + | "target_capabilities" + | "target_list" + | "target_status" + ) +} + +fn tool_is_open_world(name: &str) -> bool { + matches!( + name, + "ttd_load_trace" + | "ttd_trace_list" + | "live_launch" + | "live_attach" + | "dump_open" + | "target_write_dump" + ) } fn input_schema() -> JsonObject { diff --git a/docs/cli.md b/docs/cli.md index 0128abb..da64e18 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,9 +4,10 @@ ## How the CLI is organized -- **Discovery commands** do not require the daemon: `discover`, `recipes`, `tools`, `schema`, `trace-list`, `symbols inspect` +- **Discovery commands** do not require the daemon: `discover`, `cli-schema`, `recipes`, `tools`, `schema`, `trace-list`, `symbols inspect` - **Replay commands** usually talk to the local daemon and operate on a `session_id` and `cursor_id` -- **Platform helper commands** cover DbgEng, remote debugging recipes, live-launch probing, and WinDbg installation +- **Canonical agent debugging commands** start with `debug`, `triage`, `symbols doctor`, and `breakpoint plan` +- **Platform helper commands** cover DbgEng, remote debugging doctors/plans, live-launch probing, and WinDbg installation ## Common replay workflow @@ -32,14 +33,66 @@ Use the returned handles with analysis commands: ```powershell target\debug\windbg-tool.exe info --session 1 -target\debug\windbg-tool.exe context snapshot --session 1 --cursor 1 +target\debug\windbg-tool.exe debug snapshot --session 1 --cursor 1 target\debug\windbg-tool.exe position set --session 1 --cursor 1 --position 50 target\debug\windbg-tool.exe registers --session 1 --cursor 1 target\debug\windbg-tool.exe disasm --session 1 --cursor 1 target\debug\windbg-tool.exe memory strings --session 1 --cursor 1 --address 0x12345678 --size 256 --encoding both ``` -`open` is the preferred starting point because it loads the trace, creates a cursor, and optionally seeks to a position in one response. +`open` is the preferred starting point because it loads the trace, creates a cursor, and optionally seeks to a position in one response. `debug snapshot` is the canonical cross-backend snapshot for agents; `context snapshot` remains available as the legacy TTD-focused snapshot. + +## Canonical agent debugging commands + +Use `debug capabilities` before choosing actions. With no subject it returns a backend matrix for TTD cursors, daemon-owned live/dump targets, and remote process-server plans. With `--session/--cursor` or `--target`, it includes selected-subject status/evidence where available. + +```powershell +target\debug\windbg-tool.exe --compact debug capabilities +target\debug\windbg-tool.exe --compact debug capabilities --session 1 --cursor 1 +target\debug\windbg-tool.exe --compact debug capabilities --target 1 +``` + +Use `debug snapshot` for bounded, sectioned context capture. Each section has independent status, duration, truncation, diagnostics, and the primitive follow-up command. TTD subjects use `--session` and `--cursor`; live or dump subjects use `--target`. + +```powershell +target\debug\windbg-tool.exe --compact debug snapshot --session 1 --cursor 1 --include stack --include current_disassembly +target\debug\windbg-tool.exe --compact debug snapshot --target 1 --max-frames 16 --max-modules 32 +``` + +Use `triage ` for evidence plus hypotheses rather than a verdict-only answer: + +```powershell +target\debug\windbg-tool.exe --compact triage crash --session 1 --cursor 1 +target\debug\windbg-tool.exe --compact triage hang --target 1 +``` + +Use `symbols doctor` and `breakpoint plan` when an agent needs to validate names/source paths or dry-run a mutation: + +```powershell +target\debug\windbg-tool.exe --compact symbols doctor --session 1 --cursor 1 --address 0x7ff600001000 +target\debug\windbg-tool.exe --compact breakpoint plan --target 1 --address 0x7ff600001000 +target\debug\windbg-tool.exe --compact breakpoint plan --session 1 --cursor 1 --address 0x12345678 --kind write --size 8 --direction previous +``` + +## Remote debugging doctors and plans + +`remote doctor`, `remote status`, and `remote plan` are read-only helpers for DbgEng process-server style workflows. TCP connect probing is opt-in because it can be visible to network monitoring. + +```powershell +target\debug\windbg-tool.exe --compact remote doctor --transport tcp:port=5005 +target\debug\windbg-tool.exe --compact remote status --server target-host --transport tcp:port=5005 --probe-connect --timeout-ms 1000 +target\debug\windbg-tool.exe --compact remote plan --server target-host --transport tcp:port=5005 +``` + +## Optional agent action log + +Set `WINDBG_TOOL_ACTION_LOG` to append one JSONL entry per CLI invocation. By default entries include only the command path, success status, exit code, and duration; raw arguments are redacted. Set `WINDBG_TOOL_ACTION_LOG_FULL=1` only when full command-line logging is safe for your environment. + +```powershell +$env:WINDBG_TOOL_ACTION_LOG = "D:\logs\windbg-tool-actions.jsonl" +target\debug\windbg-tool.exe --compact debug capabilities +target\debug\windbg-tool.exe --compact debug log summarize --path D:\logs\windbg-tool-actions.jsonl +``` ## Session and cursor basics @@ -55,26 +108,57 @@ CLI output is JSON by default. These flags make it easier to script: - `--compact` for single-line JSON - `--field ` to extract one field - `--raw` to print scalar values without JSON quotes +- `--envelope` or `WINDBG_TOOL_ENVELOPE=1` to wrap success and failure output in a stable agent contract Example: ```powershell target\debug\windbg-tool.exe --field session_id --raw open C:\path\to\trace.run target\debug\windbg-tool.exe --compact registers --session 1 --cursor 1 +target\debug\windbg-tool.exe --compact --envelope sessions +``` + +In envelope mode, successful commands return `{ "schema_version": 1, "ok": true, "data": ... }`. `--field` selects from `data`, so `--envelope --field session_id --raw open ...` still prints the raw session id. Failures return `{ "schema_version": 1, "ok": false, "error": ... }` and ignore `--field`/`--raw` so agents always receive the full error reason. + +Structured error codes use stable exit codes: + +| Code | Exit | Retryable | +| --- | ---: | --- | +| `invalid_argument` | 2 | false | +| `daemon_unavailable` | 3 | true | +| `daemon_error` | 4 | depends on cause | +| `session_not_found` | 5 | false | +| `cursor_not_found` | 6 | false | +| `timeout` | 7 | true | +| `tool_error` | 8 | depends on cause | +| `internal` | 1 | false | + +Daemon connection failures include the selected pipe and a hint to run `windbg-tool daemon ensure`. + +## CLI schema discovery + +Use `cli-schema` for machine-readable command paths, arguments, possible values, defaults, conflicts, and command metadata: + +```powershell +target\debug\windbg-tool.exe --compact cli-schema +target\debug\windbg-tool.exe --compact cli-schema memory read ``` +The schema includes curated metadata where available and inferred metadata for other leaf commands. Bounded collection outputs include additive `returned`, `limit`, and `truncated` fields when the command has a fixed result budget. + ## Command groups worth learning first | Goal | Commands | | --- | --- | -| Discover capabilities | `discover`, `recipes`, `tools`, `schema ` | +| Discover capabilities | `discover`, `cli-schema [command...]`, `recipes`, `tools`, `schema ` | +| Canonical agent context | `debug capabilities`, `debug snapshot`, `triage `, `symbols doctor`, `breakpoint plan`, `debug log summarize` | | Manage daemon/session state | `daemon ensure`, `daemon status`, `sessions`, `open`, `load`, `close`, `info` | | Move through a trace | `cursor create`, `position get`, `position set`, `step`, `replay to`, `replay watch-memory`, `sweep watch-memory` | | Inspect trace metadata | `threads`, `modules`, `exceptions`, `keyframes`, `timeline events`, `module info`, `module audit` | | Inspect runtime state | `registers`, `register-context`, `active-threads`, `command-line`, `architecture state` | | Inspect code and memory | `disasm`, `memory read`, `memory dump`, `memory strings`, `memory dps`, `memory classify`, `memory chase`, `object vtable` | -| Symbol and source triage | `symbols diagnose`, `symbols inspect`, `symbols exports`, `symbols nearest`, `source resolve` | -| WinDbg, live, dump, and remote helpers | `remote explain`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `dump create`, `dump open`, `dump inspect`, `target dump`, `windbg status` | +| Symbol and source triage | `symbols doctor`, `symbols diagnose`, `symbols inspect`, `symbols exports`, `symbols nearest`, `source resolve` | +| WinDbg, live, dump, and remote helpers | `remote explain`, `remote doctor`, `remote status`, `remote plan`, `remote server-command`, `remote connect-command`, `dbgeng server`, `live capabilities`, `dump create`, `dump open`, `dump inspect`, `target dump`, `windbg status` | ## Useful non-replay commands @@ -83,7 +167,8 @@ These are good starting points even before you have a trace open: ```powershell target\debug\windbg-tool.exe discover target\debug\windbg-tool.exe recipes -target\debug\windbg-tool.exe remote explain +target\debug\windbg-tool.exe debug capabilities +target\debug\windbg-tool.exe remote doctor --transport tcp:port=5005 target\debug\windbg-tool.exe symbols inspect C:\Windows\System32\notepad.exe target\debug\windbg-tool.exe windbg status ``` diff --git a/docs/mcp.md b/docs/mcp.md index f0495a2..0c39e10 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -106,8 +106,9 @@ Load the trace, create a cursor, move to the end of the trace, and search backwa ## Practical notes -- Tool results are returned as JSON text content +- Tool results include both JSON text content and MCP `structuredContent` - Tool failures are returned as MCP tool results with `isError: true` +- Tool definitions include MCP annotations that hint whether a tool is read-only, destructive, idempotent, or open-world - If native replay is unavailable, some tools return placeholder or empty results with warnings instead of full trace-backed data ## Privacy