diff --git a/README.md b/README.md index aed752d..d05289f 100644 --- a/README.md +++ b/README.md @@ -58,4 +58,15 @@ https://github.com/ChatCut-Inc/agent-plugin.git ## Support +For hosted MCP startup, sign-in or tool failures, use **ChatCut MCP Doctor** in +Codex's skill picker (`$chatcut:chatcut-mcp-doctor` for the Git plugin, +`$chatcut-desktop:chatcut-mcp-doctor` for the existing store identity), or +`/chatcut:chatcut-mcp-doctor` in Claude Code. It runs locally even when ChatCut +MCP cannot connect and prepares a report, explanation and private feedback draft. +Doctor is included from Codex 1.10.22 / Claude Code plugin 1.10.24; check the +installed version and skill catalog when using an older package. Local diagnosis +does not require ChatCut authentication. +See [Doctor discovery/fallback](codex/skills/chatcut-mcp-doctor/references/discovery.md). +The host's own `/doctor` command is separate. + For product information, visit [chatcut.io](https://chatcut.io). diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index 395d78e..33c11ef 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "chatcut", "displayName": "ChatCut", - "version": "1.10.23", + "version": "1.10.24", "description": "ChatCut plugin for editable video editing and creation workflows from Claude Code.", "author": { "name": "ChatCut", @@ -37,7 +37,7 @@ "headers": { "x-chatcut-mcp-client": "claude_code", "x-chatcut-mcp-surface": "embedded-preview", - "x-chatcut-plugin-version": "1.10.23" + "x-chatcut-plugin-version": "1.10.24" } } }, diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md new file mode 100644 index 0000000..e27adc4 --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -0,0 +1,49 @@ +--- +name: chatcut-mcp-doctor +description: Diagnose hosted ChatCut MCP connection, startup, authentication or tool-call failures from a working Codex or Claude Code agent, even when ChatCut tools cannot connect. Produce evidence-based findings, repair guidance, rechecks and local feedback materials. Use for ChatCut Doctor or MCP连接错误; not for Desktop ACP or unrelated video editing. +--- + +# ChatCut MCP Doctor + +This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. + +## Start here + +- In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” +- In Claude Code with the ChatCut plugin, invoke `/chatcut:chatcut-mcp-doctor`. A separately installed project/personal copy uses `/chatcut-mcp-doctor`. +- `/doctor` and `codex doctor` belong to the host; this Skill does not replace them. If the entry is absent or the plugin is old, read [discovery and fallback](references/discovery.md). A browser-only host without local execution cannot run this collector; retain the error and explain that boundary. + +## Preserve, collect, explain + +Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. + +Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. + +```text +node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output +``` + +Use `--host claude` for Claude Code. Omit unavailable optional inputs. If there is no registered URL, omit it; do not silently choose production. `--offline` keeps the registered URL as evidence but sends **no requests**, including upload. Otherwise the collector makes bounded anonymous entry/metadata checks and optional wire checks; an initial anonymous 401 is expected. Use `--upload` for the redacted report when transfer is authorized, after briefly explaining it. An intentionally disabled server is preserved without requests. + +The command writes the existing schema-2 report plus `.diagnosis.json`, `.summary.md` and `.issue-draft.md` sidecars. Read the findings and actual gaps before answering; do not merely echo a category. Findings identify observed boundaries, not root causes. Distinguish original failure, host evidence and later probes. Missing/truncated/denied logs remain gaps. A healthy later probe does not establish recovery. Proxy presence, busy/300-second timeouts and generic transport errors do not justify blaming security software, clearing locks/credentials, restarting or changing timeouts/environments. + +Wire failures/incomplete captures are later **direct-route** checks even if fetch metadata succeeds. Bounded native ERROR records and App `status=failed` notices are visible as unassociated host evidence; they do not fill a missing original error/time/request ID. Ordinary App starting/ready notices do not demonstrate failure. Local summaries show credential-redacted error excerpts; issue drafts keep structured states and evidence pointers without copying native log excerpts. + +## Repair and recheck + +For an observed missing registration or a normal sign-in requirement, read [repair and recheck](references/repair-and-recheck.md). Apply only the minimal change within the user's authorization and actual host/scope. Keep the prior state and rollback. Other findings need evidence-led investigation; this version has no automatic credential/network repair. + +Verify the affected boundary in the **same host and scope** after a change. Registration lookup success proves registration only. Auth success requires the host's authenticated state. Tool recovery requires a safe read-only call such as `list_projects` actually succeeding. If the original operation mutates state, costs credits, or cannot be safely repeated, do not replay it; say that its recovery remains unverified. Record the actual action, time/source and verification result in the summary and draft; never label an unperformed repair as tried. Keep the report's original failure immutable and its collector `recoveryVerified:false`; agent recheck results are separate evidence. + +## Feedback without starting over + +Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. + +```text +node "/scripts/doctor.mjs" --report +node "/scripts/doctor.mjs" --upload-report +``` + +`--report` rebuilds sidecars offline without recollecting. It overwrites derived sidecars, so save any agent-written repair notes separately first. Use `--upload-report` only for an authorized first transfer or a single requested retry after failure; it reuses the original diagnostic ID without new probes, keeps the original report unchanged, and writes a separate upload receipt. Do not repeatedly recollect or resend an acknowledged report. `ingest_accepted` is not verified final storage or an opened support ticket. + +Use [ChatCut contact](https://chatcut.io/contact) for private support. The draft names no public issue repository because that destination is undecided. Do not open an issue, publish evidence, or message anyone without authorization. Root-cause rules and prevalence based on natural incidents remain future work; controlled tests are not user incidents. diff --git a/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md b/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md new file mode 100644 index 0000000..7d99c81 --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md @@ -0,0 +1,307 @@ +# Hosted MCP connection diagnostics + +Use this branch when a registered hosted ChatCut connection fails, including +OAuth failing before a browser opens, or authentication completing without usable +tools, and failures of already available hosted tools. Preserve evidence before reconnecting or restarting. This version gathers +evidence; it does not repair configuration, credentials, network settings, or the +original connection. + +Briefly tell the user that you will collect ChatCut connection diagnostics and +send the redacted report to ChatCut. Run under the host's existing execution and +network permissions; do not add a separate manual step when those permit it. +Honor a refusal or a denied read/network operation. Do not change permission +settings to make collection work. + +Invoke each helper as a standalone `node` command with its quoted path. Do not +combine it with `cd`, shell variables, pipelines, or other commands; command +allowlists may permit the helper while rejecting the combined shell operation. +Use the host's file-writing tool for the dedicated snapshot and error file. + +## Capture the current host, including unfamiliar failures + +The Agent creates a dedicated `.mcp-snapshot.json` from facts already available +through the current host. The user does not fill a form. Do not copy an entire +configuration, credential file, environment, or conversation. Preserve unfamiliar +errors as well as known OAuth errors; classify uncertain stages as unknown. +Static CLI configuration describes registration, not tools loaded into the +current chat. Browser sign-in does not establish MCP authentication. + +First generate a new snapshot template with the bundled helper, then add only +observed groups from the schema below. This sets the snapshot schema and capture +time mechanically; it does not invent the original failure time or host state: + +```text +node /scripts/mcp-doctor.mjs --create-snapshot +``` + +Validate the completed file locally before collecting or uploading: + +```text +node /scripts/mcp-doctor.mjs --validate-snapshot --server-url +``` + +Snapshot `schemaVersion` is **1**, independently of the outer report version. +Keep unavailable groups omitted. Each observation's `observedAt` may be `null` +when its actual time is unknown. If validation fails, correct only the reported +schema error using already available facts; do not guess times or relax the +credential/config file restriction. If facts remain unavailable, collect the +other evidence and preserve the specific gap. + +Snapshot schema 1 accepts this shape; omit unavailable groups, use null for an +unknown tool count/enabled flag, and label each group's actual source: + +```json +{ + "schemaVersion": 1, + "observedAt": "2026-10-22T12:00:00Z", + "hostVersion": "actual-running-host-version", + "pluginVersion": "actual-loaded-plugin-version-if-known", + "configuration": { + "source": "host_settings", + "serverName": "chatcut", + "url": "https://api.chatcut.io/api/external-mcp/mcp", + "enabled": true + }, + "session": { + "source": "current_session", + "conversationId": "host-chat-id-if-available", + "connectionStatus": "failed", + "authStatus": "unknown", + "visibleToolCount": 0, + "visibleTools": [] + }, + "observations": [ + { + "stage": "initialize", + "operation": "initialize", + "source": "runtime_log", + "observedAt": "2026-10-22T11:59:00Z", + "outcome": "failed", + "requestId": "server-request-id-only-if-recorded", + "message": "Exact original error, including an unfamiliar error code" + } + ] +} +``` + +Sources: `current_session`, `tool_search`, `host_settings`, +`cli_configuration`, `runtime_log`. A session group cannot claim +`cli_configuration` as its source. Connection status is `connected`, `failed`, +`needs_authentication`, `disabled`, or `unknown`; auth status is +`authenticated`, `needs_authentication`, or `unknown`. +Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` +(`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, +`protocolCode`, `message`, and `originalAttemptMatch` (`confirmed`/`unconfirmed`). +Only `host_registration` / `lookup_registered_server` observations from +`cli_configuration` or `host_settings` may additionally carry `lookupResult`: +`not_found`, `found`, or `unavailable`. `not_found` requires an explicit absence +response from a completed named lookup or a successful complete scoped inventory; +do not derive it from an exit code, permission/parse error, missing CLI, or empty +tool list. Preserve the actual response in `message`. Failed/inconclusive lookups +without this fact retain unknown registration state and do not recommend adding +an entry. This is still agent-supplied evidence, not independent verification. +Omit the match flag when unknown. Set `confirmed` only when the captured failed +runtime/session observation explicitly identifies the original failed attempt +and its actual server request ID; registration, nearby timestamps, successful +operations, and later probe IDs are not confirmation. An original error must +also have been recorded for that observation to close the original-request gap. +This remains Agent-supplied confirmation, not independent helper verification. +New stage names are preserved. Do not put an +MCP session credential in `conversationId` or guess a server request ID. +Only include ChatCut tool names; keep tool arguments and results out. + +## Run once for the reported failure + +### Preserve the failed call before independent checks + +When the host exposes the original failed tool result or runtime event, the Agent +copies only its error and available identifiers into a new dedicated +`.mcp-failure.json`. Do this before retries, reconnects or later probes. +Do not read a conversation transcript or collect tool arguments/results to build +it. Use the host's existing error metadata and supported read-only runtime logs; +the user does not fill this record. Keep the full original error message, code, +cause and stack when available, including unfamiliar failures. + +```json +{ + "schemaVersion": 1, + "source": "host_tool_result", + "serverName": "chatcut", + "occurredAt": null, + "conversationId": null, + "callId": null, + "operation": "list_projects", + "requestId": null, + "error": { "message": "Exact original error; add original code/cause/stack when available" } +} +``` + +Supported sources are `host_tool_result`, `host_runtime_event`, and +`captured_stderr`. `occurredAt` is the failed event's actual timestamp with +timezone, when exposed; never substitute the current time, file modification +time, snapshot capture or later probe time. Omit unknown optional IDs or leave +them null. A local `callId` and `conversationId` identify client-side work and +are useful even if the request never reaches ChatCut; they are not server +request IDs. Copy a `requestId` only when the original failed call actually +provides it. All supplied facts remain labelled as Agent-supplied evidence. + +Prefer `--failure-record ` to `--original-error` when +this structured source is available; choose one input. The collector centers +its bounded log window on the supplied failure time and uses its conversation +ID when available. A missing time remains an explicit gap. For an exec caller, +use already retained stderr; discarded stderr cannot be recovered by this Skill. +Do not start another authenticated session to manufacture replacement evidence. + +If only the original error text is accessible, use the text-file fallback below. +Do not call a report with only startup-status notices a captured tool failure. + +1. Obtain the existing server's registered URL through the host. Preserve its + environment. The helper accepts ChatCut production/beta/dev endpoints and + loopback development servers; it reports unsupported endpoints rather than + silently probing production. If no registered URL is available, omit + `--server-url` to collect a local report without probing or uploading. + Never read credential stores or paste complete + configuration, environment, or conversation dumps into the report. + An intentionally disabled hosted server is not a failed connection; preserve + that choice instead of starting diagnostics or re-enabling it. +2. Save only the original connection error and its available cause chain into a + new temporary text file. Use the exact original text, not a generated root + cause. If unavailable, omit `--original-error`. Obtain the actual bundled + host version if accessible without starting a session. Do not treat update + metadata as the running version. +3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: + + ```text + node /scripts/mcp-doctor.mjs --host codex --server-url --host-snapshot --original-error --host-version --upload + ``` + + For Claude Code, use `--host claude`. Quote paths as shell arguments; do not + interpolate error text into shell code. Omit unknown optional fields. Supply + `--occurred-at` only when the failure's actual ISO timestamp and timezone are + known. A missing timestamp remains unknown; the collection time is separate. + If transfer is denied, omit `--upload` and retain the local JSON report. + +Use an available Node.js 18+ runtime. Do not install a runtime or ChatCut Desktop +just for diagnostics. Codex SQLite extraction additionally needs `node:sqlite` +(available on recent Node 22+/24 runtimes). Without it, other collection still +runs and the report marks the missing SQLite evidence. A denied file read is a +coverage gap, not proof that the server has no errors. + +The helper reads only bounded ChatCut MCP runtime log records or debug log tails. +Codex SQLite locations follow `CODEX_SQLITE_HOME` when present. If the current +host already supplies its actual `sqlite_home` or `log_dir`, pass that directory +with `--codex-sqlite-home` or `--codex-log-dir`; do not read whole config files to +find it. These options select existing log locations and do not enable logging. +On Windows, SQLite error 14 can coexist with readable WAL files in a read-only +sandbox. The report preserves the original error and bounded file-access facts; +it does not prove a specific security product caused the restriction. Never copy +the whole log database, ignore its WAL with immutable mode, or relax permissions. +On Windows, the helper also reads the existing Codex App startup-status notices +under `%LOCALAPPDATA%/Codex/Logs/YYYY/MM/DD`. This is independent of TUI logging. +It selects only `AppServerConnection` startup notices for the hosted ChatCut +server, uses the current conversation ID when available, and keeps at most 30 +records from three 128 KiB tails. Missing/rotated/denied logs remain explicit +gaps. The known Windows MXC `LOCALAPPDATA` sandbox redirect falls back to the +real user's `AppData/Local` without changing permissions. An already supplied alternative App log root can be selected with +`--codex-app-log-dir`; do not scan browser profiles or session transcripts. + +The report names selected/omitted native files and byte ranges that were not read. +For an existing reported gap, use `--codex-app-log-file ` +to select at most three known native files. With exactly one selected file, +`--codex-app-log-offset ` reads at most 128 KiB from +that segment; unread ranges remain explicit. Preserve the original failure's +known time/window. Do not repeatedly upload replacement reports or scan entire +log history to fill an unknown failure time. App startup notices cover startup; +they do not cover every later tool-call failure. SQLite/native log extraction +retains available local conversation/call IDs without promoting them to original +server request IDs or independently confirmed attempt associations. +For a separately chosen future reproduction, Codex's explicit `log_dir` setting +can enable its native plaintext log. Do not change global settings or relaunch +the user's host automatically. Existing logs cannot be reconstructed retroactively. +It never reads `auth.json`, OAuth credential stores, session transcripts, or +other project files. It makes one unauthenticated initialize probe and at most +five public metadata GETs, all within the selected origin and with a 3.5-second +timeout each. An anonymous 401 challenge is expected. It validates resource / +issuer consistency and this product's OAuth-code/PKCE metadata. It never logs in, +refreshes credentials, or lists tools using extracted credentials. + +Codex exec prints diagnostics inline; `--json` stdout alone is not a complete +MCP error stream. Preserve the original stderr/startup error when it is already +visible to the current host. The helper cannot recover discarded parent-process +stderr or reconstruct an old exec failure from App logs. + +If the current host already exposes native `mcpServer/startupStatus/updated` +notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, +`runtimeStatus` and `toolsError` from that same thread in the error file and +snapshot observations (`source: current_session`). Record the native method and +actual thread ID. Authentication inventory alone is not proof of initialization +or usable tools. The status query may perform new auth-discovery requests; keep +those separate from retained runtime errors. Do not start another app-server, +start/reload a thread, invoke OAuth, or launch a daemon to obtain the original +state. These interfaces are not guaranteed to be exposed to a third-party Skill. +`codex doctor --json` is a general installation/config/auth health check, not a +targeted export of the failed MCP attempt. Do not ingest its entire report. +`feedback/upload` submits to OpenAI; it is not ChatCut's log export endpoint. + +The CLI also makes at most two sequential anonymous metadata GETs to compare +the relevant path-scoped and root endpoints at the HTTP/1.1 framing level. +`wireProbes` records selected response headers, request ID, first framing byte, +chunk validity, bounded byte counts/hashes and peer/TLS evidence from that same +connection. It retains no metadata body, cookies or credentials. Each probe +stops at 128 KiB / 3.5 seconds. These are direct connections, explicitly outside +the host/fetch proxy route; normal certificate verification remains enabled. +Complete fixed-length/chunked messages finish at their verified boundary without +waiting for peer close. Invalid HTTP status lines and unsupported informational +responses never count as complete final responses. `completionReason` and +`peerEndObserved` distinguish message completion from connection termination. + +When an HTTPS request/body fails, one additional direct TLS connection may +record the peer certificate issuer and protocol, with the same timeout and +normal certificate verification. This is a separately labelled route, not the +original host's TLS/proxy path. Proxy/CA environment presence is boolean only. +A healthy later check cannot establish recovery or identify antivirus/VPN as +the cause. A disabled connection sends neither probes nor an upload. + +## Interpret the report + +- `originalFailure` is the original error supplied by the Agent. `hostLogs` + are matching records in a bounded time window, with unconfirmed attempt + association. `probes` are new requests made later. Keep these separate. +- `hostSnapshot` is Agent-supplied evidence with per-group sources, not an + independently verified host API result. `evidenceSummary` keeps original + stage observations separate from independent checks. Unknown stages and + unfamiliar errors remain available; missing stages are `not_observed`. +- Timestamped text records outside the requested window are excluded. Records + without parseable timestamps are labelled time-unknown, never window matches. +- Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. + A healthy later probe never proves the original host connection recovered. +- Preserve original error messages and codes. Redact actual credentials, not + entire error categories. No separate metadata response body is retained; + original parser errors may quote response text. +- Missing debug logs cannot be reconstructed. Claude's detailed debug logs may + require `claude --debug=mcp` during a future, separately chosen reproduction; + this helper does not enable logging or relaunch Claude. +- A `diagnosticId` identifies the diagnostic report and its new probes. It is + not the original failed request's ID. Server response request IDs are useful + only when actually present and matched; time/path matching is weaker evidence. +- Match a wire response's `x-request-id` with the server's metadata response + record. The application record observes bytes before Node's HTTP framing; + a successful finish does not prove valid framing after Node/ALB/local HTTPS + inspection, nor receipt by Codex. Different requests may have different paths. + +The local JSON is written before any upload. `ingest_accepted` means that the +endpoint acknowledged the batch; `storageVerified:false` remains until support +finds every fragment in the final log store. Failed uploads preserve the local +report and upload error; do not automatically repeat the run or claim success. +Output larger than the upload budget stays local with an explicit upload error. + +Return the diagnostic ID, local report location, one new observed fact, and the +next action justified by it. If no new actionable fact was obtained, state the +specific missing evidence. Do not assign blame to antivirus, VPN, firewall, host, +or service merely from a generic failure message. Do not claim a user failure +percentage from diagnostic samples. + +This branch runs when the Agent loads it and has access to local execution. It +is not an installation hook or an all-user failure monitor. It cannot inspect a +web host's remote connection process or internal logs. With Claude Code tool +search disabled, connection failures may not be passed to the Agent at all. diff --git a/claude/skills/chatcut-mcp-doctor/references/discovery.md b/claude/skills/chatcut-mcp-doctor/references/discovery.md new file mode 100644 index 0000000..38d0259 --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/references/discovery.md @@ -0,0 +1,42 @@ +# Discovery and fallback + +The Skill is a local package, not a tool served by the MCP being diagnosed. +Use the host's actual skill catalog to check it loaded. A folder existing on disk +does not prove a command is available in the current chat. + +| Host/package | Entry | Verification | +| --- | --- | --- | +| Codex ChatCut Git plugin | Skill picker; `$chatcut:chatcut-mcp-doctor` | Confirm the named Skill and path in the running host catalog; invoke it and obtain a local report | +| Codex existing `chatcut-desktop` store identity | Skill picker; `$chatcut-desktop:chatcut-mcp-doctor` | Confirm the installed version and actual catalog entry; store approval/update is separate from Git availability | +| Codex standalone project/personal copy | `$chatcut-mcp-doctor` | Confirm it in the catalog, then invoke it | +| Claude Code ChatCut plugin | `/chatcut:chatcut-mcp-doctor` | Confirm the command in `/` or `/skills`, then invoke it | +| Claude personal/project Skill copy | `/chatcut-mcp-doctor` | Confirm the same catalog before use | + +Official entry references: [OpenAI skills](https://developers.openai.com/plugins/build/skills), +[Claude Code skills](https://code.claude.com/docs/en/skills). +Claude's namespaced plugin commands differ from Codex's `$` invocation. Do not +promise an unqualified `/doctor`; it can refer to the host's own health check. + +If the current package lacks Doctor, explain that its version does not include +this Skill. The existing collector may still be bundled at +`skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs` (Claude: +`chatcut-plugin-basics-claude`). That collector writes JSON but older versions do +not include these summary/draft sidecars. + +An agent with a trusted local copy of this repository can read this SKILL.md +directly and execute `scripts/doctor.mjs` without installing or connecting MCP. +For an explicitly requested standalone installation, copy the complete +`chatcut-mcp-doctor` directory (including scripts and references) into a host +supported project/personal skills directory, then verify discovery in a new +host session if necessary. Codex supports project `.agents/skills/`; Claude uses +`.claude/skills/`. Do not change global settings or install it merely to inspect +an error. A standalone copy may have no parent plugin manifest; that is a package +version evidence gap and does not block collection. + +For users without a local copy, the official plugin sources/install guidance are +[agent-plugin](https://github.com/ChatCut-Inc/agent-plugin), +[Codex setup](https://chatcut.io/chatgpt), and +[Claude Code setup](https://chatcut.io/claude). Verify a released package actually +contains this Skill before recommending it (Codex 1.10.22 / Claude plugin 1.10.24 +or later). Git source availability does not prove store approval or a host update. +Do not download and execute an unreviewed remote shell script. diff --git a/claude/skills/chatcut-mcp-doctor/references/repair-and-recheck.md b/claude/skills/chatcut-mcp-doctor/references/repair-and-recheck.md new file mode 100644 index 0000000..c42f67e --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/references/repair-and-recheck.md @@ -0,0 +1,131 @@ +# Repair and recheck + +The helper is read-only with respect to host configuration and credentials. +Keep a separate repair note beside the report with: observed problem and source, +authorized change, prior state/rollback, actual commands, and same-host recheck +result. This is agent-recorded evidence, not a collector-verified recovery claim. + +## Missing manual registration — bounded configuration repair + +Only use this path when the actual host reports that the named registration is +absent and the user wants that host connected. A plugin-managed server should +be enabled/installed through its existing plugin UI; do not add a duplicate +manual server to bypass a failed plugin installation. An absent tool list alone +does not establish missing registration. + +A failed lookup is not necessarily an absent registration. Record the exact +host response and its source/scope. Only an explicit no-entry response from a +completed host lookup (or a successful complete scoped inventory) permits +`lookupResult: not_found` in the registration observation. Permission errors, +configuration parse failures, unavailable CLI/status APIs and generic nonzero +exit codes leave registration existence unknown: use `unavailable` or omit +the field, retain the error and check the same scope read-only. Do not add or +replace an entry as a response to an unreadable state. A `found` response can +coexist with a later connection/health failure and must not trigger registration. + +Confirm the intended endpoint/environment from the user's setup or trusted +package metadata. Production is `https://api.chatcut.io/api/external-mcp/mcp`; +preserve beta/dev when selected. Check local CLI help for the installed version. +Examples below register **only a missing manual server**, using a scope the user +selected. Do not overwrite an existing registration or display headers/tokens. + +Codex CLI: + +```text +codex mcp get chatcut +codex mcp add chatcut --url +codex mcp get chatcut +``` + +Codex writes to the selected `CODEX_HOME`. Do not change that value to a different +host/profile as a workaround. The exact absent-entry lookup can be retained as +the `host_registration` observation. Rollback for a newly created manual entry +is `codex mcp remove chatcut` in that same home. Lookup success proves the entry +exists, not that OAuth, initialization or tools work. + +Claude Code CLI: + +```text +claude mcp get chatcut +claude mcp add --transport http --scope local chatcut +claude mcp get chatcut +``` + +Use the actual desired scope (`local`, `project`, `user`) rather than silently +escalating it. For the newly added local entry, rollback is +`claude mcp remove --scope local chatcut`. Plugin server names such as +`plugin:chatcut:chatcut` are not manual entries to recreate. Verify commands +against the installed CLI; report unavailable CLI capabilities explicitly. + +## Authentication required — guided host action + +An initial sign-in requirement is normal setup, not proof of a failure. For an +already registered Codex manual server, the host supports +`codex mcp login `. For Claude Code, use `/mcp` and choose the +actual server's authentication action in the interactive host. Recent CLI versions +also support `claude mcp login `; verify local help before +offering that command on an older version. A browser/account sign-in alone is insufficient. + +A bounded Codex SQLite startup WARN from `codex_mcp::rmcp_client` can explicitly +report `OAuth refresh credentials for server chatcut are missing an authorization +server issuer; authorization required: OAuth authorization required`. Preserve +that native response, source and record time as **unassociated host evidence**. +Check the same host/server's current authentication status first; offer its normal +authorization action only if authorization is still required and the user wants +to reconnect. A historical warning does +not establish which original request failed, why the stored refresh state lacks +metadata, or that credentials should be removed. Do not derive this guidance +from a generic WARN, busy error or timeout. An explicitly observed `oauth` failure +is an authentication-attempt fact; a timeout there is not proof of network or +registration failure. A `browser_render` error while tools remain connected is +a tool execution fact and does not call for reconnecting MCP. + +The collector does not perform OAuth login. Use the host's guided action and respect +the user's login/permission boundaries; never inspect or clear credential +stores, copy tokens, or change OAuth settings. If a login attempt fails, preserve +the actual new error separately from the original failure. After authorized +sign-in, verify authenticated status and a safe read in that same host. + +## Explicit errors — scoped next checks + +Keep these as distinct observations, using the actual original error or a +separately attributed server record. A server-side record without an established +request match must not be inserted as a stage of the local report. + +- `Another External MCP request is still using this session` confirms that this + request received a session-occupied rejection. Check existing in-flight + operations in the same project/session and their status first. It does not + establish a stuck lock. Do not clear locks or issue concurrent retries. +- An original call timing out after 300 seconds establishes that call's timeout. + Preserve its original time and call/request IDs when available; confirm whether + its existing backend operation completed or is still running through a safe + status read. Do not infer session occupancy, cancel it or replay a mutation. +- `Waiting for browser execution timed out. The browser job may still be running.` + is an execution-wait timeout, not proof of an MCP disconnect or a failed job. + Read the existing job's status using its actual job/project ID before any next + action. If no job ID is available, retain that gap; do not submit the job again. +- `Bad Request: Unsupported protocol version: (supported versions: + )` is an explicit protocol rejection. Preserve the original text, + requested/supported versions and actual host/server versions; inspect their + protocol negotiation and check a compatible combination within that setup. + If the matched server record shows final OAuth acceptance, do not respond by + repeatedly logging in. Do not automatically edit protocol headers or downgrade + a global CLI. Earlier JWT-verification errors alone do not override the final + OAuth verification result. + +An anonymous 401 remains an expected authentication challenge. An authenticated +request's explicit `Invalid bearer token` rejection is different: preserve the +final OAuth result and its supplied reason. Use the existing host's normal +authorization action when required; do not invent an expiry reason when the +original reason is unavailable or treat an intermediate JWT rejection followed +by OAuth acceptance as failed authentication. + +## Other failures + +For startup/metadata/network errors, use observed status, original code/cause, +bounded native logs and later probe failures to choose the next read-only check. +No generic repair for 300-second timeouts, busy requests, malformed framing, +TLS interception or a missing historical log has been established. Do not clear +locks, kill processes, restart the host, increase timeouts or switch environment +based only on those symptoms. Preserve the report and prepare the private +feedback draft when the cause remains unconfirmed. diff --git a/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs new file mode 100644 index 0000000..3d4bdba --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -0,0 +1,166 @@ +import { redact } from "./mcp-doctor.mjs"; + +// Findings name observed boundaries, not inferred causes or user failure rates. +export function diagnose(report) { + const findings = []; + const add = (kind, evidence, next, severity = "attention", observed) => + findings.push({ kind, severity, evidence, next, ...(observed && { observed }) }); + const snapshot = report.hostSnapshot; + const session = snapshot?.session; + const observations = snapshot?.observations ?? []; + const failed = observations.map((item, index) => ({ ...item, index })) + .filter((item) => item.outcome === "failed"); + const original = report.originalFailure; + if (report.upload?.status === "skipped_intentionally_disabled" || snapshot?.configuration?.enabled === false || session?.connectionStatus === "disabled") + add("intentionally_disabled", ["hostSnapshot"], "Preserve the disabled choice; do not enable or probe it.", "info"); + for (const [index, item] of observations.entries()) { + const evidence = [`hostSnapshot.observations[${index}]`]; + if (item.stage === "host_registration" && item.operation === "lookup_registered_server" && + ["cli_configuration", "host_settings"].includes(item.source)) { + const observed = { + source: item.source, outcome: item.outcome, + lookupResult: item.lookupResult ?? "unavailable", + messageExcerpt: redact(item.message ?? "").slice(0, 2000), + }; + if (item.lookupResult === "not_found" && ["success", "failed"].includes(item.outcome)) + add("not_registered", evidence, "The host explicitly reported no entry in the queried scope. Confirm the intended host/scope/endpoint; use the registration repair guide, then check the same scope again.", "attention", observed); + else if (item.outcome !== "success" || item.lookupResult === "unavailable") + add("registration_lookup_unavailable", evidence, "The lookup did not establish absence. Preserve its native error and check the same host/scope through an available read-only status entry; do not add or replace a registration on this evidence.", "attention", observed); + continue; + } + if (item.outcome !== "failed") continue; + if (["initialize", "plugin_loading", "tools_list", "tool_discovery"].includes(item.stage)) + add("startup_or_discovery_failed", evidence, "Preserve the exact native error. Check the selected host's current status; follow only the matching repair guide."); + else if (["authorization", "token_exchange", "token_refresh", "oauth"].includes(item.stage)) + add("authentication_attempt_failed", evidence, "Use the host's authentication UI only if authorized; retain the actual auth failure and its event time. Do not clear credentials or infer a network/registration cause.", "attention", { + source: item.source, stage: item.stage, operation: item.operation ?? null, + observedAt: item.observedAt ?? null, + messageExcerpt: redact(item.message ?? "").slice(0, 2000), + }); + else if (["tools_call", "tool_call"].includes(item.stage)) + add("tool_call_failed", evidence, "Keep operation and original call IDs. Recheck only a safe read operation; never replay a write or generation just to test recovery."); + else if (item.stage === "browser_render") + add("tool_execution_failed", evidence, "The host reports a tool rendering/execution failure. Preserve its native error and current tool availability; this observation does not demonstrate an MCP disconnect or justify changing registration/authentication.", "attention", { + source: item.source, stage: item.stage, operation: item.operation ?? null, + connectionStatus: session?.connectionStatus ?? "unknown", + messageExcerpt: redact(item.message ?? "").slice(0, 2000), + }); + else + add("observed_stage_failed", evidence, "Retain the unfamiliar stage and error; root cause needs more evidence."); + } + if (session?.authStatus === "needs_authentication" || session?.connectionStatus === "needs_authentication") + add("authentication_required", ["hostSnapshot.session"], "A sign-in requirement can be normal first use. Use the actual host's MCP sign-in entry; do not infer a broken token.", "info"); + if (session?.connectionStatus === "failed" && !failed.length) + add("host_connection_failed_stage_unconfirmed", ["hostSnapshot.session"], "The host reports a failed connection. Preserve its native startup/runtime error if exposed; the exact stage and cause are not yet established."); + if (session?.authStatus === "authenticated" && session?.visibleToolCount === 0) + add("authenticated_tools_absent", ["hostSnapshot.session"], "Authentication inventory does not prove initialization or tools/list success. Preserve the host's discovery status and error before reconnecting."); + if (original?.status === "recorded" && !failed.length) { + if (["initialize", "tools/list"].includes(original.operation)) + add("startup_or_discovery_failed", ["originalFailure.operation", "originalFailure"], "The original record identifies the failed startup/discovery operation. Preserve its native error; later healthy probes do not establish host recovery."); + else if (original.source === "agent_supplied_host_tool_result" && original.operation) + add("tool_call_failed", ["originalFailure.operation", "originalFailure"], "Preserve the actual failed host tool result. Recheck only a safe read operation after an authorized change; do not replay a write or generation."); + else + add("original_failure_recorded_stage_unconfirmed", ["originalFailure"], "Preserve this original error. Obtain its host stage/status if available; do not infer a cause from message keywords."); + } + const probes = [report.entryProbe, ...(report.probes ?? [])].filter(Boolean); + probes.forEach((probe, index) => { + if (["failed", "http_rejected", "protocol_error", "missing_authentication_challenge", "invalid_initialize_response", "invalid_metadata_shape", "invalid_metadata"].includes(probe.outcome) || + (index > 0 && probe.outcome !== "metadata_read")) + add("later_network_or_metadata_check_failed", [index === 0 && report.entryProbe ? "entryProbe" : `probes[${index - (report.entryProbe ? 1 : 0)}]`], + "This is a later anonymous check, not the original attempt. Retain the URL, status/error and request ID; it does not establish VPN, firewall or service blame."); + }); + (report.wireProbes ?? []).forEach((probe, index) => { + const invalid = ["failed", "invalid_framing", "invalid_response", "unsupported_response"].includes(probe.outcome) || probe.framing?.status === "invalid"; + const incomplete = probe.outcome === "incomplete" || probe.framing?.status === "incomplete" || probe.captureLimited === true || Boolean(probe.headerGap); + if (!invalid && !incomplete) return; + add(invalid ? "later_wire_check_failed" : "later_wire_check_incomplete", [`wireProbes[${index}]`], + "This later direct HTTP/1.1 check does not reproduce the original host/fetch route. Retain its framing, completion/error details and actual request ID for targeted comparison; it does not prove the original attempt failed or recovered, or identify ALB/proxy blame.", "attention", { + outcome: probe.outcome, route: probe.route ?? null, + httpStatus: probe.status ?? null, + framingStatus: probe.framing?.status ?? null, + framingReason: probe.framing?.reason ?? null, + completionReason: probe.completionReason ?? null, + headerGap: probe.headerGap ?? null, captureLimited: probe.captureLimited ?? false, + errorCode: probe.error?.code ?? null, + messageExcerpt: redact(probe.error?.message ?? "").slice(0, 2000), + }); + }); + (report.hostLogs?.entries ?? []).forEach((entry, index) => { + const message = entry.message ?? ""; + // One observed native startup response, not a generic keyword/root-cause + // classifier. A warning alone or another module/message is insufficient. + if (entry.source === "codex_runtime_sqlite" && entry.target === "codex_mcp::rmcp_client" && + ["WARN", "ERROR"].includes(entry.level) && + message === 'MCP server startup failed server_name="chatcut" error=OAuth refresh credentials for server chatcut are missing an authorization server issuer; authorization required: OAuth authorization required') { + add("host_reauthorization_required_unassociated", [`hostLogs.entries[${index}]`], + "This native Codex startup record explicitly required OAuth authorization at its recorded time. Check the same host/server's current authentication status first; offer its normal sign-in action only if authorization is still required and the user wants to reconnect. Do not clear or edit credentials, add a duplicate registration, or infer association with the original failed call.", "attention", { + source: entry.source, level: entry.level, target: entry.target, + state: "authorization_required", occurredAt: entry.occurredAt ?? null, + originalAttemptMatch: "unconfirmed", + messageExcerpt: redact(message).slice(0, 2000), + }); + return; + } + let status; + if (entry.source === "codex_app_log") { + // The native App event's state, rather than words inside its error text. + if (!/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test(message)) return; + status = [...message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1]; + if (status !== "failed") return; + } else { + if (!["codex_runtime_sqlite", "codex_tui_log", "claude_debug_log"].includes(entry.source)) return; + const nativeErrorLevel = String(entry.level ?? "").toUpperCase() === "ERROR" || + /^(?:\d{4}-\d{2}-\d{2}T\S+\s+)?(?:\[ERROR\]|ERROR)(?:\s|$)/i.test(message); + if (!nativeErrorLevel) return; + } + add("host_runtime_error_unassociated", [`hostLogs.entries[${index}]`], + "A bounded native host error was collected, but its association with the original failed attempt is unconfirmed. Inspect the local record and available time/call context; preserve missing or truncated coverage and do not infer a root cause or recovery.", "attention", { + source: entry.source, ...(status ? { status } : { level: "ERROR" }), + occurredAt: entry.occurredAt ?? null, + originalAttemptMatch: "unconfirmed", + messageExcerpt: redact(message).slice(0, 2000), + }); + }); + if (!findings.some((item) => item.severity === "attention") && original?.status !== "recorded") + add("no_incident_demonstrated", ["originalFailure.status", "hostSnapshot", "entryProbe"], + "No original incident has been demonstrated. An anonymous 401 is expected; authenticated host tools still need separate verification.", "info"); + const gaps = [ + ...(report.evidenceSummary?.gaps ?? []).map((reason) => ({ source: "evidenceSummary", reason })), + ...(report.gaps ?? []), ...(report.hostLogs?.gaps ?? []), + ]; + return { + schemaVersion: 1, diagnosticId: report.diagnosticId, + findings, gaps, rootCause: "unconfirmed", recoveryVerified: false, + provenance: { + original: "originalFailure; supplied original error, time and IDs only", + host: "hostSnapshot is agent supplied; hostLogs are bounded and attempt association may be unknown", + later: "entryProbe/probes/wireProbes are independent later requests; success never proves original recovery", + }, + }; +} + +function publicExcerpt(text, original = {}) { + let excerpt = redact(text ?? "Unavailable; original error must not be invented."); + for (const id of [original.callId, original.conversationId]) + if (typeof id === "string" && id.length) excerpt = excerpt.replaceAll(id, "[local ID removed]"); + return excerpt + .replace(/\b((?:call|conversation|thread|session)[_-]?id\s*[:=]\s*)["']?[^\s,"'<>]+/gi, "$1[local ID removed]") + .replace(/[A-Za-z]:[\\/][^\r\n"<>]*/g, "[local path removed]") + .replace(/\/(?:Users|home|tmp|var)\/[^\s"<>]+/g, "[local path removed]") + .replace(/\b[^\s@]+@[^\s@]+\.[a-z]{2,}\b/gi, "[email removed]") + .slice(0, 2000); +} + +export function renderPacket(report, diagnosis) { + const facts = (includePrivateExcerpts) => diagnosis.findings.map((item) => { + const observed = item.observed && Object.fromEntries(Object.entries(item.observed) + .filter(([key]) => includePrivateExcerpts || key !== "messageExcerpt")); + return `- ${item.kind}: ${item.next} Evidence: ${item.evidence.join(", ")}${observed ? ` Observed: ${redact(JSON.stringify(observed))}` : ""}`; + }).join("\n"); + const gaps = diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? gap.error?.message ?? "see local report"}`).join("\n") || "- None recorded; coverage is still bounded."; + const common = (includePrivateExcerpts) => `Diagnostic ID: ${report.diagnosticId}\nHost: ${report.host} ${report.hostVersion ?? "version unknown"}\nCollector: ${report.collectorVersion}; package: ${report.pluginVersion ?? "unknown"} (not proof of loaded version)\n\n${facts(includePrivateExcerpts)}\n\nRoot cause: unconfirmed. Original host recovery: unverified.\n`; + const summary = `# ChatCut MCP diagnosis\n\n${common(true)}\n## Evidence boundaries\n\n${Object.values(diagnosis.provenance).map((value) => `- ${value}`).join("\n")}\n\n## Original failure\n\n${redact(JSON.stringify(report.originalFailure, null, 2))}\n\n## Missing evidence\n\n${gaps}\n\n## Feedback\n\nUpload: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. An ingest receipt is not a support ticket or proof of complete storage.\nKeep this report for targeted follow-up; do not rerun collection just to resend it.\n`; + // Deliberately excludes host log dumps, local call/conversation IDs, paths and full stacks. + const draft = `# ChatCut MCP issue draft — review before sharing\n\n${common(false)}\n## Symptom and reproduction\n\n[Add the user's symptom and minimal safe steps; do not replay a high-impact operation.]\n\n## Original error excerpt\n\n${publicExcerpt(report.originalFailure?.message, report.originalFailure)}\n\nOriginal failure time: ${report.originalFailure?.occurredAt ?? "unknown"}. Later checks are not this attempt.\n\n## Checks and attempted repairs\n\nDoctor collected the original error if available, bounded host logs and later anonymous probes when allowed. No repair is performed by the helper.\n[Record any actual authorized change and the same-host verification result. Leave unperformed steps unperformed.]\n\n## Evidence gaps\n\n${diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? "details retained locally"}`).join("\n")}\n\n## Delivery\n\nDiagnostic ingest: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. No ticket has been opened.\nUse https://chatcut.io/contact for private support. A public issue destination has not been selected; do not publish automatically. Review the excerpt for project names, personal data and secrets before sharing. Keep the full local JSON private unless an appropriate recipient requests it.\n`; + return { summary, draft }; +} diff --git a/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs new file mode 100644 index 0000000..ae58b09 --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -0,0 +1,76 @@ +import { readFile, stat, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { basename, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCli, submitReport, errorDetails } from "./mcp-doctor.mjs"; +import { diagnose, renderPacket } from "./diagnosis.mjs"; + +export async function writePacket(path, report) { + const diagnosis = diagnose(report); + const { summary, draft } = renderPacket(report, diagnosis); + const files = { diagnosis: `${path}.diagnosis.json`, summary: `${path}.summary.md`, issueDraft: `${path}.issue-draft.md` }; + // These are derived sidecars. The source report and diagnostic ID stay unchanged. + await writeFile(files.diagnosis, JSON.stringify(diagnosis, null, 2) + "\n", { mode: 0o600 }); + await writeFile(files.summary, summary, { mode: 0o600 }); + await writeFile(files.issueDraft, draft, { mode: 0o600 }); + return { diagnosticId: report.diagnosticId, report: path, ...files, findings: diagnosis.findings, upload: report.upload, recoveryVerified: false }; +} + +async function readReport(path) { + // Do not accept config/auth/session files even as a mistakenly supplied input. + if (!/(?:\.mcp-report|chatcut-mcp-doctor-[a-f0-9-]+)\.json$/i.test(basename(path))) + throw new Error("Use the dedicated .mcp-report.json or default chatcut-mcp-doctor-.json report, never config/auth/session files"); + if ((await stat(path)).size > 2 * 1024 * 1024) throw new Error("Report exceeds 2 MiB; retained locally without reading"); + const bytes = await readFile(path); + const report = JSON.parse(bytes); + if (report.schemaVersion !== 2 || report.scope !== "cloud" || !["codex", "claude"].includes(report.host) || + !/^[a-f0-9-]{36}$/i.test(report.diagnosticId ?? "") || !report.originalFailure || !report.hostLogs) + throw new Error("Not a schema-2 ChatCut MCP report"); + return { report, sourceReportSha256: createHash("sha256").update(bytes).digest("hex") }; +} + +async function readReceipt(path, report, sourceReportSha256) { + try { + if ((await stat(path)).size > 32 * 1024) throw new Error("Upload receipt exceeds 32 KiB"); + const stored = JSON.parse(await readFile(path, "utf8")); + if (stored.schemaVersion !== 1 || stored.diagnosticId !== report.diagnosticId || + stored.sourceReportSha256 !== sourceReportSha256 || !stored.receipt || + typeof stored.receipt.status !== "string" || stored.receipt.storageVerified !== false) + throw new Error("Upload receipt does not match this diagnostic report; preserve both files for review"); + return stored.receipt; + } catch (error) { if (error.code !== "ENOENT") throw error; } +} + +export async function main(args = process.argv.slice(2)) { + let result; + if (["--report", "--upload-report"].includes(args[0])) { + if (args.length !== 2) throw new Error("Use --report or --upload-report alone"); + const path = resolve(args[1]); + const { report, sourceReportSha256 } = await readReport(path); + const receiptPath = `${path}.upload.json`; + const prior = await readReceipt(receiptPath, report, sourceReportSha256); + if (args[0] === "--upload-report") { + if (report.upload?.status === "ingest_accepted") + throw new Error("Ingest already acknowledged this report; do not send it again"); + if (prior?.status === "ingest_accepted") throw new Error("Ingest already acknowledged this report; do not send it again"); + report.upload = await submitReport(report); + await writeFile(receiptPath, JSON.stringify({ schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, receipt: report.upload }, null, 2) + "\n", { mode: 0o600 }); + } else { + if (prior) report.upload = prior; + } + result = { path, report }; + } else { + if (args.includes("--help")) { + console.log("ChatCut MCP Doctor: collector options below also produce a diagnosis, summary and private issue draft. --offline sends no requests. --report rebuilds feedback offline. --upload-report sends that report once without new probes; use only for an authorized retry. No automatic repair or ticket submission."); + } + result = await runCli(args); + } + if (result) console.log(JSON.stringify(await writePacket(result.path, result.report))); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + }); +} diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs new file mode 100644 index 0000000..326b4fd --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -0,0 +1,1373 @@ +import { randomUUID, createHash } from "node:crypto"; +import { open, opendir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { homedir, tmpdir, platform, arch } from "node:os"; +import { join, resolve, basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { connect as connectTls } from "node:tls"; +import { isIP } from "node:net"; +import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; +import { probeMetadataWire } from "./mcp-wire.mjs"; +import { failureFromRecord } from "./mcp-failure.mjs"; + +const READ_LIMIT = 128 * 1024; +const UPLOAD_LIMIT = 256 * 1024; +const TIMEOUT_MS = 3500; +const CHATCUT_MESSAGE = + /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|\bplugin(?:_name)?[=: "\t]+chatcut(?:@chatcut-inc)?(?:["\s}:]|$)|https:\/\/(?:api\.chatcut\.(?:io|dev)|api-dev\.chatcut\.org)\//i; +const TEXT_ECHO = + /^(?:user (?:prompt|message)|assistant message|tool (?:call|result))\s*:|^(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[]/i; + +function redactFieldValue(_match, prefix, value) { + if (!value) return prefix; + const quote = value.match(/^\\*["']/)?.[0]; + return prefix + (quote ? quote + "[REDACTED]" + quote : "[REDACTED]"); +} + +export function redact(text, jsonDepth = 0) { + let value = String(text); + // A host may quote/stringify an entire JSON response inside its error. Decode + // only JSON string tokens containing this credential label, redact their full + // value, then restore the quoting. A partial escaped-field regex can leak the + // suffix after an escaped quote/backslash. Input collection is size bounded. + if (jsonDepth < 16) value = value.replace(/"(?:\\.|[^"\\\r\n])*"/g, (token) => { + try { + const decoded = JSON.parse(token); + if (/mcp[-_]session[-_]id/i.test(decoded)) { + const sanitized = redact(decoded, jsonDepth + 1); + if (sanitized !== decoded) return JSON.stringify(sanitized); + } + } catch { /* Non-JSON diagnostic text is retained for the normal rules. */ } + return token; + }); + return value + .replace(/\b(Mcp-Session-Id[ \t]*:[ \t]*)\S+/gi, "$1[REDACTED]") + .replace( + /(["']mcp[-_]session[-_]id["']\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*')/gi, + redactFieldValue, + ) + .replace( + /\b((?:mcp[-_]session[-_]id)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^\s"'<>]+)/gi, + redactFieldValue, + ) + .replace(/((?:\\*["'])mcp[-_]session[-_]id(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, "$1[REDACTED]") + .replace( + /\bBearer\s+(?!(?:token|required)\b|<|(?:realm|scope|error(?:_description|_uri)?|resource_metadata)\s*=)[A-Za-z0-9._~+/=-]+/gi, + "Bearer [REDACTED]", + ) + .replace(/\b(Authorization\s*[:=]\s*Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[REDACTED]") + .replace( + /\b((?:set-)?cookie\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|\[REDACTED\]|(?:(?![\r\n]|(?]+/gi, + "$1[REDACTED]", + ) + .replace( + /\b((?:access_?token|refresh_?token|id_?token|session_?token|client_?secret|api[_-]?key|token)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|[A-Za-z0-9._~+/=-]+)/gi, + redactFieldValue, + ) + .replace( + /((?:\\*["'])(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, + "$1[REDACTED]", + ) + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"); +} + +function clean(value) { + return JSON.parse( + JSON.stringify(value, (key, item) => + /^(?:mcp[-_]session[-_]id|access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( + key, + ) + ? "[REDACTED]" + : typeof item === "string" + ? redact(item) + : item, + ), + ); +} + +export function errorDetails(error, seen = new Set()) { + if (!error || typeof error !== "object") return { message: redact(error) }; + if (seen.has(error) || seen.size >= 8) return { unavailable: "error_chain_depth_or_cycle" }; + seen.add(error); + const result = {}; + for (const key of ["name", "message", "code", "errno", "errcode", "errstr", "syscall", "stack"]) { + if (typeof error[key] === "string" || typeof error[key] === "number") result[key] = error[key]; + } + if (error.cause) result.cause = errorDetails(error.cause, seen); + if (Array.isArray(error.errors)) { + result.errors = error.errors.slice(0, 8).map((item) => errorDetails(item, seen)); + if (error.errors.length > 8) result.omittedErrors = error.errors.length - 8; + } + return clean(result); +} + +export function serverUrl(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + const hosted = ["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes( + url.hostname, + ); + if ( + (!local && (!hosted || url.protocol !== "https:" || (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/api\/external-mcp\/(?:mcp|claude|v2\/mcp)\/?$/.test(url.pathname) + ) { + throw new Error( + "Unsupported registered MCP URL; preserve its configuration and report unsupported_endpoint", + ); + } + return url; +} + +async function limitedBody(response, limit = READ_LIMIT) { + const reader = response.body?.getReader(); + if (!reader) return ""; + const parts = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return Buffer.concat(parts).toString("utf8"); + bytes += value.length; + if (bytes > limit) { + const error = new Error(`Response exceeds ${limit} bytes; body not retained`); + error.code = "DIAGNOSTIC_BODY_LIMIT"; + throw error; + } + parts.push(Buffer.from(value)); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export async function probeMetadata(url, diagnosticId, fetchImpl = fetch, expected = {}) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url, + startedAt, + implementation: `node-fetch/${process.version}`, + stage: "request", + status: null, + bodyComplete: false, + jsonValid: null, + originalAttemptMatch: "unconfirmed", + }; + try { + const response = await fetchImpl(url, { + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { accept: "application/json", "x-chatcut-diagnostic-id": diagnosticId }, + }); + result.status = response.status; + result.responseHeaders = {}; + for (const key of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + "www-authenticate", + "retry-after", + "location", + ]) { + const value = response.headers.get(key); + if (value !== null) result.responseHeaders[key] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + result.stage = "json_parse"; + try { + const parsed = JSON.parse(body); + result.jsonValid = true; + result.metadataShape = { + object: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed), + issuerPresent: typeof parsed?.issuer === "string", + resourcePresent: typeof parsed?.resource === "string", + }; + result.metadataValidation = inspectMetadata(parsed, url, expected); + } catch (error) { + result.jsonValid = false; + throw error; + } + result.stage = "complete"; + const required = new URL(url).pathname.includes("oauth-protected-resource") + ? "resourcePresent" + : "issuerPresent"; + result.outcome = !response.ok + ? "http_rejected" + : result.metadataShape.object && result.metadataShape[required] + ? result.metadataValidation.valid + ? "metadata_read" + : "invalid_metadata" + : "invalid_metadata_shape"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +async function guardProbe(url, operation) { + const startedAt = new Date().toISOString(); + try { + return await operation(); + } catch (error) { + return { + evidenceKind: "later_probe", + url, + startedAt, + outcome: "failed", + stage: "probe_collector", + status: null, + originalAttemptMatch: "unconfirmed", + error: errorDetails(error), + gap: "partial_probe_evidence_unavailable; original_failure_retained", + }; + } +} + +export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: endpoint.href, + method: "POST", + operation: "initialize", + implementation: "node-fetch/" + process.version, + startedAt: new Date().toISOString(), + stage: "request", + status: null, + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + }; + try { + const response = await fetchImpl(endpoint.href, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "x-chatcut-diagnostic-id": diagnosticId, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: diagnosticId, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.3.0" }, + }, + }), + }); + result.status = response.status; + result.responseHeaders = {}; + for (const name of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "www-authenticate", + "retry-after", + "location", + "x-request-id", + "x-amzn-trace-id", + "server", + "via", + ]) { + const value = response.headers.get(name); + if (value !== null) result.responseHeaders[name] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + let parsed; + try { + parsed = JSON.parse(body); + } catch (error) { + result.parseError = errorDetails(error); + if (!response.ok) result.responseError = { message: redact(body) }; + } + if (parsed?.error) result.protocolError = clean(parsed.error); + if (response.status === 401) { + const header = response.headers.get("www-authenticate") ?? ""; + const metadataHint = header.match(/resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i); + result.resourceMetadataUrl = metadataHint?.[1] ?? metadataHint?.[2] ?? null; + result.outcome = /\bBearer\b/i.test(header) + ? "authentication_challenge" + : "missing_authentication_challenge"; + } else if (!response.ok) result.outcome = "http_rejected"; + else if (parsed?.error) result.outcome = "protocol_error"; + else if (parsed?.jsonrpc === "2.0" && typeof parsed.result?.protocolVersion === "string") { + result.outcome = "anonymous_initialize_returned"; + result.serverInfo = parsed.result.serverInfo; + result.protocolVersion = parsed.result.protocolVersion; + } else result.outcome = "invalid_initialize_response"; + result.stage = "complete"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +export function inspectTransport(endpoint) { + if (endpoint.protocol !== "https:") + return Promise.resolve({ status: "not_applicable", reason: "not_tls" }); + return new Promise((resolveProbe) => { + const startedAt = new Date().toISOString(); + let settled = false; + const host = endpoint.hostname.replace(/^\[|\]$/g, ""); + const socket = connectTls({ + host, + port: Number(endpoint.port || 443), + rejectUnauthorized: true, + servername: isIP(host) ? undefined : host, + }); + const finish = (details) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolveProbe( + clean({ + evidenceKind: "later_probe", + implementation: "node:tls", + startedAt, + route: "direct_tls; does_not_reproduce_host_or_fetch_proxy_route", + originalAttemptMatch: "unconfirmed", + ...details, + }), + ); + }; + const timer = setTimeout( + () => + finish({ + status: "failed", + error: { + name: "TimeoutError", + message: "Direct TLS context timed out", + code: "ETIMEDOUT", + }, + }), + TIMEOUT_MS, + ); + socket.once("secureConnect", () => { + const certificate = socket.getPeerCertificate(); + finish({ + status: "observed", + tlsProtocol: socket.getProtocol(), + authorized: socket.authorized, + certificate: { + issuer: certificate.issuer, + validFrom: certificate.valid_from, + validTo: certificate.valid_to, + fingerprint256: certificate.fingerprint256, + }, + }); + }); + socket.once("error", (error) => finish({ status: "failed", error: errorDetails(error) })); + }); +} + +function logMessage(body) { + const marker = body.lastIndexOf("}:"); + return (marker < 0 ? body : body.slice(marker + 2)).trim(); +} + +function runtimeContext(body) { + const boundary = body.lastIndexOf("}:"); + if (boundary < 0) return {}; + const context = body.slice(0, boundary); + const value = (field) => + [ + ...context.matchAll( + new RegExp(`(?:^|[\\s{])${field}="?([A-Za-z0-9_.-]{1,256})(?:"|[\\s}:]|$)`, "g"), + ), + ].at(-1)?.[1]; + const conversationId = value("thread_id") ?? value("thread\\.id") ?? value("conversation\\.id"); + const callId = value("call_id"); + return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; +} + +function hostedTextRecord(line) { + if (/^\s/.test(line)) return false; + // Inspect explicit echo prefixes before stripping trace context: a payload + // itself can contain "}:" and must not become a diagnostic via lastIndex. + if (/(?:^|\}:\s*)(?:tool (?:call|result)\s*:|(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[])/i.test(line)) return false; + let body = line.replace( + /^\d{4}-\d{2}-\d{2}T\S+\s+(?:(?:\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\]|TRACE|DEBUG|INFO|WARN|ERROR)\s+)?/i, + "", + ); + const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); + if (module) { + if ( + !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::)/.test(module[1]) + ) + return false; + body = body.slice(module[0].length); + } + if ( + /^(?:user(?: prompt| message)?|assistant(?: message)?|system|developer|transcript)\s*:|^[{[]/i.test( + body, + ) + ) + return false; + const scope = body.match( + /^(?:MCP server|Server)\s+(?:"([^"]+)"|'([^']+)'|((?:plugin:[\w.-]+:)?[\w.-]+))(?::\s*|\s+|$)/i, + ); + const context = body.split(/\berror\s*=/i)[0]; + const server = + context.match(/\bserver_name=["']?([\w:.-]+)/)?.[1] ?? scope?.[1] ?? scope?.[2] ?? scope?.[3]; + if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; + if (!CHATCUT_MESSAGE.test(body)) return false; + if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) return false; + const message = scope ? body.slice(scope[0].length) : logMessage(body); + // Runtime error contents can name tools; only explicit echo records are excluded. + if (TEXT_ECHO.test(message)) return false; + if ( + /^(?:Sending|Sent|Received|Receiving)\s+(?:MCP\s+|JSON-RPC\s+)?(?:request|response|message)\b/i.test( + message, + ) && + /"method"\s*:\s*"tools\/call"|"result"\s*:\s*\{\s*"content"\s*:/i.test(message) + ) + return false; + if (/^[{[]/.test(message)) { + try { + const value = JSON.parse(message); + const records = Array.isArray(value) ? value : [value]; + if ( + records.some( + (record) => + record && + (record.method === "tools/call" || + [ + "function_call", + "function_call_output", + "tool_call", + "tool_result", + "user", + "assistant", + "user_message", + "assistant_message", + ].includes(record.type) || + ["user", "assistant", "system", "developer"].includes(record.role) || + Array.isArray(record.result?.content)), + ) + ) + return false; + } catch { + // Unparseable diagnostic text is not proof of a tool or conversation envelope. + } + } + return true; +} + +async function textLog(path, source, gaps, window, select = hostedTextRecord) { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + const start = window.readOffset ?? Math.max(0, info.size - READ_LIMIT); + if (!Number.isSafeInteger(start) || start < 0 || start > info.size) + throw new Error("Log segment offset must point inside the selected existing file"); + const buffer = Buffer.alloc(Math.min(info.size - start, READ_LIMIT)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); + if (start) { + const previous = Buffer.alloc(1); + await handle.read(previous, 0, 1, start - 1); + if (previous[0] !== 10) lines.shift(); + gaps.push({ + source, + reason: "only_log_tail_read", + sourceFile: basename(path), + bytesBeforeWindow: start, + omittedBytes: start, + }); + } + if (start + bytesRead < info.size) { + if (!buffer.subarray(0, bytesRead).toString("utf8").endsWith("\n")) lines.pop(); + gaps.push({ + source, + reason: "only_log_segment_read", + sourceFile: basename(path), + offset: start, + bytesRead, + remainingBytes: info.size - start - bytesRead, + }); + } + const entries = []; + for (let i = 0; i < lines.length; i++) { + if (!select(lines[i])) continue; + const block = [lines[i]]; + while ( + source !== "codex_app_log" && + i + 1 < lines.length && + /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1]) && + !/^\s*(?:\d{4}-\d{2}-\d{2}T|MCP server\s|Server\s|\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\])/i.test( + lines[i + 1], + ) && + !TEXT_ECHO.test(lines[i + 1].trimStart()) + ) + block.push(lines[++i]); + const occurredAt = logTime(block[0]); + if ( + occurredAt && + (Date.parse(occurredAt) < window.since * 1000 || + Date.parse(occurredAt) > window.until * 1000) + ) + continue; + entries.push({ + source, + sourceFile: basename(path), + occurredAt, + timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", + message: redact(block.join("\n")), + ...runtimeContext(block[0]), + originalAttemptMatch: "unconfirmed", + }); + } + if (entries.length > 30) + gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); + const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; + if (unknownTimes) + gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); + return entries.slice(-30); + } finally { + await handle.close(); + } +} + +async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { + if ( + readOffset !== undefined && + (selectedFiles.length !== 1 || !Number.isSafeInteger(readOffset) || readOffset < 0) + ) + throw new Error( + "A segment offset requires exactly one selected native App log file and a nonnegative integer", + ); + const files = []; + const dates = new Set(); + for (const time of [window.since * 1000, window.until * 1000]) { + const date = new Date(time); + dates.add(date.toISOString().slice(0, 10).replaceAll("-", "/")); + dates.add( + [date.getFullYear(), date.getMonth() + 1, date.getDate()] + .map((value) => String(value).padStart(2, "0")) + .join("/"), + ); + } + let scanned = 0; + const deadline = Date.now() + 1500; + for (const date of dates) { + try { + const dir = join(root, date); + for await (const item of await opendir(dir)) { + if (++scanned > 200 || Date.now() > deadline) { + gaps.push({ source: "codex_app_log", reason: "directory_scan_limit" }); + break; + } + if (!item.isFile() || !/^codex-desktop-.*\.log$/.test(item.name)) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= window.since * 1000) files.push({ path, modified: info.mtimeMs }); + } + } catch (error) { + gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); + } + if (scanned > 200 || Date.now() > deadline) break; + } + files.sort((a, b) => b.modified - a.modified); + if (selectedFiles.length) { + if (selectedFiles.length > 3) + throw new Error("Select at most three existing Codex App log files"); + files.length = 0; + for (const path of [...new Set(selectedFiles)]) { + if (!/^codex-desktop-.*\.log$/.test(basename(path))) + throw new Error("Select only existing native codex-desktop-*.log files"); + const info = await stat(path); + if (!info.isFile()) throw new Error("Codex App log selection must be a file"); + files.push({ path, modified: info.mtimeMs }); + } + } + const entries = []; + let filesRead = 0; + for (const file of files.slice(0, 3)) { + try { + // App logs mix unrelated UI activity with runtime notices, so accept only this native event. + entries.push( + ...(await textLog(file.path, "codex_app_log", gaps, { ...window, readOffset }, (line) => { + if ( + !/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test( + line, + ) + ) + return false; + const server = [...line.matchAll(/(?:^|\s)server=([^\s]+)/g)].at(-1)?.[1]; + if (!["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; + const thread = [...line.matchAll(/(?:^|\s)threadId=([^\s]+)/g)].at(-1)?.[1]; + return !conversationId || !thread || thread === "null" || thread === conversationId; + })), + ); + filesRead++; + } catch (error) { + gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (files.length > 3) + gaps.push({ + source: "codex_app_log", + reason: "log_file_limit", + omitted: files.length - 3, + omittedFiles: files.slice(3, 13).map((file) => basename(file.path)), + }); + if (entries.length > 30) + gaps.push({ + source: "codex_app_log", + reason: "log_record_limit", + omitted: entries.length - 30, + }); + return { + entries: entries + .sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")) + .slice(-30), + scan: { + directoryEntriesScanned: scanned, + matchingFiles: files.length, + filesRead, + maximumTailBytesPerFile: READ_LIMIT, + currentConversationFilterApplied: Boolean(conversationId), + ...(conversationId && { conversationId }), + selectedFileMode: selectedFiles.length > 0, + ...(readOffset !== undefined && { selectedByteOffset: readOffset }), + filesSelected: files.slice(0, 3).map((file) => basename(file.path)), + observationScope: + "hosted_mcp_startup_notices_only; tool_request_failures_need_original_failure_record_or_matching_runtime_logs", + }, + }; +} + +async function inspectSqliteAccess(path) { + const files = {}; + for (const suffix of ["", "-wal", "-shm"]) { + const name = suffix || "database"; + let handle; + try { + const info = await stat(path + suffix); + handle = await open(path + suffix, "r"); + const header = Buffer.alloc(suffix ? 1 : 20); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + files[name] = { exists: true, readable: true, bytes: info.size }; + if ( + !suffix && + bytesRead === 20 && + header.subarray(0, 16).toString() === "SQLite format 3\0" + ) { + files[name].journalMode = header[18] === 2 ? "wal" : "rollback"; + } + } catch (error) { + files[name] = { readable: false, error: errorDetails(error) }; + } finally { + try { + await handle?.close(); + } catch (error) { + files[name].closeError = errorDetails(error); + } + } + } + return { + files, + interpretation: + "File reads and SQLite WAL/shared-memory access are different operations; readable files do not prove SQLite access. No copy, checkpoint, immutable mode or permission change attempted.", + }; +} + +export async function collectHostLogs(host, options = {}) { + const userHome = options.home ?? homedir(); + const gaps = []; + const entries = []; + let appLog; + const now = options.now ?? Date.now(); + const center = options.occurredAt ? Date.parse(options.occurredAt) : now; + const since = Math.floor((center - (options.occurredAt ? 15 : 30) * 60_000) / 1000); + const until = Math.min(now, center + 15 * 60_000) / 1000; + if (host === "codex") { + const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); + const sqliteRoot = options.codexSqliteHome ?? process.env.CODEX_SQLITE_HOME ?? root; + const logRoot = options.codexLogDir ?? join(root, "log"); + const databasePath = join(sqliteRoot, "logs_2.sqlite"); + let db; + try { + await stat(databasePath); + const { DatabaseSync } = await import("node:sqlite"); + db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); + db.exec("PRAGMA query_only=ON"); + const hasThreadId = db + .prepare("PRAGMA table_info(logs)") + .all() + .some((column) => column.name === "thread_id"); + const rows = db + .prepare( + `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, + ) + .all(since, until); + for (const row of rows) { + const message = logMessage(row.feedback_log_body ?? ""); + const original = row.feedback_log_body ?? ""; + const context = runtimeContext(original); + if (row.thread_id) context.conversationId = row.thread_id; + if ( + options.conversationId && + context.conversationId && + context.conversationId !== options.conversationId + ) + continue; + if (!hostedTextRecord(`${row.target}: ${original}`) || + !hostedTextRecord(`MCP server "chatcut" ${message}`)) + continue; + if ( + !["ERROR", "WARN"].includes(row.level) && + !/omitting MCP server|cached MCP catalog/.test(message) + ) + continue; + entries.push({ + source: "codex_runtime_sqlite", + rowId: row.id, + occurredAt: new Date(row.ts * 1000).toISOString(), + target: row.target, + level: row.level, + message: redact(message), + ...context, + originalAttemptMatch: "unconfirmed", + }); + if (entries.length === 30) break; + } + if (rows.length === 200 || entries.length === 30) + gaps.push({ source: "codex_runtime_sqlite", reason: "log_record_limit" }); + } catch (error) { + gaps.push({ + source: "codex_runtime_sqlite", + reason: "unavailable", + error: errorDetails(error), + locationSource: options.codexSqliteHome + ? "explicit_host_location" + : process.env.CODEX_SQLITE_HOME + ? "CODEX_SQLITE_HOME" + : "CODEX_HOME_default; sqlite_home_config_override_not_inspected", + ...(platform() === "win32" && error?.errcode === 14 + ? { accessEvidence: await inspectSqliteAccess(databasePath) } + : {}), + }); + } finally { + db?.close(); + } + try { + entries.push( + ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { + since, + until, + })), + ); + } catch (error) { + gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); + } + const appData = options.localAppData ?? process.env.LOCALAPPDATA; + const sandboxAppData = + !options.localAppData && + /[\\/]Packages[\\/]sandbox\.[^\\/]+[\\/]AC(?:[\\/]|$)/i.test(appData ?? ""); + const appLogRoot = + options.codexAppLogDir ?? + ((options.platform ?? platform()) === "win32" + ? join( + // MXC redirects LOCALAPPDATA; the running App logs stay in the user profile. + (!sandboxAppData && appData) || join(userHome, "AppData", "Local"), + "Codex", + "Logs", + ) + : null); + if (appLogRoot) { + const result = await codexAppLogs( + appLogRoot, + gaps, + { since, until }, + options.conversationId, + options.codexAppLogFiles, + options.codexAppLogOffset, + ); + entries.push(...result.entries); + appLog = { + ...result.scan, + locationSource: options.codexAppLogDir ? "explicit_host_location" : "windows_default", + sandboxLocalAppDataRedirected: Boolean(sandboxAppData), + }; + if (!result.entries.length) + gaps.push({ source: "codex_app_log", reason: "no_matching_records_in_bounded_window" }); + } + } else { + const root = + options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(userHome, ".claude"); + try { + const dir = join(root, "debug"); + const names = await readdir(dir, { withFileTypes: true }); + const files = []; + const deadline = Date.now() + 1500; + for (const item of names) { + if (Date.now() > deadline) { + gaps.push({ source: "claude_debug_log", reason: "directory_scan_deadline" }); + break; + } + if (!item.isFile() || !item.name.endsWith(".txt")) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= since * 1000) files.push({ path, modified: info.mtimeMs }); + } + files.sort((a, b) => b.modified - a.modified); + for (const item of files.slice(0, 3)) + entries.push(...(await textLog(item.path, "claude_debug_log", gaps, { since, until }))); + if (files.length > 3) + gaps.push({ + source: "claude_debug_log", + reason: "log_file_limit", + omitted: files.length - 3, + }); + } catch (error) { + gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (!entries.length) + gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); + return clean({ + entries, + gaps, + ...(appLog ? { appLog } : {}), + window: { + since: new Date(since * 1000).toISOString(), + until: new Date(until * 1000).toISOString(), + }, + }); +} + +export function fragmentEvents(report) { + const json = JSON.stringify(clean(report)); + if (Buffer.byteLength(json) > UPLOAD_LIMIT) + throw new Error("Report exceeds upload budget; complete local report retained"); + const chunks = []; + let chunk = ""; + let bytes = 0; + for (const char of json) { + const size = Buffer.byteLength(char); + if (bytes + size > 4096) { + chunks.push(chunk); + chunk = ""; + bytes = 0; + } + chunk += char; + bytes += size; + } + if (chunk) chunks.push(chunk); + const sha256 = createHash("sha256").update(json).digest("hex"); + // Canonical fields and a map avoid growing the backend dataset's flat schema. + return chunks.map((fragment, fragmentIndex) => ({ + name: "mcp.doctor.report_fragment", + properties: { + clientEventName: null, + eventName: "mcp.doctor.report_fragment", + attributes: { + mcpDoctor: { + diagnosticId: report.diagnosticId, + schemaVersion: report.schemaVersion, + collectedAt: report.collectedAt, + host: report.host, + scope: "cloud", + fragmentIndex, + fragmentCount: chunks.length, + reportSha256: sha256, + fragment, + }, + }, + }, + })); +} + +export async function submitReport(report, fetchImpl = fetch) { + if (report.upload?.status === "skipped_intentionally_disabled" || + report.hostSnapshot?.configuration?.enabled === false || + report.hostSnapshot?.session?.connectionStatus === "disabled") + return { status: "skipped_intentionally_disabled", storageVerified: false }; + if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; + try { + const endpoint = serverUrl(report.serverUrl); + const events = fragmentEvents(report); + const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { + method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { "content-type": "application/json" }, body: JSON.stringify(events), + }); + const body = await limitedBody(response, 16 * 1024); + if (!response.ok || JSON.parse(body).ok !== true) + throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); + return { + status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, + reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256, + }; + } catch (error) { + return { status: "failed", storageVerified: false, error: errorDetails(error) }; + } +} + +export async function runDoctor(options, dependencies = {}) { + if (!["codex", "claude"].includes(options.host)) + throw new Error("--host must be codex or claude"); + if (options.originalError && options.failureRecord) + throw new Error("Choose one original failure input: --failure-record or --original-error"); + let endpoint = null; + let endpointError; + try { endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; } + catch (error) { endpointError = errorDetails(error); } + if ( + options.occurredAt && + (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || + !Number.isFinite(Date.parse(options.occurredAt))) + ) { + throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); + } + const fetchImpl = dependencies.fetchImpl ?? fetch; + const diagnosticId = randomUUID(); + const report = { + schemaVersion: 2, + collectorVersion: "1.3.0", + diagnosticId, + collectedAt: new Date().toISOString(), + scope: "cloud", + host: options.host, + hostVersion: options.hostVersion ?? null, + hostVersionSource: options.hostVersion ? "agent_supplied" : "unavailable", + environment: { + platform: platform(), + architecture: arch(), + nodeVersion: process.version, + proxyEnvironmentPresent: [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ].some((key) => Boolean(process.env[key])), + probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", + noProxyEnvironmentPresent: Boolean(process.env.NO_PROXY || process.env.no_proxy), + nodeUseEnvProxyRequested: process.env.NODE_USE_ENV_PROXY === "1", + extraCaConfigured: Boolean(process.env.NODE_EXTRA_CA_CERTS), + tlsVerificationDisabledInEnvironment: process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0", + }, + serverUrl: endpoint?.href ?? null, + originalFailure: { + evidenceKind: "original_failure", + occurredAt: options.occurredAt ?? null, + timeSource: options.occurredAt + ? "command_line; agent_supplied_not_independently_verified" + : "unavailable", + source: "agent_supplied_error_file", + status: "unavailable", + }, + probes: [], + wireProbes: [], + gaps: [], + outcome: "diagnosis_only; original_connection_recovery_unverified", + coverage: "triggered_diagnostics_only; not_all_users_or_all_failures", + correlation: + "diagnosticId identifies this report and later probes, not the original host request", + }; + let disabledBySnapshot = false; + if (endpointError) report.gaps.push({ source: "registration", reason: "unsupported_endpoint; no_requests_sent", error: endpointError }); + if (options.hostSnapshot) { + try { + if (!basename(String(options.hostSnapshot)).endsWith(".mcp-snapshot.json")) + throw new Error( + "Host snapshot must be a dedicated .mcp-snapshot.json file, never a credential/config file", + ); + const info = await stat(options.hostSnapshot); + if (info.size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + const snapshot = JSON.parse(await readFile(options.hostSnapshot, "utf8")); + disabledBySnapshot = + snapshot?.configuration?.enabled === false || + snapshot?.session?.connectionStatus === "disabled"; + report.hostSnapshot = clean(validateSnapshot(snapshot, endpoint)); + report.hostSnapshotSource = + "agent_supplied; see each field source; not_independently_verified"; + if (!report.hostVersion && report.hostSnapshot.hostVersion) { + report.hostVersion = report.hostSnapshot.hostVersion; + report.hostVersionSource = "agent_supplied_host_snapshot"; + } + } catch (error) { + report.gaps.push({ + source: "host_snapshot", + reason: "unavailable", + error: errorDetails(error), + action: + "Create a new dedicated snapshot with --create-snapshot ; fill only observed facts, then --validate-snapshot before collection. Do not rename or read a credential/config file.", + }); + } + } + try { + const manifest = + options.host === "codex" + ? "../../../.codex-plugin/plugin.json" + : "../../../.claude-plugin/plugin.json"; + report.pluginVersion = JSON.parse( + await readFile(new URL(manifest, import.meta.url), "utf8"), + ).version; + report.pluginVersionSource = + "collector_package_manifest; installed_host_plugin_not_independently_verified"; + } catch (error) { + report.gaps.push({ source: "plugin_version", error: errorDetails(error) }); + } + if (options.originalError) { + try { + if (!/\.(?:txt|log)$/i.test(basename(String(options.originalError)))) { + throw new Error( + "Original error must be a dedicated .txt or .log file; credential/config/session files are not inputs", + ); + } + const info = await stat(options.originalError); + if (info.size > 64 * 1024) + throw new Error("Original error file exceeds 64 KiB; source file retained without reading"); + const message = await readFile(options.originalError, "utf8"); + if (!message.trim()) + throw new Error("Original error file is empty; original failure remains unavailable"); + report.originalFailure.message = redact(message); + report.originalFailure.status = "recorded"; + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "unavailable", + error: errorDetails(error), + }); + } + } else if (!options.failureRecord) + report.gaps.push({ + source: "original_failure", + reason: "not_provided; do_not_invent_original_error", + }); + if (options.failureRecord) { + try { + if (!basename(String(options.failureRecord)).endsWith(".mcp-failure.json")) + throw new Error( + "Use a dedicated .mcp-failure.json file, never a config or session transcript", + ); + if ((await stat(options.failureRecord)).size > 64 * 1024) + throw new Error("Failure record exceeds 64 KiB; complete source retained without reading"); + report.originalFailure = clean( + failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), + ); + if (!report.originalFailure.occurredAt && options.occurredAt) { + report.originalFailure.occurredAt = options.occurredAt; + report.originalFailure.timeSource = + "command_line; agent_supplied_not_independently_verified"; + } + if ( + options.occurredAt && + report.originalFailure.occurredAt && + Date.parse(options.occurredAt) !== Date.parse(report.originalFailure.occurredAt) + ) { + report.originalFailure.timeConflict = { + failureRecord: report.originalFailure.occurredAt, + commandLine: options.occurredAt, + }; + report.originalFailure.occurredAt = null; + report.originalFailure.timeSource = "conflicting_supplied_times"; + report.gaps.push({ + source: "original_failure", + reason: "conflicting_supplied_failure_times", + }); + } + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "failure_record_unavailable", + error: errorDetails(error), + }); + } + } + try { + report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { + occurredAt: report.originalFailure.occurredAt, + codexSqliteHome: options.codexSqliteHome, + codexLogDir: options.codexLogDir, + codexAppLogDir: options.codexAppLogDir, + codexAppLogFiles: options.codexAppLogFiles, + codexAppLogOffset: options.codexAppLogOffset, + conversationId: + report.originalFailure.conversationId ?? + report.hostSnapshot?.session?.conversationId ?? + process.env.CODEX_THREAD_ID, + }); + } catch (error) { + report.hostLogs = { + entries: [], + gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], + }; + } + const disabled = + disabledBySnapshot || + report.hostSnapshot?.configuration?.enabled === false || + report.hostSnapshot?.session?.connectionStatus === "disabled"; + if (options.offline || disabled || !endpoint) { + report.gaps.push({ + source: "probes", + reason: disabled + ? "host_reports_intentionally_disabled; no_requests_sent" + : options.offline + ? "offline_requested; no_requests_sent" + : "registered_endpoint_unavailable; no_requests_sent", + }); + } else { + report.entryProbe = await guardProbe(endpoint.href, () => + probeEntry(endpoint, diagnosticId, fetchImpl), + ); + const resource = endpoint.pathname.replace(/\/$/, ""); + const queue = [ + new URL("/.well-known/oauth-protected-resource" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server", endpoint.origin).href, + ]; + const enqueue = (value, label) => { + try { + const url = new URL(value); + if ( + url.origin !== endpoint.origin || + url.username || + url.password || + url.search || + url.hash || + !url.pathname.startsWith("/.well-known/") + ) + throw new Error( + "Unsupported metadata target; no cross-origin or credential-bearing probes", + ); + if (!queue.includes(url.href)) queue.push(url.href); + } catch (error) { + report.gaps.push({ source: label, error: errorDetails(error) }); + } + }; + if (report.entryProbe.resourceMetadataUrl) + enqueue(report.entryProbe.resourceMetadataUrl, "challenge_metadata_target"); + let issuer; + for (let index = 0; index < queue.length && index < 5; index++) { + const url = queue[index]; + const result = await guardProbe(url, () => + probeMetadata(url, diagnosticId, fetchImpl, { + resource: endpoint.href, + issuer: url.includes("oauth-authorization-server") ? issuer : undefined, + }), + ); + report.probes.push(result); + if (url === queue[0] && result.outcome !== "metadata_read") + enqueue( + new URL("/.well-known/oauth-protected-resource", endpoint.origin).href, + "root_resource_metadata_target", + ); + const fields = result.metadataValidation?.fields; + if (url.includes("oauth-protected-resource") && result.metadataValidation?.valid) { + issuer = fields.authorization_servers[0]; + const auth = new URL(issuer); + enqueue( + new URL( + "/.well-known/oauth-authorization-server" + auth.pathname.replace(/\/$/, ""), + auth.origin, + ).href, + "issuer_metadata_target", + ); + } + } + if (queue.length > 5) + report.gaps.push({ + source: "metadata_probes", + reason: "probe_limit", + omitted: queue.length - 5, + }); + if (options.wireEvidence) { + const failed = report.probes.find((probe) => probe.outcome !== "metadata_read"); + const selected = new URL(failed?.url ?? queue[0]); + const match = selected.pathname.match( + /^\/\.well-known\/(oauth-protected-resource|oauth-authorization-server|openid-configuration)/, + ); + if (match) { + const root = new URL("/.well-known/" + match[1], endpoint.origin); + const scoped = new URL(root.pathname + resource, endpoint.origin); + for (const url of [scoped.href, root.href]) { + try { + report.wireProbes.push( + await (dependencies.probeWire ?? probeMetadataWire)(url, diagnosticId, errorDetails), + ); + } catch (error) { + report.gaps.push({ source: "wire_probe", url, error: errorDetails(error) }); + } + } + } + } + if ( + endpoint.protocol === "https:" && + [report.entryProbe, ...report.probes].some( + (probe) => probe.outcome === "failed" && probe.stage !== "probe_collector", + ) + ) { + try { + report.transportContext = await (dependencies.inspectTransport ?? inspectTransport)( + endpoint, + ); + } catch (error) { + report.transportContext = { status: "failed", error: errorDetails(error) }; + } + } + } + report.evidenceSummary = evidenceSummary(report); + report.upload = { + status: disabled + ? "skipped_intentionally_disabled" + : options.offline + ? "skipped_offline" + : !endpoint + ? "skipped_endpoint_unavailable" + : options.upload + ? "pending" + : "not_requested", + storageVerified: false, + }; + const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); + const save = async (flag) => + writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); + await save("wx"); + if (options.upload && !options.offline && !disabled && endpoint) { + report.upload = await submitReport(report, fetchImpl); + await save("w"); + } + return { path, report: clean(report) }; +} + +export async function runCli(args = process.argv.slice(2)) { + try { + const { values } = parseArgs({ + args, + options: { + host: { type: "string" }, + "server-url": { type: "string" }, + "host-version": { type: "string" }, + "host-snapshot": { type: "string" }, + "original-error": { type: "string" }, + "failure-record": { type: "string" }, + "occurred-at": { type: "string" }, + "codex-sqlite-home": { type: "string" }, + "codex-log-dir": { type: "string" }, + "codex-app-log-dir": { type: "string" }, + "codex-app-log-file": { type: "string", multiple: true }, + "codex-app-log-offset": { type: "string" }, + output: { type: "string" }, + upload: { type: "boolean", default: false }, + offline: { type: "boolean", default: false }, + "wire-evidence": { type: "boolean", default: true }, + "create-snapshot": { type: "string" }, + "validate-snapshot": { type: "string" }, + help: { type: "boolean", default: false }, + }, + }); + if (values["create-snapshot"]) { + const path = resolve(values["create-snapshot"]); + if (!basename(path).endsWith(".mcp-snapshot.json")) + throw new Error("Use a new dedicated .mcp-snapshot.json filename"); + await writeFile( + path, + JSON.stringify( + { schemaVersion: 1, observedAt: new Date().toISOString(), observations: [] }, + null, + 2, + ) + "\n", + { flag: "wx", mode: 0o600 }, + ); + console.log( + JSON.stringify({ + path, + snapshotCreated: true, + action: + "Add only observed configuration/session facts using connection-diagnostics.md; omit unknown groups. observedAt is snapshot capture time, not original failure time.", + }), + ); + } else if (values["validate-snapshot"]) { + const path = values["validate-snapshot"]; + if (!basename(path).endsWith(".mcp-snapshot.json")) + throw new Error("Use a dedicated .mcp-snapshot.json file"); + if ((await stat(path)).size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + validateSnapshot( + JSON.parse(await readFile(path, "utf8")), + values["server-url"] ? serverUrl(values["server-url"]) : null, + ); + console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); + } else if (values.help) { + console.log( + "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--offline] [--upload]", + ); + } else { + const { path, report } = await runDoctor({ + host: values.host, + serverUrl: values["server-url"], + hostVersion: values["host-version"], + hostSnapshot: values["host-snapshot"], + originalError: values["original-error"], + failureRecord: values["failure-record"], + occurredAt: values["occurred-at"], + codexSqliteHome: values["codex-sqlite-home"], + codexLogDir: values["codex-log-dir"], + codexAppLogDir: values["codex-app-log-dir"], + codexAppLogFiles: values["codex-app-log-file"], + codexAppLogOffset: + values["codex-app-log-offset"] === undefined + ? undefined + : Number(values["codex-app-log-offset"]), + output: values.output, + upload: values.upload, + offline: values.offline, + wireEvidence: values["wire-evidence"], + }); + console.log( + JSON.stringify({ + diagnosticId: report.diagnosticId, + path, + upload: report.upload, + evidenceGaps: report.evidenceSummary.gaps, + collectionGaps: report.gaps, + recoveryVerified: false, + }), + ); + return { path, report }; + } + } catch (error) { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await runCli(); diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs new file mode 100644 index 0000000..71111f2 --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -0,0 +1,376 @@ +const STAGES = [ + "plugin_loading", + "discovery", + "client_registration", + "authorization", + "token_exchange", + "token_refresh", + "initialize", + "tools_list", + "session_tools", +]; +const SOURCES = [ + "current_session", + "tool_search", + "host_settings", + "cli_configuration", + "runtime_log", +]; +const TIMESTAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/; + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function keys(value, allowed) { + if (!object(value) || Object.keys(value).some((key) => !allowed.includes(key))) + throw new Error("Snapshot contains unsupported fields; use only the dedicated evidence schema"); +} + +function source(value) { + if (!SOURCES.includes(value)) + throw new Error("Snapshot evidence needs an explicit supported source"); +} + +function instant(value) { + return ( + typeof value === "string" && + new RegExp("^" + TIMESTAMP.source + "$").test(value) && + Number.isFinite(Date.parse(value)) + ); +} + +export function validateSnapshot(input, endpoint) { + keys(input, [ + "schemaVersion", + "observedAt", + "hostVersion", + "pluginVersion", + "configuration", + "session", + "observations", + ]); + if (input.schemaVersion !== 1) + throw new Error( + "Snapshot schemaVersion must be 1 (the outer Doctor report uses a separate schemaVersion); generate a template with --create-snapshot", + ); + if (!instant(input.observedAt)) + throw new Error( + "Snapshot observedAt must be its capture time as a complete ISO timestamp with timezone, e.g. 2026-10-23T01:00:00Z; original failure time may remain unknown", + ); + for (const key of ["hostVersion", "pluginVersion"]) + if (input[key] !== undefined && (typeof input[key] !== "string" || input[key].length > 256)) + throw new Error("Invalid snapshot version"); + if (input.configuration) { + const config = input.configuration; + keys(config, ["source", "serverName", "url", "enabled"]); + source(config.source); + if (!["chatcut", "plugin:chatcut:chatcut"].includes(config.serverName)) + throw new Error("Snapshot must describe the hosted ChatCut server"); + if (!publicUrl(config.url, true)) + throw new Error("Snapshot URL must be a public URL without embedded credentials"); + if (endpoint && new URL(config.url).href !== endpoint.href) + throw new Error( + "Snapshot URL differs from the explicitly selected server; preserve both configurations", + ); + if (config.enabled !== null && typeof config.enabled !== "boolean") + throw new Error("Snapshot enabled state must be boolean or null"); + } + if (input.session) { + const session = input.session; + keys(session, [ + "source", + "conversationId", + "connectionStatus", + "authStatus", + "visibleToolCount", + "visibleTools", + ]); + source(session.source); + if (session.source === "cli_configuration") + throw new Error("Static CLI configuration cannot describe current session tool availability"); + if ( + !["connected", "failed", "needs_authentication", "disabled", "unknown"].includes( + session.connectionStatus, + ) + ) + throw new Error("Unsupported connection status"); + if (!["authenticated", "needs_authentication", "unknown"].includes(session.authStatus)) + throw new Error("Unsupported authentication status"); + if ( + session.visibleToolCount !== null && + (!Number.isSafeInteger(session.visibleToolCount) || session.visibleToolCount < 0) + ) + throw new Error("Visible tool count must be a nonnegative integer or null"); + if (session.conversationId !== undefined && typeof session.conversationId !== "string") + throw new Error("conversationId must be a host conversation identifier"); + if ( + session.visibleTools !== undefined && + (!Array.isArray(session.visibleTools) || + session.visibleTools.length > 256 || + session.visibleTools.some( + (name) => typeof name !== "string" || !/^[A-Za-z0-9_:.-]{1,256}$/.test(name), + )) + ) + throw new Error("visibleTools must contain only ChatCut tool names, at most 256"); + if ( + session.visibleTools && + session.visibleToolCount !== null && + session.visibleTools.length > session.visibleToolCount + ) + throw new Error("Tool name count exceeds the observed tool count"); + } + if (input.observations !== undefined) { + if (!Array.isArray(input.observations) || input.observations.length > 32) + throw new Error("Snapshot accepts at most 32 stage observations"); + for (const item of input.observations) { + keys(item, [ + "stage", + "operation", + "source", + "observedAt", + "outcome", + "requestId", + "httpStatus", + "protocolCode", + "message", + "originalAttemptMatch", + "lookupResult", + ]); + source(item.source); + if (item.lookupResult !== undefined && + (item.stage !== "host_registration" || item.operation !== "lookup_registered_server" || + !["cli_configuration", "host_settings"].includes(item.source) || + !["not_found", "found", "unavailable"].includes(item.lookupResult) || + (item.lookupResult === "not_found" && item.outcome === "unknown"))) + throw new Error("lookupResult is a host registration lookup result: not_found only for an explicit absence response; found or unavailable otherwise"); + if ( + !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || + !["success", "failed", "unknown"].includes(item.outcome) || + (item.observedAt !== null && !instant(item.observedAt)) + ) + throw new Error( + "Stage observation needs stage, outcome, and observedAt (ISO timestamp with timezone, or null when the original time is unknown)", + ); + for (const field of ["operation", "requestId", "message"]) + if (item[field] !== undefined && typeof item[field] !== "string") + throw new Error("Observation text fields must be strings"); + for (const field of ["httpStatus", "protocolCode"]) + if (item[field] !== undefined && !Number.isSafeInteger(item[field])) + throw new Error("Observation status/code must be an integer"); + if ( + item.originalAttemptMatch !== undefined && + !["confirmed", "unconfirmed"].includes(item.originalAttemptMatch) + ) + throw new Error( + "Original attempt match must be confirmed or unconfirmed; omit when unknown", + ); + if ( + item.originalAttemptMatch === "confirmed" && + (!item.requestId || + item.outcome !== "failed" || + !["current_session", "runtime_log"].includes(item.source)) + ) + throw new Error( + "Confirmed original request association requires its failed runtime/session observation and actual request ID", + ); + } + } + return input; +} + +export function logTime(line) { + const match = line.match(TIMESTAMP)?.[0]; + return match && Number.isFinite(Date.parse(match)) ? new Date(match).toISOString() : null; +} + +export function stageHint(message) { + if (/tools\/list|tool catalog|tool list/i.test(message)) return "tools_list"; + if (/initializ|handshake/i.test(message)) return "initialize"; + if (/refresh.{0,20}token|token.{0,20}refresh/i.test(message)) return "token_refresh"; + if (/token.{0,20}exchange|exchange.{0,20}token|\/mcp\/token/i.test(message)) + return "token_exchange"; + if (/registr|\/mcp\/register/i.test(message)) return "client_registration"; + if (/callback|authoriz|oauth/i.test(message) && !/metadata|discovery/i.test(message)) + return "authorization"; + if (/metadata|discovery/i.test(message)) return "discovery"; + return "unclassified"; +} + +export function evidenceSummary(report) { + const snapshot = report.hostSnapshot; + const observations = snapshot?.observations ?? []; + const gaps = []; + if (!snapshot) gaps.push("current_host_snapshot_unavailable"); + if (!snapshot?.configuration) gaps.push("registration_state_unavailable"); + if (!snapshot?.session || snapshot.session.visibleToolCount === null) + gaps.push("current_session_tools_unavailable"); + if (report.originalFailure.status !== "recorded") gaps.push("original_failure_unavailable"); + if (!report.originalFailure.occurredAt) gaps.push("original_failure_time_unavailable"); + const originalRequests = + report.originalFailure.status === "recorded" + ? observations.flatMap((item, observationIndex) => + item.requestId && + item.originalAttemptMatch === "confirmed" && + item.outcome === "failed" && + ["current_session", "runtime_log"].includes(item.source) + ? [{ requestId: item.requestId, source: item.source, observationIndex }] + : [], + ) + : []; + if (report.originalFailure.status === "recorded" && report.originalFailure.requestId) + originalRequests.push({ + requestId: report.originalFailure.requestId, + source: report.originalFailure.source, + }); + if (!originalRequests.length) gaps.push("original_request_id_unavailable"); + if (!snapshot?.pluginVersion) gaps.push("actual_loaded_plugin_version_unavailable"); + if (!report.hostLogs.entries.length) gaps.push("matching_host_runtime_logs_unavailable"); + const appNotices = report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log"); + const failedAppNotices = appNotices.filter( + (entry) => [...entry.message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed", + ); + if (appNotices.length === report.hostLogs.entries.length && !failedAppNotices.length) + gaps.push("matching_host_failure_logs_unavailable"); + const stages = STAGES.map((stage) => ({ + stage, + observations: observations.filter((item) => item.stage === stage), + status: observations.some((item) => item.stage === stage) + ? "observed; see individual attempts" + : "not_observed", + })); + const unknown = observations.filter((item) => !STAGES.includes(item.stage)); + const logHints = report.hostLogs.entries.map((entry, index) => ({ + entryIndex: index, + stage: stageHint(entry.message), + interpretation: "text_hint_only; attempt_match_unconfirmed", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), + })); + return { + stages, + otherObservations: unknown, + logHints, + originalRequestCorrelation: { + status: originalRequests.length ? "agent_reported_same_attempt" : "unconfirmed", + observations: originalRequests, + provenance: + "agent_supplied_confirmation; not_independently_verified; never_inferred_from_time_or_other_ids", + }, + originalLocalCall: report.originalFailure.callId + ? { + callId: report.originalFailure.callId, + conversationId: report.originalFailure.conversationId ?? null, + operation: report.originalFailure.operation ?? null, + provenance: + "original_failure_record; agent_supplied_not_independently_verified; local_call_id_is_not_server_request_id", + } + : null, + hostLogCoverage: { + records: report.hostLogs.entries.length, + appStartupRecords: appNotices.length, + failedAppStartupRecords: failedAppNotices.length, + originalToolFailureRecord: + report.originalFailure.source === "agent_supplied_host_tool_result", + qualification: + "startup notices are not tool-request failure evidence; log presence alone does not prove original failure capture", + }, + gaps, + independentChecks: + "entryProbe/probes/wireProbes/transportContext are later requests, never original stage completions; server observation is before Node framing, not proof of downstream wire validity", + recoveryVerified: false, + }; +} + +function publicUrl(value, localAllowed = false) { + try { + const url = new URL(value); + return ( + !url.username && + !url.password && + !url.hash && + (url.protocol === "https:" || + (localAllowed && + url.protocol === "http:" && + ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) + ); + } catch { + return false; + } +} + +export function inspectMetadata(document, url, expected = {}) { + const issues = []; + const result = { valid: false, issues, fields: {} }; + if (!object(document)) { + issues.push({ field: "document", reason: "expected_object" }); + return result; + } + for (const field of [ + "resource", + "authorization_servers", + "issuer", + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "grant_types_supported", + "response_types_supported", + "code_challenge_methods_supported", + "token_endpoint_auth_methods_supported", + ]) + if (document[field] !== undefined) result.fields[field] = document[field]; + const protectedResource = new URL(url).pathname.includes("oauth-protected-resource"); + const requireUrl = (field) => { + if (typeof document[field] !== "string" || !publicUrl(document[field], true)) + issues.push({ field, reason: "missing_or_invalid_public_url" }); + }; + if (protectedResource) { + requireUrl("resource"); + const suffix = new URL(url).pathname.split("/.well-known/oauth-protected-resource")[1]; + const resource = expected.resource ?? (suffix ? new URL(url).origin + suffix : undefined); + if (resource && document.resource !== resource) + issues.push({ + field: "resource", + reason: "resource_mismatch", + expected: resource, + actual: document.resource, + }); + if ( + !Array.isArray(document.authorization_servers) || + !document.authorization_servers.length || + document.authorization_servers.some((item) => !publicUrl(item, true)) + ) + issues.push({ + field: "authorization_servers", + reason: "missing_or_invalid_authorization_servers", + }); + } else { + requireUrl("issuer"); + requireUrl("authorization_endpoint"); + requireUrl("token_endpoint"); + if ( + Array.isArray(document.grant_types_supported) && + !document.grant_types_supported.includes("authorization_code") + ) + issues.push({ field: "grant_types_supported", reason: "authorization_code_not_supported" }); + if ( + !Array.isArray(document.response_types_supported) || + !document.response_types_supported.includes("code") + ) + issues.push({ field: "response_types_supported", reason: "code_response_not_advertised" }); + if (expected.issuer && document.issuer !== expected.issuer) + issues.push({ + field: "issuer", + reason: "issuer_mismatch", + expected: expected.issuer, + actual: document.issuer, + }); + if ( + !Array.isArray(document.code_challenge_methods_supported) || + !document.code_challenge_methods_supported.includes("S256") + ) + issues.push({ field: "code_challenge_methods_supported", reason: "S256_not_advertised" }); + } + result.valid = issues.length === 0; + return result; +} diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs new file mode 100644 index 0000000..ef9e338 --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs @@ -0,0 +1,83 @@ +const SOURCES = ["host_tool_result", "host_runtime_event", "captured_stderr"]; +const FIELDS = [ + "schemaVersion", + "source", + "serverName", + "occurredAt", + "conversationId", + "callId", + "operation", + "requestId", + "error", +]; + +export function validateFailureRecord(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + Object.keys(input).some((key) => !FIELDS.includes(key)) + ) + throw new Error( + "Use one dedicated failure record, without tool arguments, results or conversation content", + ); + if ( + input.schemaVersion !== 1 || + !SOURCES.includes(input.source) || + !["chatcut", "plugin:chatcut:chatcut"].includes(input.serverName) + ) + throw new Error( + "Failure record must identify the hosted ChatCut server and original error source", + ); + if ( + input.occurredAt !== null && + (typeof input.occurredAt !== "string" || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( + input.occurredAt, + ) || + !Number.isFinite(Date.parse(input.occurredAt))) + ) + throw new Error( + "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", + ); + for (const key of ["conversationId", "callId", "operation", "requestId"]) + if ( + input[key] !== undefined && + input[key] !== null && + (typeof input[key] !== "string" || !/^[A-Za-z0-9_.:/-]{1,256}$/.test(input[key])) + ) + throw new Error( + `Invalid original failure ${key}; never substitute a credential or a later probe ID`, + ); + if ( + !input.error || + typeof input.error !== "object" || + Array.isArray(input.error) || + typeof input.error.message !== "string" || + !input.error.message.trim() + ) + throw new Error( + "Failure record needs the original nonempty error.message; preserve its code, cause and stack when available", + ); + return input; +} + +export function failureFromRecord(input) { + const record = validateFailureRecord(input); + return { + evidenceKind: "original_failure", + status: "recorded", + source: `agent_supplied_${record.source}`, + occurredAt: record.occurredAt, + timeSource: record.occurredAt + ? "host_failure_record; agent_supplied_not_independently_verified" + : "unavailable", + message: record.error.message, + error: record.error, + ...(record.conversationId && { conversationId: record.conversationId }), + ...(record.callId && { callId: record.callId }), + ...(record.operation && { operation: record.operation }), + ...(record.requestId && { requestId: record.requestId }), + association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", + }; +} diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-wire.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-wire.mjs new file mode 100644 index 0000000..57079a5 --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-wire.mjs @@ -0,0 +1,296 @@ +import { createHash } from "node:crypto"; +import { connect, isIP } from "node:net"; +import { connect as connectTls } from "node:tls"; + +const LIMIT = 128 * 1024; +const HEADER_LIMIT = 16 * 1024; +const TIMEOUT_MS = 3500; +const HEADERS = new Set([ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "connection", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + "cache-control", + "age", + "x-cache", + "x-amz-cf-id", + "x-amz-cf-pop", +]); + +function target(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ( + (!local && + (!["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes(url.hostname) || + url.protocol !== "https:" || + (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/\.well-known\/(?:oauth-protected-resource|oauth-authorization-server|openid-configuration)(?:\/[a-zA-Z0-9/_-]*)?$/.test( + url.pathname, + ) + ) + throw new Error( + "Wire evidence is restricted to anonymous ChatCut metadata on the selected origin", + ); + return url; +} + +function framing(headers, body, ended, limited) { + const result = { + status: "incomplete", + firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, + bodyRepresentation: "after_transfer_decoding; content_encoding_not_decoded", + }; + const invalid = (reason) => ({ ...result, status: "invalid", reason }); + const incomplete = (reason) => ({ ...result, reason }); + const valid = (decoded) => ({ + ...result, + status: "valid", + bodyBytes: decoded.length, + bodySha256: createHash("sha256").update(decoded).digest("hex"), + }); + const transfer = headers["transfer-encoding"] + ?.toLowerCase() + .split(",") + .map((v) => v.trim()); + if (transfer?.at(-1) === "chunked") { + if (headers["content-length"] !== undefined) + return invalid("both_transfer_encoding_and_content_length"); + let offset = 0; + const parts = []; + while (offset < body.length) { + const end = body.indexOf("\r\n", offset); + if (end < 0) { + if (body.length > offset && !/[0-9a-f]/i.test(String.fromCharCode(body[offset]))) + return invalid("invalid_chunk_size"); + return ended && !limited + ? invalid("unterminated_chunk_size") + : incomplete("chunk_size_not_complete"); + } + const sizeText = body.subarray(offset, end).toString("latin1").split(";")[0]; + if (!/^[0-9a-f]{1,16}$/i.test(sizeText)) return invalid("invalid_chunk_size"); + if (offset === 0) result.firstChunkSizeHex = sizeText; + const size = Number.parseInt(sizeText, 16); + offset = end + 2; + if (!Number.isSafeInteger(size)) return incomplete("chunk_exceeds_capture_limit"); + if (size === 0) { + const trailerEnd = body.indexOf("\r\n\r\n", offset); + const messageEnd = body.subarray(offset, offset + 2).equals(Buffer.from("\r\n")) + ? offset + 2 + : trailerEnd >= 0 + ? trailerEnd + 4 + : null; + if (messageEnd !== null) { + if (messageEnd !== body.length) return invalid("bytes_after_terminal_chunk"); + return valid(Buffer.concat(parts)); + } + return ended && !limited + ? invalid("unterminated_trailers") + : incomplete("trailers_not_complete"); + } + if (offset + size + 2 > body.length) + return ended && !limited ? invalid("truncated_chunk") : incomplete("chunk_not_complete"); + if (!body.subarray(offset + size, offset + size + 2).equals(Buffer.from("\r\n"))) + return invalid("missing_chunk_terminator"); + parts.push(body.subarray(offset, offset + size)); + offset += size + 2; + } + return ended && !limited + ? invalid("missing_terminal_chunk") + : incomplete("terminal_chunk_not_observed"); + } + if (transfer) return incomplete("unsupported_transfer_encoding"); + if (headers["content-length"] !== undefined) { + if (!/^\d+$/.test(headers["content-length"])) return invalid("invalid_content_length"); + const expected = Number(headers["content-length"]); + if (body.length === expected) return valid(body); + if (body.length > expected) return invalid("content_length_mismatch"); + return ended && !limited ? invalid("content_length_mismatch") : incomplete("body_not_complete"); + } + return ended && !limited ? valid(body) : incomplete("close_delimited_body_not_complete"); +} + +function inspectResponse(captured, ended, limited) { + const headerEnd = captured.indexOf("\r\n\r\n"); + const result = { responseHeaders: {} }; + if (headerEnd < 0 || headerEnd > HEADER_LIMIT) { + result.headerGap = + captured.length > HEADER_LIMIT ? "header_limit" : "complete_headers_unavailable"; + return result; + } + const lines = captured.subarray(0, headerEnd).toString("latin1").split("\r\n"); + const status = lines.shift().match(/^HTTP\/(1\.[01]) ([1-5]\d{2}) [\t\x20-\x7e\x80-\xff]*$/); + result.httpVersion = status?.[1] ?? null; + result.status = status ? Number(status[2]) : null; + result.responseState = !status + ? "invalid_status_line" + : result.status < 200 + ? "unsupported_informational_response" + : "final_response"; + for (const line of lines) { + const colon = line.indexOf(":"); + if (colon < 0) continue; + const name = line.slice(0, colon).toLowerCase(); + if (HEADERS.has(name)) { + const field = line.slice(colon + 1).trim(); + result.responseHeaders[name] = + result.responseHeaders[name] === undefined + ? field + : result.responseHeaders[name] + "," + field; + } + } + result.stage = "http_framing"; + const body = captured.subarray(headerEnd + 4); + result.framing = + result.responseState === "final_response" + ? framing(result.responseHeaders, body, ended, limited) + : { + status: "not_evaluated", + reason: result.responseState, + firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, + }; + return result; +} + +export async function probeMetadataWire(value, diagnosticId, describeError) { + const url = target(value); + if (!/^[0-9a-f-]{36}$/i.test(diagnosticId)) throw new Error("Invalid diagnostic ID"); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: url.href, + startedAt: new Date().toISOString(), + implementation: "node:net/tls; HTTP/1.1", + route: "direct_connection; environment_proxy_not_used; host_and_fetch_route_not_reproduced", + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + acceptEncoding: "identity", + byteLimit: LIMIT, + stage: "connect", + }; + return new Promise((resolve) => { + let socket; + let done = false; + let bytes = 0; + let limited = false; + let ended = false; + let headerSeen = false; + const parts = []; + const finish = (error, completionReason) => { + if (done) return; + done = true; + clearTimeout(timer); + const captured = Buffer.concat(parts); + result.capturedBytes = captured.length; + result.captureLimited = limited; + Object.assign(result, inspectResponse(captured, ended, limited)); + result.completionReason = completionReason; + result.peerEndObserved = ended; + result.outcome = error + ? "failed" + : result.responseState === "invalid_status_line" + ? "invalid_response" + : result.responseState === "unsupported_informational_response" + ? "unsupported_response" + : result.framing?.status === "invalid" + ? "invalid_framing" + : !limited && result.framing?.status === "valid" + ? "response_observed" + : "incomplete"; + if (error) result.error = describeError(error); + result.durationMs = Date.now() - started; + socket?.destroy(); + resolve(result); + }; + const timer = setTimeout( + () => + finish( + Object.assign(new Error("Metadata wire probe timed out after 3500ms"), { + code: "DIAGNOSTIC_WIRE_TIMEOUT", + }), + "timeout", + ), + TIMEOUT_MS, + ); + try { + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + const options = { + host: hostname, + port: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + }; + const send = () => { + result.stage = "response"; + result.peer = { address: socket.remoteAddress, port: socket.remotePort }; + if (url.protocol === "https:") { + const certificate = socket.getPeerCertificate(); + result.tls = { + authorized: socket.authorized, + protocol: socket.getProtocol(), + certificate: { issuer: certificate.issuer, fingerprint256: certificate.fingerprint256 }, + }; + } + socket.write( + `GET ${url.pathname} HTTP/1.1\r\nHost: ${url.host}\r\nAccept: application/json\r\nAccept-Encoding: identity\r\nConnection: close\r\nX-ChatCut-Diagnostic-ID: ${diagnosticId}\r\n\r\n`, + ); + }; + socket = + url.protocol === "https:" + ? connectTls( + { + ...options, + servername: isIP(hostname) ? undefined : hostname, + rejectUnauthorized: true, + ALPNProtocols: ["http/1.1"], + }, + send, + ) + : connect(options, send); + socket.on("data", (part) => { + if (done) return; + const remaining = LIMIT - bytes; + parts.push(part.subarray(0, Math.max(0, remaining))); + bytes += Math.min(part.length, remaining); + if (part.length > remaining) { + limited = true; + finish(undefined, "capture_limit"); + return; + } + if (!headerSeen) { + const captured = Buffer.concat(parts); + const headerEnd = captured.indexOf("\r\n\r\n"); + headerSeen = headerEnd >= 0; + if ((!headerSeen && bytes > HEADER_LIMIT) || headerEnd > HEADER_LIMIT) { + limited = true; + finish(undefined, "header_limit"); + return; + } + } + if (headerSeen) { + const observed = inspectResponse(Buffer.concat(parts), false, false); + if (observed.responseState !== "final_response") + finish(undefined, "invalid_or_unsupported_response"); + else if (observed.framing.status === "valid") finish(undefined, "message_boundary"); + else if (observed.framing.status === "invalid") finish(undefined, "invalid_framing"); + } + }); + socket.once("end", () => { + ended = true; + finish(undefined, "peer_end"); + }); + socket.once("error", (error) => finish(error, "transport_error")); + socket.once("close", () => finish(undefined, "socket_close")); + } catch (error) { + finish(error, "collector_error"); + } + }); +} diff --git a/claude/skills/chatcut-plugin-basics-claude/SKILL.md b/claude/skills/chatcut-plugin-basics-claude/SKILL.md index 89fa6ff..62655de 100644 --- a/claude/skills/chatcut-plugin-basics-claude/SKILL.md +++ b/claude/skills/chatcut-plugin-basics-claude/SKILL.md @@ -26,9 +26,9 @@ ChatCut tools are served through the configured Hosted MCP server: `chatcut` for If the tools are available and authenticated, continue normal work without reinstalling or starting a new session. Otherwise, check registration with `claude mcp get chatcut` and `claude mcp get plugin:chatcut:chatcut` (or inspect `/mcp` when the CLI is unavailable). If either exists, use that server and do not create a duplicate. Follow the matching path: -When an existing connection or OAuth attempt reports an error, or tools remain absent after successful authentication, preserve that evidence and follow [connection diagnostics](references/connection-diagnostics.md) before retrying login or changing anything. An initial sign-in requirement by itself is expected, not a confirmed connection defect. The diagnostic helper collects evidence independently of the broken MCP connection; it does not require ChatCut Desktop. +When an existing connection or OAuth attempt reports an error, or tools remain absent after successful authentication, preserve that evidence and load the independent [ChatCut MCP Doctor](../chatcut-mcp-doctor/SKILL.md) before retrying login or changing anything. An initial sign-in requirement by itself is expected, not a confirmed connection defect. The diagnostic helper collects evidence independently of the broken MCP connection; it does not require ChatCut Desktop. -Also use connection diagnostics when previously available tools disappear or the host reports an unfamiliar MCP/protocol error. Capture the current-host snapshot described in that reference; preserve unknown errors and stages. +Also use ChatCut MCP Doctor when previously available tools disappear or the host reports an unfamiliar MCP/protocol error. Capture the current-host snapshot described in that reference; preserve unknown errors and stages. For a failed hosted ChatCut tool request after connection, immediately preserve the original tool error and any available failure time, conversation ID and local call ID in the dedicated failure record described there. The Agent gathers these facts; do not ask the user to manually reconstruct them. Startup-status notices and later successful checks do not establish what happened to that tool request. diff --git a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md index c133f82..7ca1dc0 100644 --- a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md +++ b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md @@ -1,299 +1,3 @@ -# Hosted MCP connection diagnostics +# Hosted MCP Doctor -Use this branch when a registered hosted ChatCut connection fails, including -OAuth failing before a browser opens, or authentication completing without usable -tools, and failures of already available hosted tools. Preserve evidence before reconnecting or restarting. This version gathers -evidence; it does not repair configuration, credentials, network settings, or the -original connection. - -Briefly tell the user that you will collect ChatCut connection diagnostics and -send the redacted report to ChatCut. Run under the host's existing execution and -network permissions; do not add a separate manual step when those permit it. -Honor a refusal or a denied read/network operation. Do not change permission -settings to make collection work. - -Invoke each helper as a standalone `node` command with its quoted path. Do not -combine it with `cd`, shell variables, pipelines, or other commands; command -allowlists may permit the helper while rejecting the combined shell operation. -Use the host's file-writing tool for the dedicated snapshot and error file. - -## Capture the current host, including unfamiliar failures - -The Agent creates a dedicated `.mcp-snapshot.json` from facts already available -through the current host. The user does not fill a form. Do not copy an entire -configuration, credential file, environment, or conversation. Preserve unfamiliar -errors as well as known OAuth errors; classify uncertain stages as unknown. -Static CLI configuration describes registration, not tools loaded into the -current chat. Browser sign-in does not establish MCP authentication. - -First generate a new snapshot template with the bundled helper, then add only -observed groups from the schema below. This sets the snapshot schema and capture -time mechanically; it does not invent the original failure time or host state: - -```text -node /scripts/mcp-doctor.mjs --create-snapshot -``` - -Validate the completed file locally before collecting or uploading: - -```text -node /scripts/mcp-doctor.mjs --validate-snapshot --server-url -``` - -Snapshot `schemaVersion` is **1**, independently of the outer report version. -Keep unavailable groups omitted. Each observation's `observedAt` may be `null` -when its actual time is unknown. If validation fails, correct only the reported -schema error using already available facts; do not guess times or relax the -credential/config file restriction. If facts remain unavailable, collect the -other evidence and preserve the specific gap. - -Snapshot schema 1 accepts this shape; omit unavailable groups, use null for an -unknown tool count/enabled flag, and label each group's actual source: - -```json -{ - "schemaVersion": 1, - "observedAt": "2026-10-22T12:00:00Z", - "hostVersion": "actual-running-host-version", - "pluginVersion": "actual-loaded-plugin-version-if-known", - "configuration": { - "source": "host_settings", - "serverName": "chatcut", - "url": "https://api.chatcut.io/api/external-mcp/mcp", - "enabled": true - }, - "session": { - "source": "current_session", - "conversationId": "host-chat-id-if-available", - "connectionStatus": "failed", - "authStatus": "unknown", - "visibleToolCount": 0, - "visibleTools": [] - }, - "observations": [ - { - "stage": "initialize", - "operation": "initialize", - "source": "runtime_log", - "observedAt": "2026-10-22T11:59:00Z", - "outcome": "failed", - "requestId": "server-request-id-only-if-recorded", - "message": "Exact original error, including an unfamiliar error code" - } - ] -} -``` - -Sources: `current_session`, `tool_search`, `host_settings`, -`cli_configuration`, `runtime_log`. A session group cannot claim -`cli_configuration` as its source. Connection status is `connected`, `failed`, -`needs_authentication`, `disabled`, or `unknown`; auth status is -`authenticated`, `needs_authentication`, or `unknown`. -Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` -(`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, -`protocolCode`, `message`, and `originalAttemptMatch` (`confirmed`/`unconfirmed`). -Omit the match flag when unknown. Set `confirmed` only when the captured failed -runtime/session observation explicitly identifies the original failed attempt -and its actual server request ID; registration, nearby timestamps, successful -operations, and later probe IDs are not confirmation. An original error must -also have been recorded for that observation to close the original-request gap. -This remains Agent-supplied confirmation, not independent helper verification. -New stage names are preserved. Do not put an -MCP session credential in `conversationId` or guess a server request ID. -Only include ChatCut tool names; keep tool arguments and results out. - -## Run once for the reported failure - -### Preserve the failed call before independent checks - -When the host exposes the original failed tool result or runtime event, the Agent -copies only its error and available identifiers into a new dedicated -`.mcp-failure.json`. Do this before retries, reconnects or later probes. -Do not read a conversation transcript or collect tool arguments/results to build -it. Use the host's existing error metadata and supported read-only runtime logs; -the user does not fill this record. Keep the full original error message, code, -cause and stack when available, including unfamiliar failures. - -```json -{ - "schemaVersion": 1, - "source": "host_tool_result", - "serverName": "chatcut", - "occurredAt": null, - "conversationId": null, - "callId": null, - "operation": "list_projects", - "requestId": null, - "error": { "message": "Exact original error; add original code/cause/stack when available" } -} -``` - -Supported sources are `host_tool_result`, `host_runtime_event`, and -`captured_stderr`. `occurredAt` is the failed event's actual timestamp with -timezone, when exposed; never substitute the current time, file modification -time, snapshot capture or later probe time. Omit unknown optional IDs or leave -them null. A local `callId` and `conversationId` identify client-side work and -are useful even if the request never reaches ChatCut; they are not server -request IDs. Copy a `requestId` only when the original failed call actually -provides it. All supplied facts remain labelled as Agent-supplied evidence. - -Prefer `--failure-record ` to `--original-error` when -this structured source is available; choose one input. The collector centers -its bounded log window on the supplied failure time and uses its conversation -ID when available. A missing time remains an explicit gap. For an exec caller, -use already retained stderr; discarded stderr cannot be recovered by this Skill. -Do not start another authenticated session to manufacture replacement evidence. - -If only the original error text is accessible, use the text-file fallback below. -Do not call a report with only startup-status notices a captured tool failure. - -1. Obtain the existing server's registered URL through the host. Preserve its - environment. The helper accepts ChatCut production/beta/dev endpoints and - loopback development servers; it reports unsupported endpoints rather than - silently probing production. If no registered URL is available, omit - `--server-url` to collect a local report without probing or uploading. - Never read credential stores or paste complete - configuration, environment, or conversation dumps into the report. - An intentionally disabled hosted server is not a failed connection; preserve - that choice instead of starting diagnostics or re-enabling it. -2. Save only the original connection error and its available cause chain into a - new temporary text file. Use the exact original text, not a generated root - cause. If unavailable, omit `--original-error`. Obtain the actual bundled - host version if accessible without starting a session. Do not treat update - metadata as the running version. -3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: - - ```text - node /scripts/mcp-doctor.mjs --host codex --server-url --host-snapshot --original-error --host-version --upload - ``` - - For Claude Code, use `--host claude`. Quote paths as shell arguments; do not - interpolate error text into shell code. Omit unknown optional fields. Supply - `--occurred-at` only when the failure's actual ISO timestamp and timezone are - known. A missing timestamp remains unknown; the collection time is separate. - If transfer is denied, omit `--upload` and retain the local JSON report. - -Use an available Node.js 18+ runtime. Do not install a runtime or ChatCut Desktop -just for diagnostics. Codex SQLite extraction additionally needs `node:sqlite` -(available on recent Node 22+/24 runtimes). Without it, other collection still -runs and the report marks the missing SQLite evidence. A denied file read is a -coverage gap, not proof that the server has no errors. - -The helper reads only bounded ChatCut MCP runtime log records or debug log tails. -Codex SQLite locations follow `CODEX_SQLITE_HOME` when present. If the current -host already supplies its actual `sqlite_home` or `log_dir`, pass that directory -with `--codex-sqlite-home` or `--codex-log-dir`; do not read whole config files to -find it. These options select existing log locations and do not enable logging. -On Windows, SQLite error 14 can coexist with readable WAL files in a read-only -sandbox. The report preserves the original error and bounded file-access facts; -it does not prove a specific security product caused the restriction. Never copy -the whole log database, ignore its WAL with immutable mode, or relax permissions. -On Windows, the helper also reads the existing Codex App startup-status notices -under `%LOCALAPPDATA%/Codex/Logs/YYYY/MM/DD`. This is independent of TUI logging. -It selects only `AppServerConnection` startup notices for the hosted ChatCut -server, uses the current conversation ID when available, and keeps at most 30 -records from three 128 KiB tails. Missing/rotated/denied logs remain explicit -gaps. The known Windows MXC `LOCALAPPDATA` sandbox redirect falls back to the -real user's `AppData/Local` without changing permissions. An already supplied alternative App log root can be selected with -`--codex-app-log-dir`; do not scan browser profiles or session transcripts. - -The report names selected/omitted native files and byte ranges that were not read. -For an existing reported gap, use `--codex-app-log-file ` -to select at most three known native files. With exactly one selected file, -`--codex-app-log-offset ` reads at most 128 KiB from -that segment; unread ranges remain explicit. Preserve the original failure's -known time/window. Do not repeatedly upload replacement reports or scan entire -log history to fill an unknown failure time. App startup notices cover startup; -they do not cover every later tool-call failure. SQLite/native log extraction -retains available local conversation/call IDs without promoting them to original -server request IDs or independently confirmed attempt associations. -For a separately chosen future reproduction, Codex's explicit `log_dir` setting -can enable its native plaintext log. Do not change global settings or relaunch -the user's host automatically. Existing logs cannot be reconstructed retroactively. -It never reads `auth.json`, OAuth credential stores, session transcripts, or -other project files. It makes one unauthenticated initialize probe and at most -five public metadata GETs, all within the selected origin and with a 3.5-second -timeout each. An anonymous 401 challenge is expected. It validates resource / -issuer consistency and this product's OAuth-code/PKCE metadata. It never logs in, -refreshes credentials, or lists tools using extracted credentials. - -Codex exec prints diagnostics inline; `--json` stdout alone is not a complete -MCP error stream. Preserve the original stderr/startup error when it is already -visible to the current host. The helper cannot recover discarded parent-process -stderr or reconstruct an old exec failure from App logs. - -If the current host already exposes native `mcpServer/startupStatus/updated` -notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, -`runtimeStatus` and `toolsError` from that same thread in the error file and -snapshot observations (`source: current_session`). Record the native method and -actual thread ID. Authentication inventory alone is not proof of initialization -or usable tools. The status query may perform new auth-discovery requests; keep -those separate from retained runtime errors. Do not start another app-server, -start/reload a thread, invoke OAuth, or launch a daemon to obtain the original -state. These interfaces are not guaranteed to be exposed to a third-party Skill. -`codex doctor --json` is a general installation/config/auth health check, not a -targeted export of the failed MCP attempt. Do not ingest its entire report. -`feedback/upload` submits to OpenAI; it is not ChatCut's log export endpoint. - -The CLI also makes at most two sequential anonymous metadata GETs to compare -the relevant path-scoped and root endpoints at the HTTP/1.1 framing level. -`wireProbes` records selected response headers, request ID, first framing byte, -chunk validity, bounded byte counts/hashes and peer/TLS evidence from that same -connection. It retains no metadata body, cookies or credentials. Each probe -stops at 128 KiB / 3.5 seconds. These are direct connections, explicitly outside -the host/fetch proxy route; normal certificate verification remains enabled. -Complete fixed-length/chunked messages finish at their verified boundary without -waiting for peer close. Invalid HTTP status lines and unsupported informational -responses never count as complete final responses. `completionReason` and -`peerEndObserved` distinguish message completion from connection termination. - -When an HTTPS request/body fails, one additional direct TLS connection may -record the peer certificate issuer and protocol, with the same timeout and -normal certificate verification. This is a separately labelled route, not the -original host's TLS/proxy path. Proxy/CA environment presence is boolean only. -A healthy later check cannot establish recovery or identify antivirus/VPN as -the cause. A disabled connection sends neither probes nor an upload. - -## Interpret the report - -- `originalFailure` is the original error supplied by the Agent. `hostLogs` - are matching records in a bounded time window, with unconfirmed attempt - association. `probes` are new requests made later. Keep these separate. -- `hostSnapshot` is Agent-supplied evidence with per-group sources, not an - independently verified host API result. `evidenceSummary` keeps original - stage observations separate from independent checks. Unknown stages and - unfamiliar errors remain available; missing stages are `not_observed`. -- Timestamped text records outside the requested window are excluded. Records - without parseable timestamps are labelled time-unknown, never window matches. -- Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. - A healthy later probe never proves the original host connection recovered. -- Preserve original error messages and codes. Redact actual credentials, not - entire error categories. No separate metadata response body is retained; - original parser errors may quote response text. -- Missing debug logs cannot be reconstructed. Claude's detailed debug logs may - require `claude --debug=mcp` during a future, separately chosen reproduction; - this helper does not enable logging or relaunch Claude. -- A `diagnosticId` identifies the diagnostic report and its new probes. It is - not the original failed request's ID. Server response request IDs are useful - only when actually present and matched; time/path matching is weaker evidence. -- Match a wire response's `x-request-id` with the server's metadata response - record. The application record observes bytes before Node's HTTP framing; - a successful finish does not prove valid framing after Node/ALB/local HTTPS - inspection, nor receipt by Codex. Different requests may have different paths. - -The local JSON is written before any upload. `ingest_accepted` means that the -endpoint acknowledged the batch; `storageVerified:false` remains until support -finds every fragment in the final log store. Failed uploads preserve the local -report and upload error; do not automatically repeat the run or claim success. -Output larger than the upload budget stays local with an explicit upload error. - -Return the diagnostic ID, local report location, one new observed fact, and the -next action justified by it. If no new actionable fact was obtained, state the -specific missing evidence. Do not assign blame to antivirus, VPN, firewall, host, -or service merely from a generic failure message. Do not claim a user failure -percentage from diagnostic samples. - -This branch runs when the Agent loads it and has access to local execution. It -is not an installation hook or an all-user failure monitor. It cannot inspect a -web host's remote connection process or internal logs. With Claude Code tool -search disabled, connection failures may not be passed to the Agent at all. +For a hosted ChatCut connection or tool failure, load the independent [ChatCut MCP Doctor Skill](../../chatcut-mcp-doctor/SKILL.md). It runs locally even when the ChatCut MCP is unavailable. Preserve the original error before retries. The former scripts/mcp-doctor.mjs entry remains compatible. diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs index 513ba4a..0e45dfe 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -1,1325 +1,6 @@ -import { randomUUID, createHash } from "node:crypto"; -import { open, opendir, readFile, readdir, stat, writeFile } from "node:fs/promises"; -import { homedir, tmpdir, platform, arch } from "node:os"; -import { join, resolve, basename } from "node:path"; +// Compatibility entry; the standalone Doctor owns the collector. +export * from "../../chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { parseArgs } from "node:util"; -import { connect as connectTls } from "node:tls"; -import { isIP } from "node:net"; -import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; -import { probeMetadataWire } from "./mcp-wire.mjs"; -import { failureFromRecord } from "./mcp-failure.mjs"; - -const READ_LIMIT = 128 * 1024; -const UPLOAD_LIMIT = 256 * 1024; -const TIMEOUT_MS = 3500; -const CHATCUT_MESSAGE = - /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|\bplugin(?:_name)?[=: "\t]+chatcut(?:@chatcut-inc)?(?:["\s}:]|$)|https:\/\/(?:api\.chatcut\.(?:io|dev)|api-dev\.chatcut\.org)\//i; -const TEXT_ECHO = - /^(?:user (?:prompt|message)|assistant message|tool (?:call|result))\s*:|^(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[]/i; - -function redactFieldValue(_match, prefix, value) { - if (!value) return prefix; - const quote = value.match(/^\\*["']/)?.[0]; - return prefix + (quote ? quote + "[REDACTED]" + quote : "[REDACTED]"); -} - -export function redact(text) { - return String(text) - .replace( - /\bBearer\s+(?!(?:token|required)\b|<|(?:realm|scope|error(?:_description|_uri)?|resource_metadata)\s*=)[A-Za-z0-9._~+/=-]+/gi, - "Bearer [REDACTED]", - ) - .replace(/\b(Authorization\s*[:=]\s*Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[REDACTED]") - .replace( - /\b((?:set-)?cookie\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|\[REDACTED\]|(?:(?![\r\n]|(?]+/gi, - "$1[REDACTED]", - ) - .replace( - /\b((?:access_?token|refresh_?token|id_?token|session_?token|client_?secret|api[_-]?key|token)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|[A-Za-z0-9._~+/=-]+)/gi, - redactFieldValue, - ) - .replace( - /((?:\\*["'])(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, - "$1[REDACTED]", - ) - .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"); -} - -function clean(value) { - return JSON.parse( - JSON.stringify(value, (key, item) => - /^(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( - key, - ) - ? "[REDACTED]" - : typeof item === "string" - ? redact(item) - : item, - ), - ); -} - -export function errorDetails(error, seen = new Set()) { - if (!error || typeof error !== "object") return { message: redact(error) }; - if (seen.has(error) || seen.size >= 8) return { unavailable: "error_chain_depth_or_cycle" }; - seen.add(error); - const result = {}; - for (const key of ["name", "message", "code", "errno", "errcode", "errstr", "syscall", "stack"]) { - if (typeof error[key] === "string" || typeof error[key] === "number") result[key] = error[key]; - } - if (error.cause) result.cause = errorDetails(error.cause, seen); - if (Array.isArray(error.errors)) { - result.errors = error.errors.slice(0, 8).map((item) => errorDetails(item, seen)); - if (error.errors.length > 8) result.omittedErrors = error.errors.length - 8; - } - return clean(result); -} - -export function serverUrl(value) { - const url = new URL(value); - const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); - const hosted = ["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes( - url.hostname, - ); - if ( - (!local && (!hosted || url.protocol !== "https:" || (url.port && url.port !== "443"))) || - (local && !["http:", "https:"].includes(url.protocol)) || - url.username || - url.password || - url.search || - url.hash || - !/^\/api\/external-mcp\/(?:mcp|claude|v2\/mcp)\/?$/.test(url.pathname) - ) { - throw new Error( - "Unsupported registered MCP URL; preserve its configuration and report unsupported_endpoint", - ); - } - return url; -} - -async function limitedBody(response, limit = READ_LIMIT) { - const reader = response.body?.getReader(); - if (!reader) return ""; - const parts = []; - let bytes = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) return Buffer.concat(parts).toString("utf8"); - bytes += value.length; - if (bytes > limit) { - const error = new Error(`Response exceeds ${limit} bytes; body not retained`); - error.code = "DIAGNOSTIC_BODY_LIMIT"; - throw error; - } - parts.push(Buffer.from(value)); - } - } finally { - await reader.cancel().catch(() => {}); - reader.releaseLock(); - } -} - -export async function probeMetadata(url, diagnosticId, fetchImpl = fetch, expected = {}) { - const startedAt = new Date().toISOString(); - const started = Date.now(); - const result = { - evidenceKind: "later_probe", - url, - startedAt, - implementation: `node-fetch/${process.version}`, - stage: "request", - status: null, - bodyComplete: false, - jsonValid: null, - originalAttemptMatch: "unconfirmed", - }; - try { - const response = await fetchImpl(url, { - redirect: "manual", - signal: AbortSignal.timeout(TIMEOUT_MS), - headers: { accept: "application/json", "x-chatcut-diagnostic-id": diagnosticId }, - }); - result.status = response.status; - result.responseHeaders = {}; - for (const key of [ - "content-type", - "content-length", - "content-encoding", - "transfer-encoding", - "server", - "via", - "x-request-id", - "x-amzn-trace-id", - "www-authenticate", - "retry-after", - "location", - ]) { - const value = response.headers.get(key); - if (value !== null) result.responseHeaders[key] = value; - } - result.stage = "response_body"; - const body = await limitedBody(response); - result.bodyComplete = true; - result.bodyBytes = Buffer.byteLength(body); - result.bodySha256 = createHash("sha256").update(body).digest("hex"); - result.stage = "json_parse"; - try { - const parsed = JSON.parse(body); - result.jsonValid = true; - result.metadataShape = { - object: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed), - issuerPresent: typeof parsed?.issuer === "string", - resourcePresent: typeof parsed?.resource === "string", - }; - result.metadataValidation = inspectMetadata(parsed, url, expected); - } catch (error) { - result.jsonValid = false; - throw error; - } - result.stage = "complete"; - const required = new URL(url).pathname.includes("oauth-protected-resource") - ? "resourcePresent" - : "issuerPresent"; - result.outcome = !response.ok - ? "http_rejected" - : result.metadataShape.object && result.metadataShape[required] - ? result.metadataValidation.valid - ? "metadata_read" - : "invalid_metadata" - : "invalid_metadata_shape"; - } catch (error) { - result.outcome = "failed"; - result.error = errorDetails(error); - } - result.durationMs = Date.now() - started; - return clean(result); -} - -async function guardProbe(url, operation) { - const startedAt = new Date().toISOString(); - try { - return await operation(); - } catch (error) { - return { - evidenceKind: "later_probe", - url, - startedAt, - outcome: "failed", - stage: "probe_collector", - status: null, - originalAttemptMatch: "unconfirmed", - error: errorDetails(error), - gap: "partial_probe_evidence_unavailable; original_failure_retained", - }; - } -} - -export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { - const started = Date.now(); - const result = { - evidenceKind: "later_probe", - url: endpoint.href, - method: "POST", - operation: "initialize", - implementation: "node-fetch/" + process.version, - startedAt: new Date().toISOString(), - stage: "request", - status: null, - originalAttemptMatch: "unconfirmed", - credentialsSent: false, - }; - try { - const response = await fetchImpl(endpoint.href, { - method: "POST", - redirect: "manual", - signal: AbortSignal.timeout(TIMEOUT_MS), - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - "x-chatcut-diagnostic-id": diagnosticId, - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: diagnosticId, - method: "initialize", - params: { - protocolVersion: "2025-11-25", - capabilities: {}, - clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.3" }, - }, - }), - }); - result.status = response.status; - result.responseHeaders = {}; - for (const name of [ - "content-type", - "content-length", - "content-encoding", - "transfer-encoding", - "www-authenticate", - "retry-after", - "location", - "x-request-id", - "x-amzn-trace-id", - "server", - "via", - ]) { - const value = response.headers.get(name); - if (value !== null) result.responseHeaders[name] = value; - } - result.stage = "response_body"; - const body = await limitedBody(response); - result.bodyComplete = true; - result.bodyBytes = Buffer.byteLength(body); - result.bodySha256 = createHash("sha256").update(body).digest("hex"); - let parsed; - try { - parsed = JSON.parse(body); - } catch (error) { - result.parseError = errorDetails(error); - if (!response.ok) result.responseError = { message: redact(body) }; - } - if (parsed?.error) result.protocolError = clean(parsed.error); - if (response.status === 401) { - const header = response.headers.get("www-authenticate") ?? ""; - const metadataHint = header.match(/resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i); - result.resourceMetadataUrl = metadataHint?.[1] ?? metadataHint?.[2] ?? null; - result.outcome = /\bBearer\b/i.test(header) - ? "authentication_challenge" - : "missing_authentication_challenge"; - } else if (!response.ok) result.outcome = "http_rejected"; - else if (parsed?.error) result.outcome = "protocol_error"; - else if (parsed?.jsonrpc === "2.0" && typeof parsed.result?.protocolVersion === "string") { - result.outcome = "anonymous_initialize_returned"; - result.serverInfo = parsed.result.serverInfo; - result.protocolVersion = parsed.result.protocolVersion; - } else result.outcome = "invalid_initialize_response"; - result.stage = "complete"; - } catch (error) { - result.outcome = "failed"; - result.error = errorDetails(error); - } - result.durationMs = Date.now() - started; - return clean(result); -} - -export function inspectTransport(endpoint) { - if (endpoint.protocol !== "https:") - return Promise.resolve({ status: "not_applicable", reason: "not_tls" }); - return new Promise((resolveProbe) => { - const startedAt = new Date().toISOString(); - let settled = false; - const host = endpoint.hostname.replace(/^\[|\]$/g, ""); - const socket = connectTls({ - host, - port: Number(endpoint.port || 443), - rejectUnauthorized: true, - servername: isIP(host) ? undefined : host, - }); - const finish = (details) => { - if (settled) return; - settled = true; - clearTimeout(timer); - socket.destroy(); - resolveProbe( - clean({ - evidenceKind: "later_probe", - implementation: "node:tls", - startedAt, - route: "direct_tls; does_not_reproduce_host_or_fetch_proxy_route", - originalAttemptMatch: "unconfirmed", - ...details, - }), - ); - }; - const timer = setTimeout( - () => - finish({ - status: "failed", - error: { - name: "TimeoutError", - message: "Direct TLS context timed out", - code: "ETIMEDOUT", - }, - }), - TIMEOUT_MS, - ); - socket.once("secureConnect", () => { - const certificate = socket.getPeerCertificate(); - finish({ - status: "observed", - tlsProtocol: socket.getProtocol(), - authorized: socket.authorized, - certificate: { - issuer: certificate.issuer, - validFrom: certificate.valid_from, - validTo: certificate.valid_to, - fingerprint256: certificate.fingerprint256, - }, - }); - }); - socket.once("error", (error) => finish({ status: "failed", error: errorDetails(error) })); - }); -} - -function logMessage(body) { - const marker = body.lastIndexOf("}:"); - return (marker < 0 ? body : body.slice(marker + 2)).trim(); -} - -function runtimeContext(body) { - const boundary = body.lastIndexOf("}:"); - if (boundary < 0) return {}; - const context = body.slice(0, boundary); - const value = (field) => - [ - ...context.matchAll( - new RegExp(`(?:^|[\\s{])${field}="?([A-Za-z0-9_.-]{1,256})(?:"|[\\s}:]|$)`, "g"), - ), - ].at(-1)?.[1]; - const conversationId = value("thread_id") ?? value("thread\\.id") ?? value("conversation\\.id"); - const callId = value("call_id"); - return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; -} - -function hostedTextRecord(line) { - if (/^\s/.test(line)) return false; - let body = line.replace( - /^\d{4}-\d{2}-\d{2}T\S+\s+(?:(?:\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\]|TRACE|DEBUG|INFO|WARN|ERROR)\s+)?/i, - "", - ); - const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); - if (module) { - if ( - !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::)/.test(module[1]) - ) - return false; - body = body.slice(module[0].length); - } - if ( - /^(?:user(?: prompt| message)?|assistant(?: message)?|system|developer|transcript)\s*:|^[{[]/i.test( - body, - ) - ) - return false; - const scope = body.match( - /^(?:MCP server|Server)\s+(?:"([^"]+)"|'([^']+)'|((?:plugin:[\w.-]+:)?[\w.-]+))(?::\s*|\s+|$)/i, - ); - const context = body.split(/\berror\s*=/i)[0]; - const server = - context.match(/\bserver_name=["']?([\w:.-]+)/)?.[1] ?? scope?.[1] ?? scope?.[2] ?? scope?.[3]; - if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; - if (!CHATCUT_MESSAGE.test(body)) return false; - if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) return false; - const message = scope ? body.slice(scope[0].length) : body; - // Runtime error contents can name tools; only explicit echo records are excluded. - if (TEXT_ECHO.test(message)) return false; - if ( - /^(?:Sending|Sent|Received|Receiving)\s+(?:MCP\s+|JSON-RPC\s+)?(?:request|response|message)\b/i.test( - message, - ) && - /"method"\s*:\s*"tools\/call"|"result"\s*:\s*\{\s*"content"\s*:/i.test(message) - ) - return false; - if (/^[{[]/.test(message)) { - try { - const value = JSON.parse(message); - const records = Array.isArray(value) ? value : [value]; - if ( - records.some( - (record) => - record && - (record.method === "tools/call" || - [ - "function_call", - "function_call_output", - "tool_call", - "tool_result", - "user", - "assistant", - "user_message", - "assistant_message", - ].includes(record.type) || - ["user", "assistant", "system", "developer"].includes(record.role) || - Array.isArray(record.result?.content)), - ) - ) - return false; - } catch { - // Unparseable diagnostic text is not proof of a tool or conversation envelope. - } - } - return true; -} - -async function textLog(path, source, gaps, window, select = hostedTextRecord) { - const handle = await open(path, "r"); - try { - const info = await handle.stat(); - const start = window.readOffset ?? Math.max(0, info.size - READ_LIMIT); - if (!Number.isSafeInteger(start) || start < 0 || start > info.size) - throw new Error("Log segment offset must point inside the selected existing file"); - const buffer = Buffer.alloc(Math.min(info.size - start, READ_LIMIT)); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); - const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); - if (start) { - const previous = Buffer.alloc(1); - await handle.read(previous, 0, 1, start - 1); - if (previous[0] !== 10) lines.shift(); - gaps.push({ - source, - reason: "only_log_tail_read", - sourceFile: basename(path), - bytesBeforeWindow: start, - omittedBytes: start, - }); - } - if (start + bytesRead < info.size) { - if (!buffer.subarray(0, bytesRead).toString("utf8").endsWith("\n")) lines.pop(); - gaps.push({ - source, - reason: "only_log_segment_read", - sourceFile: basename(path), - offset: start, - bytesRead, - remainingBytes: info.size - start - bytesRead, - }); - } - const entries = []; - for (let i = 0; i < lines.length; i++) { - if (!select(lines[i])) continue; - const block = [lines[i]]; - while ( - source !== "codex_app_log" && - i + 1 < lines.length && - /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1]) && - !/^\s*(?:\d{4}-\d{2}-\d{2}T|MCP server\s|Server\s|\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\])/i.test( - lines[i + 1], - ) && - !TEXT_ECHO.test(lines[i + 1].trimStart()) - ) - block.push(lines[++i]); - const occurredAt = logTime(block[0]); - if ( - occurredAt && - (Date.parse(occurredAt) < window.since * 1000 || - Date.parse(occurredAt) > window.until * 1000) - ) - continue; - entries.push({ - source, - sourceFile: basename(path), - occurredAt, - timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", - message: redact(block.join("\n")), - ...runtimeContext(block[0]), - originalAttemptMatch: "unconfirmed", - }); - } - if (entries.length > 30) - gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); - const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; - if (unknownTimes) - gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); - return entries.slice(-30); - } finally { - await handle.close(); - } -} - -async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { - if ( - readOffset !== undefined && - (selectedFiles.length !== 1 || !Number.isSafeInteger(readOffset) || readOffset < 0) - ) - throw new Error( - "A segment offset requires exactly one selected native App log file and a nonnegative integer", - ); - const files = []; - const dates = new Set(); - for (const time of [window.since * 1000, window.until * 1000]) { - const date = new Date(time); - dates.add(date.toISOString().slice(0, 10).replaceAll("-", "/")); - dates.add( - [date.getFullYear(), date.getMonth() + 1, date.getDate()] - .map((value) => String(value).padStart(2, "0")) - .join("/"), - ); - } - let scanned = 0; - const deadline = Date.now() + 1500; - for (const date of dates) { - try { - const dir = join(root, date); - for await (const item of await opendir(dir)) { - if (++scanned > 200 || Date.now() > deadline) { - gaps.push({ source: "codex_app_log", reason: "directory_scan_limit" }); - break; - } - if (!item.isFile() || !/^codex-desktop-.*\.log$/.test(item.name)) continue; - const path = join(dir, item.name); - const info = await stat(path); - if (info.mtimeMs >= window.since * 1000) files.push({ path, modified: info.mtimeMs }); - } - } catch (error) { - gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); - } - if (scanned > 200 || Date.now() > deadline) break; - } - files.sort((a, b) => b.modified - a.modified); - if (selectedFiles.length) { - if (selectedFiles.length > 3) - throw new Error("Select at most three existing Codex App log files"); - files.length = 0; - for (const path of [...new Set(selectedFiles)]) { - if (!/^codex-desktop-.*\.log$/.test(basename(path))) - throw new Error("Select only existing native codex-desktop-*.log files"); - const info = await stat(path); - if (!info.isFile()) throw new Error("Codex App log selection must be a file"); - files.push({ path, modified: info.mtimeMs }); - } - } - const entries = []; - let filesRead = 0; - for (const file of files.slice(0, 3)) { - try { - // App logs mix unrelated UI activity with runtime notices, so accept only this native event. - entries.push( - ...(await textLog(file.path, "codex_app_log", gaps, { ...window, readOffset }, (line) => { - if ( - !/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test( - line, - ) - ) - return false; - const server = [...line.matchAll(/(?:^|\s)server=([^\s]+)/g)].at(-1)?.[1]; - if (!["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; - const thread = [...line.matchAll(/(?:^|\s)threadId=([^\s]+)/g)].at(-1)?.[1]; - return !conversationId || !thread || thread === "null" || thread === conversationId; - })), - ); - filesRead++; - } catch (error) { - gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); - } - } - if (files.length > 3) - gaps.push({ - source: "codex_app_log", - reason: "log_file_limit", - omitted: files.length - 3, - omittedFiles: files.slice(3, 13).map((file) => basename(file.path)), - }); - if (entries.length > 30) - gaps.push({ - source: "codex_app_log", - reason: "log_record_limit", - omitted: entries.length - 30, - }); - return { - entries: entries - .sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")) - .slice(-30), - scan: { - directoryEntriesScanned: scanned, - matchingFiles: files.length, - filesRead, - maximumTailBytesPerFile: READ_LIMIT, - currentConversationFilterApplied: Boolean(conversationId), - ...(conversationId && { conversationId }), - selectedFileMode: selectedFiles.length > 0, - ...(readOffset !== undefined && { selectedByteOffset: readOffset }), - filesSelected: files.slice(0, 3).map((file) => basename(file.path)), - observationScope: - "hosted_mcp_startup_notices_only; tool_request_failures_need_original_failure_record_or_matching_runtime_logs", - }, - }; -} - -async function inspectSqliteAccess(path) { - const files = {}; - for (const suffix of ["", "-wal", "-shm"]) { - const name = suffix || "database"; - let handle; - try { - const info = await stat(path + suffix); - handle = await open(path + suffix, "r"); - const header = Buffer.alloc(suffix ? 1 : 20); - const { bytesRead } = await handle.read(header, 0, header.length, 0); - files[name] = { exists: true, readable: true, bytes: info.size }; - if ( - !suffix && - bytesRead === 20 && - header.subarray(0, 16).toString() === "SQLite format 3\0" - ) { - files[name].journalMode = header[18] === 2 ? "wal" : "rollback"; - } - } catch (error) { - files[name] = { readable: false, error: errorDetails(error) }; - } finally { - try { - await handle?.close(); - } catch (error) { - files[name].closeError = errorDetails(error); - } - } - } - return { - files, - interpretation: - "File reads and SQLite WAL/shared-memory access are different operations; readable files do not prove SQLite access. No copy, checkpoint, immutable mode or permission change attempted.", - }; -} - -export async function collectHostLogs(host, options = {}) { - const userHome = options.home ?? homedir(); - const gaps = []; - const entries = []; - let appLog; - const now = options.now ?? Date.now(); - const center = options.occurredAt ? Date.parse(options.occurredAt) : now; - const since = Math.floor((center - (options.occurredAt ? 15 : 30) * 60_000) / 1000); - const until = Math.min(now, center + 15 * 60_000) / 1000; - if (host === "codex") { - const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); - const sqliteRoot = options.codexSqliteHome ?? process.env.CODEX_SQLITE_HOME ?? root; - const logRoot = options.codexLogDir ?? join(root, "log"); - const databasePath = join(sqliteRoot, "logs_2.sqlite"); - let db; - try { - await stat(databasePath); - const { DatabaseSync } = await import("node:sqlite"); - db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); - db.exec("PRAGMA query_only=ON"); - const hasThreadId = db - .prepare("PRAGMA table_info(logs)") - .all() - .some((column) => column.name === "thread_id"); - const rows = db - .prepare( - `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, - ) - .all(since, until); - for (const row of rows) { - const message = logMessage(row.feedback_log_body ?? ""); - const original = row.feedback_log_body ?? ""; - const context = runtimeContext(original); - if (row.thread_id) context.conversationId = row.thread_id; - if ( - options.conversationId && - context.conversationId && - context.conversationId !== options.conversationId - ) - continue; - if (!CHATCUT_MESSAGE.test(original) || /server_name="?chatcut_desktop/.test(original)) - continue; - if ( - !["ERROR", "WARN"].includes(row.level) && - !/omitting MCP server|cached MCP catalog/.test(message) - ) - continue; - entries.push({ - source: "codex_runtime_sqlite", - rowId: row.id, - occurredAt: new Date(row.ts * 1000).toISOString(), - target: row.target, - level: row.level, - message: redact(message), - ...context, - originalAttemptMatch: "unconfirmed", - }); - if (entries.length === 30) break; - } - if (rows.length === 200 || entries.length === 30) - gaps.push({ source: "codex_runtime_sqlite", reason: "log_record_limit" }); - } catch (error) { - gaps.push({ - source: "codex_runtime_sqlite", - reason: "unavailable", - error: errorDetails(error), - locationSource: options.codexSqliteHome - ? "explicit_host_location" - : process.env.CODEX_SQLITE_HOME - ? "CODEX_SQLITE_HOME" - : "CODEX_HOME_default; sqlite_home_config_override_not_inspected", - ...(platform() === "win32" && error?.errcode === 14 - ? { accessEvidence: await inspectSqliteAccess(databasePath) } - : {}), - }); - } finally { - db?.close(); - } - try { - entries.push( - ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { - since, - until, - })), - ); - } catch (error) { - gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); - } - const appData = options.localAppData ?? process.env.LOCALAPPDATA; - const sandboxAppData = - !options.localAppData && - /[\\/]Packages[\\/]sandbox\.[^\\/]+[\\/]AC(?:[\\/]|$)/i.test(appData ?? ""); - const appLogRoot = - options.codexAppLogDir ?? - ((options.platform ?? platform()) === "win32" - ? join( - // MXC redirects LOCALAPPDATA; the running App logs stay in the user profile. - (!sandboxAppData && appData) || join(userHome, "AppData", "Local"), - "Codex", - "Logs", - ) - : null); - if (appLogRoot) { - const result = await codexAppLogs( - appLogRoot, - gaps, - { since, until }, - options.conversationId, - options.codexAppLogFiles, - options.codexAppLogOffset, - ); - entries.push(...result.entries); - appLog = { - ...result.scan, - locationSource: options.codexAppLogDir ? "explicit_host_location" : "windows_default", - sandboxLocalAppDataRedirected: Boolean(sandboxAppData), - }; - if (!result.entries.length) - gaps.push({ source: "codex_app_log", reason: "no_matching_records_in_bounded_window" }); - } - } else { - const root = - options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(userHome, ".claude"); - try { - const dir = join(root, "debug"); - const names = await readdir(dir, { withFileTypes: true }); - const files = []; - const deadline = Date.now() + 1500; - for (const item of names) { - if (Date.now() > deadline) { - gaps.push({ source: "claude_debug_log", reason: "directory_scan_deadline" }); - break; - } - if (!item.isFile() || !item.name.endsWith(".txt")) continue; - const path = join(dir, item.name); - const info = await stat(path); - if (info.mtimeMs >= since * 1000) files.push({ path, modified: info.mtimeMs }); - } - files.sort((a, b) => b.modified - a.modified); - for (const item of files.slice(0, 3)) - entries.push(...(await textLog(item.path, "claude_debug_log", gaps, { since, until }))); - if (files.length > 3) - gaps.push({ - source: "claude_debug_log", - reason: "log_file_limit", - omitted: files.length - 3, - }); - } catch (error) { - gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); - } - } - if (!entries.length) - gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); - return clean({ - entries, - gaps, - ...(appLog ? { appLog } : {}), - window: { - since: new Date(since * 1000).toISOString(), - until: new Date(until * 1000).toISOString(), - }, - }); -} - -export function fragmentEvents(report) { - const json = JSON.stringify(clean(report)); - if (Buffer.byteLength(json) > UPLOAD_LIMIT) - throw new Error("Report exceeds upload budget; complete local report retained"); - const chunks = []; - let chunk = ""; - let bytes = 0; - for (const char of json) { - const size = Buffer.byteLength(char); - if (bytes + size > 4096) { - chunks.push(chunk); - chunk = ""; - bytes = 0; - } - chunk += char; - bytes += size; - } - if (chunk) chunks.push(chunk); - const sha256 = createHash("sha256").update(json).digest("hex"); - // Canonical fields and a map avoid growing the backend dataset's flat schema. - return chunks.map((fragment, fragmentIndex) => ({ - name: "mcp.doctor.report_fragment", - properties: { - clientEventName: null, - eventName: "mcp.doctor.report_fragment", - attributes: { - mcpDoctor: { - diagnosticId: report.diagnosticId, - schemaVersion: report.schemaVersion, - collectedAt: report.collectedAt, - host: report.host, - scope: "cloud", - fragmentIndex, - fragmentCount: chunks.length, - reportSha256: sha256, - fragment, - }, - }, - }, - })); -} - -export async function runDoctor(options, dependencies = {}) { - if (!["codex", "claude"].includes(options.host)) - throw new Error("--host must be codex or claude"); - if (options.originalError && options.failureRecord) - throw new Error("Choose one original failure input: --failure-record or --original-error"); - const endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; - if ( - options.occurredAt && - (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || - !Number.isFinite(Date.parse(options.occurredAt))) - ) { - throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); - } - const fetchImpl = dependencies.fetchImpl ?? fetch; - const diagnosticId = randomUUID(); - const report = { - schemaVersion: 2, - collectorVersion: "1.2.3", - diagnosticId, - collectedAt: new Date().toISOString(), - scope: "cloud", - host: options.host, - hostVersion: options.hostVersion ?? null, - hostVersionSource: options.hostVersion ? "agent_supplied" : "unavailable", - environment: { - platform: platform(), - architecture: arch(), - nodeVersion: process.version, - proxyEnvironmentPresent: [ - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", - ].some((key) => Boolean(process.env[key])), - probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", - noProxyEnvironmentPresent: Boolean(process.env.NO_PROXY || process.env.no_proxy), - nodeUseEnvProxyRequested: process.env.NODE_USE_ENV_PROXY === "1", - extraCaConfigured: Boolean(process.env.NODE_EXTRA_CA_CERTS), - tlsVerificationDisabledInEnvironment: process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0", - }, - serverUrl: endpoint?.href ?? null, - originalFailure: { - evidenceKind: "original_failure", - occurredAt: options.occurredAt ?? null, - timeSource: options.occurredAt - ? "command_line; agent_supplied_not_independently_verified" - : "unavailable", - source: "agent_supplied_error_file", - status: "unavailable", - }, - probes: [], - wireProbes: [], - gaps: [], - outcome: "diagnosis_only; original_connection_recovery_unverified", - coverage: "triggered_diagnostics_only; not_all_users_or_all_failures", - correlation: - "diagnosticId identifies this report and later probes, not the original host request", - }; - let disabledBySnapshot = false; - if (options.hostSnapshot) { - try { - if (!basename(String(options.hostSnapshot)).endsWith(".mcp-snapshot.json")) - throw new Error( - "Host snapshot must be a dedicated .mcp-snapshot.json file, never a credential/config file", - ); - const info = await stat(options.hostSnapshot); - if (info.size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); - const snapshot = JSON.parse(await readFile(options.hostSnapshot, "utf8")); - disabledBySnapshot = - snapshot?.configuration?.enabled === false || - snapshot?.session?.connectionStatus === "disabled"; - report.hostSnapshot = clean(validateSnapshot(snapshot, endpoint)); - report.hostSnapshotSource = - "agent_supplied; see each field source; not_independently_verified"; - if (!report.hostVersion && report.hostSnapshot.hostVersion) { - report.hostVersion = report.hostSnapshot.hostVersion; - report.hostVersionSource = "agent_supplied_host_snapshot"; - } - } catch (error) { - report.gaps.push({ - source: "host_snapshot", - reason: "unavailable", - error: errorDetails(error), - action: - "Create a new dedicated snapshot with --create-snapshot ; fill only observed facts, then --validate-snapshot before collection. Do not rename or read a credential/config file.", - }); - } - } - try { - const manifest = - options.host === "codex" - ? "../../../.codex-plugin/plugin.json" - : "../../../.claude-plugin/plugin.json"; - report.pluginVersion = JSON.parse( - await readFile(new URL(manifest, import.meta.url), "utf8"), - ).version; - report.pluginVersionSource = - "collector_package_manifest; installed_host_plugin_not_independently_verified"; - } catch (error) { - report.gaps.push({ source: "plugin_version", error: errorDetails(error) }); - } - if (options.originalError) { - try { - if (!/\.(?:txt|log)$/i.test(basename(String(options.originalError)))) { - throw new Error( - "Original error must be a dedicated .txt or .log file; credential/config/session files are not inputs", - ); - } - const info = await stat(options.originalError); - if (info.size > 64 * 1024) - throw new Error("Original error file exceeds 64 KiB; source file retained without reading"); - const message = await readFile(options.originalError, "utf8"); - if (!message.trim()) - throw new Error("Original error file is empty; original failure remains unavailable"); - report.originalFailure.message = redact(message); - report.originalFailure.status = "recorded"; - } catch (error) { - report.gaps.push({ - source: "original_failure", - reason: "unavailable", - error: errorDetails(error), - }); - } - } else if (!options.failureRecord) - report.gaps.push({ - source: "original_failure", - reason: "not_provided; do_not_invent_original_error", - }); - if (options.failureRecord) { - try { - if (!basename(String(options.failureRecord)).endsWith(".mcp-failure.json")) - throw new Error( - "Use a dedicated .mcp-failure.json file, never a config or session transcript", - ); - if ((await stat(options.failureRecord)).size > 64 * 1024) - throw new Error("Failure record exceeds 64 KiB; complete source retained without reading"); - report.originalFailure = clean( - failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), - ); - if (!report.originalFailure.occurredAt && options.occurredAt) { - report.originalFailure.occurredAt = options.occurredAt; - report.originalFailure.timeSource = - "command_line; agent_supplied_not_independently_verified"; - } - if ( - options.occurredAt && - report.originalFailure.occurredAt && - Date.parse(options.occurredAt) !== Date.parse(report.originalFailure.occurredAt) - ) { - report.originalFailure.timeConflict = { - failureRecord: report.originalFailure.occurredAt, - commandLine: options.occurredAt, - }; - report.originalFailure.occurredAt = null; - report.originalFailure.timeSource = "conflicting_supplied_times"; - report.gaps.push({ - source: "original_failure", - reason: "conflicting_supplied_failure_times", - }); - } - } catch (error) { - report.gaps.push({ - source: "original_failure", - reason: "failure_record_unavailable", - error: errorDetails(error), - }); - } - } - try { - report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { - occurredAt: report.originalFailure.occurredAt, - codexSqliteHome: options.codexSqliteHome, - codexLogDir: options.codexLogDir, - codexAppLogDir: options.codexAppLogDir, - codexAppLogFiles: options.codexAppLogFiles, - codexAppLogOffset: options.codexAppLogOffset, - conversationId: - report.originalFailure.conversationId ?? - report.hostSnapshot?.session?.conversationId ?? - process.env.CODEX_THREAD_ID, - }); - } catch (error) { - report.hostLogs = { - entries: [], - gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], - }; - } - const disabled = - disabledBySnapshot || - report.hostSnapshot?.configuration?.enabled === false || - report.hostSnapshot?.session?.connectionStatus === "disabled"; - if (disabled || !endpoint) { - report.gaps.push({ - source: "probes", - reason: disabled - ? "host_reports_intentionally_disabled; no_requests_sent" - : "registered_endpoint_unavailable; no_requests_sent", - }); - } else { - report.entryProbe = await guardProbe(endpoint.href, () => - probeEntry(endpoint, diagnosticId, fetchImpl), - ); - const resource = endpoint.pathname.replace(/\/$/, ""); - const queue = [ - new URL("/.well-known/oauth-protected-resource" + resource, endpoint.origin).href, - new URL("/.well-known/oauth-authorization-server" + resource, endpoint.origin).href, - new URL("/.well-known/oauth-authorization-server", endpoint.origin).href, - ]; - const enqueue = (value, label) => { - try { - const url = new URL(value); - if ( - url.origin !== endpoint.origin || - url.username || - url.password || - url.search || - url.hash || - !url.pathname.startsWith("/.well-known/") - ) - throw new Error( - "Unsupported metadata target; no cross-origin or credential-bearing probes", - ); - if (!queue.includes(url.href)) queue.push(url.href); - } catch (error) { - report.gaps.push({ source: label, error: errorDetails(error) }); - } - }; - if (report.entryProbe.resourceMetadataUrl) - enqueue(report.entryProbe.resourceMetadataUrl, "challenge_metadata_target"); - let issuer; - for (let index = 0; index < queue.length && index < 5; index++) { - const url = queue[index]; - const result = await guardProbe(url, () => - probeMetadata(url, diagnosticId, fetchImpl, { - resource: endpoint.href, - issuer: url.includes("oauth-authorization-server") ? issuer : undefined, - }), - ); - report.probes.push(result); - if (url === queue[0] && result.outcome !== "metadata_read") - enqueue( - new URL("/.well-known/oauth-protected-resource", endpoint.origin).href, - "root_resource_metadata_target", - ); - const fields = result.metadataValidation?.fields; - if (url.includes("oauth-protected-resource") && result.metadataValidation?.valid) { - issuer = fields.authorization_servers[0]; - const auth = new URL(issuer); - enqueue( - new URL( - "/.well-known/oauth-authorization-server" + auth.pathname.replace(/\/$/, ""), - auth.origin, - ).href, - "issuer_metadata_target", - ); - } - } - if (queue.length > 5) - report.gaps.push({ - source: "metadata_probes", - reason: "probe_limit", - omitted: queue.length - 5, - }); - if (options.wireEvidence) { - const failed = report.probes.find((probe) => probe.outcome !== "metadata_read"); - const selected = new URL(failed?.url ?? queue[0]); - const match = selected.pathname.match( - /^\/\.well-known\/(oauth-protected-resource|oauth-authorization-server|openid-configuration)/, - ); - if (match) { - const root = new URL("/.well-known/" + match[1], endpoint.origin); - const scoped = new URL(root.pathname + resource, endpoint.origin); - for (const url of [scoped.href, root.href]) { - try { - report.wireProbes.push( - await (dependencies.probeWire ?? probeMetadataWire)(url, diagnosticId, errorDetails), - ); - } catch (error) { - report.gaps.push({ source: "wire_probe", url, error: errorDetails(error) }); - } - } - } - } - if ( - endpoint.protocol === "https:" && - [report.entryProbe, ...report.probes].some( - (probe) => probe.outcome === "failed" && probe.stage !== "probe_collector", - ) - ) { - try { - report.transportContext = await (dependencies.inspectTransport ?? inspectTransport)( - endpoint, - ); - } catch (error) { - report.transportContext = { status: "failed", error: errorDetails(error) }; - } - } - } - report.evidenceSummary = evidenceSummary(report); - report.upload = { - status: disabled - ? "skipped_intentionally_disabled" - : !endpoint - ? "skipped_endpoint_unavailable" - : options.upload - ? "pending" - : "not_requested", - storageVerified: false, - }; - const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); - const save = async (flag) => - writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); - await save("wx"); - if (options.upload && !disabled && endpoint) { - try { - const events = fragmentEvents(report); - const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { - method: "POST", - redirect: "manual", - signal: AbortSignal.timeout(TIMEOUT_MS), - headers: { "content-type": "application/json" }, - body: JSON.stringify(events), - }); - const body = await limitedBody(response, 16 * 1024); - if (!response.ok || JSON.parse(body).ok !== true) - throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); - report.upload = { - status: "ingest_accepted", - storageVerified: false, - fragmentCount: events.length, - reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256, - }; - } catch (error) { - report.upload = { status: "failed", storageVerified: false, error: errorDetails(error) }; - } - await save("w"); - } - return { path, report: clean(report) }; -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - try { - const { values } = parseArgs({ - options: { - host: { type: "string" }, - "server-url": { type: "string" }, - "host-version": { type: "string" }, - "host-snapshot": { type: "string" }, - "original-error": { type: "string" }, - "failure-record": { type: "string" }, - "occurred-at": { type: "string" }, - "codex-sqlite-home": { type: "string" }, - "codex-log-dir": { type: "string" }, - "codex-app-log-dir": { type: "string" }, - "codex-app-log-file": { type: "string", multiple: true }, - "codex-app-log-offset": { type: "string" }, - output: { type: "string" }, - upload: { type: "boolean", default: false }, - "wire-evidence": { type: "boolean", default: true }, - "create-snapshot": { type: "string" }, - "validate-snapshot": { type: "string" }, - help: { type: "boolean", default: false }, - }, - }); - if (values["create-snapshot"]) { - const path = resolve(values["create-snapshot"]); - if (!basename(path).endsWith(".mcp-snapshot.json")) - throw new Error("Use a new dedicated .mcp-snapshot.json filename"); - await writeFile( - path, - JSON.stringify( - { schemaVersion: 1, observedAt: new Date().toISOString(), observations: [] }, - null, - 2, - ) + "\n", - { flag: "wx", mode: 0o600 }, - ); - console.log( - JSON.stringify({ - path, - snapshotCreated: true, - action: - "Add only observed configuration/session facts using connection-diagnostics.md; omit unknown groups. observedAt is snapshot capture time, not original failure time.", - }), - ); - } else if (values["validate-snapshot"]) { - const path = values["validate-snapshot"]; - if (!basename(path).endsWith(".mcp-snapshot.json")) - throw new Error("Use a dedicated .mcp-snapshot.json file"); - if ((await stat(path)).size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); - validateSnapshot( - JSON.parse(await readFile(path, "utf8")), - values["server-url"] ? serverUrl(values["server-url"]) : null, - ); - console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); - } else if (values.help) { - console.log( - "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--upload]", - ); - } else { - const { path, report } = await runDoctor({ - host: values.host, - serverUrl: values["server-url"], - hostVersion: values["host-version"], - hostSnapshot: values["host-snapshot"], - originalError: values["original-error"], - failureRecord: values["failure-record"], - occurredAt: values["occurred-at"], - codexSqliteHome: values["codex-sqlite-home"], - codexLogDir: values["codex-log-dir"], - codexAppLogDir: values["codex-app-log-dir"], - codexAppLogFiles: values["codex-app-log-file"], - codexAppLogOffset: - values["codex-app-log-offset"] === undefined - ? undefined - : Number(values["codex-app-log-offset"]), - output: values.output, - upload: values.upload, - wireEvidence: values["wire-evidence"], - }); - console.log( - JSON.stringify({ - diagnosticId: report.diagnosticId, - path, - upload: report.upload, - evidenceGaps: report.evidenceSummary.gaps, - collectionGaps: report.gaps, - recoveryVerified: false, - }), - ); - } - } catch (error) { - console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); - process.exitCode = 1; - } -} +import { runCli } from "../../chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await runCli(); diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs index da0ed7c..54ff8d2 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs @@ -1,369 +1,2 @@ -const STAGES = [ - "plugin_loading", - "discovery", - "client_registration", - "authorization", - "token_exchange", - "token_refresh", - "initialize", - "tools_list", - "session_tools", -]; -const SOURCES = [ - "current_session", - "tool_search", - "host_settings", - "cli_configuration", - "runtime_log", -]; -const TIMESTAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/; - -function object(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function keys(value, allowed) { - if (!object(value) || Object.keys(value).some((key) => !allowed.includes(key))) - throw new Error("Snapshot contains unsupported fields; use only the dedicated evidence schema"); -} - -function source(value) { - if (!SOURCES.includes(value)) - throw new Error("Snapshot evidence needs an explicit supported source"); -} - -function instant(value) { - return ( - typeof value === "string" && - new RegExp("^" + TIMESTAMP.source + "$").test(value) && - Number.isFinite(Date.parse(value)) - ); -} - -export function validateSnapshot(input, endpoint) { - keys(input, [ - "schemaVersion", - "observedAt", - "hostVersion", - "pluginVersion", - "configuration", - "session", - "observations", - ]); - if (input.schemaVersion !== 1) - throw new Error( - "Snapshot schemaVersion must be 1 (the outer Doctor report uses a separate schemaVersion); generate a template with --create-snapshot", - ); - if (!instant(input.observedAt)) - throw new Error( - "Snapshot observedAt must be its capture time as a complete ISO timestamp with timezone, e.g. 2026-10-23T01:00:00Z; original failure time may remain unknown", - ); - for (const key of ["hostVersion", "pluginVersion"]) - if (input[key] !== undefined && (typeof input[key] !== "string" || input[key].length > 256)) - throw new Error("Invalid snapshot version"); - if (input.configuration) { - const config = input.configuration; - keys(config, ["source", "serverName", "url", "enabled"]); - source(config.source); - if (!["chatcut", "plugin:chatcut:chatcut"].includes(config.serverName)) - throw new Error("Snapshot must describe the hosted ChatCut server"); - if (!publicUrl(config.url, true)) - throw new Error("Snapshot URL must be a public URL without embedded credentials"); - if (endpoint && new URL(config.url).href !== endpoint.href) - throw new Error( - "Snapshot URL differs from the explicitly selected server; preserve both configurations", - ); - if (config.enabled !== null && typeof config.enabled !== "boolean") - throw new Error("Snapshot enabled state must be boolean or null"); - } - if (input.session) { - const session = input.session; - keys(session, [ - "source", - "conversationId", - "connectionStatus", - "authStatus", - "visibleToolCount", - "visibleTools", - ]); - source(session.source); - if (session.source === "cli_configuration") - throw new Error("Static CLI configuration cannot describe current session tool availability"); - if ( - !["connected", "failed", "needs_authentication", "disabled", "unknown"].includes( - session.connectionStatus, - ) - ) - throw new Error("Unsupported connection status"); - if (!["authenticated", "needs_authentication", "unknown"].includes(session.authStatus)) - throw new Error("Unsupported authentication status"); - if ( - session.visibleToolCount !== null && - (!Number.isSafeInteger(session.visibleToolCount) || session.visibleToolCount < 0) - ) - throw new Error("Visible tool count must be a nonnegative integer or null"); - if (session.conversationId !== undefined && typeof session.conversationId !== "string") - throw new Error("conversationId must be a host conversation identifier"); - if ( - session.visibleTools !== undefined && - (!Array.isArray(session.visibleTools) || - session.visibleTools.length > 256 || - session.visibleTools.some( - (name) => typeof name !== "string" || !/^[A-Za-z0-9_:.-]{1,256}$/.test(name), - )) - ) - throw new Error("visibleTools must contain only ChatCut tool names, at most 256"); - if ( - session.visibleTools && - session.visibleToolCount !== null && - session.visibleTools.length > session.visibleToolCount - ) - throw new Error("Tool name count exceeds the observed tool count"); - } - if (input.observations !== undefined) { - if (!Array.isArray(input.observations) || input.observations.length > 32) - throw new Error("Snapshot accepts at most 32 stage observations"); - for (const item of input.observations) { - keys(item, [ - "stage", - "operation", - "source", - "observedAt", - "outcome", - "requestId", - "httpStatus", - "protocolCode", - "message", - "originalAttemptMatch", - ]); - source(item.source); - if ( - !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || - !["success", "failed", "unknown"].includes(item.outcome) || - (item.observedAt !== null && !instant(item.observedAt)) - ) - throw new Error( - "Stage observation needs stage, outcome, and observedAt (ISO timestamp with timezone, or null when the original time is unknown)", - ); - for (const field of ["operation", "requestId", "message"]) - if (item[field] !== undefined && typeof item[field] !== "string") - throw new Error("Observation text fields must be strings"); - for (const field of ["httpStatus", "protocolCode"]) - if (item[field] !== undefined && !Number.isSafeInteger(item[field])) - throw new Error("Observation status/code must be an integer"); - if ( - item.originalAttemptMatch !== undefined && - !["confirmed", "unconfirmed"].includes(item.originalAttemptMatch) - ) - throw new Error( - "Original attempt match must be confirmed or unconfirmed; omit when unknown", - ); - if ( - item.originalAttemptMatch === "confirmed" && - (!item.requestId || - item.outcome !== "failed" || - !["current_session", "runtime_log"].includes(item.source)) - ) - throw new Error( - "Confirmed original request association requires its failed runtime/session observation and actual request ID", - ); - } - } - return input; -} - -export function logTime(line) { - const match = line.match(TIMESTAMP)?.[0]; - return match && Number.isFinite(Date.parse(match)) ? new Date(match).toISOString() : null; -} - -export function stageHint(message) { - if (/tools\/list|tool catalog|tool list/i.test(message)) return "tools_list"; - if (/initializ|handshake/i.test(message)) return "initialize"; - if (/refresh.{0,20}token|token.{0,20}refresh/i.test(message)) return "token_refresh"; - if (/token.{0,20}exchange|exchange.{0,20}token|\/mcp\/token/i.test(message)) - return "token_exchange"; - if (/registr|\/mcp\/register/i.test(message)) return "client_registration"; - if (/callback|authoriz|oauth/i.test(message) && !/metadata|discovery/i.test(message)) - return "authorization"; - if (/metadata|discovery/i.test(message)) return "discovery"; - return "unclassified"; -} - -export function evidenceSummary(report) { - const snapshot = report.hostSnapshot; - const observations = snapshot?.observations ?? []; - const gaps = []; - if (!snapshot) gaps.push("current_host_snapshot_unavailable"); - if (!snapshot?.configuration) gaps.push("registration_state_unavailable"); - if (!snapshot?.session || snapshot.session.visibleToolCount === null) - gaps.push("current_session_tools_unavailable"); - if (report.originalFailure.status !== "recorded") gaps.push("original_failure_unavailable"); - if (!report.originalFailure.occurredAt) gaps.push("original_failure_time_unavailable"); - const originalRequests = - report.originalFailure.status === "recorded" - ? observations.flatMap((item, observationIndex) => - item.requestId && - item.originalAttemptMatch === "confirmed" && - item.outcome === "failed" && - ["current_session", "runtime_log"].includes(item.source) - ? [{ requestId: item.requestId, source: item.source, observationIndex }] - : [], - ) - : []; - if (report.originalFailure.status === "recorded" && report.originalFailure.requestId) - originalRequests.push({ - requestId: report.originalFailure.requestId, - source: report.originalFailure.source, - }); - if (!originalRequests.length) gaps.push("original_request_id_unavailable"); - if (!snapshot?.pluginVersion) gaps.push("actual_loaded_plugin_version_unavailable"); - if (!report.hostLogs.entries.length) gaps.push("matching_host_runtime_logs_unavailable"); - const appNotices = report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log"); - const failedAppNotices = appNotices.filter( - (entry) => [...entry.message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed", - ); - if (appNotices.length === report.hostLogs.entries.length && !failedAppNotices.length) - gaps.push("matching_host_failure_logs_unavailable"); - const stages = STAGES.map((stage) => ({ - stage, - observations: observations.filter((item) => item.stage === stage), - status: observations.some((item) => item.stage === stage) - ? "observed; see individual attempts" - : "not_observed", - })); - const unknown = observations.filter((item) => !STAGES.includes(item.stage)); - const logHints = report.hostLogs.entries.map((entry, index) => ({ - entryIndex: index, - stage: stageHint(entry.message), - interpretation: "text_hint_only; attempt_match_unconfirmed", - ...(entry.conversationId && { conversationId: entry.conversationId }), - ...(entry.callId && { callId: entry.callId }), - })); - return { - stages, - otherObservations: unknown, - logHints, - originalRequestCorrelation: { - status: originalRequests.length ? "agent_reported_same_attempt" : "unconfirmed", - observations: originalRequests, - provenance: - "agent_supplied_confirmation; not_independently_verified; never_inferred_from_time_or_other_ids", - }, - originalLocalCall: report.originalFailure.callId - ? { - callId: report.originalFailure.callId, - conversationId: report.originalFailure.conversationId ?? null, - operation: report.originalFailure.operation ?? null, - provenance: - "original_failure_record; agent_supplied_not_independently_verified; local_call_id_is_not_server_request_id", - } - : null, - hostLogCoverage: { - records: report.hostLogs.entries.length, - appStartupRecords: appNotices.length, - failedAppStartupRecords: failedAppNotices.length, - originalToolFailureRecord: - report.originalFailure.source === "agent_supplied_host_tool_result", - qualification: - "startup notices are not tool-request failure evidence; log presence alone does not prove original failure capture", - }, - gaps, - independentChecks: - "entryProbe/probes/wireProbes/transportContext are later requests, never original stage completions; server observation is before Node framing, not proof of downstream wire validity", - recoveryVerified: false, - }; -} - -function publicUrl(value, localAllowed = false) { - try { - const url = new URL(value); - return ( - !url.username && - !url.password && - !url.hash && - (url.protocol === "https:" || - (localAllowed && - url.protocol === "http:" && - ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) - ); - } catch { - return false; - } -} - -export function inspectMetadata(document, url, expected = {}) { - const issues = []; - const result = { valid: false, issues, fields: {} }; - if (!object(document)) { - issues.push({ field: "document", reason: "expected_object" }); - return result; - } - for (const field of [ - "resource", - "authorization_servers", - "issuer", - "authorization_endpoint", - "token_endpoint", - "registration_endpoint", - "grant_types_supported", - "response_types_supported", - "code_challenge_methods_supported", - "token_endpoint_auth_methods_supported", - ]) - if (document[field] !== undefined) result.fields[field] = document[field]; - const protectedResource = new URL(url).pathname.includes("oauth-protected-resource"); - const requireUrl = (field) => { - if (typeof document[field] !== "string" || !publicUrl(document[field], true)) - issues.push({ field, reason: "missing_or_invalid_public_url" }); - }; - if (protectedResource) { - requireUrl("resource"); - const suffix = new URL(url).pathname.split("/.well-known/oauth-protected-resource")[1]; - const resource = expected.resource ?? (suffix ? new URL(url).origin + suffix : undefined); - if (resource && document.resource !== resource) - issues.push({ - field: "resource", - reason: "resource_mismatch", - expected: resource, - actual: document.resource, - }); - if ( - !Array.isArray(document.authorization_servers) || - !document.authorization_servers.length || - document.authorization_servers.some((item) => !publicUrl(item, true)) - ) - issues.push({ - field: "authorization_servers", - reason: "missing_or_invalid_authorization_servers", - }); - } else { - requireUrl("issuer"); - requireUrl("authorization_endpoint"); - requireUrl("token_endpoint"); - if ( - Array.isArray(document.grant_types_supported) && - !document.grant_types_supported.includes("authorization_code") - ) - issues.push({ field: "grant_types_supported", reason: "authorization_code_not_supported" }); - if ( - !Array.isArray(document.response_types_supported) || - !document.response_types_supported.includes("code") - ) - issues.push({ field: "response_types_supported", reason: "code_response_not_advertised" }); - if (expected.issuer && document.issuer !== expected.issuer) - issues.push({ - field: "issuer", - reason: "issuer_mismatch", - expected: expected.issuer, - actual: document.issuer, - }); - if ( - !Array.isArray(document.code_challenge_methods_supported) || - !document.code_challenge_methods_supported.includes("S256") - ) - issues.push({ field: "code_challenge_methods_supported", reason: "S256_not_advertised" }); - } - result.valid = issues.length === 0; - return result; -} +// Compatibility entry; the standalone Doctor owns the collector. +export * from "../../chatcut-mcp-doctor/scripts/mcp-evidence.mjs"; diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs index ef9e338..35cf382 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs @@ -1,83 +1,2 @@ -const SOURCES = ["host_tool_result", "host_runtime_event", "captured_stderr"]; -const FIELDS = [ - "schemaVersion", - "source", - "serverName", - "occurredAt", - "conversationId", - "callId", - "operation", - "requestId", - "error", -]; - -export function validateFailureRecord(input) { - if ( - !input || - typeof input !== "object" || - Array.isArray(input) || - Object.keys(input).some((key) => !FIELDS.includes(key)) - ) - throw new Error( - "Use one dedicated failure record, without tool arguments, results or conversation content", - ); - if ( - input.schemaVersion !== 1 || - !SOURCES.includes(input.source) || - !["chatcut", "plugin:chatcut:chatcut"].includes(input.serverName) - ) - throw new Error( - "Failure record must identify the hosted ChatCut server and original error source", - ); - if ( - input.occurredAt !== null && - (typeof input.occurredAt !== "string" || - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( - input.occurredAt, - ) || - !Number.isFinite(Date.parse(input.occurredAt))) - ) - throw new Error( - "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", - ); - for (const key of ["conversationId", "callId", "operation", "requestId"]) - if ( - input[key] !== undefined && - input[key] !== null && - (typeof input[key] !== "string" || !/^[A-Za-z0-9_.:/-]{1,256}$/.test(input[key])) - ) - throw new Error( - `Invalid original failure ${key}; never substitute a credential or a later probe ID`, - ); - if ( - !input.error || - typeof input.error !== "object" || - Array.isArray(input.error) || - typeof input.error.message !== "string" || - !input.error.message.trim() - ) - throw new Error( - "Failure record needs the original nonempty error.message; preserve its code, cause and stack when available", - ); - return input; -} - -export function failureFromRecord(input) { - const record = validateFailureRecord(input); - return { - evidenceKind: "original_failure", - status: "recorded", - source: `agent_supplied_${record.source}`, - occurredAt: record.occurredAt, - timeSource: record.occurredAt - ? "host_failure_record; agent_supplied_not_independently_verified" - : "unavailable", - message: record.error.message, - error: record.error, - ...(record.conversationId && { conversationId: record.conversationId }), - ...(record.callId && { callId: record.callId }), - ...(record.operation && { operation: record.operation }), - ...(record.requestId && { requestId: record.requestId }), - association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", - }; -} +// Compatibility entry; the standalone Doctor owns the collector. +export * from "../../chatcut-mcp-doctor/scripts/mcp-failure.mjs"; diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-wire.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-wire.mjs index 57079a5..f5d9565 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-wire.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-wire.mjs @@ -1,296 +1,2 @@ -import { createHash } from "node:crypto"; -import { connect, isIP } from "node:net"; -import { connect as connectTls } from "node:tls"; - -const LIMIT = 128 * 1024; -const HEADER_LIMIT = 16 * 1024; -const TIMEOUT_MS = 3500; -const HEADERS = new Set([ - "content-type", - "content-length", - "content-encoding", - "transfer-encoding", - "connection", - "server", - "via", - "x-request-id", - "x-amzn-trace-id", - "cache-control", - "age", - "x-cache", - "x-amz-cf-id", - "x-amz-cf-pop", -]); - -function target(value) { - const url = new URL(value); - const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); - if ( - (!local && - (!["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes(url.hostname) || - url.protocol !== "https:" || - (url.port && url.port !== "443"))) || - (local && !["http:", "https:"].includes(url.protocol)) || - url.username || - url.password || - url.search || - url.hash || - !/^\/\.well-known\/(?:oauth-protected-resource|oauth-authorization-server|openid-configuration)(?:\/[a-zA-Z0-9/_-]*)?$/.test( - url.pathname, - ) - ) - throw new Error( - "Wire evidence is restricted to anonymous ChatCut metadata on the selected origin", - ); - return url; -} - -function framing(headers, body, ended, limited) { - const result = { - status: "incomplete", - firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, - bodyRepresentation: "after_transfer_decoding; content_encoding_not_decoded", - }; - const invalid = (reason) => ({ ...result, status: "invalid", reason }); - const incomplete = (reason) => ({ ...result, reason }); - const valid = (decoded) => ({ - ...result, - status: "valid", - bodyBytes: decoded.length, - bodySha256: createHash("sha256").update(decoded).digest("hex"), - }); - const transfer = headers["transfer-encoding"] - ?.toLowerCase() - .split(",") - .map((v) => v.trim()); - if (transfer?.at(-1) === "chunked") { - if (headers["content-length"] !== undefined) - return invalid("both_transfer_encoding_and_content_length"); - let offset = 0; - const parts = []; - while (offset < body.length) { - const end = body.indexOf("\r\n", offset); - if (end < 0) { - if (body.length > offset && !/[0-9a-f]/i.test(String.fromCharCode(body[offset]))) - return invalid("invalid_chunk_size"); - return ended && !limited - ? invalid("unterminated_chunk_size") - : incomplete("chunk_size_not_complete"); - } - const sizeText = body.subarray(offset, end).toString("latin1").split(";")[0]; - if (!/^[0-9a-f]{1,16}$/i.test(sizeText)) return invalid("invalid_chunk_size"); - if (offset === 0) result.firstChunkSizeHex = sizeText; - const size = Number.parseInt(sizeText, 16); - offset = end + 2; - if (!Number.isSafeInteger(size)) return incomplete("chunk_exceeds_capture_limit"); - if (size === 0) { - const trailerEnd = body.indexOf("\r\n\r\n", offset); - const messageEnd = body.subarray(offset, offset + 2).equals(Buffer.from("\r\n")) - ? offset + 2 - : trailerEnd >= 0 - ? trailerEnd + 4 - : null; - if (messageEnd !== null) { - if (messageEnd !== body.length) return invalid("bytes_after_terminal_chunk"); - return valid(Buffer.concat(parts)); - } - return ended && !limited - ? invalid("unterminated_trailers") - : incomplete("trailers_not_complete"); - } - if (offset + size + 2 > body.length) - return ended && !limited ? invalid("truncated_chunk") : incomplete("chunk_not_complete"); - if (!body.subarray(offset + size, offset + size + 2).equals(Buffer.from("\r\n"))) - return invalid("missing_chunk_terminator"); - parts.push(body.subarray(offset, offset + size)); - offset += size + 2; - } - return ended && !limited - ? invalid("missing_terminal_chunk") - : incomplete("terminal_chunk_not_observed"); - } - if (transfer) return incomplete("unsupported_transfer_encoding"); - if (headers["content-length"] !== undefined) { - if (!/^\d+$/.test(headers["content-length"])) return invalid("invalid_content_length"); - const expected = Number(headers["content-length"]); - if (body.length === expected) return valid(body); - if (body.length > expected) return invalid("content_length_mismatch"); - return ended && !limited ? invalid("content_length_mismatch") : incomplete("body_not_complete"); - } - return ended && !limited ? valid(body) : incomplete("close_delimited_body_not_complete"); -} - -function inspectResponse(captured, ended, limited) { - const headerEnd = captured.indexOf("\r\n\r\n"); - const result = { responseHeaders: {} }; - if (headerEnd < 0 || headerEnd > HEADER_LIMIT) { - result.headerGap = - captured.length > HEADER_LIMIT ? "header_limit" : "complete_headers_unavailable"; - return result; - } - const lines = captured.subarray(0, headerEnd).toString("latin1").split("\r\n"); - const status = lines.shift().match(/^HTTP\/(1\.[01]) ([1-5]\d{2}) [\t\x20-\x7e\x80-\xff]*$/); - result.httpVersion = status?.[1] ?? null; - result.status = status ? Number(status[2]) : null; - result.responseState = !status - ? "invalid_status_line" - : result.status < 200 - ? "unsupported_informational_response" - : "final_response"; - for (const line of lines) { - const colon = line.indexOf(":"); - if (colon < 0) continue; - const name = line.slice(0, colon).toLowerCase(); - if (HEADERS.has(name)) { - const field = line.slice(colon + 1).trim(); - result.responseHeaders[name] = - result.responseHeaders[name] === undefined - ? field - : result.responseHeaders[name] + "," + field; - } - } - result.stage = "http_framing"; - const body = captured.subarray(headerEnd + 4); - result.framing = - result.responseState === "final_response" - ? framing(result.responseHeaders, body, ended, limited) - : { - status: "not_evaluated", - reason: result.responseState, - firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, - }; - return result; -} - -export async function probeMetadataWire(value, diagnosticId, describeError) { - const url = target(value); - if (!/^[0-9a-f-]{36}$/i.test(diagnosticId)) throw new Error("Invalid diagnostic ID"); - const started = Date.now(); - const result = { - evidenceKind: "later_probe", - url: url.href, - startedAt: new Date().toISOString(), - implementation: "node:net/tls; HTTP/1.1", - route: "direct_connection; environment_proxy_not_used; host_and_fetch_route_not_reproduced", - originalAttemptMatch: "unconfirmed", - credentialsSent: false, - acceptEncoding: "identity", - byteLimit: LIMIT, - stage: "connect", - }; - return new Promise((resolve) => { - let socket; - let done = false; - let bytes = 0; - let limited = false; - let ended = false; - let headerSeen = false; - const parts = []; - const finish = (error, completionReason) => { - if (done) return; - done = true; - clearTimeout(timer); - const captured = Buffer.concat(parts); - result.capturedBytes = captured.length; - result.captureLimited = limited; - Object.assign(result, inspectResponse(captured, ended, limited)); - result.completionReason = completionReason; - result.peerEndObserved = ended; - result.outcome = error - ? "failed" - : result.responseState === "invalid_status_line" - ? "invalid_response" - : result.responseState === "unsupported_informational_response" - ? "unsupported_response" - : result.framing?.status === "invalid" - ? "invalid_framing" - : !limited && result.framing?.status === "valid" - ? "response_observed" - : "incomplete"; - if (error) result.error = describeError(error); - result.durationMs = Date.now() - started; - socket?.destroy(); - resolve(result); - }; - const timer = setTimeout( - () => - finish( - Object.assign(new Error("Metadata wire probe timed out after 3500ms"), { - code: "DIAGNOSTIC_WIRE_TIMEOUT", - }), - "timeout", - ), - TIMEOUT_MS, - ); - try { - const hostname = url.hostname.replace(/^\[|\]$/g, ""); - const options = { - host: hostname, - port: Number(url.port || (url.protocol === "https:" ? 443 : 80)), - }; - const send = () => { - result.stage = "response"; - result.peer = { address: socket.remoteAddress, port: socket.remotePort }; - if (url.protocol === "https:") { - const certificate = socket.getPeerCertificate(); - result.tls = { - authorized: socket.authorized, - protocol: socket.getProtocol(), - certificate: { issuer: certificate.issuer, fingerprint256: certificate.fingerprint256 }, - }; - } - socket.write( - `GET ${url.pathname} HTTP/1.1\r\nHost: ${url.host}\r\nAccept: application/json\r\nAccept-Encoding: identity\r\nConnection: close\r\nX-ChatCut-Diagnostic-ID: ${diagnosticId}\r\n\r\n`, - ); - }; - socket = - url.protocol === "https:" - ? connectTls( - { - ...options, - servername: isIP(hostname) ? undefined : hostname, - rejectUnauthorized: true, - ALPNProtocols: ["http/1.1"], - }, - send, - ) - : connect(options, send); - socket.on("data", (part) => { - if (done) return; - const remaining = LIMIT - bytes; - parts.push(part.subarray(0, Math.max(0, remaining))); - bytes += Math.min(part.length, remaining); - if (part.length > remaining) { - limited = true; - finish(undefined, "capture_limit"); - return; - } - if (!headerSeen) { - const captured = Buffer.concat(parts); - const headerEnd = captured.indexOf("\r\n\r\n"); - headerSeen = headerEnd >= 0; - if ((!headerSeen && bytes > HEADER_LIMIT) || headerEnd > HEADER_LIMIT) { - limited = true; - finish(undefined, "header_limit"); - return; - } - } - if (headerSeen) { - const observed = inspectResponse(Buffer.concat(parts), false, false); - if (observed.responseState !== "final_response") - finish(undefined, "invalid_or_unsupported_response"); - else if (observed.framing.status === "valid") finish(undefined, "message_boundary"); - else if (observed.framing.status === "invalid") finish(undefined, "invalid_framing"); - } - }); - socket.once("end", () => { - ended = true; - finish(undefined, "peer_end"); - }); - socket.once("error", (error) => finish(error, "transport_error")); - socket.once("close", () => finish(undefined, "socket_close")); - } catch (error) { - finish(error, "collector_error"); - } - }); -} +// Compatibility entry; the standalone Doctor owns the collector. +export * from "../../chatcut-mcp-doctor/scripts/mcp-wire.mjs"; diff --git a/codex/.codex-plugin/plugin.json b/codex/.codex-plugin/plugin.json index 5d170b4..8ca44e5 100644 --- a/codex/.codex-plugin/plugin.json +++ b/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "chatcut", - "version": "1.10.21", + "version": "1.10.22", "description": "ChatCut Codex plugin for editable video editing and creation workflows from Codex.", "author": { "name": "ChatCut", diff --git a/codex/.mcp.json b/codex/.mcp.json index c02fca1..fc685d3 100644 --- a/codex/.mcp.json +++ b/codex/.mcp.json @@ -3,7 +3,7 @@ "chatcut": { "http_headers": { "x-chatcut-mcp-surface": "codex", - "x-chatcut-plugin-version": "1.10.21" + "x-chatcut-plugin-version": "1.10.22" }, "oauth_resource": "https://api.chatcut.io/api/external-mcp/mcp", "url": "https://api.chatcut.io/api/external-mcp/mcp" diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md new file mode 100644 index 0000000..e27adc4 --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -0,0 +1,49 @@ +--- +name: chatcut-mcp-doctor +description: Diagnose hosted ChatCut MCP connection, startup, authentication or tool-call failures from a working Codex or Claude Code agent, even when ChatCut tools cannot connect. Produce evidence-based findings, repair guidance, rechecks and local feedback materials. Use for ChatCut Doctor or MCP连接错误; not for Desktop ACP or unrelated video editing. +--- + +# ChatCut MCP Doctor + +This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. + +## Start here + +- In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” +- In Claude Code with the ChatCut plugin, invoke `/chatcut:chatcut-mcp-doctor`. A separately installed project/personal copy uses `/chatcut-mcp-doctor`. +- `/doctor` and `codex doctor` belong to the host; this Skill does not replace them. If the entry is absent or the plugin is old, read [discovery and fallback](references/discovery.md). A browser-only host without local execution cannot run this collector; retain the error and explain that boundary. + +## Preserve, collect, explain + +Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. + +Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. + +```text +node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output +``` + +Use `--host claude` for Claude Code. Omit unavailable optional inputs. If there is no registered URL, omit it; do not silently choose production. `--offline` keeps the registered URL as evidence but sends **no requests**, including upload. Otherwise the collector makes bounded anonymous entry/metadata checks and optional wire checks; an initial anonymous 401 is expected. Use `--upload` for the redacted report when transfer is authorized, after briefly explaining it. An intentionally disabled server is preserved without requests. + +The command writes the existing schema-2 report plus `.diagnosis.json`, `.summary.md` and `.issue-draft.md` sidecars. Read the findings and actual gaps before answering; do not merely echo a category. Findings identify observed boundaries, not root causes. Distinguish original failure, host evidence and later probes. Missing/truncated/denied logs remain gaps. A healthy later probe does not establish recovery. Proxy presence, busy/300-second timeouts and generic transport errors do not justify blaming security software, clearing locks/credentials, restarting or changing timeouts/environments. + +Wire failures/incomplete captures are later **direct-route** checks even if fetch metadata succeeds. Bounded native ERROR records and App `status=failed` notices are visible as unassociated host evidence; they do not fill a missing original error/time/request ID. Ordinary App starting/ready notices do not demonstrate failure. Local summaries show credential-redacted error excerpts; issue drafts keep structured states and evidence pointers without copying native log excerpts. + +## Repair and recheck + +For an observed missing registration or a normal sign-in requirement, read [repair and recheck](references/repair-and-recheck.md). Apply only the minimal change within the user's authorization and actual host/scope. Keep the prior state and rollback. Other findings need evidence-led investigation; this version has no automatic credential/network repair. + +Verify the affected boundary in the **same host and scope** after a change. Registration lookup success proves registration only. Auth success requires the host's authenticated state. Tool recovery requires a safe read-only call such as `list_projects` actually succeeding. If the original operation mutates state, costs credits, or cannot be safely repeated, do not replay it; say that its recovery remains unverified. Record the actual action, time/source and verification result in the summary and draft; never label an unperformed repair as tried. Keep the report's original failure immutable and its collector `recoveryVerified:false`; agent recheck results are separate evidence. + +## Feedback without starting over + +Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. + +```text +node "/scripts/doctor.mjs" --report +node "/scripts/doctor.mjs" --upload-report +``` + +`--report` rebuilds sidecars offline without recollecting. It overwrites derived sidecars, so save any agent-written repair notes separately first. Use `--upload-report` only for an authorized first transfer or a single requested retry after failure; it reuses the original diagnostic ID without new probes, keeps the original report unchanged, and writes a separate upload receipt. Do not repeatedly recollect or resend an acknowledged report. `ingest_accepted` is not verified final storage or an opened support ticket. + +Use [ChatCut contact](https://chatcut.io/contact) for private support. The draft names no public issue repository because that destination is undecided. Do not open an issue, publish evidence, or message anyone without authorization. Root-cause rules and prevalence based on natural incidents remain future work; controlled tests are not user incidents. diff --git a/codex/skills/chatcut-mcp-doctor/agents/openai.yaml b/codex/skills/chatcut-mcp-doctor/agents/openai.yaml new file mode 100644 index 0000000..e447e56 --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "ChatCut MCP Doctor" + short_description: "Diagnose ChatCut MCP failures and prepare feedback locally" diff --git a/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md b/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md new file mode 100644 index 0000000..7d99c81 --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md @@ -0,0 +1,307 @@ +# Hosted MCP connection diagnostics + +Use this branch when a registered hosted ChatCut connection fails, including +OAuth failing before a browser opens, or authentication completing without usable +tools, and failures of already available hosted tools. Preserve evidence before reconnecting or restarting. This version gathers +evidence; it does not repair configuration, credentials, network settings, or the +original connection. + +Briefly tell the user that you will collect ChatCut connection diagnostics and +send the redacted report to ChatCut. Run under the host's existing execution and +network permissions; do not add a separate manual step when those permit it. +Honor a refusal or a denied read/network operation. Do not change permission +settings to make collection work. + +Invoke each helper as a standalone `node` command with its quoted path. Do not +combine it with `cd`, shell variables, pipelines, or other commands; command +allowlists may permit the helper while rejecting the combined shell operation. +Use the host's file-writing tool for the dedicated snapshot and error file. + +## Capture the current host, including unfamiliar failures + +The Agent creates a dedicated `.mcp-snapshot.json` from facts already available +through the current host. The user does not fill a form. Do not copy an entire +configuration, credential file, environment, or conversation. Preserve unfamiliar +errors as well as known OAuth errors; classify uncertain stages as unknown. +Static CLI configuration describes registration, not tools loaded into the +current chat. Browser sign-in does not establish MCP authentication. + +First generate a new snapshot template with the bundled helper, then add only +observed groups from the schema below. This sets the snapshot schema and capture +time mechanically; it does not invent the original failure time or host state: + +```text +node /scripts/mcp-doctor.mjs --create-snapshot +``` + +Validate the completed file locally before collecting or uploading: + +```text +node /scripts/mcp-doctor.mjs --validate-snapshot --server-url +``` + +Snapshot `schemaVersion` is **1**, independently of the outer report version. +Keep unavailable groups omitted. Each observation's `observedAt` may be `null` +when its actual time is unknown. If validation fails, correct only the reported +schema error using already available facts; do not guess times or relax the +credential/config file restriction. If facts remain unavailable, collect the +other evidence and preserve the specific gap. + +Snapshot schema 1 accepts this shape; omit unavailable groups, use null for an +unknown tool count/enabled flag, and label each group's actual source: + +```json +{ + "schemaVersion": 1, + "observedAt": "2026-10-22T12:00:00Z", + "hostVersion": "actual-running-host-version", + "pluginVersion": "actual-loaded-plugin-version-if-known", + "configuration": { + "source": "host_settings", + "serverName": "chatcut", + "url": "https://api.chatcut.io/api/external-mcp/mcp", + "enabled": true + }, + "session": { + "source": "current_session", + "conversationId": "host-chat-id-if-available", + "connectionStatus": "failed", + "authStatus": "unknown", + "visibleToolCount": 0, + "visibleTools": [] + }, + "observations": [ + { + "stage": "initialize", + "operation": "initialize", + "source": "runtime_log", + "observedAt": "2026-10-22T11:59:00Z", + "outcome": "failed", + "requestId": "server-request-id-only-if-recorded", + "message": "Exact original error, including an unfamiliar error code" + } + ] +} +``` + +Sources: `current_session`, `tool_search`, `host_settings`, +`cli_configuration`, `runtime_log`. A session group cannot claim +`cli_configuration` as its source. Connection status is `connected`, `failed`, +`needs_authentication`, `disabled`, or `unknown`; auth status is +`authenticated`, `needs_authentication`, or `unknown`. +Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` +(`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, +`protocolCode`, `message`, and `originalAttemptMatch` (`confirmed`/`unconfirmed`). +Only `host_registration` / `lookup_registered_server` observations from +`cli_configuration` or `host_settings` may additionally carry `lookupResult`: +`not_found`, `found`, or `unavailable`. `not_found` requires an explicit absence +response from a completed named lookup or a successful complete scoped inventory; +do not derive it from an exit code, permission/parse error, missing CLI, or empty +tool list. Preserve the actual response in `message`. Failed/inconclusive lookups +without this fact retain unknown registration state and do not recommend adding +an entry. This is still agent-supplied evidence, not independent verification. +Omit the match flag when unknown. Set `confirmed` only when the captured failed +runtime/session observation explicitly identifies the original failed attempt +and its actual server request ID; registration, nearby timestamps, successful +operations, and later probe IDs are not confirmation. An original error must +also have been recorded for that observation to close the original-request gap. +This remains Agent-supplied confirmation, not independent helper verification. +New stage names are preserved. Do not put an +MCP session credential in `conversationId` or guess a server request ID. +Only include ChatCut tool names; keep tool arguments and results out. + +## Run once for the reported failure + +### Preserve the failed call before independent checks + +When the host exposes the original failed tool result or runtime event, the Agent +copies only its error and available identifiers into a new dedicated +`.mcp-failure.json`. Do this before retries, reconnects or later probes. +Do not read a conversation transcript or collect tool arguments/results to build +it. Use the host's existing error metadata and supported read-only runtime logs; +the user does not fill this record. Keep the full original error message, code, +cause and stack when available, including unfamiliar failures. + +```json +{ + "schemaVersion": 1, + "source": "host_tool_result", + "serverName": "chatcut", + "occurredAt": null, + "conversationId": null, + "callId": null, + "operation": "list_projects", + "requestId": null, + "error": { "message": "Exact original error; add original code/cause/stack when available" } +} +``` + +Supported sources are `host_tool_result`, `host_runtime_event`, and +`captured_stderr`. `occurredAt` is the failed event's actual timestamp with +timezone, when exposed; never substitute the current time, file modification +time, snapshot capture or later probe time. Omit unknown optional IDs or leave +them null. A local `callId` and `conversationId` identify client-side work and +are useful even if the request never reaches ChatCut; they are not server +request IDs. Copy a `requestId` only when the original failed call actually +provides it. All supplied facts remain labelled as Agent-supplied evidence. + +Prefer `--failure-record ` to `--original-error` when +this structured source is available; choose one input. The collector centers +its bounded log window on the supplied failure time and uses its conversation +ID when available. A missing time remains an explicit gap. For an exec caller, +use already retained stderr; discarded stderr cannot be recovered by this Skill. +Do not start another authenticated session to manufacture replacement evidence. + +If only the original error text is accessible, use the text-file fallback below. +Do not call a report with only startup-status notices a captured tool failure. + +1. Obtain the existing server's registered URL through the host. Preserve its + environment. The helper accepts ChatCut production/beta/dev endpoints and + loopback development servers; it reports unsupported endpoints rather than + silently probing production. If no registered URL is available, omit + `--server-url` to collect a local report without probing or uploading. + Never read credential stores or paste complete + configuration, environment, or conversation dumps into the report. + An intentionally disabled hosted server is not a failed connection; preserve + that choice instead of starting diagnostics or re-enabling it. +2. Save only the original connection error and its available cause chain into a + new temporary text file. Use the exact original text, not a generated root + cause. If unavailable, omit `--original-error`. Obtain the actual bundled + host version if accessible without starting a session. Do not treat update + metadata as the running version. +3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: + + ```text + node /scripts/mcp-doctor.mjs --host codex --server-url --host-snapshot --original-error --host-version --upload + ``` + + For Claude Code, use `--host claude`. Quote paths as shell arguments; do not + interpolate error text into shell code. Omit unknown optional fields. Supply + `--occurred-at` only when the failure's actual ISO timestamp and timezone are + known. A missing timestamp remains unknown; the collection time is separate. + If transfer is denied, omit `--upload` and retain the local JSON report. + +Use an available Node.js 18+ runtime. Do not install a runtime or ChatCut Desktop +just for diagnostics. Codex SQLite extraction additionally needs `node:sqlite` +(available on recent Node 22+/24 runtimes). Without it, other collection still +runs and the report marks the missing SQLite evidence. A denied file read is a +coverage gap, not proof that the server has no errors. + +The helper reads only bounded ChatCut MCP runtime log records or debug log tails. +Codex SQLite locations follow `CODEX_SQLITE_HOME` when present. If the current +host already supplies its actual `sqlite_home` or `log_dir`, pass that directory +with `--codex-sqlite-home` or `--codex-log-dir`; do not read whole config files to +find it. These options select existing log locations and do not enable logging. +On Windows, SQLite error 14 can coexist with readable WAL files in a read-only +sandbox. The report preserves the original error and bounded file-access facts; +it does not prove a specific security product caused the restriction. Never copy +the whole log database, ignore its WAL with immutable mode, or relax permissions. +On Windows, the helper also reads the existing Codex App startup-status notices +under `%LOCALAPPDATA%/Codex/Logs/YYYY/MM/DD`. This is independent of TUI logging. +It selects only `AppServerConnection` startup notices for the hosted ChatCut +server, uses the current conversation ID when available, and keeps at most 30 +records from three 128 KiB tails. Missing/rotated/denied logs remain explicit +gaps. The known Windows MXC `LOCALAPPDATA` sandbox redirect falls back to the +real user's `AppData/Local` without changing permissions. An already supplied alternative App log root can be selected with +`--codex-app-log-dir`; do not scan browser profiles or session transcripts. + +The report names selected/omitted native files and byte ranges that were not read. +For an existing reported gap, use `--codex-app-log-file ` +to select at most three known native files. With exactly one selected file, +`--codex-app-log-offset ` reads at most 128 KiB from +that segment; unread ranges remain explicit. Preserve the original failure's +known time/window. Do not repeatedly upload replacement reports or scan entire +log history to fill an unknown failure time. App startup notices cover startup; +they do not cover every later tool-call failure. SQLite/native log extraction +retains available local conversation/call IDs without promoting them to original +server request IDs or independently confirmed attempt associations. +For a separately chosen future reproduction, Codex's explicit `log_dir` setting +can enable its native plaintext log. Do not change global settings or relaunch +the user's host automatically. Existing logs cannot be reconstructed retroactively. +It never reads `auth.json`, OAuth credential stores, session transcripts, or +other project files. It makes one unauthenticated initialize probe and at most +five public metadata GETs, all within the selected origin and with a 3.5-second +timeout each. An anonymous 401 challenge is expected. It validates resource / +issuer consistency and this product's OAuth-code/PKCE metadata. It never logs in, +refreshes credentials, or lists tools using extracted credentials. + +Codex exec prints diagnostics inline; `--json` stdout alone is not a complete +MCP error stream. Preserve the original stderr/startup error when it is already +visible to the current host. The helper cannot recover discarded parent-process +stderr or reconstruct an old exec failure from App logs. + +If the current host already exposes native `mcpServer/startupStatus/updated` +notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, +`runtimeStatus` and `toolsError` from that same thread in the error file and +snapshot observations (`source: current_session`). Record the native method and +actual thread ID. Authentication inventory alone is not proof of initialization +or usable tools. The status query may perform new auth-discovery requests; keep +those separate from retained runtime errors. Do not start another app-server, +start/reload a thread, invoke OAuth, or launch a daemon to obtain the original +state. These interfaces are not guaranteed to be exposed to a third-party Skill. +`codex doctor --json` is a general installation/config/auth health check, not a +targeted export of the failed MCP attempt. Do not ingest its entire report. +`feedback/upload` submits to OpenAI; it is not ChatCut's log export endpoint. + +The CLI also makes at most two sequential anonymous metadata GETs to compare +the relevant path-scoped and root endpoints at the HTTP/1.1 framing level. +`wireProbes` records selected response headers, request ID, first framing byte, +chunk validity, bounded byte counts/hashes and peer/TLS evidence from that same +connection. It retains no metadata body, cookies or credentials. Each probe +stops at 128 KiB / 3.5 seconds. These are direct connections, explicitly outside +the host/fetch proxy route; normal certificate verification remains enabled. +Complete fixed-length/chunked messages finish at their verified boundary without +waiting for peer close. Invalid HTTP status lines and unsupported informational +responses never count as complete final responses. `completionReason` and +`peerEndObserved` distinguish message completion from connection termination. + +When an HTTPS request/body fails, one additional direct TLS connection may +record the peer certificate issuer and protocol, with the same timeout and +normal certificate verification. This is a separately labelled route, not the +original host's TLS/proxy path. Proxy/CA environment presence is boolean only. +A healthy later check cannot establish recovery or identify antivirus/VPN as +the cause. A disabled connection sends neither probes nor an upload. + +## Interpret the report + +- `originalFailure` is the original error supplied by the Agent. `hostLogs` + are matching records in a bounded time window, with unconfirmed attempt + association. `probes` are new requests made later. Keep these separate. +- `hostSnapshot` is Agent-supplied evidence with per-group sources, not an + independently verified host API result. `evidenceSummary` keeps original + stage observations separate from independent checks. Unknown stages and + unfamiliar errors remain available; missing stages are `not_observed`. +- Timestamped text records outside the requested window are excluded. Records + without parseable timestamps are labelled time-unknown, never window matches. +- Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. + A healthy later probe never proves the original host connection recovered. +- Preserve original error messages and codes. Redact actual credentials, not + entire error categories. No separate metadata response body is retained; + original parser errors may quote response text. +- Missing debug logs cannot be reconstructed. Claude's detailed debug logs may + require `claude --debug=mcp` during a future, separately chosen reproduction; + this helper does not enable logging or relaunch Claude. +- A `diagnosticId` identifies the diagnostic report and its new probes. It is + not the original failed request's ID. Server response request IDs are useful + only when actually present and matched; time/path matching is weaker evidence. +- Match a wire response's `x-request-id` with the server's metadata response + record. The application record observes bytes before Node's HTTP framing; + a successful finish does not prove valid framing after Node/ALB/local HTTPS + inspection, nor receipt by Codex. Different requests may have different paths. + +The local JSON is written before any upload. `ingest_accepted` means that the +endpoint acknowledged the batch; `storageVerified:false` remains until support +finds every fragment in the final log store. Failed uploads preserve the local +report and upload error; do not automatically repeat the run or claim success. +Output larger than the upload budget stays local with an explicit upload error. + +Return the diagnostic ID, local report location, one new observed fact, and the +next action justified by it. If no new actionable fact was obtained, state the +specific missing evidence. Do not assign blame to antivirus, VPN, firewall, host, +or service merely from a generic failure message. Do not claim a user failure +percentage from diagnostic samples. + +This branch runs when the Agent loads it and has access to local execution. It +is not an installation hook or an all-user failure monitor. It cannot inspect a +web host's remote connection process or internal logs. With Claude Code tool +search disabled, connection failures may not be passed to the Agent at all. diff --git a/codex/skills/chatcut-mcp-doctor/references/discovery.md b/codex/skills/chatcut-mcp-doctor/references/discovery.md new file mode 100644 index 0000000..38d0259 --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/references/discovery.md @@ -0,0 +1,42 @@ +# Discovery and fallback + +The Skill is a local package, not a tool served by the MCP being diagnosed. +Use the host's actual skill catalog to check it loaded. A folder existing on disk +does not prove a command is available in the current chat. + +| Host/package | Entry | Verification | +| --- | --- | --- | +| Codex ChatCut Git plugin | Skill picker; `$chatcut:chatcut-mcp-doctor` | Confirm the named Skill and path in the running host catalog; invoke it and obtain a local report | +| Codex existing `chatcut-desktop` store identity | Skill picker; `$chatcut-desktop:chatcut-mcp-doctor` | Confirm the installed version and actual catalog entry; store approval/update is separate from Git availability | +| Codex standalone project/personal copy | `$chatcut-mcp-doctor` | Confirm it in the catalog, then invoke it | +| Claude Code ChatCut plugin | `/chatcut:chatcut-mcp-doctor` | Confirm the command in `/` or `/skills`, then invoke it | +| Claude personal/project Skill copy | `/chatcut-mcp-doctor` | Confirm the same catalog before use | + +Official entry references: [OpenAI skills](https://developers.openai.com/plugins/build/skills), +[Claude Code skills](https://code.claude.com/docs/en/skills). +Claude's namespaced plugin commands differ from Codex's `$` invocation. Do not +promise an unqualified `/doctor`; it can refer to the host's own health check. + +If the current package lacks Doctor, explain that its version does not include +this Skill. The existing collector may still be bundled at +`skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs` (Claude: +`chatcut-plugin-basics-claude`). That collector writes JSON but older versions do +not include these summary/draft sidecars. + +An agent with a trusted local copy of this repository can read this SKILL.md +directly and execute `scripts/doctor.mjs` without installing or connecting MCP. +For an explicitly requested standalone installation, copy the complete +`chatcut-mcp-doctor` directory (including scripts and references) into a host +supported project/personal skills directory, then verify discovery in a new +host session if necessary. Codex supports project `.agents/skills/`; Claude uses +`.claude/skills/`. Do not change global settings or install it merely to inspect +an error. A standalone copy may have no parent plugin manifest; that is a package +version evidence gap and does not block collection. + +For users without a local copy, the official plugin sources/install guidance are +[agent-plugin](https://github.com/ChatCut-Inc/agent-plugin), +[Codex setup](https://chatcut.io/chatgpt), and +[Claude Code setup](https://chatcut.io/claude). Verify a released package actually +contains this Skill before recommending it (Codex 1.10.22 / Claude plugin 1.10.24 +or later). Git source availability does not prove store approval or a host update. +Do not download and execute an unreviewed remote shell script. diff --git a/codex/skills/chatcut-mcp-doctor/references/repair-and-recheck.md b/codex/skills/chatcut-mcp-doctor/references/repair-and-recheck.md new file mode 100644 index 0000000..c42f67e --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/references/repair-and-recheck.md @@ -0,0 +1,131 @@ +# Repair and recheck + +The helper is read-only with respect to host configuration and credentials. +Keep a separate repair note beside the report with: observed problem and source, +authorized change, prior state/rollback, actual commands, and same-host recheck +result. This is agent-recorded evidence, not a collector-verified recovery claim. + +## Missing manual registration — bounded configuration repair + +Only use this path when the actual host reports that the named registration is +absent and the user wants that host connected. A plugin-managed server should +be enabled/installed through its existing plugin UI; do not add a duplicate +manual server to bypass a failed plugin installation. An absent tool list alone +does not establish missing registration. + +A failed lookup is not necessarily an absent registration. Record the exact +host response and its source/scope. Only an explicit no-entry response from a +completed host lookup (or a successful complete scoped inventory) permits +`lookupResult: not_found` in the registration observation. Permission errors, +configuration parse failures, unavailable CLI/status APIs and generic nonzero +exit codes leave registration existence unknown: use `unavailable` or omit +the field, retain the error and check the same scope read-only. Do not add or +replace an entry as a response to an unreadable state. A `found` response can +coexist with a later connection/health failure and must not trigger registration. + +Confirm the intended endpoint/environment from the user's setup or trusted +package metadata. Production is `https://api.chatcut.io/api/external-mcp/mcp`; +preserve beta/dev when selected. Check local CLI help for the installed version. +Examples below register **only a missing manual server**, using a scope the user +selected. Do not overwrite an existing registration or display headers/tokens. + +Codex CLI: + +```text +codex mcp get chatcut +codex mcp add chatcut --url +codex mcp get chatcut +``` + +Codex writes to the selected `CODEX_HOME`. Do not change that value to a different +host/profile as a workaround. The exact absent-entry lookup can be retained as +the `host_registration` observation. Rollback for a newly created manual entry +is `codex mcp remove chatcut` in that same home. Lookup success proves the entry +exists, not that OAuth, initialization or tools work. + +Claude Code CLI: + +```text +claude mcp get chatcut +claude mcp add --transport http --scope local chatcut +claude mcp get chatcut +``` + +Use the actual desired scope (`local`, `project`, `user`) rather than silently +escalating it. For the newly added local entry, rollback is +`claude mcp remove --scope local chatcut`. Plugin server names such as +`plugin:chatcut:chatcut` are not manual entries to recreate. Verify commands +against the installed CLI; report unavailable CLI capabilities explicitly. + +## Authentication required — guided host action + +An initial sign-in requirement is normal setup, not proof of a failure. For an +already registered Codex manual server, the host supports +`codex mcp login `. For Claude Code, use `/mcp` and choose the +actual server's authentication action in the interactive host. Recent CLI versions +also support `claude mcp login `; verify local help before +offering that command on an older version. A browser/account sign-in alone is insufficient. + +A bounded Codex SQLite startup WARN from `codex_mcp::rmcp_client` can explicitly +report `OAuth refresh credentials for server chatcut are missing an authorization +server issuer; authorization required: OAuth authorization required`. Preserve +that native response, source and record time as **unassociated host evidence**. +Check the same host/server's current authentication status first; offer its normal +authorization action only if authorization is still required and the user wants +to reconnect. A historical warning does +not establish which original request failed, why the stored refresh state lacks +metadata, or that credentials should be removed. Do not derive this guidance +from a generic WARN, busy error or timeout. An explicitly observed `oauth` failure +is an authentication-attempt fact; a timeout there is not proof of network or +registration failure. A `browser_render` error while tools remain connected is +a tool execution fact and does not call for reconnecting MCP. + +The collector does not perform OAuth login. Use the host's guided action and respect +the user's login/permission boundaries; never inspect or clear credential +stores, copy tokens, or change OAuth settings. If a login attempt fails, preserve +the actual new error separately from the original failure. After authorized +sign-in, verify authenticated status and a safe read in that same host. + +## Explicit errors — scoped next checks + +Keep these as distinct observations, using the actual original error or a +separately attributed server record. A server-side record without an established +request match must not be inserted as a stage of the local report. + +- `Another External MCP request is still using this session` confirms that this + request received a session-occupied rejection. Check existing in-flight + operations in the same project/session and their status first. It does not + establish a stuck lock. Do not clear locks or issue concurrent retries. +- An original call timing out after 300 seconds establishes that call's timeout. + Preserve its original time and call/request IDs when available; confirm whether + its existing backend operation completed or is still running through a safe + status read. Do not infer session occupancy, cancel it or replay a mutation. +- `Waiting for browser execution timed out. The browser job may still be running.` + is an execution-wait timeout, not proof of an MCP disconnect or a failed job. + Read the existing job's status using its actual job/project ID before any next + action. If no job ID is available, retain that gap; do not submit the job again. +- `Bad Request: Unsupported protocol version: (supported versions: + )` is an explicit protocol rejection. Preserve the original text, + requested/supported versions and actual host/server versions; inspect their + protocol negotiation and check a compatible combination within that setup. + If the matched server record shows final OAuth acceptance, do not respond by + repeatedly logging in. Do not automatically edit protocol headers or downgrade + a global CLI. Earlier JWT-verification errors alone do not override the final + OAuth verification result. + +An anonymous 401 remains an expected authentication challenge. An authenticated +request's explicit `Invalid bearer token` rejection is different: preserve the +final OAuth result and its supplied reason. Use the existing host's normal +authorization action when required; do not invent an expiry reason when the +original reason is unavailable or treat an intermediate JWT rejection followed +by OAuth acceptance as failed authentication. + +## Other failures + +For startup/metadata/network errors, use observed status, original code/cause, +bounded native logs and later probe failures to choose the next read-only check. +No generic repair for 300-second timeouts, busy requests, malformed framing, +TLS interception or a missing historical log has been established. Do not clear +locks, kill processes, restart the host, increase timeouts or switch environment +based only on those symptoms. Preserve the report and prepare the private +feedback draft when the cause remains unconfirmed. diff --git a/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs new file mode 100644 index 0000000..3d4bdba --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -0,0 +1,166 @@ +import { redact } from "./mcp-doctor.mjs"; + +// Findings name observed boundaries, not inferred causes or user failure rates. +export function diagnose(report) { + const findings = []; + const add = (kind, evidence, next, severity = "attention", observed) => + findings.push({ kind, severity, evidence, next, ...(observed && { observed }) }); + const snapshot = report.hostSnapshot; + const session = snapshot?.session; + const observations = snapshot?.observations ?? []; + const failed = observations.map((item, index) => ({ ...item, index })) + .filter((item) => item.outcome === "failed"); + const original = report.originalFailure; + if (report.upload?.status === "skipped_intentionally_disabled" || snapshot?.configuration?.enabled === false || session?.connectionStatus === "disabled") + add("intentionally_disabled", ["hostSnapshot"], "Preserve the disabled choice; do not enable or probe it.", "info"); + for (const [index, item] of observations.entries()) { + const evidence = [`hostSnapshot.observations[${index}]`]; + if (item.stage === "host_registration" && item.operation === "lookup_registered_server" && + ["cli_configuration", "host_settings"].includes(item.source)) { + const observed = { + source: item.source, outcome: item.outcome, + lookupResult: item.lookupResult ?? "unavailable", + messageExcerpt: redact(item.message ?? "").slice(0, 2000), + }; + if (item.lookupResult === "not_found" && ["success", "failed"].includes(item.outcome)) + add("not_registered", evidence, "The host explicitly reported no entry in the queried scope. Confirm the intended host/scope/endpoint; use the registration repair guide, then check the same scope again.", "attention", observed); + else if (item.outcome !== "success" || item.lookupResult === "unavailable") + add("registration_lookup_unavailable", evidence, "The lookup did not establish absence. Preserve its native error and check the same host/scope through an available read-only status entry; do not add or replace a registration on this evidence.", "attention", observed); + continue; + } + if (item.outcome !== "failed") continue; + if (["initialize", "plugin_loading", "tools_list", "tool_discovery"].includes(item.stage)) + add("startup_or_discovery_failed", evidence, "Preserve the exact native error. Check the selected host's current status; follow only the matching repair guide."); + else if (["authorization", "token_exchange", "token_refresh", "oauth"].includes(item.stage)) + add("authentication_attempt_failed", evidence, "Use the host's authentication UI only if authorized; retain the actual auth failure and its event time. Do not clear credentials or infer a network/registration cause.", "attention", { + source: item.source, stage: item.stage, operation: item.operation ?? null, + observedAt: item.observedAt ?? null, + messageExcerpt: redact(item.message ?? "").slice(0, 2000), + }); + else if (["tools_call", "tool_call"].includes(item.stage)) + add("tool_call_failed", evidence, "Keep operation and original call IDs. Recheck only a safe read operation; never replay a write or generation just to test recovery."); + else if (item.stage === "browser_render") + add("tool_execution_failed", evidence, "The host reports a tool rendering/execution failure. Preserve its native error and current tool availability; this observation does not demonstrate an MCP disconnect or justify changing registration/authentication.", "attention", { + source: item.source, stage: item.stage, operation: item.operation ?? null, + connectionStatus: session?.connectionStatus ?? "unknown", + messageExcerpt: redact(item.message ?? "").slice(0, 2000), + }); + else + add("observed_stage_failed", evidence, "Retain the unfamiliar stage and error; root cause needs more evidence."); + } + if (session?.authStatus === "needs_authentication" || session?.connectionStatus === "needs_authentication") + add("authentication_required", ["hostSnapshot.session"], "A sign-in requirement can be normal first use. Use the actual host's MCP sign-in entry; do not infer a broken token.", "info"); + if (session?.connectionStatus === "failed" && !failed.length) + add("host_connection_failed_stage_unconfirmed", ["hostSnapshot.session"], "The host reports a failed connection. Preserve its native startup/runtime error if exposed; the exact stage and cause are not yet established."); + if (session?.authStatus === "authenticated" && session?.visibleToolCount === 0) + add("authenticated_tools_absent", ["hostSnapshot.session"], "Authentication inventory does not prove initialization or tools/list success. Preserve the host's discovery status and error before reconnecting."); + if (original?.status === "recorded" && !failed.length) { + if (["initialize", "tools/list"].includes(original.operation)) + add("startup_or_discovery_failed", ["originalFailure.operation", "originalFailure"], "The original record identifies the failed startup/discovery operation. Preserve its native error; later healthy probes do not establish host recovery."); + else if (original.source === "agent_supplied_host_tool_result" && original.operation) + add("tool_call_failed", ["originalFailure.operation", "originalFailure"], "Preserve the actual failed host tool result. Recheck only a safe read operation after an authorized change; do not replay a write or generation."); + else + add("original_failure_recorded_stage_unconfirmed", ["originalFailure"], "Preserve this original error. Obtain its host stage/status if available; do not infer a cause from message keywords."); + } + const probes = [report.entryProbe, ...(report.probes ?? [])].filter(Boolean); + probes.forEach((probe, index) => { + if (["failed", "http_rejected", "protocol_error", "missing_authentication_challenge", "invalid_initialize_response", "invalid_metadata_shape", "invalid_metadata"].includes(probe.outcome) || + (index > 0 && probe.outcome !== "metadata_read")) + add("later_network_or_metadata_check_failed", [index === 0 && report.entryProbe ? "entryProbe" : `probes[${index - (report.entryProbe ? 1 : 0)}]`], + "This is a later anonymous check, not the original attempt. Retain the URL, status/error and request ID; it does not establish VPN, firewall or service blame."); + }); + (report.wireProbes ?? []).forEach((probe, index) => { + const invalid = ["failed", "invalid_framing", "invalid_response", "unsupported_response"].includes(probe.outcome) || probe.framing?.status === "invalid"; + const incomplete = probe.outcome === "incomplete" || probe.framing?.status === "incomplete" || probe.captureLimited === true || Boolean(probe.headerGap); + if (!invalid && !incomplete) return; + add(invalid ? "later_wire_check_failed" : "later_wire_check_incomplete", [`wireProbes[${index}]`], + "This later direct HTTP/1.1 check does not reproduce the original host/fetch route. Retain its framing, completion/error details and actual request ID for targeted comparison; it does not prove the original attempt failed or recovered, or identify ALB/proxy blame.", "attention", { + outcome: probe.outcome, route: probe.route ?? null, + httpStatus: probe.status ?? null, + framingStatus: probe.framing?.status ?? null, + framingReason: probe.framing?.reason ?? null, + completionReason: probe.completionReason ?? null, + headerGap: probe.headerGap ?? null, captureLimited: probe.captureLimited ?? false, + errorCode: probe.error?.code ?? null, + messageExcerpt: redact(probe.error?.message ?? "").slice(0, 2000), + }); + }); + (report.hostLogs?.entries ?? []).forEach((entry, index) => { + const message = entry.message ?? ""; + // One observed native startup response, not a generic keyword/root-cause + // classifier. A warning alone or another module/message is insufficient. + if (entry.source === "codex_runtime_sqlite" && entry.target === "codex_mcp::rmcp_client" && + ["WARN", "ERROR"].includes(entry.level) && + message === 'MCP server startup failed server_name="chatcut" error=OAuth refresh credentials for server chatcut are missing an authorization server issuer; authorization required: OAuth authorization required') { + add("host_reauthorization_required_unassociated", [`hostLogs.entries[${index}]`], + "This native Codex startup record explicitly required OAuth authorization at its recorded time. Check the same host/server's current authentication status first; offer its normal sign-in action only if authorization is still required and the user wants to reconnect. Do not clear or edit credentials, add a duplicate registration, or infer association with the original failed call.", "attention", { + source: entry.source, level: entry.level, target: entry.target, + state: "authorization_required", occurredAt: entry.occurredAt ?? null, + originalAttemptMatch: "unconfirmed", + messageExcerpt: redact(message).slice(0, 2000), + }); + return; + } + let status; + if (entry.source === "codex_app_log") { + // The native App event's state, rather than words inside its error text. + if (!/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test(message)) return; + status = [...message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1]; + if (status !== "failed") return; + } else { + if (!["codex_runtime_sqlite", "codex_tui_log", "claude_debug_log"].includes(entry.source)) return; + const nativeErrorLevel = String(entry.level ?? "").toUpperCase() === "ERROR" || + /^(?:\d{4}-\d{2}-\d{2}T\S+\s+)?(?:\[ERROR\]|ERROR)(?:\s|$)/i.test(message); + if (!nativeErrorLevel) return; + } + add("host_runtime_error_unassociated", [`hostLogs.entries[${index}]`], + "A bounded native host error was collected, but its association with the original failed attempt is unconfirmed. Inspect the local record and available time/call context; preserve missing or truncated coverage and do not infer a root cause or recovery.", "attention", { + source: entry.source, ...(status ? { status } : { level: "ERROR" }), + occurredAt: entry.occurredAt ?? null, + originalAttemptMatch: "unconfirmed", + messageExcerpt: redact(message).slice(0, 2000), + }); + }); + if (!findings.some((item) => item.severity === "attention") && original?.status !== "recorded") + add("no_incident_demonstrated", ["originalFailure.status", "hostSnapshot", "entryProbe"], + "No original incident has been demonstrated. An anonymous 401 is expected; authenticated host tools still need separate verification.", "info"); + const gaps = [ + ...(report.evidenceSummary?.gaps ?? []).map((reason) => ({ source: "evidenceSummary", reason })), + ...(report.gaps ?? []), ...(report.hostLogs?.gaps ?? []), + ]; + return { + schemaVersion: 1, diagnosticId: report.diagnosticId, + findings, gaps, rootCause: "unconfirmed", recoveryVerified: false, + provenance: { + original: "originalFailure; supplied original error, time and IDs only", + host: "hostSnapshot is agent supplied; hostLogs are bounded and attempt association may be unknown", + later: "entryProbe/probes/wireProbes are independent later requests; success never proves original recovery", + }, + }; +} + +function publicExcerpt(text, original = {}) { + let excerpt = redact(text ?? "Unavailable; original error must not be invented."); + for (const id of [original.callId, original.conversationId]) + if (typeof id === "string" && id.length) excerpt = excerpt.replaceAll(id, "[local ID removed]"); + return excerpt + .replace(/\b((?:call|conversation|thread|session)[_-]?id\s*[:=]\s*)["']?[^\s,"'<>]+/gi, "$1[local ID removed]") + .replace(/[A-Za-z]:[\\/][^\r\n"<>]*/g, "[local path removed]") + .replace(/\/(?:Users|home|tmp|var)\/[^\s"<>]+/g, "[local path removed]") + .replace(/\b[^\s@]+@[^\s@]+\.[a-z]{2,}\b/gi, "[email removed]") + .slice(0, 2000); +} + +export function renderPacket(report, diagnosis) { + const facts = (includePrivateExcerpts) => diagnosis.findings.map((item) => { + const observed = item.observed && Object.fromEntries(Object.entries(item.observed) + .filter(([key]) => includePrivateExcerpts || key !== "messageExcerpt")); + return `- ${item.kind}: ${item.next} Evidence: ${item.evidence.join(", ")}${observed ? ` Observed: ${redact(JSON.stringify(observed))}` : ""}`; + }).join("\n"); + const gaps = diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? gap.error?.message ?? "see local report"}`).join("\n") || "- None recorded; coverage is still bounded."; + const common = (includePrivateExcerpts) => `Diagnostic ID: ${report.diagnosticId}\nHost: ${report.host} ${report.hostVersion ?? "version unknown"}\nCollector: ${report.collectorVersion}; package: ${report.pluginVersion ?? "unknown"} (not proof of loaded version)\n\n${facts(includePrivateExcerpts)}\n\nRoot cause: unconfirmed. Original host recovery: unverified.\n`; + const summary = `# ChatCut MCP diagnosis\n\n${common(true)}\n## Evidence boundaries\n\n${Object.values(diagnosis.provenance).map((value) => `- ${value}`).join("\n")}\n\n## Original failure\n\n${redact(JSON.stringify(report.originalFailure, null, 2))}\n\n## Missing evidence\n\n${gaps}\n\n## Feedback\n\nUpload: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. An ingest receipt is not a support ticket or proof of complete storage.\nKeep this report for targeted follow-up; do not rerun collection just to resend it.\n`; + // Deliberately excludes host log dumps, local call/conversation IDs, paths and full stacks. + const draft = `# ChatCut MCP issue draft — review before sharing\n\n${common(false)}\n## Symptom and reproduction\n\n[Add the user's symptom and minimal safe steps; do not replay a high-impact operation.]\n\n## Original error excerpt\n\n${publicExcerpt(report.originalFailure?.message, report.originalFailure)}\n\nOriginal failure time: ${report.originalFailure?.occurredAt ?? "unknown"}. Later checks are not this attempt.\n\n## Checks and attempted repairs\n\nDoctor collected the original error if available, bounded host logs and later anonymous probes when allowed. No repair is performed by the helper.\n[Record any actual authorized change and the same-host verification result. Leave unperformed steps unperformed.]\n\n## Evidence gaps\n\n${diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? "details retained locally"}`).join("\n")}\n\n## Delivery\n\nDiagnostic ingest: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. No ticket has been opened.\nUse https://chatcut.io/contact for private support. A public issue destination has not been selected; do not publish automatically. Review the excerpt for project names, personal data and secrets before sharing. Keep the full local JSON private unless an appropriate recipient requests it.\n`; + return { summary, draft }; +} diff --git a/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs new file mode 100644 index 0000000..ae58b09 --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -0,0 +1,76 @@ +import { readFile, stat, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { basename, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCli, submitReport, errorDetails } from "./mcp-doctor.mjs"; +import { diagnose, renderPacket } from "./diagnosis.mjs"; + +export async function writePacket(path, report) { + const diagnosis = diagnose(report); + const { summary, draft } = renderPacket(report, diagnosis); + const files = { diagnosis: `${path}.diagnosis.json`, summary: `${path}.summary.md`, issueDraft: `${path}.issue-draft.md` }; + // These are derived sidecars. The source report and diagnostic ID stay unchanged. + await writeFile(files.diagnosis, JSON.stringify(diagnosis, null, 2) + "\n", { mode: 0o600 }); + await writeFile(files.summary, summary, { mode: 0o600 }); + await writeFile(files.issueDraft, draft, { mode: 0o600 }); + return { diagnosticId: report.diagnosticId, report: path, ...files, findings: diagnosis.findings, upload: report.upload, recoveryVerified: false }; +} + +async function readReport(path) { + // Do not accept config/auth/session files even as a mistakenly supplied input. + if (!/(?:\.mcp-report|chatcut-mcp-doctor-[a-f0-9-]+)\.json$/i.test(basename(path))) + throw new Error("Use the dedicated .mcp-report.json or default chatcut-mcp-doctor-.json report, never config/auth/session files"); + if ((await stat(path)).size > 2 * 1024 * 1024) throw new Error("Report exceeds 2 MiB; retained locally without reading"); + const bytes = await readFile(path); + const report = JSON.parse(bytes); + if (report.schemaVersion !== 2 || report.scope !== "cloud" || !["codex", "claude"].includes(report.host) || + !/^[a-f0-9-]{36}$/i.test(report.diagnosticId ?? "") || !report.originalFailure || !report.hostLogs) + throw new Error("Not a schema-2 ChatCut MCP report"); + return { report, sourceReportSha256: createHash("sha256").update(bytes).digest("hex") }; +} + +async function readReceipt(path, report, sourceReportSha256) { + try { + if ((await stat(path)).size > 32 * 1024) throw new Error("Upload receipt exceeds 32 KiB"); + const stored = JSON.parse(await readFile(path, "utf8")); + if (stored.schemaVersion !== 1 || stored.diagnosticId !== report.diagnosticId || + stored.sourceReportSha256 !== sourceReportSha256 || !stored.receipt || + typeof stored.receipt.status !== "string" || stored.receipt.storageVerified !== false) + throw new Error("Upload receipt does not match this diagnostic report; preserve both files for review"); + return stored.receipt; + } catch (error) { if (error.code !== "ENOENT") throw error; } +} + +export async function main(args = process.argv.slice(2)) { + let result; + if (["--report", "--upload-report"].includes(args[0])) { + if (args.length !== 2) throw new Error("Use --report or --upload-report alone"); + const path = resolve(args[1]); + const { report, sourceReportSha256 } = await readReport(path); + const receiptPath = `${path}.upload.json`; + const prior = await readReceipt(receiptPath, report, sourceReportSha256); + if (args[0] === "--upload-report") { + if (report.upload?.status === "ingest_accepted") + throw new Error("Ingest already acknowledged this report; do not send it again"); + if (prior?.status === "ingest_accepted") throw new Error("Ingest already acknowledged this report; do not send it again"); + report.upload = await submitReport(report); + await writeFile(receiptPath, JSON.stringify({ schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, receipt: report.upload }, null, 2) + "\n", { mode: 0o600 }); + } else { + if (prior) report.upload = prior; + } + result = { path, report }; + } else { + if (args.includes("--help")) { + console.log("ChatCut MCP Doctor: collector options below also produce a diagnosis, summary and private issue draft. --offline sends no requests. --report rebuilds feedback offline. --upload-report sends that report once without new probes; use only for an authorized retry. No automatic repair or ticket submission."); + } + result = await runCli(args); + } + if (result) console.log(JSON.stringify(await writePacket(result.path, result.report))); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + }); +} diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs new file mode 100644 index 0000000..326b4fd --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -0,0 +1,1373 @@ +import { randomUUID, createHash } from "node:crypto"; +import { open, opendir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { homedir, tmpdir, platform, arch } from "node:os"; +import { join, resolve, basename } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { connect as connectTls } from "node:tls"; +import { isIP } from "node:net"; +import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; +import { probeMetadataWire } from "./mcp-wire.mjs"; +import { failureFromRecord } from "./mcp-failure.mjs"; + +const READ_LIMIT = 128 * 1024; +const UPLOAD_LIMIT = 256 * 1024; +const TIMEOUT_MS = 3500; +const CHATCUT_MESSAGE = + /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|\bplugin(?:_name)?[=: "\t]+chatcut(?:@chatcut-inc)?(?:["\s}:]|$)|https:\/\/(?:api\.chatcut\.(?:io|dev)|api-dev\.chatcut\.org)\//i; +const TEXT_ECHO = + /^(?:user (?:prompt|message)|assistant message|tool (?:call|result))\s*:|^(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[]/i; + +function redactFieldValue(_match, prefix, value) { + if (!value) return prefix; + const quote = value.match(/^\\*["']/)?.[0]; + return prefix + (quote ? quote + "[REDACTED]" + quote : "[REDACTED]"); +} + +export function redact(text, jsonDepth = 0) { + let value = String(text); + // A host may quote/stringify an entire JSON response inside its error. Decode + // only JSON string tokens containing this credential label, redact their full + // value, then restore the quoting. A partial escaped-field regex can leak the + // suffix after an escaped quote/backslash. Input collection is size bounded. + if (jsonDepth < 16) value = value.replace(/"(?:\\.|[^"\\\r\n])*"/g, (token) => { + try { + const decoded = JSON.parse(token); + if (/mcp[-_]session[-_]id/i.test(decoded)) { + const sanitized = redact(decoded, jsonDepth + 1); + if (sanitized !== decoded) return JSON.stringify(sanitized); + } + } catch { /* Non-JSON diagnostic text is retained for the normal rules. */ } + return token; + }); + return value + .replace(/\b(Mcp-Session-Id[ \t]*:[ \t]*)\S+/gi, "$1[REDACTED]") + .replace( + /(["']mcp[-_]session[-_]id["']\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*')/gi, + redactFieldValue, + ) + .replace( + /\b((?:mcp[-_]session[-_]id)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^\s"'<>]+)/gi, + redactFieldValue, + ) + .replace(/((?:\\*["'])mcp[-_]session[-_]id(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, "$1[REDACTED]") + .replace( + /\bBearer\s+(?!(?:token|required)\b|<|(?:realm|scope|error(?:_description|_uri)?|resource_metadata)\s*=)[A-Za-z0-9._~+/=-]+/gi, + "Bearer [REDACTED]", + ) + .replace(/\b(Authorization\s*[:=]\s*Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[REDACTED]") + .replace( + /\b((?:set-)?cookie\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|\[REDACTED\]|(?:(?![\r\n]|(?]+/gi, + "$1[REDACTED]", + ) + .replace( + /\b((?:access_?token|refresh_?token|id_?token|session_?token|client_?secret|api[_-]?key|token)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|[A-Za-z0-9._~+/=-]+)/gi, + redactFieldValue, + ) + .replace( + /((?:\\*["'])(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, + "$1[REDACTED]", + ) + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"); +} + +function clean(value) { + return JSON.parse( + JSON.stringify(value, (key, item) => + /^(?:mcp[-_]session[-_]id|access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( + key, + ) + ? "[REDACTED]" + : typeof item === "string" + ? redact(item) + : item, + ), + ); +} + +export function errorDetails(error, seen = new Set()) { + if (!error || typeof error !== "object") return { message: redact(error) }; + if (seen.has(error) || seen.size >= 8) return { unavailable: "error_chain_depth_or_cycle" }; + seen.add(error); + const result = {}; + for (const key of ["name", "message", "code", "errno", "errcode", "errstr", "syscall", "stack"]) { + if (typeof error[key] === "string" || typeof error[key] === "number") result[key] = error[key]; + } + if (error.cause) result.cause = errorDetails(error.cause, seen); + if (Array.isArray(error.errors)) { + result.errors = error.errors.slice(0, 8).map((item) => errorDetails(item, seen)); + if (error.errors.length > 8) result.omittedErrors = error.errors.length - 8; + } + return clean(result); +} + +export function serverUrl(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + const hosted = ["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes( + url.hostname, + ); + if ( + (!local && (!hosted || url.protocol !== "https:" || (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/api\/external-mcp\/(?:mcp|claude|v2\/mcp)\/?$/.test(url.pathname) + ) { + throw new Error( + "Unsupported registered MCP URL; preserve its configuration and report unsupported_endpoint", + ); + } + return url; +} + +async function limitedBody(response, limit = READ_LIMIT) { + const reader = response.body?.getReader(); + if (!reader) return ""; + const parts = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return Buffer.concat(parts).toString("utf8"); + bytes += value.length; + if (bytes > limit) { + const error = new Error(`Response exceeds ${limit} bytes; body not retained`); + error.code = "DIAGNOSTIC_BODY_LIMIT"; + throw error; + } + parts.push(Buffer.from(value)); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export async function probeMetadata(url, diagnosticId, fetchImpl = fetch, expected = {}) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url, + startedAt, + implementation: `node-fetch/${process.version}`, + stage: "request", + status: null, + bodyComplete: false, + jsonValid: null, + originalAttemptMatch: "unconfirmed", + }; + try { + const response = await fetchImpl(url, { + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { accept: "application/json", "x-chatcut-diagnostic-id": diagnosticId }, + }); + result.status = response.status; + result.responseHeaders = {}; + for (const key of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + "www-authenticate", + "retry-after", + "location", + ]) { + const value = response.headers.get(key); + if (value !== null) result.responseHeaders[key] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + result.stage = "json_parse"; + try { + const parsed = JSON.parse(body); + result.jsonValid = true; + result.metadataShape = { + object: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed), + issuerPresent: typeof parsed?.issuer === "string", + resourcePresent: typeof parsed?.resource === "string", + }; + result.metadataValidation = inspectMetadata(parsed, url, expected); + } catch (error) { + result.jsonValid = false; + throw error; + } + result.stage = "complete"; + const required = new URL(url).pathname.includes("oauth-protected-resource") + ? "resourcePresent" + : "issuerPresent"; + result.outcome = !response.ok + ? "http_rejected" + : result.metadataShape.object && result.metadataShape[required] + ? result.metadataValidation.valid + ? "metadata_read" + : "invalid_metadata" + : "invalid_metadata_shape"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +async function guardProbe(url, operation) { + const startedAt = new Date().toISOString(); + try { + return await operation(); + } catch (error) { + return { + evidenceKind: "later_probe", + url, + startedAt, + outcome: "failed", + stage: "probe_collector", + status: null, + originalAttemptMatch: "unconfirmed", + error: errorDetails(error), + gap: "partial_probe_evidence_unavailable; original_failure_retained", + }; + } +} + +export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: endpoint.href, + method: "POST", + operation: "initialize", + implementation: "node-fetch/" + process.version, + startedAt: new Date().toISOString(), + stage: "request", + status: null, + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + }; + try { + const response = await fetchImpl(endpoint.href, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "x-chatcut-diagnostic-id": diagnosticId, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: diagnosticId, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.3.0" }, + }, + }), + }); + result.status = response.status; + result.responseHeaders = {}; + for (const name of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "www-authenticate", + "retry-after", + "location", + "x-request-id", + "x-amzn-trace-id", + "server", + "via", + ]) { + const value = response.headers.get(name); + if (value !== null) result.responseHeaders[name] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + let parsed; + try { + parsed = JSON.parse(body); + } catch (error) { + result.parseError = errorDetails(error); + if (!response.ok) result.responseError = { message: redact(body) }; + } + if (parsed?.error) result.protocolError = clean(parsed.error); + if (response.status === 401) { + const header = response.headers.get("www-authenticate") ?? ""; + const metadataHint = header.match(/resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i); + result.resourceMetadataUrl = metadataHint?.[1] ?? metadataHint?.[2] ?? null; + result.outcome = /\bBearer\b/i.test(header) + ? "authentication_challenge" + : "missing_authentication_challenge"; + } else if (!response.ok) result.outcome = "http_rejected"; + else if (parsed?.error) result.outcome = "protocol_error"; + else if (parsed?.jsonrpc === "2.0" && typeof parsed.result?.protocolVersion === "string") { + result.outcome = "anonymous_initialize_returned"; + result.serverInfo = parsed.result.serverInfo; + result.protocolVersion = parsed.result.protocolVersion; + } else result.outcome = "invalid_initialize_response"; + result.stage = "complete"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +export function inspectTransport(endpoint) { + if (endpoint.protocol !== "https:") + return Promise.resolve({ status: "not_applicable", reason: "not_tls" }); + return new Promise((resolveProbe) => { + const startedAt = new Date().toISOString(); + let settled = false; + const host = endpoint.hostname.replace(/^\[|\]$/g, ""); + const socket = connectTls({ + host, + port: Number(endpoint.port || 443), + rejectUnauthorized: true, + servername: isIP(host) ? undefined : host, + }); + const finish = (details) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolveProbe( + clean({ + evidenceKind: "later_probe", + implementation: "node:tls", + startedAt, + route: "direct_tls; does_not_reproduce_host_or_fetch_proxy_route", + originalAttemptMatch: "unconfirmed", + ...details, + }), + ); + }; + const timer = setTimeout( + () => + finish({ + status: "failed", + error: { + name: "TimeoutError", + message: "Direct TLS context timed out", + code: "ETIMEDOUT", + }, + }), + TIMEOUT_MS, + ); + socket.once("secureConnect", () => { + const certificate = socket.getPeerCertificate(); + finish({ + status: "observed", + tlsProtocol: socket.getProtocol(), + authorized: socket.authorized, + certificate: { + issuer: certificate.issuer, + validFrom: certificate.valid_from, + validTo: certificate.valid_to, + fingerprint256: certificate.fingerprint256, + }, + }); + }); + socket.once("error", (error) => finish({ status: "failed", error: errorDetails(error) })); + }); +} + +function logMessage(body) { + const marker = body.lastIndexOf("}:"); + return (marker < 0 ? body : body.slice(marker + 2)).trim(); +} + +function runtimeContext(body) { + const boundary = body.lastIndexOf("}:"); + if (boundary < 0) return {}; + const context = body.slice(0, boundary); + const value = (field) => + [ + ...context.matchAll( + new RegExp(`(?:^|[\\s{])${field}="?([A-Za-z0-9_.-]{1,256})(?:"|[\\s}:]|$)`, "g"), + ), + ].at(-1)?.[1]; + const conversationId = value("thread_id") ?? value("thread\\.id") ?? value("conversation\\.id"); + const callId = value("call_id"); + return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; +} + +function hostedTextRecord(line) { + if (/^\s/.test(line)) return false; + // Inspect explicit echo prefixes before stripping trace context: a payload + // itself can contain "}:" and must not become a diagnostic via lastIndex. + if (/(?:^|\}:\s*)(?:tool (?:call|result)\s*:|(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[])/i.test(line)) return false; + let body = line.replace( + /^\d{4}-\d{2}-\d{2}T\S+\s+(?:(?:\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\]|TRACE|DEBUG|INFO|WARN|ERROR)\s+)?/i, + "", + ); + const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); + if (module) { + if ( + !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::)/.test(module[1]) + ) + return false; + body = body.slice(module[0].length); + } + if ( + /^(?:user(?: prompt| message)?|assistant(?: message)?|system|developer|transcript)\s*:|^[{[]/i.test( + body, + ) + ) + return false; + const scope = body.match( + /^(?:MCP server|Server)\s+(?:"([^"]+)"|'([^']+)'|((?:plugin:[\w.-]+:)?[\w.-]+))(?::\s*|\s+|$)/i, + ); + const context = body.split(/\berror\s*=/i)[0]; + const server = + context.match(/\bserver_name=["']?([\w:.-]+)/)?.[1] ?? scope?.[1] ?? scope?.[2] ?? scope?.[3]; + if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; + if (!CHATCUT_MESSAGE.test(body)) return false; + if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) return false; + const message = scope ? body.slice(scope[0].length) : logMessage(body); + // Runtime error contents can name tools; only explicit echo records are excluded. + if (TEXT_ECHO.test(message)) return false; + if ( + /^(?:Sending|Sent|Received|Receiving)\s+(?:MCP\s+|JSON-RPC\s+)?(?:request|response|message)\b/i.test( + message, + ) && + /"method"\s*:\s*"tools\/call"|"result"\s*:\s*\{\s*"content"\s*:/i.test(message) + ) + return false; + if (/^[{[]/.test(message)) { + try { + const value = JSON.parse(message); + const records = Array.isArray(value) ? value : [value]; + if ( + records.some( + (record) => + record && + (record.method === "tools/call" || + [ + "function_call", + "function_call_output", + "tool_call", + "tool_result", + "user", + "assistant", + "user_message", + "assistant_message", + ].includes(record.type) || + ["user", "assistant", "system", "developer"].includes(record.role) || + Array.isArray(record.result?.content)), + ) + ) + return false; + } catch { + // Unparseable diagnostic text is not proof of a tool or conversation envelope. + } + } + return true; +} + +async function textLog(path, source, gaps, window, select = hostedTextRecord) { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + const start = window.readOffset ?? Math.max(0, info.size - READ_LIMIT); + if (!Number.isSafeInteger(start) || start < 0 || start > info.size) + throw new Error("Log segment offset must point inside the selected existing file"); + const buffer = Buffer.alloc(Math.min(info.size - start, READ_LIMIT)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); + if (start) { + const previous = Buffer.alloc(1); + await handle.read(previous, 0, 1, start - 1); + if (previous[0] !== 10) lines.shift(); + gaps.push({ + source, + reason: "only_log_tail_read", + sourceFile: basename(path), + bytesBeforeWindow: start, + omittedBytes: start, + }); + } + if (start + bytesRead < info.size) { + if (!buffer.subarray(0, bytesRead).toString("utf8").endsWith("\n")) lines.pop(); + gaps.push({ + source, + reason: "only_log_segment_read", + sourceFile: basename(path), + offset: start, + bytesRead, + remainingBytes: info.size - start - bytesRead, + }); + } + const entries = []; + for (let i = 0; i < lines.length; i++) { + if (!select(lines[i])) continue; + const block = [lines[i]]; + while ( + source !== "codex_app_log" && + i + 1 < lines.length && + /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1]) && + !/^\s*(?:\d{4}-\d{2}-\d{2}T|MCP server\s|Server\s|\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\])/i.test( + lines[i + 1], + ) && + !TEXT_ECHO.test(lines[i + 1].trimStart()) + ) + block.push(lines[++i]); + const occurredAt = logTime(block[0]); + if ( + occurredAt && + (Date.parse(occurredAt) < window.since * 1000 || + Date.parse(occurredAt) > window.until * 1000) + ) + continue; + entries.push({ + source, + sourceFile: basename(path), + occurredAt, + timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", + message: redact(block.join("\n")), + ...runtimeContext(block[0]), + originalAttemptMatch: "unconfirmed", + }); + } + if (entries.length > 30) + gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); + const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; + if (unknownTimes) + gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); + return entries.slice(-30); + } finally { + await handle.close(); + } +} + +async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { + if ( + readOffset !== undefined && + (selectedFiles.length !== 1 || !Number.isSafeInteger(readOffset) || readOffset < 0) + ) + throw new Error( + "A segment offset requires exactly one selected native App log file and a nonnegative integer", + ); + const files = []; + const dates = new Set(); + for (const time of [window.since * 1000, window.until * 1000]) { + const date = new Date(time); + dates.add(date.toISOString().slice(0, 10).replaceAll("-", "/")); + dates.add( + [date.getFullYear(), date.getMonth() + 1, date.getDate()] + .map((value) => String(value).padStart(2, "0")) + .join("/"), + ); + } + let scanned = 0; + const deadline = Date.now() + 1500; + for (const date of dates) { + try { + const dir = join(root, date); + for await (const item of await opendir(dir)) { + if (++scanned > 200 || Date.now() > deadline) { + gaps.push({ source: "codex_app_log", reason: "directory_scan_limit" }); + break; + } + if (!item.isFile() || !/^codex-desktop-.*\.log$/.test(item.name)) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= window.since * 1000) files.push({ path, modified: info.mtimeMs }); + } + } catch (error) { + gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); + } + if (scanned > 200 || Date.now() > deadline) break; + } + files.sort((a, b) => b.modified - a.modified); + if (selectedFiles.length) { + if (selectedFiles.length > 3) + throw new Error("Select at most three existing Codex App log files"); + files.length = 0; + for (const path of [...new Set(selectedFiles)]) { + if (!/^codex-desktop-.*\.log$/.test(basename(path))) + throw new Error("Select only existing native codex-desktop-*.log files"); + const info = await stat(path); + if (!info.isFile()) throw new Error("Codex App log selection must be a file"); + files.push({ path, modified: info.mtimeMs }); + } + } + const entries = []; + let filesRead = 0; + for (const file of files.slice(0, 3)) { + try { + // App logs mix unrelated UI activity with runtime notices, so accept only this native event. + entries.push( + ...(await textLog(file.path, "codex_app_log", gaps, { ...window, readOffset }, (line) => { + if ( + !/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test( + line, + ) + ) + return false; + const server = [...line.matchAll(/(?:^|\s)server=([^\s]+)/g)].at(-1)?.[1]; + if (!["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; + const thread = [...line.matchAll(/(?:^|\s)threadId=([^\s]+)/g)].at(-1)?.[1]; + return !conversationId || !thread || thread === "null" || thread === conversationId; + })), + ); + filesRead++; + } catch (error) { + gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (files.length > 3) + gaps.push({ + source: "codex_app_log", + reason: "log_file_limit", + omitted: files.length - 3, + omittedFiles: files.slice(3, 13).map((file) => basename(file.path)), + }); + if (entries.length > 30) + gaps.push({ + source: "codex_app_log", + reason: "log_record_limit", + omitted: entries.length - 30, + }); + return { + entries: entries + .sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")) + .slice(-30), + scan: { + directoryEntriesScanned: scanned, + matchingFiles: files.length, + filesRead, + maximumTailBytesPerFile: READ_LIMIT, + currentConversationFilterApplied: Boolean(conversationId), + ...(conversationId && { conversationId }), + selectedFileMode: selectedFiles.length > 0, + ...(readOffset !== undefined && { selectedByteOffset: readOffset }), + filesSelected: files.slice(0, 3).map((file) => basename(file.path)), + observationScope: + "hosted_mcp_startup_notices_only; tool_request_failures_need_original_failure_record_or_matching_runtime_logs", + }, + }; +} + +async function inspectSqliteAccess(path) { + const files = {}; + for (const suffix of ["", "-wal", "-shm"]) { + const name = suffix || "database"; + let handle; + try { + const info = await stat(path + suffix); + handle = await open(path + suffix, "r"); + const header = Buffer.alloc(suffix ? 1 : 20); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + files[name] = { exists: true, readable: true, bytes: info.size }; + if ( + !suffix && + bytesRead === 20 && + header.subarray(0, 16).toString() === "SQLite format 3\0" + ) { + files[name].journalMode = header[18] === 2 ? "wal" : "rollback"; + } + } catch (error) { + files[name] = { readable: false, error: errorDetails(error) }; + } finally { + try { + await handle?.close(); + } catch (error) { + files[name].closeError = errorDetails(error); + } + } + } + return { + files, + interpretation: + "File reads and SQLite WAL/shared-memory access are different operations; readable files do not prove SQLite access. No copy, checkpoint, immutable mode or permission change attempted.", + }; +} + +export async function collectHostLogs(host, options = {}) { + const userHome = options.home ?? homedir(); + const gaps = []; + const entries = []; + let appLog; + const now = options.now ?? Date.now(); + const center = options.occurredAt ? Date.parse(options.occurredAt) : now; + const since = Math.floor((center - (options.occurredAt ? 15 : 30) * 60_000) / 1000); + const until = Math.min(now, center + 15 * 60_000) / 1000; + if (host === "codex") { + const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); + const sqliteRoot = options.codexSqliteHome ?? process.env.CODEX_SQLITE_HOME ?? root; + const logRoot = options.codexLogDir ?? join(root, "log"); + const databasePath = join(sqliteRoot, "logs_2.sqlite"); + let db; + try { + await stat(databasePath); + const { DatabaseSync } = await import("node:sqlite"); + db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); + db.exec("PRAGMA query_only=ON"); + const hasThreadId = db + .prepare("PRAGMA table_info(logs)") + .all() + .some((column) => column.name === "thread_id"); + const rows = db + .prepare( + `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, + ) + .all(since, until); + for (const row of rows) { + const message = logMessage(row.feedback_log_body ?? ""); + const original = row.feedback_log_body ?? ""; + const context = runtimeContext(original); + if (row.thread_id) context.conversationId = row.thread_id; + if ( + options.conversationId && + context.conversationId && + context.conversationId !== options.conversationId + ) + continue; + if (!hostedTextRecord(`${row.target}: ${original}`) || + !hostedTextRecord(`MCP server "chatcut" ${message}`)) + continue; + if ( + !["ERROR", "WARN"].includes(row.level) && + !/omitting MCP server|cached MCP catalog/.test(message) + ) + continue; + entries.push({ + source: "codex_runtime_sqlite", + rowId: row.id, + occurredAt: new Date(row.ts * 1000).toISOString(), + target: row.target, + level: row.level, + message: redact(message), + ...context, + originalAttemptMatch: "unconfirmed", + }); + if (entries.length === 30) break; + } + if (rows.length === 200 || entries.length === 30) + gaps.push({ source: "codex_runtime_sqlite", reason: "log_record_limit" }); + } catch (error) { + gaps.push({ + source: "codex_runtime_sqlite", + reason: "unavailable", + error: errorDetails(error), + locationSource: options.codexSqliteHome + ? "explicit_host_location" + : process.env.CODEX_SQLITE_HOME + ? "CODEX_SQLITE_HOME" + : "CODEX_HOME_default; sqlite_home_config_override_not_inspected", + ...(platform() === "win32" && error?.errcode === 14 + ? { accessEvidence: await inspectSqliteAccess(databasePath) } + : {}), + }); + } finally { + db?.close(); + } + try { + entries.push( + ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { + since, + until, + })), + ); + } catch (error) { + gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); + } + const appData = options.localAppData ?? process.env.LOCALAPPDATA; + const sandboxAppData = + !options.localAppData && + /[\\/]Packages[\\/]sandbox\.[^\\/]+[\\/]AC(?:[\\/]|$)/i.test(appData ?? ""); + const appLogRoot = + options.codexAppLogDir ?? + ((options.platform ?? platform()) === "win32" + ? join( + // MXC redirects LOCALAPPDATA; the running App logs stay in the user profile. + (!sandboxAppData && appData) || join(userHome, "AppData", "Local"), + "Codex", + "Logs", + ) + : null); + if (appLogRoot) { + const result = await codexAppLogs( + appLogRoot, + gaps, + { since, until }, + options.conversationId, + options.codexAppLogFiles, + options.codexAppLogOffset, + ); + entries.push(...result.entries); + appLog = { + ...result.scan, + locationSource: options.codexAppLogDir ? "explicit_host_location" : "windows_default", + sandboxLocalAppDataRedirected: Boolean(sandboxAppData), + }; + if (!result.entries.length) + gaps.push({ source: "codex_app_log", reason: "no_matching_records_in_bounded_window" }); + } + } else { + const root = + options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(userHome, ".claude"); + try { + const dir = join(root, "debug"); + const names = await readdir(dir, { withFileTypes: true }); + const files = []; + const deadline = Date.now() + 1500; + for (const item of names) { + if (Date.now() > deadline) { + gaps.push({ source: "claude_debug_log", reason: "directory_scan_deadline" }); + break; + } + if (!item.isFile() || !item.name.endsWith(".txt")) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= since * 1000) files.push({ path, modified: info.mtimeMs }); + } + files.sort((a, b) => b.modified - a.modified); + for (const item of files.slice(0, 3)) + entries.push(...(await textLog(item.path, "claude_debug_log", gaps, { since, until }))); + if (files.length > 3) + gaps.push({ + source: "claude_debug_log", + reason: "log_file_limit", + omitted: files.length - 3, + }); + } catch (error) { + gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (!entries.length) + gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); + return clean({ + entries, + gaps, + ...(appLog ? { appLog } : {}), + window: { + since: new Date(since * 1000).toISOString(), + until: new Date(until * 1000).toISOString(), + }, + }); +} + +export function fragmentEvents(report) { + const json = JSON.stringify(clean(report)); + if (Buffer.byteLength(json) > UPLOAD_LIMIT) + throw new Error("Report exceeds upload budget; complete local report retained"); + const chunks = []; + let chunk = ""; + let bytes = 0; + for (const char of json) { + const size = Buffer.byteLength(char); + if (bytes + size > 4096) { + chunks.push(chunk); + chunk = ""; + bytes = 0; + } + chunk += char; + bytes += size; + } + if (chunk) chunks.push(chunk); + const sha256 = createHash("sha256").update(json).digest("hex"); + // Canonical fields and a map avoid growing the backend dataset's flat schema. + return chunks.map((fragment, fragmentIndex) => ({ + name: "mcp.doctor.report_fragment", + properties: { + clientEventName: null, + eventName: "mcp.doctor.report_fragment", + attributes: { + mcpDoctor: { + diagnosticId: report.diagnosticId, + schemaVersion: report.schemaVersion, + collectedAt: report.collectedAt, + host: report.host, + scope: "cloud", + fragmentIndex, + fragmentCount: chunks.length, + reportSha256: sha256, + fragment, + }, + }, + }, + })); +} + +export async function submitReport(report, fetchImpl = fetch) { + if (report.upload?.status === "skipped_intentionally_disabled" || + report.hostSnapshot?.configuration?.enabled === false || + report.hostSnapshot?.session?.connectionStatus === "disabled") + return { status: "skipped_intentionally_disabled", storageVerified: false }; + if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; + try { + const endpoint = serverUrl(report.serverUrl); + const events = fragmentEvents(report); + const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { + method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { "content-type": "application/json" }, body: JSON.stringify(events), + }); + const body = await limitedBody(response, 16 * 1024); + if (!response.ok || JSON.parse(body).ok !== true) + throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); + return { + status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, + reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256, + }; + } catch (error) { + return { status: "failed", storageVerified: false, error: errorDetails(error) }; + } +} + +export async function runDoctor(options, dependencies = {}) { + if (!["codex", "claude"].includes(options.host)) + throw new Error("--host must be codex or claude"); + if (options.originalError && options.failureRecord) + throw new Error("Choose one original failure input: --failure-record or --original-error"); + let endpoint = null; + let endpointError; + try { endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; } + catch (error) { endpointError = errorDetails(error); } + if ( + options.occurredAt && + (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || + !Number.isFinite(Date.parse(options.occurredAt))) + ) { + throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); + } + const fetchImpl = dependencies.fetchImpl ?? fetch; + const diagnosticId = randomUUID(); + const report = { + schemaVersion: 2, + collectorVersion: "1.3.0", + diagnosticId, + collectedAt: new Date().toISOString(), + scope: "cloud", + host: options.host, + hostVersion: options.hostVersion ?? null, + hostVersionSource: options.hostVersion ? "agent_supplied" : "unavailable", + environment: { + platform: platform(), + architecture: arch(), + nodeVersion: process.version, + proxyEnvironmentPresent: [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ].some((key) => Boolean(process.env[key])), + probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", + noProxyEnvironmentPresent: Boolean(process.env.NO_PROXY || process.env.no_proxy), + nodeUseEnvProxyRequested: process.env.NODE_USE_ENV_PROXY === "1", + extraCaConfigured: Boolean(process.env.NODE_EXTRA_CA_CERTS), + tlsVerificationDisabledInEnvironment: process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0", + }, + serverUrl: endpoint?.href ?? null, + originalFailure: { + evidenceKind: "original_failure", + occurredAt: options.occurredAt ?? null, + timeSource: options.occurredAt + ? "command_line; agent_supplied_not_independently_verified" + : "unavailable", + source: "agent_supplied_error_file", + status: "unavailable", + }, + probes: [], + wireProbes: [], + gaps: [], + outcome: "diagnosis_only; original_connection_recovery_unverified", + coverage: "triggered_diagnostics_only; not_all_users_or_all_failures", + correlation: + "diagnosticId identifies this report and later probes, not the original host request", + }; + let disabledBySnapshot = false; + if (endpointError) report.gaps.push({ source: "registration", reason: "unsupported_endpoint; no_requests_sent", error: endpointError }); + if (options.hostSnapshot) { + try { + if (!basename(String(options.hostSnapshot)).endsWith(".mcp-snapshot.json")) + throw new Error( + "Host snapshot must be a dedicated .mcp-snapshot.json file, never a credential/config file", + ); + const info = await stat(options.hostSnapshot); + if (info.size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + const snapshot = JSON.parse(await readFile(options.hostSnapshot, "utf8")); + disabledBySnapshot = + snapshot?.configuration?.enabled === false || + snapshot?.session?.connectionStatus === "disabled"; + report.hostSnapshot = clean(validateSnapshot(snapshot, endpoint)); + report.hostSnapshotSource = + "agent_supplied; see each field source; not_independently_verified"; + if (!report.hostVersion && report.hostSnapshot.hostVersion) { + report.hostVersion = report.hostSnapshot.hostVersion; + report.hostVersionSource = "agent_supplied_host_snapshot"; + } + } catch (error) { + report.gaps.push({ + source: "host_snapshot", + reason: "unavailable", + error: errorDetails(error), + action: + "Create a new dedicated snapshot with --create-snapshot ; fill only observed facts, then --validate-snapshot before collection. Do not rename or read a credential/config file.", + }); + } + } + try { + const manifest = + options.host === "codex" + ? "../../../.codex-plugin/plugin.json" + : "../../../.claude-plugin/plugin.json"; + report.pluginVersion = JSON.parse( + await readFile(new URL(manifest, import.meta.url), "utf8"), + ).version; + report.pluginVersionSource = + "collector_package_manifest; installed_host_plugin_not_independently_verified"; + } catch (error) { + report.gaps.push({ source: "plugin_version", error: errorDetails(error) }); + } + if (options.originalError) { + try { + if (!/\.(?:txt|log)$/i.test(basename(String(options.originalError)))) { + throw new Error( + "Original error must be a dedicated .txt or .log file; credential/config/session files are not inputs", + ); + } + const info = await stat(options.originalError); + if (info.size > 64 * 1024) + throw new Error("Original error file exceeds 64 KiB; source file retained without reading"); + const message = await readFile(options.originalError, "utf8"); + if (!message.trim()) + throw new Error("Original error file is empty; original failure remains unavailable"); + report.originalFailure.message = redact(message); + report.originalFailure.status = "recorded"; + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "unavailable", + error: errorDetails(error), + }); + } + } else if (!options.failureRecord) + report.gaps.push({ + source: "original_failure", + reason: "not_provided; do_not_invent_original_error", + }); + if (options.failureRecord) { + try { + if (!basename(String(options.failureRecord)).endsWith(".mcp-failure.json")) + throw new Error( + "Use a dedicated .mcp-failure.json file, never a config or session transcript", + ); + if ((await stat(options.failureRecord)).size > 64 * 1024) + throw new Error("Failure record exceeds 64 KiB; complete source retained without reading"); + report.originalFailure = clean( + failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), + ); + if (!report.originalFailure.occurredAt && options.occurredAt) { + report.originalFailure.occurredAt = options.occurredAt; + report.originalFailure.timeSource = + "command_line; agent_supplied_not_independently_verified"; + } + if ( + options.occurredAt && + report.originalFailure.occurredAt && + Date.parse(options.occurredAt) !== Date.parse(report.originalFailure.occurredAt) + ) { + report.originalFailure.timeConflict = { + failureRecord: report.originalFailure.occurredAt, + commandLine: options.occurredAt, + }; + report.originalFailure.occurredAt = null; + report.originalFailure.timeSource = "conflicting_supplied_times"; + report.gaps.push({ + source: "original_failure", + reason: "conflicting_supplied_failure_times", + }); + } + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "failure_record_unavailable", + error: errorDetails(error), + }); + } + } + try { + report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { + occurredAt: report.originalFailure.occurredAt, + codexSqliteHome: options.codexSqliteHome, + codexLogDir: options.codexLogDir, + codexAppLogDir: options.codexAppLogDir, + codexAppLogFiles: options.codexAppLogFiles, + codexAppLogOffset: options.codexAppLogOffset, + conversationId: + report.originalFailure.conversationId ?? + report.hostSnapshot?.session?.conversationId ?? + process.env.CODEX_THREAD_ID, + }); + } catch (error) { + report.hostLogs = { + entries: [], + gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], + }; + } + const disabled = + disabledBySnapshot || + report.hostSnapshot?.configuration?.enabled === false || + report.hostSnapshot?.session?.connectionStatus === "disabled"; + if (options.offline || disabled || !endpoint) { + report.gaps.push({ + source: "probes", + reason: disabled + ? "host_reports_intentionally_disabled; no_requests_sent" + : options.offline + ? "offline_requested; no_requests_sent" + : "registered_endpoint_unavailable; no_requests_sent", + }); + } else { + report.entryProbe = await guardProbe(endpoint.href, () => + probeEntry(endpoint, diagnosticId, fetchImpl), + ); + const resource = endpoint.pathname.replace(/\/$/, ""); + const queue = [ + new URL("/.well-known/oauth-protected-resource" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server", endpoint.origin).href, + ]; + const enqueue = (value, label) => { + try { + const url = new URL(value); + if ( + url.origin !== endpoint.origin || + url.username || + url.password || + url.search || + url.hash || + !url.pathname.startsWith("/.well-known/") + ) + throw new Error( + "Unsupported metadata target; no cross-origin or credential-bearing probes", + ); + if (!queue.includes(url.href)) queue.push(url.href); + } catch (error) { + report.gaps.push({ source: label, error: errorDetails(error) }); + } + }; + if (report.entryProbe.resourceMetadataUrl) + enqueue(report.entryProbe.resourceMetadataUrl, "challenge_metadata_target"); + let issuer; + for (let index = 0; index < queue.length && index < 5; index++) { + const url = queue[index]; + const result = await guardProbe(url, () => + probeMetadata(url, diagnosticId, fetchImpl, { + resource: endpoint.href, + issuer: url.includes("oauth-authorization-server") ? issuer : undefined, + }), + ); + report.probes.push(result); + if (url === queue[0] && result.outcome !== "metadata_read") + enqueue( + new URL("/.well-known/oauth-protected-resource", endpoint.origin).href, + "root_resource_metadata_target", + ); + const fields = result.metadataValidation?.fields; + if (url.includes("oauth-protected-resource") && result.metadataValidation?.valid) { + issuer = fields.authorization_servers[0]; + const auth = new URL(issuer); + enqueue( + new URL( + "/.well-known/oauth-authorization-server" + auth.pathname.replace(/\/$/, ""), + auth.origin, + ).href, + "issuer_metadata_target", + ); + } + } + if (queue.length > 5) + report.gaps.push({ + source: "metadata_probes", + reason: "probe_limit", + omitted: queue.length - 5, + }); + if (options.wireEvidence) { + const failed = report.probes.find((probe) => probe.outcome !== "metadata_read"); + const selected = new URL(failed?.url ?? queue[0]); + const match = selected.pathname.match( + /^\/\.well-known\/(oauth-protected-resource|oauth-authorization-server|openid-configuration)/, + ); + if (match) { + const root = new URL("/.well-known/" + match[1], endpoint.origin); + const scoped = new URL(root.pathname + resource, endpoint.origin); + for (const url of [scoped.href, root.href]) { + try { + report.wireProbes.push( + await (dependencies.probeWire ?? probeMetadataWire)(url, diagnosticId, errorDetails), + ); + } catch (error) { + report.gaps.push({ source: "wire_probe", url, error: errorDetails(error) }); + } + } + } + } + if ( + endpoint.protocol === "https:" && + [report.entryProbe, ...report.probes].some( + (probe) => probe.outcome === "failed" && probe.stage !== "probe_collector", + ) + ) { + try { + report.transportContext = await (dependencies.inspectTransport ?? inspectTransport)( + endpoint, + ); + } catch (error) { + report.transportContext = { status: "failed", error: errorDetails(error) }; + } + } + } + report.evidenceSummary = evidenceSummary(report); + report.upload = { + status: disabled + ? "skipped_intentionally_disabled" + : options.offline + ? "skipped_offline" + : !endpoint + ? "skipped_endpoint_unavailable" + : options.upload + ? "pending" + : "not_requested", + storageVerified: false, + }; + const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); + const save = async (flag) => + writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); + await save("wx"); + if (options.upload && !options.offline && !disabled && endpoint) { + report.upload = await submitReport(report, fetchImpl); + await save("w"); + } + return { path, report: clean(report) }; +} + +export async function runCli(args = process.argv.slice(2)) { + try { + const { values } = parseArgs({ + args, + options: { + host: { type: "string" }, + "server-url": { type: "string" }, + "host-version": { type: "string" }, + "host-snapshot": { type: "string" }, + "original-error": { type: "string" }, + "failure-record": { type: "string" }, + "occurred-at": { type: "string" }, + "codex-sqlite-home": { type: "string" }, + "codex-log-dir": { type: "string" }, + "codex-app-log-dir": { type: "string" }, + "codex-app-log-file": { type: "string", multiple: true }, + "codex-app-log-offset": { type: "string" }, + output: { type: "string" }, + upload: { type: "boolean", default: false }, + offline: { type: "boolean", default: false }, + "wire-evidence": { type: "boolean", default: true }, + "create-snapshot": { type: "string" }, + "validate-snapshot": { type: "string" }, + help: { type: "boolean", default: false }, + }, + }); + if (values["create-snapshot"]) { + const path = resolve(values["create-snapshot"]); + if (!basename(path).endsWith(".mcp-snapshot.json")) + throw new Error("Use a new dedicated .mcp-snapshot.json filename"); + await writeFile( + path, + JSON.stringify( + { schemaVersion: 1, observedAt: new Date().toISOString(), observations: [] }, + null, + 2, + ) + "\n", + { flag: "wx", mode: 0o600 }, + ); + console.log( + JSON.stringify({ + path, + snapshotCreated: true, + action: + "Add only observed configuration/session facts using connection-diagnostics.md; omit unknown groups. observedAt is snapshot capture time, not original failure time.", + }), + ); + } else if (values["validate-snapshot"]) { + const path = values["validate-snapshot"]; + if (!basename(path).endsWith(".mcp-snapshot.json")) + throw new Error("Use a dedicated .mcp-snapshot.json file"); + if ((await stat(path)).size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + validateSnapshot( + JSON.parse(await readFile(path, "utf8")), + values["server-url"] ? serverUrl(values["server-url"]) : null, + ); + console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); + } else if (values.help) { + console.log( + "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--offline] [--upload]", + ); + } else { + const { path, report } = await runDoctor({ + host: values.host, + serverUrl: values["server-url"], + hostVersion: values["host-version"], + hostSnapshot: values["host-snapshot"], + originalError: values["original-error"], + failureRecord: values["failure-record"], + occurredAt: values["occurred-at"], + codexSqliteHome: values["codex-sqlite-home"], + codexLogDir: values["codex-log-dir"], + codexAppLogDir: values["codex-app-log-dir"], + codexAppLogFiles: values["codex-app-log-file"], + codexAppLogOffset: + values["codex-app-log-offset"] === undefined + ? undefined + : Number(values["codex-app-log-offset"]), + output: values.output, + upload: values.upload, + offline: values.offline, + wireEvidence: values["wire-evidence"], + }); + console.log( + JSON.stringify({ + diagnosticId: report.diagnosticId, + path, + upload: report.upload, + evidenceGaps: report.evidenceSummary.gaps, + collectionGaps: report.gaps, + recoveryVerified: false, + }), + ); + return { path, report }; + } + } catch (error) { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await runCli(); diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs new file mode 100644 index 0000000..71111f2 --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -0,0 +1,376 @@ +const STAGES = [ + "plugin_loading", + "discovery", + "client_registration", + "authorization", + "token_exchange", + "token_refresh", + "initialize", + "tools_list", + "session_tools", +]; +const SOURCES = [ + "current_session", + "tool_search", + "host_settings", + "cli_configuration", + "runtime_log", +]; +const TIMESTAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/; + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function keys(value, allowed) { + if (!object(value) || Object.keys(value).some((key) => !allowed.includes(key))) + throw new Error("Snapshot contains unsupported fields; use only the dedicated evidence schema"); +} + +function source(value) { + if (!SOURCES.includes(value)) + throw new Error("Snapshot evidence needs an explicit supported source"); +} + +function instant(value) { + return ( + typeof value === "string" && + new RegExp("^" + TIMESTAMP.source + "$").test(value) && + Number.isFinite(Date.parse(value)) + ); +} + +export function validateSnapshot(input, endpoint) { + keys(input, [ + "schemaVersion", + "observedAt", + "hostVersion", + "pluginVersion", + "configuration", + "session", + "observations", + ]); + if (input.schemaVersion !== 1) + throw new Error( + "Snapshot schemaVersion must be 1 (the outer Doctor report uses a separate schemaVersion); generate a template with --create-snapshot", + ); + if (!instant(input.observedAt)) + throw new Error( + "Snapshot observedAt must be its capture time as a complete ISO timestamp with timezone, e.g. 2026-10-23T01:00:00Z; original failure time may remain unknown", + ); + for (const key of ["hostVersion", "pluginVersion"]) + if (input[key] !== undefined && (typeof input[key] !== "string" || input[key].length > 256)) + throw new Error("Invalid snapshot version"); + if (input.configuration) { + const config = input.configuration; + keys(config, ["source", "serverName", "url", "enabled"]); + source(config.source); + if (!["chatcut", "plugin:chatcut:chatcut"].includes(config.serverName)) + throw new Error("Snapshot must describe the hosted ChatCut server"); + if (!publicUrl(config.url, true)) + throw new Error("Snapshot URL must be a public URL without embedded credentials"); + if (endpoint && new URL(config.url).href !== endpoint.href) + throw new Error( + "Snapshot URL differs from the explicitly selected server; preserve both configurations", + ); + if (config.enabled !== null && typeof config.enabled !== "boolean") + throw new Error("Snapshot enabled state must be boolean or null"); + } + if (input.session) { + const session = input.session; + keys(session, [ + "source", + "conversationId", + "connectionStatus", + "authStatus", + "visibleToolCount", + "visibleTools", + ]); + source(session.source); + if (session.source === "cli_configuration") + throw new Error("Static CLI configuration cannot describe current session tool availability"); + if ( + !["connected", "failed", "needs_authentication", "disabled", "unknown"].includes( + session.connectionStatus, + ) + ) + throw new Error("Unsupported connection status"); + if (!["authenticated", "needs_authentication", "unknown"].includes(session.authStatus)) + throw new Error("Unsupported authentication status"); + if ( + session.visibleToolCount !== null && + (!Number.isSafeInteger(session.visibleToolCount) || session.visibleToolCount < 0) + ) + throw new Error("Visible tool count must be a nonnegative integer or null"); + if (session.conversationId !== undefined && typeof session.conversationId !== "string") + throw new Error("conversationId must be a host conversation identifier"); + if ( + session.visibleTools !== undefined && + (!Array.isArray(session.visibleTools) || + session.visibleTools.length > 256 || + session.visibleTools.some( + (name) => typeof name !== "string" || !/^[A-Za-z0-9_:.-]{1,256}$/.test(name), + )) + ) + throw new Error("visibleTools must contain only ChatCut tool names, at most 256"); + if ( + session.visibleTools && + session.visibleToolCount !== null && + session.visibleTools.length > session.visibleToolCount + ) + throw new Error("Tool name count exceeds the observed tool count"); + } + if (input.observations !== undefined) { + if (!Array.isArray(input.observations) || input.observations.length > 32) + throw new Error("Snapshot accepts at most 32 stage observations"); + for (const item of input.observations) { + keys(item, [ + "stage", + "operation", + "source", + "observedAt", + "outcome", + "requestId", + "httpStatus", + "protocolCode", + "message", + "originalAttemptMatch", + "lookupResult", + ]); + source(item.source); + if (item.lookupResult !== undefined && + (item.stage !== "host_registration" || item.operation !== "lookup_registered_server" || + !["cli_configuration", "host_settings"].includes(item.source) || + !["not_found", "found", "unavailable"].includes(item.lookupResult) || + (item.lookupResult === "not_found" && item.outcome === "unknown"))) + throw new Error("lookupResult is a host registration lookup result: not_found only for an explicit absence response; found or unavailable otherwise"); + if ( + !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || + !["success", "failed", "unknown"].includes(item.outcome) || + (item.observedAt !== null && !instant(item.observedAt)) + ) + throw new Error( + "Stage observation needs stage, outcome, and observedAt (ISO timestamp with timezone, or null when the original time is unknown)", + ); + for (const field of ["operation", "requestId", "message"]) + if (item[field] !== undefined && typeof item[field] !== "string") + throw new Error("Observation text fields must be strings"); + for (const field of ["httpStatus", "protocolCode"]) + if (item[field] !== undefined && !Number.isSafeInteger(item[field])) + throw new Error("Observation status/code must be an integer"); + if ( + item.originalAttemptMatch !== undefined && + !["confirmed", "unconfirmed"].includes(item.originalAttemptMatch) + ) + throw new Error( + "Original attempt match must be confirmed or unconfirmed; omit when unknown", + ); + if ( + item.originalAttemptMatch === "confirmed" && + (!item.requestId || + item.outcome !== "failed" || + !["current_session", "runtime_log"].includes(item.source)) + ) + throw new Error( + "Confirmed original request association requires its failed runtime/session observation and actual request ID", + ); + } + } + return input; +} + +export function logTime(line) { + const match = line.match(TIMESTAMP)?.[0]; + return match && Number.isFinite(Date.parse(match)) ? new Date(match).toISOString() : null; +} + +export function stageHint(message) { + if (/tools\/list|tool catalog|tool list/i.test(message)) return "tools_list"; + if (/initializ|handshake/i.test(message)) return "initialize"; + if (/refresh.{0,20}token|token.{0,20}refresh/i.test(message)) return "token_refresh"; + if (/token.{0,20}exchange|exchange.{0,20}token|\/mcp\/token/i.test(message)) + return "token_exchange"; + if (/registr|\/mcp\/register/i.test(message)) return "client_registration"; + if (/callback|authoriz|oauth/i.test(message) && !/metadata|discovery/i.test(message)) + return "authorization"; + if (/metadata|discovery/i.test(message)) return "discovery"; + return "unclassified"; +} + +export function evidenceSummary(report) { + const snapshot = report.hostSnapshot; + const observations = snapshot?.observations ?? []; + const gaps = []; + if (!snapshot) gaps.push("current_host_snapshot_unavailable"); + if (!snapshot?.configuration) gaps.push("registration_state_unavailable"); + if (!snapshot?.session || snapshot.session.visibleToolCount === null) + gaps.push("current_session_tools_unavailable"); + if (report.originalFailure.status !== "recorded") gaps.push("original_failure_unavailable"); + if (!report.originalFailure.occurredAt) gaps.push("original_failure_time_unavailable"); + const originalRequests = + report.originalFailure.status === "recorded" + ? observations.flatMap((item, observationIndex) => + item.requestId && + item.originalAttemptMatch === "confirmed" && + item.outcome === "failed" && + ["current_session", "runtime_log"].includes(item.source) + ? [{ requestId: item.requestId, source: item.source, observationIndex }] + : [], + ) + : []; + if (report.originalFailure.status === "recorded" && report.originalFailure.requestId) + originalRequests.push({ + requestId: report.originalFailure.requestId, + source: report.originalFailure.source, + }); + if (!originalRequests.length) gaps.push("original_request_id_unavailable"); + if (!snapshot?.pluginVersion) gaps.push("actual_loaded_plugin_version_unavailable"); + if (!report.hostLogs.entries.length) gaps.push("matching_host_runtime_logs_unavailable"); + const appNotices = report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log"); + const failedAppNotices = appNotices.filter( + (entry) => [...entry.message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed", + ); + if (appNotices.length === report.hostLogs.entries.length && !failedAppNotices.length) + gaps.push("matching_host_failure_logs_unavailable"); + const stages = STAGES.map((stage) => ({ + stage, + observations: observations.filter((item) => item.stage === stage), + status: observations.some((item) => item.stage === stage) + ? "observed; see individual attempts" + : "not_observed", + })); + const unknown = observations.filter((item) => !STAGES.includes(item.stage)); + const logHints = report.hostLogs.entries.map((entry, index) => ({ + entryIndex: index, + stage: stageHint(entry.message), + interpretation: "text_hint_only; attempt_match_unconfirmed", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), + })); + return { + stages, + otherObservations: unknown, + logHints, + originalRequestCorrelation: { + status: originalRequests.length ? "agent_reported_same_attempt" : "unconfirmed", + observations: originalRequests, + provenance: + "agent_supplied_confirmation; not_independently_verified; never_inferred_from_time_or_other_ids", + }, + originalLocalCall: report.originalFailure.callId + ? { + callId: report.originalFailure.callId, + conversationId: report.originalFailure.conversationId ?? null, + operation: report.originalFailure.operation ?? null, + provenance: + "original_failure_record; agent_supplied_not_independently_verified; local_call_id_is_not_server_request_id", + } + : null, + hostLogCoverage: { + records: report.hostLogs.entries.length, + appStartupRecords: appNotices.length, + failedAppStartupRecords: failedAppNotices.length, + originalToolFailureRecord: + report.originalFailure.source === "agent_supplied_host_tool_result", + qualification: + "startup notices are not tool-request failure evidence; log presence alone does not prove original failure capture", + }, + gaps, + independentChecks: + "entryProbe/probes/wireProbes/transportContext are later requests, never original stage completions; server observation is before Node framing, not proof of downstream wire validity", + recoveryVerified: false, + }; +} + +function publicUrl(value, localAllowed = false) { + try { + const url = new URL(value); + return ( + !url.username && + !url.password && + !url.hash && + (url.protocol === "https:" || + (localAllowed && + url.protocol === "http:" && + ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) + ); + } catch { + return false; + } +} + +export function inspectMetadata(document, url, expected = {}) { + const issues = []; + const result = { valid: false, issues, fields: {} }; + if (!object(document)) { + issues.push({ field: "document", reason: "expected_object" }); + return result; + } + for (const field of [ + "resource", + "authorization_servers", + "issuer", + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "grant_types_supported", + "response_types_supported", + "code_challenge_methods_supported", + "token_endpoint_auth_methods_supported", + ]) + if (document[field] !== undefined) result.fields[field] = document[field]; + const protectedResource = new URL(url).pathname.includes("oauth-protected-resource"); + const requireUrl = (field) => { + if (typeof document[field] !== "string" || !publicUrl(document[field], true)) + issues.push({ field, reason: "missing_or_invalid_public_url" }); + }; + if (protectedResource) { + requireUrl("resource"); + const suffix = new URL(url).pathname.split("/.well-known/oauth-protected-resource")[1]; + const resource = expected.resource ?? (suffix ? new URL(url).origin + suffix : undefined); + if (resource && document.resource !== resource) + issues.push({ + field: "resource", + reason: "resource_mismatch", + expected: resource, + actual: document.resource, + }); + if ( + !Array.isArray(document.authorization_servers) || + !document.authorization_servers.length || + document.authorization_servers.some((item) => !publicUrl(item, true)) + ) + issues.push({ + field: "authorization_servers", + reason: "missing_or_invalid_authorization_servers", + }); + } else { + requireUrl("issuer"); + requireUrl("authorization_endpoint"); + requireUrl("token_endpoint"); + if ( + Array.isArray(document.grant_types_supported) && + !document.grant_types_supported.includes("authorization_code") + ) + issues.push({ field: "grant_types_supported", reason: "authorization_code_not_supported" }); + if ( + !Array.isArray(document.response_types_supported) || + !document.response_types_supported.includes("code") + ) + issues.push({ field: "response_types_supported", reason: "code_response_not_advertised" }); + if (expected.issuer && document.issuer !== expected.issuer) + issues.push({ + field: "issuer", + reason: "issuer_mismatch", + expected: expected.issuer, + actual: document.issuer, + }); + if ( + !Array.isArray(document.code_challenge_methods_supported) || + !document.code_challenge_methods_supported.includes("S256") + ) + issues.push({ field: "code_challenge_methods_supported", reason: "S256_not_advertised" }); + } + result.valid = issues.length === 0; + return result; +} diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs new file mode 100644 index 0000000..ef9e338 --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs @@ -0,0 +1,83 @@ +const SOURCES = ["host_tool_result", "host_runtime_event", "captured_stderr"]; +const FIELDS = [ + "schemaVersion", + "source", + "serverName", + "occurredAt", + "conversationId", + "callId", + "operation", + "requestId", + "error", +]; + +export function validateFailureRecord(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + Object.keys(input).some((key) => !FIELDS.includes(key)) + ) + throw new Error( + "Use one dedicated failure record, without tool arguments, results or conversation content", + ); + if ( + input.schemaVersion !== 1 || + !SOURCES.includes(input.source) || + !["chatcut", "plugin:chatcut:chatcut"].includes(input.serverName) + ) + throw new Error( + "Failure record must identify the hosted ChatCut server and original error source", + ); + if ( + input.occurredAt !== null && + (typeof input.occurredAt !== "string" || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( + input.occurredAt, + ) || + !Number.isFinite(Date.parse(input.occurredAt))) + ) + throw new Error( + "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", + ); + for (const key of ["conversationId", "callId", "operation", "requestId"]) + if ( + input[key] !== undefined && + input[key] !== null && + (typeof input[key] !== "string" || !/^[A-Za-z0-9_.:/-]{1,256}$/.test(input[key])) + ) + throw new Error( + `Invalid original failure ${key}; never substitute a credential or a later probe ID`, + ); + if ( + !input.error || + typeof input.error !== "object" || + Array.isArray(input.error) || + typeof input.error.message !== "string" || + !input.error.message.trim() + ) + throw new Error( + "Failure record needs the original nonempty error.message; preserve its code, cause and stack when available", + ); + return input; +} + +export function failureFromRecord(input) { + const record = validateFailureRecord(input); + return { + evidenceKind: "original_failure", + status: "recorded", + source: `agent_supplied_${record.source}`, + occurredAt: record.occurredAt, + timeSource: record.occurredAt + ? "host_failure_record; agent_supplied_not_independently_verified" + : "unavailable", + message: record.error.message, + error: record.error, + ...(record.conversationId && { conversationId: record.conversationId }), + ...(record.callId && { callId: record.callId }), + ...(record.operation && { operation: record.operation }), + ...(record.requestId && { requestId: record.requestId }), + association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", + }; +} diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-wire.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-wire.mjs new file mode 100644 index 0000000..57079a5 --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-wire.mjs @@ -0,0 +1,296 @@ +import { createHash } from "node:crypto"; +import { connect, isIP } from "node:net"; +import { connect as connectTls } from "node:tls"; + +const LIMIT = 128 * 1024; +const HEADER_LIMIT = 16 * 1024; +const TIMEOUT_MS = 3500; +const HEADERS = new Set([ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "connection", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + "cache-control", + "age", + "x-cache", + "x-amz-cf-id", + "x-amz-cf-pop", +]); + +function target(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ( + (!local && + (!["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes(url.hostname) || + url.protocol !== "https:" || + (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/\.well-known\/(?:oauth-protected-resource|oauth-authorization-server|openid-configuration)(?:\/[a-zA-Z0-9/_-]*)?$/.test( + url.pathname, + ) + ) + throw new Error( + "Wire evidence is restricted to anonymous ChatCut metadata on the selected origin", + ); + return url; +} + +function framing(headers, body, ended, limited) { + const result = { + status: "incomplete", + firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, + bodyRepresentation: "after_transfer_decoding; content_encoding_not_decoded", + }; + const invalid = (reason) => ({ ...result, status: "invalid", reason }); + const incomplete = (reason) => ({ ...result, reason }); + const valid = (decoded) => ({ + ...result, + status: "valid", + bodyBytes: decoded.length, + bodySha256: createHash("sha256").update(decoded).digest("hex"), + }); + const transfer = headers["transfer-encoding"] + ?.toLowerCase() + .split(",") + .map((v) => v.trim()); + if (transfer?.at(-1) === "chunked") { + if (headers["content-length"] !== undefined) + return invalid("both_transfer_encoding_and_content_length"); + let offset = 0; + const parts = []; + while (offset < body.length) { + const end = body.indexOf("\r\n", offset); + if (end < 0) { + if (body.length > offset && !/[0-9a-f]/i.test(String.fromCharCode(body[offset]))) + return invalid("invalid_chunk_size"); + return ended && !limited + ? invalid("unterminated_chunk_size") + : incomplete("chunk_size_not_complete"); + } + const sizeText = body.subarray(offset, end).toString("latin1").split(";")[0]; + if (!/^[0-9a-f]{1,16}$/i.test(sizeText)) return invalid("invalid_chunk_size"); + if (offset === 0) result.firstChunkSizeHex = sizeText; + const size = Number.parseInt(sizeText, 16); + offset = end + 2; + if (!Number.isSafeInteger(size)) return incomplete("chunk_exceeds_capture_limit"); + if (size === 0) { + const trailerEnd = body.indexOf("\r\n\r\n", offset); + const messageEnd = body.subarray(offset, offset + 2).equals(Buffer.from("\r\n")) + ? offset + 2 + : trailerEnd >= 0 + ? trailerEnd + 4 + : null; + if (messageEnd !== null) { + if (messageEnd !== body.length) return invalid("bytes_after_terminal_chunk"); + return valid(Buffer.concat(parts)); + } + return ended && !limited + ? invalid("unterminated_trailers") + : incomplete("trailers_not_complete"); + } + if (offset + size + 2 > body.length) + return ended && !limited ? invalid("truncated_chunk") : incomplete("chunk_not_complete"); + if (!body.subarray(offset + size, offset + size + 2).equals(Buffer.from("\r\n"))) + return invalid("missing_chunk_terminator"); + parts.push(body.subarray(offset, offset + size)); + offset += size + 2; + } + return ended && !limited + ? invalid("missing_terminal_chunk") + : incomplete("terminal_chunk_not_observed"); + } + if (transfer) return incomplete("unsupported_transfer_encoding"); + if (headers["content-length"] !== undefined) { + if (!/^\d+$/.test(headers["content-length"])) return invalid("invalid_content_length"); + const expected = Number(headers["content-length"]); + if (body.length === expected) return valid(body); + if (body.length > expected) return invalid("content_length_mismatch"); + return ended && !limited ? invalid("content_length_mismatch") : incomplete("body_not_complete"); + } + return ended && !limited ? valid(body) : incomplete("close_delimited_body_not_complete"); +} + +function inspectResponse(captured, ended, limited) { + const headerEnd = captured.indexOf("\r\n\r\n"); + const result = { responseHeaders: {} }; + if (headerEnd < 0 || headerEnd > HEADER_LIMIT) { + result.headerGap = + captured.length > HEADER_LIMIT ? "header_limit" : "complete_headers_unavailable"; + return result; + } + const lines = captured.subarray(0, headerEnd).toString("latin1").split("\r\n"); + const status = lines.shift().match(/^HTTP\/(1\.[01]) ([1-5]\d{2}) [\t\x20-\x7e\x80-\xff]*$/); + result.httpVersion = status?.[1] ?? null; + result.status = status ? Number(status[2]) : null; + result.responseState = !status + ? "invalid_status_line" + : result.status < 200 + ? "unsupported_informational_response" + : "final_response"; + for (const line of lines) { + const colon = line.indexOf(":"); + if (colon < 0) continue; + const name = line.slice(0, colon).toLowerCase(); + if (HEADERS.has(name)) { + const field = line.slice(colon + 1).trim(); + result.responseHeaders[name] = + result.responseHeaders[name] === undefined + ? field + : result.responseHeaders[name] + "," + field; + } + } + result.stage = "http_framing"; + const body = captured.subarray(headerEnd + 4); + result.framing = + result.responseState === "final_response" + ? framing(result.responseHeaders, body, ended, limited) + : { + status: "not_evaluated", + reason: result.responseState, + firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, + }; + return result; +} + +export async function probeMetadataWire(value, diagnosticId, describeError) { + const url = target(value); + if (!/^[0-9a-f-]{36}$/i.test(diagnosticId)) throw new Error("Invalid diagnostic ID"); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: url.href, + startedAt: new Date().toISOString(), + implementation: "node:net/tls; HTTP/1.1", + route: "direct_connection; environment_proxy_not_used; host_and_fetch_route_not_reproduced", + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + acceptEncoding: "identity", + byteLimit: LIMIT, + stage: "connect", + }; + return new Promise((resolve) => { + let socket; + let done = false; + let bytes = 0; + let limited = false; + let ended = false; + let headerSeen = false; + const parts = []; + const finish = (error, completionReason) => { + if (done) return; + done = true; + clearTimeout(timer); + const captured = Buffer.concat(parts); + result.capturedBytes = captured.length; + result.captureLimited = limited; + Object.assign(result, inspectResponse(captured, ended, limited)); + result.completionReason = completionReason; + result.peerEndObserved = ended; + result.outcome = error + ? "failed" + : result.responseState === "invalid_status_line" + ? "invalid_response" + : result.responseState === "unsupported_informational_response" + ? "unsupported_response" + : result.framing?.status === "invalid" + ? "invalid_framing" + : !limited && result.framing?.status === "valid" + ? "response_observed" + : "incomplete"; + if (error) result.error = describeError(error); + result.durationMs = Date.now() - started; + socket?.destroy(); + resolve(result); + }; + const timer = setTimeout( + () => + finish( + Object.assign(new Error("Metadata wire probe timed out after 3500ms"), { + code: "DIAGNOSTIC_WIRE_TIMEOUT", + }), + "timeout", + ), + TIMEOUT_MS, + ); + try { + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + const options = { + host: hostname, + port: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + }; + const send = () => { + result.stage = "response"; + result.peer = { address: socket.remoteAddress, port: socket.remotePort }; + if (url.protocol === "https:") { + const certificate = socket.getPeerCertificate(); + result.tls = { + authorized: socket.authorized, + protocol: socket.getProtocol(), + certificate: { issuer: certificate.issuer, fingerprint256: certificate.fingerprint256 }, + }; + } + socket.write( + `GET ${url.pathname} HTTP/1.1\r\nHost: ${url.host}\r\nAccept: application/json\r\nAccept-Encoding: identity\r\nConnection: close\r\nX-ChatCut-Diagnostic-ID: ${diagnosticId}\r\n\r\n`, + ); + }; + socket = + url.protocol === "https:" + ? connectTls( + { + ...options, + servername: isIP(hostname) ? undefined : hostname, + rejectUnauthorized: true, + ALPNProtocols: ["http/1.1"], + }, + send, + ) + : connect(options, send); + socket.on("data", (part) => { + if (done) return; + const remaining = LIMIT - bytes; + parts.push(part.subarray(0, Math.max(0, remaining))); + bytes += Math.min(part.length, remaining); + if (part.length > remaining) { + limited = true; + finish(undefined, "capture_limit"); + return; + } + if (!headerSeen) { + const captured = Buffer.concat(parts); + const headerEnd = captured.indexOf("\r\n\r\n"); + headerSeen = headerEnd >= 0; + if ((!headerSeen && bytes > HEADER_LIMIT) || headerEnd > HEADER_LIMIT) { + limited = true; + finish(undefined, "header_limit"); + return; + } + } + if (headerSeen) { + const observed = inspectResponse(Buffer.concat(parts), false, false); + if (observed.responseState !== "final_response") + finish(undefined, "invalid_or_unsupported_response"); + else if (observed.framing.status === "valid") finish(undefined, "message_boundary"); + else if (observed.framing.status === "invalid") finish(undefined, "invalid_framing"); + } + }); + socket.once("end", () => { + ended = true; + finish(undefined, "peer_end"); + }); + socket.once("error", (error) => finish(error, "transport_error")); + socket.once("close", () => finish(undefined, "socket_close")); + } catch (error) { + finish(error, "collector_error"); + } + }); +} diff --git a/codex/skills/chatcut-plugin-basics/SKILL.md b/codex/skills/chatcut-plugin-basics/SKILL.md index e6ec7b3..1770340 100644 --- a/codex/skills/chatcut-plugin-basics/SKILL.md +++ b/codex/skills/chatcut-plugin-basics/SKILL.md @@ -26,9 +26,9 @@ ChatCut tools are served through the configured Hosted `chatcut` MCP server, whe If the tools are available and authenticated, continue normal work without reinstalling or starting a new session. Otherwise, check registration with `codex mcp get chatcut` (or inspect Codex's MCP settings when the CLI is unavailable). If it exists, use that server and do not create a duplicate. Follow the matching MCP path: -When an existing connection or OAuth attempt reports an error, or tools remain absent after successful authentication, preserve that evidence and follow [connection diagnostics](references/connection-diagnostics.md) before retrying login or changing anything. An initial sign-in requirement by itself is expected, not a confirmed connection defect. The diagnostic helper collects evidence independently of the broken MCP connection; it does not require ChatCut Desktop. +When an existing connection or OAuth attempt reports an error, or tools remain absent after successful authentication, preserve that evidence and load the independent [ChatCut MCP Doctor](../chatcut-mcp-doctor/SKILL.md) before retrying login or changing anything. An initial sign-in requirement by itself is expected, not a confirmed connection defect. The diagnostic helper collects evidence independently of the broken MCP connection; it does not require ChatCut Desktop. -Also use connection diagnostics when previously available tools disappear or the host reports an unfamiliar MCP/protocol error. Capture the current-host snapshot described in that reference; preserve unknown errors and stages. +Also use ChatCut MCP Doctor when previously available tools disappear or the host reports an unfamiliar MCP/protocol error. Capture the current-host snapshot described in that reference; preserve unknown errors and stages. For a failed hosted ChatCut tool request after connection, immediately preserve the original tool error and any available failure time, conversation ID and local call ID in the dedicated failure record described there. The Agent gathers these facts; do not ask the user to manually reconstruct them. Startup-status notices and later successful checks do not establish what happened to that tool request. diff --git a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md index c133f82..7ca1dc0 100644 --- a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md +++ b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md @@ -1,299 +1,3 @@ -# Hosted MCP connection diagnostics +# Hosted MCP Doctor -Use this branch when a registered hosted ChatCut connection fails, including -OAuth failing before a browser opens, or authentication completing without usable -tools, and failures of already available hosted tools. Preserve evidence before reconnecting or restarting. This version gathers -evidence; it does not repair configuration, credentials, network settings, or the -original connection. - -Briefly tell the user that you will collect ChatCut connection diagnostics and -send the redacted report to ChatCut. Run under the host's existing execution and -network permissions; do not add a separate manual step when those permit it. -Honor a refusal or a denied read/network operation. Do not change permission -settings to make collection work. - -Invoke each helper as a standalone `node` command with its quoted path. Do not -combine it with `cd`, shell variables, pipelines, or other commands; command -allowlists may permit the helper while rejecting the combined shell operation. -Use the host's file-writing tool for the dedicated snapshot and error file. - -## Capture the current host, including unfamiliar failures - -The Agent creates a dedicated `.mcp-snapshot.json` from facts already available -through the current host. The user does not fill a form. Do not copy an entire -configuration, credential file, environment, or conversation. Preserve unfamiliar -errors as well as known OAuth errors; classify uncertain stages as unknown. -Static CLI configuration describes registration, not tools loaded into the -current chat. Browser sign-in does not establish MCP authentication. - -First generate a new snapshot template with the bundled helper, then add only -observed groups from the schema below. This sets the snapshot schema and capture -time mechanically; it does not invent the original failure time or host state: - -```text -node /scripts/mcp-doctor.mjs --create-snapshot -``` - -Validate the completed file locally before collecting or uploading: - -```text -node /scripts/mcp-doctor.mjs --validate-snapshot --server-url -``` - -Snapshot `schemaVersion` is **1**, independently of the outer report version. -Keep unavailable groups omitted. Each observation's `observedAt` may be `null` -when its actual time is unknown. If validation fails, correct only the reported -schema error using already available facts; do not guess times or relax the -credential/config file restriction. If facts remain unavailable, collect the -other evidence and preserve the specific gap. - -Snapshot schema 1 accepts this shape; omit unavailable groups, use null for an -unknown tool count/enabled flag, and label each group's actual source: - -```json -{ - "schemaVersion": 1, - "observedAt": "2026-10-22T12:00:00Z", - "hostVersion": "actual-running-host-version", - "pluginVersion": "actual-loaded-plugin-version-if-known", - "configuration": { - "source": "host_settings", - "serverName": "chatcut", - "url": "https://api.chatcut.io/api/external-mcp/mcp", - "enabled": true - }, - "session": { - "source": "current_session", - "conversationId": "host-chat-id-if-available", - "connectionStatus": "failed", - "authStatus": "unknown", - "visibleToolCount": 0, - "visibleTools": [] - }, - "observations": [ - { - "stage": "initialize", - "operation": "initialize", - "source": "runtime_log", - "observedAt": "2026-10-22T11:59:00Z", - "outcome": "failed", - "requestId": "server-request-id-only-if-recorded", - "message": "Exact original error, including an unfamiliar error code" - } - ] -} -``` - -Sources: `current_session`, `tool_search`, `host_settings`, -`cli_configuration`, `runtime_log`. A session group cannot claim -`cli_configuration` as its source. Connection status is `connected`, `failed`, -`needs_authentication`, `disabled`, or `unknown`; auth status is -`authenticated`, `needs_authentication`, or `unknown`. -Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` -(`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, -`protocolCode`, `message`, and `originalAttemptMatch` (`confirmed`/`unconfirmed`). -Omit the match flag when unknown. Set `confirmed` only when the captured failed -runtime/session observation explicitly identifies the original failed attempt -and its actual server request ID; registration, nearby timestamps, successful -operations, and later probe IDs are not confirmation. An original error must -also have been recorded for that observation to close the original-request gap. -This remains Agent-supplied confirmation, not independent helper verification. -New stage names are preserved. Do not put an -MCP session credential in `conversationId` or guess a server request ID. -Only include ChatCut tool names; keep tool arguments and results out. - -## Run once for the reported failure - -### Preserve the failed call before independent checks - -When the host exposes the original failed tool result or runtime event, the Agent -copies only its error and available identifiers into a new dedicated -`.mcp-failure.json`. Do this before retries, reconnects or later probes. -Do not read a conversation transcript or collect tool arguments/results to build -it. Use the host's existing error metadata and supported read-only runtime logs; -the user does not fill this record. Keep the full original error message, code, -cause and stack when available, including unfamiliar failures. - -```json -{ - "schemaVersion": 1, - "source": "host_tool_result", - "serverName": "chatcut", - "occurredAt": null, - "conversationId": null, - "callId": null, - "operation": "list_projects", - "requestId": null, - "error": { "message": "Exact original error; add original code/cause/stack when available" } -} -``` - -Supported sources are `host_tool_result`, `host_runtime_event`, and -`captured_stderr`. `occurredAt` is the failed event's actual timestamp with -timezone, when exposed; never substitute the current time, file modification -time, snapshot capture or later probe time. Omit unknown optional IDs or leave -them null. A local `callId` and `conversationId` identify client-side work and -are useful even if the request never reaches ChatCut; they are not server -request IDs. Copy a `requestId` only when the original failed call actually -provides it. All supplied facts remain labelled as Agent-supplied evidence. - -Prefer `--failure-record ` to `--original-error` when -this structured source is available; choose one input. The collector centers -its bounded log window on the supplied failure time and uses its conversation -ID when available. A missing time remains an explicit gap. For an exec caller, -use already retained stderr; discarded stderr cannot be recovered by this Skill. -Do not start another authenticated session to manufacture replacement evidence. - -If only the original error text is accessible, use the text-file fallback below. -Do not call a report with only startup-status notices a captured tool failure. - -1. Obtain the existing server's registered URL through the host. Preserve its - environment. The helper accepts ChatCut production/beta/dev endpoints and - loopback development servers; it reports unsupported endpoints rather than - silently probing production. If no registered URL is available, omit - `--server-url` to collect a local report without probing or uploading. - Never read credential stores or paste complete - configuration, environment, or conversation dumps into the report. - An intentionally disabled hosted server is not a failed connection; preserve - that choice instead of starting diagnostics or re-enabling it. -2. Save only the original connection error and its available cause chain into a - new temporary text file. Use the exact original text, not a generated root - cause. If unavailable, omit `--original-error`. Obtain the actual bundled - host version if accessible without starting a session. Do not treat update - metadata as the running version. -3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: - - ```text - node /scripts/mcp-doctor.mjs --host codex --server-url --host-snapshot --original-error --host-version --upload - ``` - - For Claude Code, use `--host claude`. Quote paths as shell arguments; do not - interpolate error text into shell code. Omit unknown optional fields. Supply - `--occurred-at` only when the failure's actual ISO timestamp and timezone are - known. A missing timestamp remains unknown; the collection time is separate. - If transfer is denied, omit `--upload` and retain the local JSON report. - -Use an available Node.js 18+ runtime. Do not install a runtime or ChatCut Desktop -just for diagnostics. Codex SQLite extraction additionally needs `node:sqlite` -(available on recent Node 22+/24 runtimes). Without it, other collection still -runs and the report marks the missing SQLite evidence. A denied file read is a -coverage gap, not proof that the server has no errors. - -The helper reads only bounded ChatCut MCP runtime log records or debug log tails. -Codex SQLite locations follow `CODEX_SQLITE_HOME` when present. If the current -host already supplies its actual `sqlite_home` or `log_dir`, pass that directory -with `--codex-sqlite-home` or `--codex-log-dir`; do not read whole config files to -find it. These options select existing log locations and do not enable logging. -On Windows, SQLite error 14 can coexist with readable WAL files in a read-only -sandbox. The report preserves the original error and bounded file-access facts; -it does not prove a specific security product caused the restriction. Never copy -the whole log database, ignore its WAL with immutable mode, or relax permissions. -On Windows, the helper also reads the existing Codex App startup-status notices -under `%LOCALAPPDATA%/Codex/Logs/YYYY/MM/DD`. This is independent of TUI logging. -It selects only `AppServerConnection` startup notices for the hosted ChatCut -server, uses the current conversation ID when available, and keeps at most 30 -records from three 128 KiB tails. Missing/rotated/denied logs remain explicit -gaps. The known Windows MXC `LOCALAPPDATA` sandbox redirect falls back to the -real user's `AppData/Local` without changing permissions. An already supplied alternative App log root can be selected with -`--codex-app-log-dir`; do not scan browser profiles or session transcripts. - -The report names selected/omitted native files and byte ranges that were not read. -For an existing reported gap, use `--codex-app-log-file ` -to select at most three known native files. With exactly one selected file, -`--codex-app-log-offset ` reads at most 128 KiB from -that segment; unread ranges remain explicit. Preserve the original failure's -known time/window. Do not repeatedly upload replacement reports or scan entire -log history to fill an unknown failure time. App startup notices cover startup; -they do not cover every later tool-call failure. SQLite/native log extraction -retains available local conversation/call IDs without promoting them to original -server request IDs or independently confirmed attempt associations. -For a separately chosen future reproduction, Codex's explicit `log_dir` setting -can enable its native plaintext log. Do not change global settings or relaunch -the user's host automatically. Existing logs cannot be reconstructed retroactively. -It never reads `auth.json`, OAuth credential stores, session transcripts, or -other project files. It makes one unauthenticated initialize probe and at most -five public metadata GETs, all within the selected origin and with a 3.5-second -timeout each. An anonymous 401 challenge is expected. It validates resource / -issuer consistency and this product's OAuth-code/PKCE metadata. It never logs in, -refreshes credentials, or lists tools using extracted credentials. - -Codex exec prints diagnostics inline; `--json` stdout alone is not a complete -MCP error stream. Preserve the original stderr/startup error when it is already -visible to the current host. The helper cannot recover discarded parent-process -stderr or reconstruct an old exec failure from App logs. - -If the current host already exposes native `mcpServer/startupStatus/updated` -notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, -`runtimeStatus` and `toolsError` from that same thread in the error file and -snapshot observations (`source: current_session`). Record the native method and -actual thread ID. Authentication inventory alone is not proof of initialization -or usable tools. The status query may perform new auth-discovery requests; keep -those separate from retained runtime errors. Do not start another app-server, -start/reload a thread, invoke OAuth, or launch a daemon to obtain the original -state. These interfaces are not guaranteed to be exposed to a third-party Skill. -`codex doctor --json` is a general installation/config/auth health check, not a -targeted export of the failed MCP attempt. Do not ingest its entire report. -`feedback/upload` submits to OpenAI; it is not ChatCut's log export endpoint. - -The CLI also makes at most two sequential anonymous metadata GETs to compare -the relevant path-scoped and root endpoints at the HTTP/1.1 framing level. -`wireProbes` records selected response headers, request ID, first framing byte, -chunk validity, bounded byte counts/hashes and peer/TLS evidence from that same -connection. It retains no metadata body, cookies or credentials. Each probe -stops at 128 KiB / 3.5 seconds. These are direct connections, explicitly outside -the host/fetch proxy route; normal certificate verification remains enabled. -Complete fixed-length/chunked messages finish at their verified boundary without -waiting for peer close. Invalid HTTP status lines and unsupported informational -responses never count as complete final responses. `completionReason` and -`peerEndObserved` distinguish message completion from connection termination. - -When an HTTPS request/body fails, one additional direct TLS connection may -record the peer certificate issuer and protocol, with the same timeout and -normal certificate verification. This is a separately labelled route, not the -original host's TLS/proxy path. Proxy/CA environment presence is boolean only. -A healthy later check cannot establish recovery or identify antivirus/VPN as -the cause. A disabled connection sends neither probes nor an upload. - -## Interpret the report - -- `originalFailure` is the original error supplied by the Agent. `hostLogs` - are matching records in a bounded time window, with unconfirmed attempt - association. `probes` are new requests made later. Keep these separate. -- `hostSnapshot` is Agent-supplied evidence with per-group sources, not an - independently verified host API result. `evidenceSummary` keeps original - stage observations separate from independent checks. Unknown stages and - unfamiliar errors remain available; missing stages are `not_observed`. -- Timestamped text records outside the requested window are excluded. Records - without parseable timestamps are labelled time-unknown, never window matches. -- Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. - A healthy later probe never proves the original host connection recovered. -- Preserve original error messages and codes. Redact actual credentials, not - entire error categories. No separate metadata response body is retained; - original parser errors may quote response text. -- Missing debug logs cannot be reconstructed. Claude's detailed debug logs may - require `claude --debug=mcp` during a future, separately chosen reproduction; - this helper does not enable logging or relaunch Claude. -- A `diagnosticId` identifies the diagnostic report and its new probes. It is - not the original failed request's ID. Server response request IDs are useful - only when actually present and matched; time/path matching is weaker evidence. -- Match a wire response's `x-request-id` with the server's metadata response - record. The application record observes bytes before Node's HTTP framing; - a successful finish does not prove valid framing after Node/ALB/local HTTPS - inspection, nor receipt by Codex. Different requests may have different paths. - -The local JSON is written before any upload. `ingest_accepted` means that the -endpoint acknowledged the batch; `storageVerified:false` remains until support -finds every fragment in the final log store. Failed uploads preserve the local -report and upload error; do not automatically repeat the run or claim success. -Output larger than the upload budget stays local with an explicit upload error. - -Return the diagnostic ID, local report location, one new observed fact, and the -next action justified by it. If no new actionable fact was obtained, state the -specific missing evidence. Do not assign blame to antivirus, VPN, firewall, host, -or service merely from a generic failure message. Do not claim a user failure -percentage from diagnostic samples. - -This branch runs when the Agent loads it and has access to local execution. It -is not an installation hook or an all-user failure monitor. It cannot inspect a -web host's remote connection process or internal logs. With Claude Code tool -search disabled, connection failures may not be passed to the Agent at all. +For a hosted ChatCut connection or tool failure, load the independent [ChatCut MCP Doctor Skill](../../chatcut-mcp-doctor/SKILL.md). It runs locally even when the ChatCut MCP is unavailable. Preserve the original error before retries. The former scripts/mcp-doctor.mjs entry remains compatible. diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs index 513ba4a..0e45dfe 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -1,1325 +1,6 @@ -import { randomUUID, createHash } from "node:crypto"; -import { open, opendir, readFile, readdir, stat, writeFile } from "node:fs/promises"; -import { homedir, tmpdir, platform, arch } from "node:os"; -import { join, resolve, basename } from "node:path"; +// Compatibility entry; the standalone Doctor owns the collector. +export * from "../../chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { parseArgs } from "node:util"; -import { connect as connectTls } from "node:tls"; -import { isIP } from "node:net"; -import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; -import { probeMetadataWire } from "./mcp-wire.mjs"; -import { failureFromRecord } from "./mcp-failure.mjs"; - -const READ_LIMIT = 128 * 1024; -const UPLOAD_LIMIT = 256 * 1024; -const TIMEOUT_MS = 3500; -const CHATCUT_MESSAGE = - /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|\bplugin(?:_name)?[=: "\t]+chatcut(?:@chatcut-inc)?(?:["\s}:]|$)|https:\/\/(?:api\.chatcut\.(?:io|dev)|api-dev\.chatcut\.org)\//i; -const TEXT_ECHO = - /^(?:user (?:prompt|message)|assistant message|tool (?:call|result))\s*:|^(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[]/i; - -function redactFieldValue(_match, prefix, value) { - if (!value) return prefix; - const quote = value.match(/^\\*["']/)?.[0]; - return prefix + (quote ? quote + "[REDACTED]" + quote : "[REDACTED]"); -} - -export function redact(text) { - return String(text) - .replace( - /\bBearer\s+(?!(?:token|required)\b|<|(?:realm|scope|error(?:_description|_uri)?|resource_metadata)\s*=)[A-Za-z0-9._~+/=-]+/gi, - "Bearer [REDACTED]", - ) - .replace(/\b(Authorization\s*[:=]\s*Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[REDACTED]") - .replace( - /\b((?:set-)?cookie\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|\[REDACTED\]|(?:(?![\r\n]|(?]+/gi, - "$1[REDACTED]", - ) - .replace( - /\b((?:access_?token|refresh_?token|id_?token|session_?token|client_?secret|api[_-]?key|token)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|[A-Za-z0-9._~+/=-]+)/gi, - redactFieldValue, - ) - .replace( - /((?:\\*["'])(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, - "$1[REDACTED]", - ) - .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"); -} - -function clean(value) { - return JSON.parse( - JSON.stringify(value, (key, item) => - /^(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( - key, - ) - ? "[REDACTED]" - : typeof item === "string" - ? redact(item) - : item, - ), - ); -} - -export function errorDetails(error, seen = new Set()) { - if (!error || typeof error !== "object") return { message: redact(error) }; - if (seen.has(error) || seen.size >= 8) return { unavailable: "error_chain_depth_or_cycle" }; - seen.add(error); - const result = {}; - for (const key of ["name", "message", "code", "errno", "errcode", "errstr", "syscall", "stack"]) { - if (typeof error[key] === "string" || typeof error[key] === "number") result[key] = error[key]; - } - if (error.cause) result.cause = errorDetails(error.cause, seen); - if (Array.isArray(error.errors)) { - result.errors = error.errors.slice(0, 8).map((item) => errorDetails(item, seen)); - if (error.errors.length > 8) result.omittedErrors = error.errors.length - 8; - } - return clean(result); -} - -export function serverUrl(value) { - const url = new URL(value); - const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); - const hosted = ["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes( - url.hostname, - ); - if ( - (!local && (!hosted || url.protocol !== "https:" || (url.port && url.port !== "443"))) || - (local && !["http:", "https:"].includes(url.protocol)) || - url.username || - url.password || - url.search || - url.hash || - !/^\/api\/external-mcp\/(?:mcp|claude|v2\/mcp)\/?$/.test(url.pathname) - ) { - throw new Error( - "Unsupported registered MCP URL; preserve its configuration and report unsupported_endpoint", - ); - } - return url; -} - -async function limitedBody(response, limit = READ_LIMIT) { - const reader = response.body?.getReader(); - if (!reader) return ""; - const parts = []; - let bytes = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) return Buffer.concat(parts).toString("utf8"); - bytes += value.length; - if (bytes > limit) { - const error = new Error(`Response exceeds ${limit} bytes; body not retained`); - error.code = "DIAGNOSTIC_BODY_LIMIT"; - throw error; - } - parts.push(Buffer.from(value)); - } - } finally { - await reader.cancel().catch(() => {}); - reader.releaseLock(); - } -} - -export async function probeMetadata(url, diagnosticId, fetchImpl = fetch, expected = {}) { - const startedAt = new Date().toISOString(); - const started = Date.now(); - const result = { - evidenceKind: "later_probe", - url, - startedAt, - implementation: `node-fetch/${process.version}`, - stage: "request", - status: null, - bodyComplete: false, - jsonValid: null, - originalAttemptMatch: "unconfirmed", - }; - try { - const response = await fetchImpl(url, { - redirect: "manual", - signal: AbortSignal.timeout(TIMEOUT_MS), - headers: { accept: "application/json", "x-chatcut-diagnostic-id": diagnosticId }, - }); - result.status = response.status; - result.responseHeaders = {}; - for (const key of [ - "content-type", - "content-length", - "content-encoding", - "transfer-encoding", - "server", - "via", - "x-request-id", - "x-amzn-trace-id", - "www-authenticate", - "retry-after", - "location", - ]) { - const value = response.headers.get(key); - if (value !== null) result.responseHeaders[key] = value; - } - result.stage = "response_body"; - const body = await limitedBody(response); - result.bodyComplete = true; - result.bodyBytes = Buffer.byteLength(body); - result.bodySha256 = createHash("sha256").update(body).digest("hex"); - result.stage = "json_parse"; - try { - const parsed = JSON.parse(body); - result.jsonValid = true; - result.metadataShape = { - object: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed), - issuerPresent: typeof parsed?.issuer === "string", - resourcePresent: typeof parsed?.resource === "string", - }; - result.metadataValidation = inspectMetadata(parsed, url, expected); - } catch (error) { - result.jsonValid = false; - throw error; - } - result.stage = "complete"; - const required = new URL(url).pathname.includes("oauth-protected-resource") - ? "resourcePresent" - : "issuerPresent"; - result.outcome = !response.ok - ? "http_rejected" - : result.metadataShape.object && result.metadataShape[required] - ? result.metadataValidation.valid - ? "metadata_read" - : "invalid_metadata" - : "invalid_metadata_shape"; - } catch (error) { - result.outcome = "failed"; - result.error = errorDetails(error); - } - result.durationMs = Date.now() - started; - return clean(result); -} - -async function guardProbe(url, operation) { - const startedAt = new Date().toISOString(); - try { - return await operation(); - } catch (error) { - return { - evidenceKind: "later_probe", - url, - startedAt, - outcome: "failed", - stage: "probe_collector", - status: null, - originalAttemptMatch: "unconfirmed", - error: errorDetails(error), - gap: "partial_probe_evidence_unavailable; original_failure_retained", - }; - } -} - -export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { - const started = Date.now(); - const result = { - evidenceKind: "later_probe", - url: endpoint.href, - method: "POST", - operation: "initialize", - implementation: "node-fetch/" + process.version, - startedAt: new Date().toISOString(), - stage: "request", - status: null, - originalAttemptMatch: "unconfirmed", - credentialsSent: false, - }; - try { - const response = await fetchImpl(endpoint.href, { - method: "POST", - redirect: "manual", - signal: AbortSignal.timeout(TIMEOUT_MS), - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - "x-chatcut-diagnostic-id": diagnosticId, - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: diagnosticId, - method: "initialize", - params: { - protocolVersion: "2025-11-25", - capabilities: {}, - clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.3" }, - }, - }), - }); - result.status = response.status; - result.responseHeaders = {}; - for (const name of [ - "content-type", - "content-length", - "content-encoding", - "transfer-encoding", - "www-authenticate", - "retry-after", - "location", - "x-request-id", - "x-amzn-trace-id", - "server", - "via", - ]) { - const value = response.headers.get(name); - if (value !== null) result.responseHeaders[name] = value; - } - result.stage = "response_body"; - const body = await limitedBody(response); - result.bodyComplete = true; - result.bodyBytes = Buffer.byteLength(body); - result.bodySha256 = createHash("sha256").update(body).digest("hex"); - let parsed; - try { - parsed = JSON.parse(body); - } catch (error) { - result.parseError = errorDetails(error); - if (!response.ok) result.responseError = { message: redact(body) }; - } - if (parsed?.error) result.protocolError = clean(parsed.error); - if (response.status === 401) { - const header = response.headers.get("www-authenticate") ?? ""; - const metadataHint = header.match(/resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i); - result.resourceMetadataUrl = metadataHint?.[1] ?? metadataHint?.[2] ?? null; - result.outcome = /\bBearer\b/i.test(header) - ? "authentication_challenge" - : "missing_authentication_challenge"; - } else if (!response.ok) result.outcome = "http_rejected"; - else if (parsed?.error) result.outcome = "protocol_error"; - else if (parsed?.jsonrpc === "2.0" && typeof parsed.result?.protocolVersion === "string") { - result.outcome = "anonymous_initialize_returned"; - result.serverInfo = parsed.result.serverInfo; - result.protocolVersion = parsed.result.protocolVersion; - } else result.outcome = "invalid_initialize_response"; - result.stage = "complete"; - } catch (error) { - result.outcome = "failed"; - result.error = errorDetails(error); - } - result.durationMs = Date.now() - started; - return clean(result); -} - -export function inspectTransport(endpoint) { - if (endpoint.protocol !== "https:") - return Promise.resolve({ status: "not_applicable", reason: "not_tls" }); - return new Promise((resolveProbe) => { - const startedAt = new Date().toISOString(); - let settled = false; - const host = endpoint.hostname.replace(/^\[|\]$/g, ""); - const socket = connectTls({ - host, - port: Number(endpoint.port || 443), - rejectUnauthorized: true, - servername: isIP(host) ? undefined : host, - }); - const finish = (details) => { - if (settled) return; - settled = true; - clearTimeout(timer); - socket.destroy(); - resolveProbe( - clean({ - evidenceKind: "later_probe", - implementation: "node:tls", - startedAt, - route: "direct_tls; does_not_reproduce_host_or_fetch_proxy_route", - originalAttemptMatch: "unconfirmed", - ...details, - }), - ); - }; - const timer = setTimeout( - () => - finish({ - status: "failed", - error: { - name: "TimeoutError", - message: "Direct TLS context timed out", - code: "ETIMEDOUT", - }, - }), - TIMEOUT_MS, - ); - socket.once("secureConnect", () => { - const certificate = socket.getPeerCertificate(); - finish({ - status: "observed", - tlsProtocol: socket.getProtocol(), - authorized: socket.authorized, - certificate: { - issuer: certificate.issuer, - validFrom: certificate.valid_from, - validTo: certificate.valid_to, - fingerprint256: certificate.fingerprint256, - }, - }); - }); - socket.once("error", (error) => finish({ status: "failed", error: errorDetails(error) })); - }); -} - -function logMessage(body) { - const marker = body.lastIndexOf("}:"); - return (marker < 0 ? body : body.slice(marker + 2)).trim(); -} - -function runtimeContext(body) { - const boundary = body.lastIndexOf("}:"); - if (boundary < 0) return {}; - const context = body.slice(0, boundary); - const value = (field) => - [ - ...context.matchAll( - new RegExp(`(?:^|[\\s{])${field}="?([A-Za-z0-9_.-]{1,256})(?:"|[\\s}:]|$)`, "g"), - ), - ].at(-1)?.[1]; - const conversationId = value("thread_id") ?? value("thread\\.id") ?? value("conversation\\.id"); - const callId = value("call_id"); - return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; -} - -function hostedTextRecord(line) { - if (/^\s/.test(line)) return false; - let body = line.replace( - /^\d{4}-\d{2}-\d{2}T\S+\s+(?:(?:\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\]|TRACE|DEBUG|INFO|WARN|ERROR)\s+)?/i, - "", - ); - const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); - if (module) { - if ( - !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::)/.test(module[1]) - ) - return false; - body = body.slice(module[0].length); - } - if ( - /^(?:user(?: prompt| message)?|assistant(?: message)?|system|developer|transcript)\s*:|^[{[]/i.test( - body, - ) - ) - return false; - const scope = body.match( - /^(?:MCP server|Server)\s+(?:"([^"]+)"|'([^']+)'|((?:plugin:[\w.-]+:)?[\w.-]+))(?::\s*|\s+|$)/i, - ); - const context = body.split(/\berror\s*=/i)[0]; - const server = - context.match(/\bserver_name=["']?([\w:.-]+)/)?.[1] ?? scope?.[1] ?? scope?.[2] ?? scope?.[3]; - if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; - if (!CHATCUT_MESSAGE.test(body)) return false; - if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) return false; - const message = scope ? body.slice(scope[0].length) : body; - // Runtime error contents can name tools; only explicit echo records are excluded. - if (TEXT_ECHO.test(message)) return false; - if ( - /^(?:Sending|Sent|Received|Receiving)\s+(?:MCP\s+|JSON-RPC\s+)?(?:request|response|message)\b/i.test( - message, - ) && - /"method"\s*:\s*"tools\/call"|"result"\s*:\s*\{\s*"content"\s*:/i.test(message) - ) - return false; - if (/^[{[]/.test(message)) { - try { - const value = JSON.parse(message); - const records = Array.isArray(value) ? value : [value]; - if ( - records.some( - (record) => - record && - (record.method === "tools/call" || - [ - "function_call", - "function_call_output", - "tool_call", - "tool_result", - "user", - "assistant", - "user_message", - "assistant_message", - ].includes(record.type) || - ["user", "assistant", "system", "developer"].includes(record.role) || - Array.isArray(record.result?.content)), - ) - ) - return false; - } catch { - // Unparseable diagnostic text is not proof of a tool or conversation envelope. - } - } - return true; -} - -async function textLog(path, source, gaps, window, select = hostedTextRecord) { - const handle = await open(path, "r"); - try { - const info = await handle.stat(); - const start = window.readOffset ?? Math.max(0, info.size - READ_LIMIT); - if (!Number.isSafeInteger(start) || start < 0 || start > info.size) - throw new Error("Log segment offset must point inside the selected existing file"); - const buffer = Buffer.alloc(Math.min(info.size - start, READ_LIMIT)); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); - const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); - if (start) { - const previous = Buffer.alloc(1); - await handle.read(previous, 0, 1, start - 1); - if (previous[0] !== 10) lines.shift(); - gaps.push({ - source, - reason: "only_log_tail_read", - sourceFile: basename(path), - bytesBeforeWindow: start, - omittedBytes: start, - }); - } - if (start + bytesRead < info.size) { - if (!buffer.subarray(0, bytesRead).toString("utf8").endsWith("\n")) lines.pop(); - gaps.push({ - source, - reason: "only_log_segment_read", - sourceFile: basename(path), - offset: start, - bytesRead, - remainingBytes: info.size - start - bytesRead, - }); - } - const entries = []; - for (let i = 0; i < lines.length; i++) { - if (!select(lines[i])) continue; - const block = [lines[i]]; - while ( - source !== "codex_app_log" && - i + 1 < lines.length && - /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1]) && - !/^\s*(?:\d{4}-\d{2}-\d{2}T|MCP server\s|Server\s|\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\])/i.test( - lines[i + 1], - ) && - !TEXT_ECHO.test(lines[i + 1].trimStart()) - ) - block.push(lines[++i]); - const occurredAt = logTime(block[0]); - if ( - occurredAt && - (Date.parse(occurredAt) < window.since * 1000 || - Date.parse(occurredAt) > window.until * 1000) - ) - continue; - entries.push({ - source, - sourceFile: basename(path), - occurredAt, - timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", - message: redact(block.join("\n")), - ...runtimeContext(block[0]), - originalAttemptMatch: "unconfirmed", - }); - } - if (entries.length > 30) - gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); - const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; - if (unknownTimes) - gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); - return entries.slice(-30); - } finally { - await handle.close(); - } -} - -async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { - if ( - readOffset !== undefined && - (selectedFiles.length !== 1 || !Number.isSafeInteger(readOffset) || readOffset < 0) - ) - throw new Error( - "A segment offset requires exactly one selected native App log file and a nonnegative integer", - ); - const files = []; - const dates = new Set(); - for (const time of [window.since * 1000, window.until * 1000]) { - const date = new Date(time); - dates.add(date.toISOString().slice(0, 10).replaceAll("-", "/")); - dates.add( - [date.getFullYear(), date.getMonth() + 1, date.getDate()] - .map((value) => String(value).padStart(2, "0")) - .join("/"), - ); - } - let scanned = 0; - const deadline = Date.now() + 1500; - for (const date of dates) { - try { - const dir = join(root, date); - for await (const item of await opendir(dir)) { - if (++scanned > 200 || Date.now() > deadline) { - gaps.push({ source: "codex_app_log", reason: "directory_scan_limit" }); - break; - } - if (!item.isFile() || !/^codex-desktop-.*\.log$/.test(item.name)) continue; - const path = join(dir, item.name); - const info = await stat(path); - if (info.mtimeMs >= window.since * 1000) files.push({ path, modified: info.mtimeMs }); - } - } catch (error) { - gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); - } - if (scanned > 200 || Date.now() > deadline) break; - } - files.sort((a, b) => b.modified - a.modified); - if (selectedFiles.length) { - if (selectedFiles.length > 3) - throw new Error("Select at most three existing Codex App log files"); - files.length = 0; - for (const path of [...new Set(selectedFiles)]) { - if (!/^codex-desktop-.*\.log$/.test(basename(path))) - throw new Error("Select only existing native codex-desktop-*.log files"); - const info = await stat(path); - if (!info.isFile()) throw new Error("Codex App log selection must be a file"); - files.push({ path, modified: info.mtimeMs }); - } - } - const entries = []; - let filesRead = 0; - for (const file of files.slice(0, 3)) { - try { - // App logs mix unrelated UI activity with runtime notices, so accept only this native event. - entries.push( - ...(await textLog(file.path, "codex_app_log", gaps, { ...window, readOffset }, (line) => { - if ( - !/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test( - line, - ) - ) - return false; - const server = [...line.matchAll(/(?:^|\s)server=([^\s]+)/g)].at(-1)?.[1]; - if (!["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; - const thread = [...line.matchAll(/(?:^|\s)threadId=([^\s]+)/g)].at(-1)?.[1]; - return !conversationId || !thread || thread === "null" || thread === conversationId; - })), - ); - filesRead++; - } catch (error) { - gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); - } - } - if (files.length > 3) - gaps.push({ - source: "codex_app_log", - reason: "log_file_limit", - omitted: files.length - 3, - omittedFiles: files.slice(3, 13).map((file) => basename(file.path)), - }); - if (entries.length > 30) - gaps.push({ - source: "codex_app_log", - reason: "log_record_limit", - omitted: entries.length - 30, - }); - return { - entries: entries - .sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")) - .slice(-30), - scan: { - directoryEntriesScanned: scanned, - matchingFiles: files.length, - filesRead, - maximumTailBytesPerFile: READ_LIMIT, - currentConversationFilterApplied: Boolean(conversationId), - ...(conversationId && { conversationId }), - selectedFileMode: selectedFiles.length > 0, - ...(readOffset !== undefined && { selectedByteOffset: readOffset }), - filesSelected: files.slice(0, 3).map((file) => basename(file.path)), - observationScope: - "hosted_mcp_startup_notices_only; tool_request_failures_need_original_failure_record_or_matching_runtime_logs", - }, - }; -} - -async function inspectSqliteAccess(path) { - const files = {}; - for (const suffix of ["", "-wal", "-shm"]) { - const name = suffix || "database"; - let handle; - try { - const info = await stat(path + suffix); - handle = await open(path + suffix, "r"); - const header = Buffer.alloc(suffix ? 1 : 20); - const { bytesRead } = await handle.read(header, 0, header.length, 0); - files[name] = { exists: true, readable: true, bytes: info.size }; - if ( - !suffix && - bytesRead === 20 && - header.subarray(0, 16).toString() === "SQLite format 3\0" - ) { - files[name].journalMode = header[18] === 2 ? "wal" : "rollback"; - } - } catch (error) { - files[name] = { readable: false, error: errorDetails(error) }; - } finally { - try { - await handle?.close(); - } catch (error) { - files[name].closeError = errorDetails(error); - } - } - } - return { - files, - interpretation: - "File reads and SQLite WAL/shared-memory access are different operations; readable files do not prove SQLite access. No copy, checkpoint, immutable mode or permission change attempted.", - }; -} - -export async function collectHostLogs(host, options = {}) { - const userHome = options.home ?? homedir(); - const gaps = []; - const entries = []; - let appLog; - const now = options.now ?? Date.now(); - const center = options.occurredAt ? Date.parse(options.occurredAt) : now; - const since = Math.floor((center - (options.occurredAt ? 15 : 30) * 60_000) / 1000); - const until = Math.min(now, center + 15 * 60_000) / 1000; - if (host === "codex") { - const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); - const sqliteRoot = options.codexSqliteHome ?? process.env.CODEX_SQLITE_HOME ?? root; - const logRoot = options.codexLogDir ?? join(root, "log"); - const databasePath = join(sqliteRoot, "logs_2.sqlite"); - let db; - try { - await stat(databasePath); - const { DatabaseSync } = await import("node:sqlite"); - db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); - db.exec("PRAGMA query_only=ON"); - const hasThreadId = db - .prepare("PRAGMA table_info(logs)") - .all() - .some((column) => column.name === "thread_id"); - const rows = db - .prepare( - `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, - ) - .all(since, until); - for (const row of rows) { - const message = logMessage(row.feedback_log_body ?? ""); - const original = row.feedback_log_body ?? ""; - const context = runtimeContext(original); - if (row.thread_id) context.conversationId = row.thread_id; - if ( - options.conversationId && - context.conversationId && - context.conversationId !== options.conversationId - ) - continue; - if (!CHATCUT_MESSAGE.test(original) || /server_name="?chatcut_desktop/.test(original)) - continue; - if ( - !["ERROR", "WARN"].includes(row.level) && - !/omitting MCP server|cached MCP catalog/.test(message) - ) - continue; - entries.push({ - source: "codex_runtime_sqlite", - rowId: row.id, - occurredAt: new Date(row.ts * 1000).toISOString(), - target: row.target, - level: row.level, - message: redact(message), - ...context, - originalAttemptMatch: "unconfirmed", - }); - if (entries.length === 30) break; - } - if (rows.length === 200 || entries.length === 30) - gaps.push({ source: "codex_runtime_sqlite", reason: "log_record_limit" }); - } catch (error) { - gaps.push({ - source: "codex_runtime_sqlite", - reason: "unavailable", - error: errorDetails(error), - locationSource: options.codexSqliteHome - ? "explicit_host_location" - : process.env.CODEX_SQLITE_HOME - ? "CODEX_SQLITE_HOME" - : "CODEX_HOME_default; sqlite_home_config_override_not_inspected", - ...(platform() === "win32" && error?.errcode === 14 - ? { accessEvidence: await inspectSqliteAccess(databasePath) } - : {}), - }); - } finally { - db?.close(); - } - try { - entries.push( - ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { - since, - until, - })), - ); - } catch (error) { - gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); - } - const appData = options.localAppData ?? process.env.LOCALAPPDATA; - const sandboxAppData = - !options.localAppData && - /[\\/]Packages[\\/]sandbox\.[^\\/]+[\\/]AC(?:[\\/]|$)/i.test(appData ?? ""); - const appLogRoot = - options.codexAppLogDir ?? - ((options.platform ?? platform()) === "win32" - ? join( - // MXC redirects LOCALAPPDATA; the running App logs stay in the user profile. - (!sandboxAppData && appData) || join(userHome, "AppData", "Local"), - "Codex", - "Logs", - ) - : null); - if (appLogRoot) { - const result = await codexAppLogs( - appLogRoot, - gaps, - { since, until }, - options.conversationId, - options.codexAppLogFiles, - options.codexAppLogOffset, - ); - entries.push(...result.entries); - appLog = { - ...result.scan, - locationSource: options.codexAppLogDir ? "explicit_host_location" : "windows_default", - sandboxLocalAppDataRedirected: Boolean(sandboxAppData), - }; - if (!result.entries.length) - gaps.push({ source: "codex_app_log", reason: "no_matching_records_in_bounded_window" }); - } - } else { - const root = - options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(userHome, ".claude"); - try { - const dir = join(root, "debug"); - const names = await readdir(dir, { withFileTypes: true }); - const files = []; - const deadline = Date.now() + 1500; - for (const item of names) { - if (Date.now() > deadline) { - gaps.push({ source: "claude_debug_log", reason: "directory_scan_deadline" }); - break; - } - if (!item.isFile() || !item.name.endsWith(".txt")) continue; - const path = join(dir, item.name); - const info = await stat(path); - if (info.mtimeMs >= since * 1000) files.push({ path, modified: info.mtimeMs }); - } - files.sort((a, b) => b.modified - a.modified); - for (const item of files.slice(0, 3)) - entries.push(...(await textLog(item.path, "claude_debug_log", gaps, { since, until }))); - if (files.length > 3) - gaps.push({ - source: "claude_debug_log", - reason: "log_file_limit", - omitted: files.length - 3, - }); - } catch (error) { - gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); - } - } - if (!entries.length) - gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); - return clean({ - entries, - gaps, - ...(appLog ? { appLog } : {}), - window: { - since: new Date(since * 1000).toISOString(), - until: new Date(until * 1000).toISOString(), - }, - }); -} - -export function fragmentEvents(report) { - const json = JSON.stringify(clean(report)); - if (Buffer.byteLength(json) > UPLOAD_LIMIT) - throw new Error("Report exceeds upload budget; complete local report retained"); - const chunks = []; - let chunk = ""; - let bytes = 0; - for (const char of json) { - const size = Buffer.byteLength(char); - if (bytes + size > 4096) { - chunks.push(chunk); - chunk = ""; - bytes = 0; - } - chunk += char; - bytes += size; - } - if (chunk) chunks.push(chunk); - const sha256 = createHash("sha256").update(json).digest("hex"); - // Canonical fields and a map avoid growing the backend dataset's flat schema. - return chunks.map((fragment, fragmentIndex) => ({ - name: "mcp.doctor.report_fragment", - properties: { - clientEventName: null, - eventName: "mcp.doctor.report_fragment", - attributes: { - mcpDoctor: { - diagnosticId: report.diagnosticId, - schemaVersion: report.schemaVersion, - collectedAt: report.collectedAt, - host: report.host, - scope: "cloud", - fragmentIndex, - fragmentCount: chunks.length, - reportSha256: sha256, - fragment, - }, - }, - }, - })); -} - -export async function runDoctor(options, dependencies = {}) { - if (!["codex", "claude"].includes(options.host)) - throw new Error("--host must be codex or claude"); - if (options.originalError && options.failureRecord) - throw new Error("Choose one original failure input: --failure-record or --original-error"); - const endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; - if ( - options.occurredAt && - (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || - !Number.isFinite(Date.parse(options.occurredAt))) - ) { - throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); - } - const fetchImpl = dependencies.fetchImpl ?? fetch; - const diagnosticId = randomUUID(); - const report = { - schemaVersion: 2, - collectorVersion: "1.2.3", - diagnosticId, - collectedAt: new Date().toISOString(), - scope: "cloud", - host: options.host, - hostVersion: options.hostVersion ?? null, - hostVersionSource: options.hostVersion ? "agent_supplied" : "unavailable", - environment: { - platform: platform(), - architecture: arch(), - nodeVersion: process.version, - proxyEnvironmentPresent: [ - "HTTP_PROXY", - "HTTPS_PROXY", - "ALL_PROXY", - "http_proxy", - "https_proxy", - "all_proxy", - ].some((key) => Boolean(process.env[key])), - probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", - noProxyEnvironmentPresent: Boolean(process.env.NO_PROXY || process.env.no_proxy), - nodeUseEnvProxyRequested: process.env.NODE_USE_ENV_PROXY === "1", - extraCaConfigured: Boolean(process.env.NODE_EXTRA_CA_CERTS), - tlsVerificationDisabledInEnvironment: process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0", - }, - serverUrl: endpoint?.href ?? null, - originalFailure: { - evidenceKind: "original_failure", - occurredAt: options.occurredAt ?? null, - timeSource: options.occurredAt - ? "command_line; agent_supplied_not_independently_verified" - : "unavailable", - source: "agent_supplied_error_file", - status: "unavailable", - }, - probes: [], - wireProbes: [], - gaps: [], - outcome: "diagnosis_only; original_connection_recovery_unverified", - coverage: "triggered_diagnostics_only; not_all_users_or_all_failures", - correlation: - "diagnosticId identifies this report and later probes, not the original host request", - }; - let disabledBySnapshot = false; - if (options.hostSnapshot) { - try { - if (!basename(String(options.hostSnapshot)).endsWith(".mcp-snapshot.json")) - throw new Error( - "Host snapshot must be a dedicated .mcp-snapshot.json file, never a credential/config file", - ); - const info = await stat(options.hostSnapshot); - if (info.size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); - const snapshot = JSON.parse(await readFile(options.hostSnapshot, "utf8")); - disabledBySnapshot = - snapshot?.configuration?.enabled === false || - snapshot?.session?.connectionStatus === "disabled"; - report.hostSnapshot = clean(validateSnapshot(snapshot, endpoint)); - report.hostSnapshotSource = - "agent_supplied; see each field source; not_independently_verified"; - if (!report.hostVersion && report.hostSnapshot.hostVersion) { - report.hostVersion = report.hostSnapshot.hostVersion; - report.hostVersionSource = "agent_supplied_host_snapshot"; - } - } catch (error) { - report.gaps.push({ - source: "host_snapshot", - reason: "unavailable", - error: errorDetails(error), - action: - "Create a new dedicated snapshot with --create-snapshot ; fill only observed facts, then --validate-snapshot before collection. Do not rename or read a credential/config file.", - }); - } - } - try { - const manifest = - options.host === "codex" - ? "../../../.codex-plugin/plugin.json" - : "../../../.claude-plugin/plugin.json"; - report.pluginVersion = JSON.parse( - await readFile(new URL(manifest, import.meta.url), "utf8"), - ).version; - report.pluginVersionSource = - "collector_package_manifest; installed_host_plugin_not_independently_verified"; - } catch (error) { - report.gaps.push({ source: "plugin_version", error: errorDetails(error) }); - } - if (options.originalError) { - try { - if (!/\.(?:txt|log)$/i.test(basename(String(options.originalError)))) { - throw new Error( - "Original error must be a dedicated .txt or .log file; credential/config/session files are not inputs", - ); - } - const info = await stat(options.originalError); - if (info.size > 64 * 1024) - throw new Error("Original error file exceeds 64 KiB; source file retained without reading"); - const message = await readFile(options.originalError, "utf8"); - if (!message.trim()) - throw new Error("Original error file is empty; original failure remains unavailable"); - report.originalFailure.message = redact(message); - report.originalFailure.status = "recorded"; - } catch (error) { - report.gaps.push({ - source: "original_failure", - reason: "unavailable", - error: errorDetails(error), - }); - } - } else if (!options.failureRecord) - report.gaps.push({ - source: "original_failure", - reason: "not_provided; do_not_invent_original_error", - }); - if (options.failureRecord) { - try { - if (!basename(String(options.failureRecord)).endsWith(".mcp-failure.json")) - throw new Error( - "Use a dedicated .mcp-failure.json file, never a config or session transcript", - ); - if ((await stat(options.failureRecord)).size > 64 * 1024) - throw new Error("Failure record exceeds 64 KiB; complete source retained without reading"); - report.originalFailure = clean( - failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), - ); - if (!report.originalFailure.occurredAt && options.occurredAt) { - report.originalFailure.occurredAt = options.occurredAt; - report.originalFailure.timeSource = - "command_line; agent_supplied_not_independently_verified"; - } - if ( - options.occurredAt && - report.originalFailure.occurredAt && - Date.parse(options.occurredAt) !== Date.parse(report.originalFailure.occurredAt) - ) { - report.originalFailure.timeConflict = { - failureRecord: report.originalFailure.occurredAt, - commandLine: options.occurredAt, - }; - report.originalFailure.occurredAt = null; - report.originalFailure.timeSource = "conflicting_supplied_times"; - report.gaps.push({ - source: "original_failure", - reason: "conflicting_supplied_failure_times", - }); - } - } catch (error) { - report.gaps.push({ - source: "original_failure", - reason: "failure_record_unavailable", - error: errorDetails(error), - }); - } - } - try { - report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { - occurredAt: report.originalFailure.occurredAt, - codexSqliteHome: options.codexSqliteHome, - codexLogDir: options.codexLogDir, - codexAppLogDir: options.codexAppLogDir, - codexAppLogFiles: options.codexAppLogFiles, - codexAppLogOffset: options.codexAppLogOffset, - conversationId: - report.originalFailure.conversationId ?? - report.hostSnapshot?.session?.conversationId ?? - process.env.CODEX_THREAD_ID, - }); - } catch (error) { - report.hostLogs = { - entries: [], - gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], - }; - } - const disabled = - disabledBySnapshot || - report.hostSnapshot?.configuration?.enabled === false || - report.hostSnapshot?.session?.connectionStatus === "disabled"; - if (disabled || !endpoint) { - report.gaps.push({ - source: "probes", - reason: disabled - ? "host_reports_intentionally_disabled; no_requests_sent" - : "registered_endpoint_unavailable; no_requests_sent", - }); - } else { - report.entryProbe = await guardProbe(endpoint.href, () => - probeEntry(endpoint, diagnosticId, fetchImpl), - ); - const resource = endpoint.pathname.replace(/\/$/, ""); - const queue = [ - new URL("/.well-known/oauth-protected-resource" + resource, endpoint.origin).href, - new URL("/.well-known/oauth-authorization-server" + resource, endpoint.origin).href, - new URL("/.well-known/oauth-authorization-server", endpoint.origin).href, - ]; - const enqueue = (value, label) => { - try { - const url = new URL(value); - if ( - url.origin !== endpoint.origin || - url.username || - url.password || - url.search || - url.hash || - !url.pathname.startsWith("/.well-known/") - ) - throw new Error( - "Unsupported metadata target; no cross-origin or credential-bearing probes", - ); - if (!queue.includes(url.href)) queue.push(url.href); - } catch (error) { - report.gaps.push({ source: label, error: errorDetails(error) }); - } - }; - if (report.entryProbe.resourceMetadataUrl) - enqueue(report.entryProbe.resourceMetadataUrl, "challenge_metadata_target"); - let issuer; - for (let index = 0; index < queue.length && index < 5; index++) { - const url = queue[index]; - const result = await guardProbe(url, () => - probeMetadata(url, diagnosticId, fetchImpl, { - resource: endpoint.href, - issuer: url.includes("oauth-authorization-server") ? issuer : undefined, - }), - ); - report.probes.push(result); - if (url === queue[0] && result.outcome !== "metadata_read") - enqueue( - new URL("/.well-known/oauth-protected-resource", endpoint.origin).href, - "root_resource_metadata_target", - ); - const fields = result.metadataValidation?.fields; - if (url.includes("oauth-protected-resource") && result.metadataValidation?.valid) { - issuer = fields.authorization_servers[0]; - const auth = new URL(issuer); - enqueue( - new URL( - "/.well-known/oauth-authorization-server" + auth.pathname.replace(/\/$/, ""), - auth.origin, - ).href, - "issuer_metadata_target", - ); - } - } - if (queue.length > 5) - report.gaps.push({ - source: "metadata_probes", - reason: "probe_limit", - omitted: queue.length - 5, - }); - if (options.wireEvidence) { - const failed = report.probes.find((probe) => probe.outcome !== "metadata_read"); - const selected = new URL(failed?.url ?? queue[0]); - const match = selected.pathname.match( - /^\/\.well-known\/(oauth-protected-resource|oauth-authorization-server|openid-configuration)/, - ); - if (match) { - const root = new URL("/.well-known/" + match[1], endpoint.origin); - const scoped = new URL(root.pathname + resource, endpoint.origin); - for (const url of [scoped.href, root.href]) { - try { - report.wireProbes.push( - await (dependencies.probeWire ?? probeMetadataWire)(url, diagnosticId, errorDetails), - ); - } catch (error) { - report.gaps.push({ source: "wire_probe", url, error: errorDetails(error) }); - } - } - } - } - if ( - endpoint.protocol === "https:" && - [report.entryProbe, ...report.probes].some( - (probe) => probe.outcome === "failed" && probe.stage !== "probe_collector", - ) - ) { - try { - report.transportContext = await (dependencies.inspectTransport ?? inspectTransport)( - endpoint, - ); - } catch (error) { - report.transportContext = { status: "failed", error: errorDetails(error) }; - } - } - } - report.evidenceSummary = evidenceSummary(report); - report.upload = { - status: disabled - ? "skipped_intentionally_disabled" - : !endpoint - ? "skipped_endpoint_unavailable" - : options.upload - ? "pending" - : "not_requested", - storageVerified: false, - }; - const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); - const save = async (flag) => - writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); - await save("wx"); - if (options.upload && !disabled && endpoint) { - try { - const events = fragmentEvents(report); - const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { - method: "POST", - redirect: "manual", - signal: AbortSignal.timeout(TIMEOUT_MS), - headers: { "content-type": "application/json" }, - body: JSON.stringify(events), - }); - const body = await limitedBody(response, 16 * 1024); - if (!response.ok || JSON.parse(body).ok !== true) - throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); - report.upload = { - status: "ingest_accepted", - storageVerified: false, - fragmentCount: events.length, - reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256, - }; - } catch (error) { - report.upload = { status: "failed", storageVerified: false, error: errorDetails(error) }; - } - await save("w"); - } - return { path, report: clean(report) }; -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - try { - const { values } = parseArgs({ - options: { - host: { type: "string" }, - "server-url": { type: "string" }, - "host-version": { type: "string" }, - "host-snapshot": { type: "string" }, - "original-error": { type: "string" }, - "failure-record": { type: "string" }, - "occurred-at": { type: "string" }, - "codex-sqlite-home": { type: "string" }, - "codex-log-dir": { type: "string" }, - "codex-app-log-dir": { type: "string" }, - "codex-app-log-file": { type: "string", multiple: true }, - "codex-app-log-offset": { type: "string" }, - output: { type: "string" }, - upload: { type: "boolean", default: false }, - "wire-evidence": { type: "boolean", default: true }, - "create-snapshot": { type: "string" }, - "validate-snapshot": { type: "string" }, - help: { type: "boolean", default: false }, - }, - }); - if (values["create-snapshot"]) { - const path = resolve(values["create-snapshot"]); - if (!basename(path).endsWith(".mcp-snapshot.json")) - throw new Error("Use a new dedicated .mcp-snapshot.json filename"); - await writeFile( - path, - JSON.stringify( - { schemaVersion: 1, observedAt: new Date().toISOString(), observations: [] }, - null, - 2, - ) + "\n", - { flag: "wx", mode: 0o600 }, - ); - console.log( - JSON.stringify({ - path, - snapshotCreated: true, - action: - "Add only observed configuration/session facts using connection-diagnostics.md; omit unknown groups. observedAt is snapshot capture time, not original failure time.", - }), - ); - } else if (values["validate-snapshot"]) { - const path = values["validate-snapshot"]; - if (!basename(path).endsWith(".mcp-snapshot.json")) - throw new Error("Use a dedicated .mcp-snapshot.json file"); - if ((await stat(path)).size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); - validateSnapshot( - JSON.parse(await readFile(path, "utf8")), - values["server-url"] ? serverUrl(values["server-url"]) : null, - ); - console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); - } else if (values.help) { - console.log( - "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--upload]", - ); - } else { - const { path, report } = await runDoctor({ - host: values.host, - serverUrl: values["server-url"], - hostVersion: values["host-version"], - hostSnapshot: values["host-snapshot"], - originalError: values["original-error"], - failureRecord: values["failure-record"], - occurredAt: values["occurred-at"], - codexSqliteHome: values["codex-sqlite-home"], - codexLogDir: values["codex-log-dir"], - codexAppLogDir: values["codex-app-log-dir"], - codexAppLogFiles: values["codex-app-log-file"], - codexAppLogOffset: - values["codex-app-log-offset"] === undefined - ? undefined - : Number(values["codex-app-log-offset"]), - output: values.output, - upload: values.upload, - wireEvidence: values["wire-evidence"], - }); - console.log( - JSON.stringify({ - diagnosticId: report.diagnosticId, - path, - upload: report.upload, - evidenceGaps: report.evidenceSummary.gaps, - collectionGaps: report.gaps, - recoveryVerified: false, - }), - ); - } - } catch (error) { - console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); - process.exitCode = 1; - } -} +import { runCli } from "../../chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await runCli(); diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs index da0ed7c..54ff8d2 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs @@ -1,369 +1,2 @@ -const STAGES = [ - "plugin_loading", - "discovery", - "client_registration", - "authorization", - "token_exchange", - "token_refresh", - "initialize", - "tools_list", - "session_tools", -]; -const SOURCES = [ - "current_session", - "tool_search", - "host_settings", - "cli_configuration", - "runtime_log", -]; -const TIMESTAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/; - -function object(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function keys(value, allowed) { - if (!object(value) || Object.keys(value).some((key) => !allowed.includes(key))) - throw new Error("Snapshot contains unsupported fields; use only the dedicated evidence schema"); -} - -function source(value) { - if (!SOURCES.includes(value)) - throw new Error("Snapshot evidence needs an explicit supported source"); -} - -function instant(value) { - return ( - typeof value === "string" && - new RegExp("^" + TIMESTAMP.source + "$").test(value) && - Number.isFinite(Date.parse(value)) - ); -} - -export function validateSnapshot(input, endpoint) { - keys(input, [ - "schemaVersion", - "observedAt", - "hostVersion", - "pluginVersion", - "configuration", - "session", - "observations", - ]); - if (input.schemaVersion !== 1) - throw new Error( - "Snapshot schemaVersion must be 1 (the outer Doctor report uses a separate schemaVersion); generate a template with --create-snapshot", - ); - if (!instant(input.observedAt)) - throw new Error( - "Snapshot observedAt must be its capture time as a complete ISO timestamp with timezone, e.g. 2026-10-23T01:00:00Z; original failure time may remain unknown", - ); - for (const key of ["hostVersion", "pluginVersion"]) - if (input[key] !== undefined && (typeof input[key] !== "string" || input[key].length > 256)) - throw new Error("Invalid snapshot version"); - if (input.configuration) { - const config = input.configuration; - keys(config, ["source", "serverName", "url", "enabled"]); - source(config.source); - if (!["chatcut", "plugin:chatcut:chatcut"].includes(config.serverName)) - throw new Error("Snapshot must describe the hosted ChatCut server"); - if (!publicUrl(config.url, true)) - throw new Error("Snapshot URL must be a public URL without embedded credentials"); - if (endpoint && new URL(config.url).href !== endpoint.href) - throw new Error( - "Snapshot URL differs from the explicitly selected server; preserve both configurations", - ); - if (config.enabled !== null && typeof config.enabled !== "boolean") - throw new Error("Snapshot enabled state must be boolean or null"); - } - if (input.session) { - const session = input.session; - keys(session, [ - "source", - "conversationId", - "connectionStatus", - "authStatus", - "visibleToolCount", - "visibleTools", - ]); - source(session.source); - if (session.source === "cli_configuration") - throw new Error("Static CLI configuration cannot describe current session tool availability"); - if ( - !["connected", "failed", "needs_authentication", "disabled", "unknown"].includes( - session.connectionStatus, - ) - ) - throw new Error("Unsupported connection status"); - if (!["authenticated", "needs_authentication", "unknown"].includes(session.authStatus)) - throw new Error("Unsupported authentication status"); - if ( - session.visibleToolCount !== null && - (!Number.isSafeInteger(session.visibleToolCount) || session.visibleToolCount < 0) - ) - throw new Error("Visible tool count must be a nonnegative integer or null"); - if (session.conversationId !== undefined && typeof session.conversationId !== "string") - throw new Error("conversationId must be a host conversation identifier"); - if ( - session.visibleTools !== undefined && - (!Array.isArray(session.visibleTools) || - session.visibleTools.length > 256 || - session.visibleTools.some( - (name) => typeof name !== "string" || !/^[A-Za-z0-9_:.-]{1,256}$/.test(name), - )) - ) - throw new Error("visibleTools must contain only ChatCut tool names, at most 256"); - if ( - session.visibleTools && - session.visibleToolCount !== null && - session.visibleTools.length > session.visibleToolCount - ) - throw new Error("Tool name count exceeds the observed tool count"); - } - if (input.observations !== undefined) { - if (!Array.isArray(input.observations) || input.observations.length > 32) - throw new Error("Snapshot accepts at most 32 stage observations"); - for (const item of input.observations) { - keys(item, [ - "stage", - "operation", - "source", - "observedAt", - "outcome", - "requestId", - "httpStatus", - "protocolCode", - "message", - "originalAttemptMatch", - ]); - source(item.source); - if ( - !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || - !["success", "failed", "unknown"].includes(item.outcome) || - (item.observedAt !== null && !instant(item.observedAt)) - ) - throw new Error( - "Stage observation needs stage, outcome, and observedAt (ISO timestamp with timezone, or null when the original time is unknown)", - ); - for (const field of ["operation", "requestId", "message"]) - if (item[field] !== undefined && typeof item[field] !== "string") - throw new Error("Observation text fields must be strings"); - for (const field of ["httpStatus", "protocolCode"]) - if (item[field] !== undefined && !Number.isSafeInteger(item[field])) - throw new Error("Observation status/code must be an integer"); - if ( - item.originalAttemptMatch !== undefined && - !["confirmed", "unconfirmed"].includes(item.originalAttemptMatch) - ) - throw new Error( - "Original attempt match must be confirmed or unconfirmed; omit when unknown", - ); - if ( - item.originalAttemptMatch === "confirmed" && - (!item.requestId || - item.outcome !== "failed" || - !["current_session", "runtime_log"].includes(item.source)) - ) - throw new Error( - "Confirmed original request association requires its failed runtime/session observation and actual request ID", - ); - } - } - return input; -} - -export function logTime(line) { - const match = line.match(TIMESTAMP)?.[0]; - return match && Number.isFinite(Date.parse(match)) ? new Date(match).toISOString() : null; -} - -export function stageHint(message) { - if (/tools\/list|tool catalog|tool list/i.test(message)) return "tools_list"; - if (/initializ|handshake/i.test(message)) return "initialize"; - if (/refresh.{0,20}token|token.{0,20}refresh/i.test(message)) return "token_refresh"; - if (/token.{0,20}exchange|exchange.{0,20}token|\/mcp\/token/i.test(message)) - return "token_exchange"; - if (/registr|\/mcp\/register/i.test(message)) return "client_registration"; - if (/callback|authoriz|oauth/i.test(message) && !/metadata|discovery/i.test(message)) - return "authorization"; - if (/metadata|discovery/i.test(message)) return "discovery"; - return "unclassified"; -} - -export function evidenceSummary(report) { - const snapshot = report.hostSnapshot; - const observations = snapshot?.observations ?? []; - const gaps = []; - if (!snapshot) gaps.push("current_host_snapshot_unavailable"); - if (!snapshot?.configuration) gaps.push("registration_state_unavailable"); - if (!snapshot?.session || snapshot.session.visibleToolCount === null) - gaps.push("current_session_tools_unavailable"); - if (report.originalFailure.status !== "recorded") gaps.push("original_failure_unavailable"); - if (!report.originalFailure.occurredAt) gaps.push("original_failure_time_unavailable"); - const originalRequests = - report.originalFailure.status === "recorded" - ? observations.flatMap((item, observationIndex) => - item.requestId && - item.originalAttemptMatch === "confirmed" && - item.outcome === "failed" && - ["current_session", "runtime_log"].includes(item.source) - ? [{ requestId: item.requestId, source: item.source, observationIndex }] - : [], - ) - : []; - if (report.originalFailure.status === "recorded" && report.originalFailure.requestId) - originalRequests.push({ - requestId: report.originalFailure.requestId, - source: report.originalFailure.source, - }); - if (!originalRequests.length) gaps.push("original_request_id_unavailable"); - if (!snapshot?.pluginVersion) gaps.push("actual_loaded_plugin_version_unavailable"); - if (!report.hostLogs.entries.length) gaps.push("matching_host_runtime_logs_unavailable"); - const appNotices = report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log"); - const failedAppNotices = appNotices.filter( - (entry) => [...entry.message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed", - ); - if (appNotices.length === report.hostLogs.entries.length && !failedAppNotices.length) - gaps.push("matching_host_failure_logs_unavailable"); - const stages = STAGES.map((stage) => ({ - stage, - observations: observations.filter((item) => item.stage === stage), - status: observations.some((item) => item.stage === stage) - ? "observed; see individual attempts" - : "not_observed", - })); - const unknown = observations.filter((item) => !STAGES.includes(item.stage)); - const logHints = report.hostLogs.entries.map((entry, index) => ({ - entryIndex: index, - stage: stageHint(entry.message), - interpretation: "text_hint_only; attempt_match_unconfirmed", - ...(entry.conversationId && { conversationId: entry.conversationId }), - ...(entry.callId && { callId: entry.callId }), - })); - return { - stages, - otherObservations: unknown, - logHints, - originalRequestCorrelation: { - status: originalRequests.length ? "agent_reported_same_attempt" : "unconfirmed", - observations: originalRequests, - provenance: - "agent_supplied_confirmation; not_independently_verified; never_inferred_from_time_or_other_ids", - }, - originalLocalCall: report.originalFailure.callId - ? { - callId: report.originalFailure.callId, - conversationId: report.originalFailure.conversationId ?? null, - operation: report.originalFailure.operation ?? null, - provenance: - "original_failure_record; agent_supplied_not_independently_verified; local_call_id_is_not_server_request_id", - } - : null, - hostLogCoverage: { - records: report.hostLogs.entries.length, - appStartupRecords: appNotices.length, - failedAppStartupRecords: failedAppNotices.length, - originalToolFailureRecord: - report.originalFailure.source === "agent_supplied_host_tool_result", - qualification: - "startup notices are not tool-request failure evidence; log presence alone does not prove original failure capture", - }, - gaps, - independentChecks: - "entryProbe/probes/wireProbes/transportContext are later requests, never original stage completions; server observation is before Node framing, not proof of downstream wire validity", - recoveryVerified: false, - }; -} - -function publicUrl(value, localAllowed = false) { - try { - const url = new URL(value); - return ( - !url.username && - !url.password && - !url.hash && - (url.protocol === "https:" || - (localAllowed && - url.protocol === "http:" && - ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) - ); - } catch { - return false; - } -} - -export function inspectMetadata(document, url, expected = {}) { - const issues = []; - const result = { valid: false, issues, fields: {} }; - if (!object(document)) { - issues.push({ field: "document", reason: "expected_object" }); - return result; - } - for (const field of [ - "resource", - "authorization_servers", - "issuer", - "authorization_endpoint", - "token_endpoint", - "registration_endpoint", - "grant_types_supported", - "response_types_supported", - "code_challenge_methods_supported", - "token_endpoint_auth_methods_supported", - ]) - if (document[field] !== undefined) result.fields[field] = document[field]; - const protectedResource = new URL(url).pathname.includes("oauth-protected-resource"); - const requireUrl = (field) => { - if (typeof document[field] !== "string" || !publicUrl(document[field], true)) - issues.push({ field, reason: "missing_or_invalid_public_url" }); - }; - if (protectedResource) { - requireUrl("resource"); - const suffix = new URL(url).pathname.split("/.well-known/oauth-protected-resource")[1]; - const resource = expected.resource ?? (suffix ? new URL(url).origin + suffix : undefined); - if (resource && document.resource !== resource) - issues.push({ - field: "resource", - reason: "resource_mismatch", - expected: resource, - actual: document.resource, - }); - if ( - !Array.isArray(document.authorization_servers) || - !document.authorization_servers.length || - document.authorization_servers.some((item) => !publicUrl(item, true)) - ) - issues.push({ - field: "authorization_servers", - reason: "missing_or_invalid_authorization_servers", - }); - } else { - requireUrl("issuer"); - requireUrl("authorization_endpoint"); - requireUrl("token_endpoint"); - if ( - Array.isArray(document.grant_types_supported) && - !document.grant_types_supported.includes("authorization_code") - ) - issues.push({ field: "grant_types_supported", reason: "authorization_code_not_supported" }); - if ( - !Array.isArray(document.response_types_supported) || - !document.response_types_supported.includes("code") - ) - issues.push({ field: "response_types_supported", reason: "code_response_not_advertised" }); - if (expected.issuer && document.issuer !== expected.issuer) - issues.push({ - field: "issuer", - reason: "issuer_mismatch", - expected: expected.issuer, - actual: document.issuer, - }); - if ( - !Array.isArray(document.code_challenge_methods_supported) || - !document.code_challenge_methods_supported.includes("S256") - ) - issues.push({ field: "code_challenge_methods_supported", reason: "S256_not_advertised" }); - } - result.valid = issues.length === 0; - return result; -} +// Compatibility entry; the standalone Doctor owns the collector. +export * from "../../chatcut-mcp-doctor/scripts/mcp-evidence.mjs"; diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs index ef9e338..35cf382 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs @@ -1,83 +1,2 @@ -const SOURCES = ["host_tool_result", "host_runtime_event", "captured_stderr"]; -const FIELDS = [ - "schemaVersion", - "source", - "serverName", - "occurredAt", - "conversationId", - "callId", - "operation", - "requestId", - "error", -]; - -export function validateFailureRecord(input) { - if ( - !input || - typeof input !== "object" || - Array.isArray(input) || - Object.keys(input).some((key) => !FIELDS.includes(key)) - ) - throw new Error( - "Use one dedicated failure record, without tool arguments, results or conversation content", - ); - if ( - input.schemaVersion !== 1 || - !SOURCES.includes(input.source) || - !["chatcut", "plugin:chatcut:chatcut"].includes(input.serverName) - ) - throw new Error( - "Failure record must identify the hosted ChatCut server and original error source", - ); - if ( - input.occurredAt !== null && - (typeof input.occurredAt !== "string" || - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( - input.occurredAt, - ) || - !Number.isFinite(Date.parse(input.occurredAt))) - ) - throw new Error( - "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", - ); - for (const key of ["conversationId", "callId", "operation", "requestId"]) - if ( - input[key] !== undefined && - input[key] !== null && - (typeof input[key] !== "string" || !/^[A-Za-z0-9_.:/-]{1,256}$/.test(input[key])) - ) - throw new Error( - `Invalid original failure ${key}; never substitute a credential or a later probe ID`, - ); - if ( - !input.error || - typeof input.error !== "object" || - Array.isArray(input.error) || - typeof input.error.message !== "string" || - !input.error.message.trim() - ) - throw new Error( - "Failure record needs the original nonempty error.message; preserve its code, cause and stack when available", - ); - return input; -} - -export function failureFromRecord(input) { - const record = validateFailureRecord(input); - return { - evidenceKind: "original_failure", - status: "recorded", - source: `agent_supplied_${record.source}`, - occurredAt: record.occurredAt, - timeSource: record.occurredAt - ? "host_failure_record; agent_supplied_not_independently_verified" - : "unavailable", - message: record.error.message, - error: record.error, - ...(record.conversationId && { conversationId: record.conversationId }), - ...(record.callId && { callId: record.callId }), - ...(record.operation && { operation: record.operation }), - ...(record.requestId && { requestId: record.requestId }), - association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", - }; -} +// Compatibility entry; the standalone Doctor owns the collector. +export * from "../../chatcut-mcp-doctor/scripts/mcp-failure.mjs"; diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-wire.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-wire.mjs index 57079a5..f5d9565 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-wire.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-wire.mjs @@ -1,296 +1,2 @@ -import { createHash } from "node:crypto"; -import { connect, isIP } from "node:net"; -import { connect as connectTls } from "node:tls"; - -const LIMIT = 128 * 1024; -const HEADER_LIMIT = 16 * 1024; -const TIMEOUT_MS = 3500; -const HEADERS = new Set([ - "content-type", - "content-length", - "content-encoding", - "transfer-encoding", - "connection", - "server", - "via", - "x-request-id", - "x-amzn-trace-id", - "cache-control", - "age", - "x-cache", - "x-amz-cf-id", - "x-amz-cf-pop", -]); - -function target(value) { - const url = new URL(value); - const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); - if ( - (!local && - (!["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes(url.hostname) || - url.protocol !== "https:" || - (url.port && url.port !== "443"))) || - (local && !["http:", "https:"].includes(url.protocol)) || - url.username || - url.password || - url.search || - url.hash || - !/^\/\.well-known\/(?:oauth-protected-resource|oauth-authorization-server|openid-configuration)(?:\/[a-zA-Z0-9/_-]*)?$/.test( - url.pathname, - ) - ) - throw new Error( - "Wire evidence is restricted to anonymous ChatCut metadata on the selected origin", - ); - return url; -} - -function framing(headers, body, ended, limited) { - const result = { - status: "incomplete", - firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, - bodyRepresentation: "after_transfer_decoding; content_encoding_not_decoded", - }; - const invalid = (reason) => ({ ...result, status: "invalid", reason }); - const incomplete = (reason) => ({ ...result, reason }); - const valid = (decoded) => ({ - ...result, - status: "valid", - bodyBytes: decoded.length, - bodySha256: createHash("sha256").update(decoded).digest("hex"), - }); - const transfer = headers["transfer-encoding"] - ?.toLowerCase() - .split(",") - .map((v) => v.trim()); - if (transfer?.at(-1) === "chunked") { - if (headers["content-length"] !== undefined) - return invalid("both_transfer_encoding_and_content_length"); - let offset = 0; - const parts = []; - while (offset < body.length) { - const end = body.indexOf("\r\n", offset); - if (end < 0) { - if (body.length > offset && !/[0-9a-f]/i.test(String.fromCharCode(body[offset]))) - return invalid("invalid_chunk_size"); - return ended && !limited - ? invalid("unterminated_chunk_size") - : incomplete("chunk_size_not_complete"); - } - const sizeText = body.subarray(offset, end).toString("latin1").split(";")[0]; - if (!/^[0-9a-f]{1,16}$/i.test(sizeText)) return invalid("invalid_chunk_size"); - if (offset === 0) result.firstChunkSizeHex = sizeText; - const size = Number.parseInt(sizeText, 16); - offset = end + 2; - if (!Number.isSafeInteger(size)) return incomplete("chunk_exceeds_capture_limit"); - if (size === 0) { - const trailerEnd = body.indexOf("\r\n\r\n", offset); - const messageEnd = body.subarray(offset, offset + 2).equals(Buffer.from("\r\n")) - ? offset + 2 - : trailerEnd >= 0 - ? trailerEnd + 4 - : null; - if (messageEnd !== null) { - if (messageEnd !== body.length) return invalid("bytes_after_terminal_chunk"); - return valid(Buffer.concat(parts)); - } - return ended && !limited - ? invalid("unterminated_trailers") - : incomplete("trailers_not_complete"); - } - if (offset + size + 2 > body.length) - return ended && !limited ? invalid("truncated_chunk") : incomplete("chunk_not_complete"); - if (!body.subarray(offset + size, offset + size + 2).equals(Buffer.from("\r\n"))) - return invalid("missing_chunk_terminator"); - parts.push(body.subarray(offset, offset + size)); - offset += size + 2; - } - return ended && !limited - ? invalid("missing_terminal_chunk") - : incomplete("terminal_chunk_not_observed"); - } - if (transfer) return incomplete("unsupported_transfer_encoding"); - if (headers["content-length"] !== undefined) { - if (!/^\d+$/.test(headers["content-length"])) return invalid("invalid_content_length"); - const expected = Number(headers["content-length"]); - if (body.length === expected) return valid(body); - if (body.length > expected) return invalid("content_length_mismatch"); - return ended && !limited ? invalid("content_length_mismatch") : incomplete("body_not_complete"); - } - return ended && !limited ? valid(body) : incomplete("close_delimited_body_not_complete"); -} - -function inspectResponse(captured, ended, limited) { - const headerEnd = captured.indexOf("\r\n\r\n"); - const result = { responseHeaders: {} }; - if (headerEnd < 0 || headerEnd > HEADER_LIMIT) { - result.headerGap = - captured.length > HEADER_LIMIT ? "header_limit" : "complete_headers_unavailable"; - return result; - } - const lines = captured.subarray(0, headerEnd).toString("latin1").split("\r\n"); - const status = lines.shift().match(/^HTTP\/(1\.[01]) ([1-5]\d{2}) [\t\x20-\x7e\x80-\xff]*$/); - result.httpVersion = status?.[1] ?? null; - result.status = status ? Number(status[2]) : null; - result.responseState = !status - ? "invalid_status_line" - : result.status < 200 - ? "unsupported_informational_response" - : "final_response"; - for (const line of lines) { - const colon = line.indexOf(":"); - if (colon < 0) continue; - const name = line.slice(0, colon).toLowerCase(); - if (HEADERS.has(name)) { - const field = line.slice(colon + 1).trim(); - result.responseHeaders[name] = - result.responseHeaders[name] === undefined - ? field - : result.responseHeaders[name] + "," + field; - } - } - result.stage = "http_framing"; - const body = captured.subarray(headerEnd + 4); - result.framing = - result.responseState === "final_response" - ? framing(result.responseHeaders, body, ended, limited) - : { - status: "not_evaluated", - reason: result.responseState, - firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, - }; - return result; -} - -export async function probeMetadataWire(value, diagnosticId, describeError) { - const url = target(value); - if (!/^[0-9a-f-]{36}$/i.test(diagnosticId)) throw new Error("Invalid diagnostic ID"); - const started = Date.now(); - const result = { - evidenceKind: "later_probe", - url: url.href, - startedAt: new Date().toISOString(), - implementation: "node:net/tls; HTTP/1.1", - route: "direct_connection; environment_proxy_not_used; host_and_fetch_route_not_reproduced", - originalAttemptMatch: "unconfirmed", - credentialsSent: false, - acceptEncoding: "identity", - byteLimit: LIMIT, - stage: "connect", - }; - return new Promise((resolve) => { - let socket; - let done = false; - let bytes = 0; - let limited = false; - let ended = false; - let headerSeen = false; - const parts = []; - const finish = (error, completionReason) => { - if (done) return; - done = true; - clearTimeout(timer); - const captured = Buffer.concat(parts); - result.capturedBytes = captured.length; - result.captureLimited = limited; - Object.assign(result, inspectResponse(captured, ended, limited)); - result.completionReason = completionReason; - result.peerEndObserved = ended; - result.outcome = error - ? "failed" - : result.responseState === "invalid_status_line" - ? "invalid_response" - : result.responseState === "unsupported_informational_response" - ? "unsupported_response" - : result.framing?.status === "invalid" - ? "invalid_framing" - : !limited && result.framing?.status === "valid" - ? "response_observed" - : "incomplete"; - if (error) result.error = describeError(error); - result.durationMs = Date.now() - started; - socket?.destroy(); - resolve(result); - }; - const timer = setTimeout( - () => - finish( - Object.assign(new Error("Metadata wire probe timed out after 3500ms"), { - code: "DIAGNOSTIC_WIRE_TIMEOUT", - }), - "timeout", - ), - TIMEOUT_MS, - ); - try { - const hostname = url.hostname.replace(/^\[|\]$/g, ""); - const options = { - host: hostname, - port: Number(url.port || (url.protocol === "https:" ? 443 : 80)), - }; - const send = () => { - result.stage = "response"; - result.peer = { address: socket.remoteAddress, port: socket.remotePort }; - if (url.protocol === "https:") { - const certificate = socket.getPeerCertificate(); - result.tls = { - authorized: socket.authorized, - protocol: socket.getProtocol(), - certificate: { issuer: certificate.issuer, fingerprint256: certificate.fingerprint256 }, - }; - } - socket.write( - `GET ${url.pathname} HTTP/1.1\r\nHost: ${url.host}\r\nAccept: application/json\r\nAccept-Encoding: identity\r\nConnection: close\r\nX-ChatCut-Diagnostic-ID: ${diagnosticId}\r\n\r\n`, - ); - }; - socket = - url.protocol === "https:" - ? connectTls( - { - ...options, - servername: isIP(hostname) ? undefined : hostname, - rejectUnauthorized: true, - ALPNProtocols: ["http/1.1"], - }, - send, - ) - : connect(options, send); - socket.on("data", (part) => { - if (done) return; - const remaining = LIMIT - bytes; - parts.push(part.subarray(0, Math.max(0, remaining))); - bytes += Math.min(part.length, remaining); - if (part.length > remaining) { - limited = true; - finish(undefined, "capture_limit"); - return; - } - if (!headerSeen) { - const captured = Buffer.concat(parts); - const headerEnd = captured.indexOf("\r\n\r\n"); - headerSeen = headerEnd >= 0; - if ((!headerSeen && bytes > HEADER_LIMIT) || headerEnd > HEADER_LIMIT) { - limited = true; - finish(undefined, "header_limit"); - return; - } - } - if (headerSeen) { - const observed = inspectResponse(Buffer.concat(parts), false, false); - if (observed.responseState !== "final_response") - finish(undefined, "invalid_or_unsupported_response"); - else if (observed.framing.status === "valid") finish(undefined, "message_boundary"); - else if (observed.framing.status === "invalid") finish(undefined, "invalid_framing"); - } - }); - socket.once("end", () => { - ended = true; - finish(undefined, "peer_end"); - }); - socket.once("error", (error) => finish(error, "transport_error")); - socket.once("close", () => finish(undefined, "socket_close")); - } catch (error) { - finish(error, "collector_error"); - } - }); -} +// Compatibility entry; the standalone Doctor owns the collector. +export * from "../../chatcut-mcp-doctor/scripts/mcp-wire.mjs"; diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index 4d0c7e9..d398435 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -1,5 +1,43 @@ # Hosted MCP evidence report v2 +## Independent Doctor Skill + +`codex/skills/chatcut-mcp-doctor` and `claude/skills/chatcut-mcp-doctor` now +package the collector independently of MCP, with diagnosis and feedback sidecars. +Use Codex's skill picker / `$chatcut:chatcut-mcp-doctor` (standalone copies use +`$chatcut-mcp-doctor`; the existing store identity uses +`$chatcut-desktop:chatcut-mcp-doctor`), or Claude Code's +`/chatcut:chatcut-mcp-doctor`. Neither +overrides the host's `/doctor`. Its description also enables agent selection for +natural-language connection-failure requests. See its [entrypoint](../codex/skills/chatcut-mcp-doctor/SKILL.md) +and [fallback guide](../codex/skills/chatcut-mcp-doctor/references/discovery.md). +The Skill is included from Codex 1.10.22 / Claude plugin 1.10.24, with collector +1.3.0. Check the installed version and catalog; Git availability, store approval +and actual host adoption are separate checks. + +The basics scripts are compatibility forwarders; each host package contains +one collector implementation under Doctor. The new `scripts/doctor.mjs` +entrypoint keeps schema-2 collection unchanged and writes `.diagnosis.json`, +`.summary.md`, `.issue-draft.md` beside the local report. `--offline` forbids +all diagnostic HTTP requests even with a registered URL and `--upload`. +`--report ` rebuilds sidecars without probes; +`--upload-report ` sends the same report/ID and retains +a separate receipt. An acknowledged report is not automatically resent. Derived +sidecars can be regenerated; keep actual agent repair notes separately. +Supplementary upload receipts bind the immutable report bytes' SHA-256 and its +diagnostic ID; a foreign receipt cannot establish delivery. Disabled snapshots +remain disabled through later submission even if snapshot validation failed. +Credential redaction includes opaque MCP session headers, and permitted native +SQLite modules still filter tool-call/content echoes before retaining records. + +Findings are evidence pointers to observed host boundaries and later checks, +not root-cause assertions. The helper performs no host repair. The repair guide +limits configuration changes to a genuinely absent manual registration in the +selected host/scope, with rollback and lookup verification. Sign-in remains a +host-guided action, and tool recovery requires an actual safe host call. +The issue draft is private review material; a public issue destination is still +undecided, and ingest acknowledgement does not create a ticket. + The Codex and Claude Code basics skills route actual connection failures to a bundled, dependency-free Node helper. This does not change the MCP URL, transport, authentication, plugin installation, or Desktop integration. It is triggered by @@ -12,6 +50,13 @@ need a runtime with `node:sqlite`; missing support is an explicit evidence gap. ## Evidence and delivery contract +Collector 1.3.0 packages the independent Doctor workflow, offline mode and +immutable-report feedback reuse. It adds explicit registration lookup results, +surfaces later wire failures and unassociated native errors, and strengthens +session-credential/content-echo filtering. These behavior changes have a new +collector identity; reports from the released 1.2.3 remain distinguishable. +The outer report schema 2, snapshot schema 1 and fragment envelope are retained. + Collector 1.2.3 adds a dedicated `.mcp-failure.json` input for the original host tool/runtime error, its available failure time and local conversation/call IDs. The Skill preserves this evidence before retries; a missing failure timestamp @@ -235,5 +280,6 @@ failures that do not trigger this Skill remain outside coverage. The previously verified beta envelope remains the fragment delivery contract; it does not establish that every new v2 field has been exercised by a real failing host. Local controlled checks and post-release case evidence remain -separate. The current coverage follow-up is local and must be reviewed before -updating the existing draft PR or any release. +separate. Source/fixture validation, production storage verification, store review +and installed-host adoption have separate evidence; a prepared ZIP or merged PR +alone does not establish the latter checks. diff --git a/tests/diagnosis-coverage.test.mjs b/tests/diagnosis-coverage.test.mjs new file mode 100644 index 0000000..8c0c0d3 --- /dev/null +++ b/tests/diagnosis-coverage.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { diagnose, renderPacket } from "../codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs"; +import { validateSnapshot } from "../codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs"; + +const report = () => ({ + schemaVersion: 2, diagnosticId: "00000000-0000-0000-0000-000000000001", + collectorVersion: "1.2.3", scope: "cloud", host: "codex", + originalFailure: { status: "unavailable", occurredAt: null }, + hostLogs: { entries: [], gaps: [] }, gaps: [], probes: [], wireProbes: [], + upload: { status: "not_requested", storageVerified: false }, +}); +const snapshot = (observation) => ({ schemaVersion: 1, observedAt: "2026-10-23T15:00:00Z", observations: [observation] }); +const lookup = (extra = {}) => ({ stage: "host_registration", operation: "lookup_registered_server", source: "cli_configuration", outcome: "failed", observedAt: null, ...extra }); + +test("failed registration reads retain unknown state; only explicit scoped absence permits registration repair", () => { + for (const message of [ + "EACCES: cannot open host configuration; registration existence unreadable", + "Invalid TOML near server name: No MCP server named 'chatcut' found.", + "Host CLI unavailable; exit 1", "Error: No MCP server named 'chatcut' found.", + ]) { + for (const extra of [{}, { lookupResult: "unavailable" }]) { + const input = report(); + input.hostSnapshot = validateSnapshot(snapshot(lookup({ message, ...extra })), null); + const before = structuredClone(input); + const result = diagnose(input); + assert.ok(!result.findings.some((item) => item.kind === "not_registered")); + const finding = result.findings.find((item) => item.kind === "registration_lookup_unavailable"); + assert.deepEqual(finding.evidence, ["hostSnapshot.observations[0]"]); + assert.equal(finding.observed.lookupResult, "unavailable"); + assert.equal(finding.observed.messageExcerpt, message); + assert.equal(finding.observed.source, "cli_configuration"); + assert.ok(renderPacket(input, result).summary.includes(message)); + assert.deepEqual(input, before); + } + } + for (const outcome of ["success", "failed"]) { + const input = report(); + input.hostSnapshot = validateSnapshot(snapshot(lookup({ lookupResult: "not_found", outcome, message: "Explicit completed scope lookup: no entry" })), null); + assert.ok(diagnose(input).findings.some((item) => item.kind === "not_registered")); + } + const presentButUnhealthy = report(); + presentButUnhealthy.hostSnapshot = snapshot(lookup({ lookupResult: "found", message: "Entry exists; its health check failed" })); + assert.ok(!diagnose(presentButUnhealthy).findings.some((item) => item.kind === "not_registered")); +}); + +test("lookupResult cannot claim absence for unknown, authentication or unrelated runtime observations", () => { + for (const extra of [{ outcome: "unknown" }, { stage: "authorization" }, { operation: "initialize" }, { source: "runtime_log" }, { lookupResult: "guessed_absent" }]) + assert.throws(() => validateSnapshot(snapshot(lookup({ lookupResult: "not_found", ...extra })), null), /lookupResult/); + assert.doesNotThrow(() => validateSnapshot(snapshot(lookup({ lookupResult: "unavailable", outcome: "unknown" })), null)); +}); + +test("wire failures and incomplete captures remain visible beside healthy anonymous fetch checks", () => { + for (const wire of [ + { outcome: "invalid_framing", framing: { status: "invalid", reason: "invalid_chunk_size" } }, + { outcome: "failed", error: { code: "ECONNRESET", message: "native wire reset" } }, + { outcome: "incomplete", framing: { status: "incomplete", reason: "body_not_complete" } }, + { outcome: "invalid_response", framing: { status: "not_evaluated", reason: "invalid_status_line" } }, + { outcome: "unsupported_response" }, + { outcome: "response_observed", framing: { status: "valid" }, captureLimited: true }, + ]) { + const input = report(); + input.entryProbe = { outcome: "authentication_challenge", status: 401 }; + input.probes = [{ outcome: "metadata_read", status: 200 }]; + input.wireProbes = [{ route: "direct_connection; host_and_fetch_route_not_reproduced", ...wire }]; + const before = structuredClone(input); + const result = diagnose(input); + const fact = result.findings.find((item) => item.evidence.includes("wireProbes[0]")); + assert.equal(fact.severity, "attention"); + assert.equal(fact.observed.outcome, wire.outcome); + assert.equal(fact.observed.framingReason, wire.framing?.reason ?? null); + assert.ok(!result.findings.some((item) => item.kind === "no_incident_demonstrated")); + const packet = renderPacket(input, result); + for (const rendered of [packet.summary, packet.draft]) { + assert.ok(rendered.includes("wireProbes[0]")); + assert.ok(rendered.includes(wire.outcome)); + if (wire.framing?.reason) assert.ok(rendered.includes(wire.framing.reason)); + assert.ok(rendered.includes("host_and_fetch_route_not_reproduced")); + } + assert.equal(result.rootCause, "unconfirmed"); + assert.equal(result.recoveryVerified, false); + assert.deepEqual(input, before); + } + const normal = report(); + normal.entryProbe = { outcome: "authentication_challenge", status: 401 }; + normal.wireProbes = [{ outcome: "response_observed", framing: { status: "valid" } }]; + assert.ok(!diagnose(normal).findings.some((item) => item.severity === "attention")); +}); + +test("native ERROR and failed App state are unassociated evidence, while ordinary startup remains informational", () => { + const input = report(); + input.hostLogs.entries = [ + { source: "codex_runtime_sqlite", level: "ERROR", message: "E_UNKNOWN_NATIVE cause ECONNRESET" }, + { source: "codex_tui_log", message: "2026-10-23T15:00:00Z ERROR codex_mcp::client: E_NATIVE_TEXT" }, + { source: "claude_debug_log", message: "[ERROR] MCP server chatcut E_CLAUDE_NATIVE" }, + { source: "codex_app_log", message: '2026-10-23T15:00:00Z info [AppServerConnection] mcp_server_startup_status_updated error="unknown native failure" server=chatcut status=failed threadId=null' }, + ...["starting", "ready"].map((status) => ({ source: "codex_app_log", message: `2026-10-23T15:00:00Z info [AppServerConnection] mcp_server_startup_status_updated error="quoted ERROR status=failed" server=chatcut status=${status} threadId=null` })), + { source: "codex_runtime_sqlite", level: "WARN", message: "cached catalog; quoted error: ERROR" }, + ]; + input.hostLogs.gaps = [{ source: "host_logs", reason: "log_record_limit", omitted: 2 }]; + const result = diagnose(input); + const native = result.findings.filter((item) => item.kind === "host_runtime_error_unassociated"); + assert.deepEqual(native.map((item) => item.evidence), [0, 1, 2, 3].map((i) => [`hostLogs.entries[${i}]`])); + assert.ok(native.every((item) => item.observed.originalAttemptMatch === "unconfirmed")); + assert.equal(input.originalFailure.status, "unavailable"); + assert.equal(input.originalFailure.occurredAt, null); + assert.equal(result.recoveryVerified, false); + assert.deepEqual(result.gaps, input.hostLogs.gaps); + const packet = renderPacket(input, result); + assert.ok(packet.summary.includes("E_UNKNOWN_NATIVE")); + assert.ok(!packet.draft.includes("E_UNKNOWN_NATIVE")); + assert.ok(packet.draft.includes("hostLogs.entries[0]")); + const startupOnly = report(); + startupOnly.hostLogs.entries = input.hostLogs.entries.slice(4); + assert.ok(!diagnose(startupOnly).findings.some((item) => item.severity === "attention")); +}); + +test("new local evidence excerpts redact credentials and do not copy private logs into issue drafts", () => { + const input = report(); + input.hostSnapshot = snapshot(lookup({ lookupResult: "unavailable", message: "EACCES token=PRIVATE_TOKEN C:\\Users\\private\\config.toml" })); + input.hostLogs.entries = [{ source: "codex_runtime_sqlite", level: "ERROR", message: "Mcp-Session-Id: PRIVATE_SESSION\nPRIVATE_LOG_BODY" }]; + const result = diagnose(input); + const packet = renderPacket(input, result); + assert.ok(!JSON.stringify(result).includes("PRIVATE_TOKEN")); + assert.ok(!JSON.stringify(result).includes("PRIVATE_SESSION")); + for (const secret of ["PRIVATE_TOKEN", "PRIVATE_SESSION", "PRIVATE_LOG_BODY", "C:\\Users\\private"]) + assert.ok(!packet.draft.includes(secret)); +}); diff --git a/tests/doctor-host-acceptance.mjs b/tests/doctor-host-acceptance.mjs new file mode 100644 index 0000000..6ba9afb --- /dev/null +++ b/tests/doctor-host-acceptance.mjs @@ -0,0 +1,302 @@ +// Opt-in native-host acceptance. No account, global configuration or production MCP. +// node tests/doctor-host-acceptance.mjs --codex --claude --evidence +// --codex-plugin verifies the local plugin marketplace/cache; --tool-failure +// initializes MCP successfully, then fails a real native read-only tools/call. +// --registration-only limits the run to scoped registration diagnosis/repair. +// The local deterministic model fixture tests host skill expansion/tool execution, +// not a real model's independent diagnostic reasoning or OAuth recovery. +import assert from "node:assert/strict"; +import { spawn, execFile } from "node:child_process"; +import { once } from "node:events"; +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { parseArgs, promisify } from "node:util"; +import { diagnose } from "../codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs"; +import { validateSnapshot } from "../codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs"; + +const { values } = parseArgs({ options: { codex: { type: "string" }, claude: { type: "string" }, evidence: { type: "string" }, "codex-plugin": { type: "boolean", default: false }, "tool-failure": { type: "boolean", default: false }, "registration-only": { type: "boolean", default: false } } }); +if (!values.evidence || (!values.codex && !values.claude)) throw new Error("Explicit host executable and new evidence directory required"); +const root = resolve(values.evidence); +await mkdir(root); // Never overwrite evidence from a previous run. +const repo = fileURLToPath(new URL("../", import.meta.url)); +const run = promisify(execFile); +const records = []; +const registrationRecords = []; +const requests = []; +const hostCommands = new Map(); +const nativeEvidence = new Map(); +const counts = new Map(); +let mcpCalls = 0; +const fixtureErrors = []; +async function handleRequest(req, res) { + let raw = ""; + for await (const chunk of req) raw += chunk; + if (req.url.includes("/api/external-mcp/")) { + mcpCalls++; + if (values["tool-failure"]) { + res.setHeader("content-type", "application/json"); + if (req.method === "GET") { res.writeHead(405); return res.end("{}"); } + const call = JSON.parse(raw || "{}"); + if (call.id === undefined) { res.writeHead(202); return res.end(); } + const result = call.method === "initialize" + ? { protocolVersion: "2025-11-25", capabilities: { tools: {} }, serverInfo: { name: "controlled-chatcut", version: "1" } } + : call.method === "tools/list" + ? { tools: [{ name: "list_projects", description: "Controlled read-only projects fixture", inputSchema: { type: "object", properties: {} } }] } + : call.method === "tools/call" + ? { isError: true, content: [{ type: "text", text: "CONTROLLED_TOOL_FAILURE: busy after 300s E_FIXTURE; no natural incident" }] } + : {}; + return res.end(JSON.stringify({ jsonrpc: "2.0", id: call.id, result })); + } + res.writeHead(503, { "content-type": "application/json" }); + return res.end('{"error":"CONTROLLED_HOST_STARTUP_FAILURE"}'); + } + const host = req.url.startsWith("/codex") ? "codex" : "claude"; + const body = JSON.parse(raw || "{}"); + requests.push({ host, url: req.url, body }); + if (host === "claude" && !body.messages) { + res.setHeader("content-type", "application/json"); return res.end("{}"); + } + const count = counts.get(host) ?? 0; + if (count === 0 && !values["tool-failure"]) await nativeEvidence.get(host); + if (values["tool-failure"] && count === 1) { + const transcript = host === "codex" ? body.input : body.messages; + const nativeResult = transcript.findLast((item) => JSON.stringify(item).includes("CONTROLLED_TOOL_FAILURE")); + assert.ok(nativeResult, "Actual host must expose the failed read-only MCP call result"); + const dir = join(root, host); + await writeFile(join(dir, "native.mcp-failure.json"), JSON.stringify({ schemaVersion: 1, source: "host_tool_result", serverName: "chatcut", operation: "list_projects", occurredAt: null, error: { message: JSON.stringify(nativeResult) } }, null, 2)); + await writeFile(join(dir, "native.mcp-snapshot.json"), JSON.stringify({ schemaVersion: 1, observedAt: new Date().toISOString(), session: { source: "current_session", connectionStatus: "connected", authStatus: "unknown", visibleToolCount: 1, visibleTools: ["list_projects"] }, observations: [{ stage: "tools_call", operation: "list_projects", source: "current_session", outcome: "failed", observedAt: null, message: "CONTROLLED_TOOL_FAILURE: host tool result retained separately" }] }, null, 2)); + } + counts.set(host, count + 1); + const commandTurn = values["tool-failure"] ? 1 : 0; + if (host === "codex") { + const tools = body.tools ?? []; + const name = tools.find((tool) => /exec_command|shell_command/.test(tool.name))?.name; + if (count === 0) assert.ok(name, `No native command tool: ${tools.map((tool) => tool.name)}`); + const namespace = tools.find((tool) => tool.name === "mcp__chatcut"); + const mcpTool = tools.find((tool) => /mcp.*list_projects/.test(tool.name))?.name ?? + (namespace?.tools?.some((tool) => tool.name === "list_projects") ? "mcp__chatcut.list_projects" : undefined); + if (values["tool-failure"] && count === 0) await writeFile(join(root, "native-tools.json"), JSON.stringify(tools, null, 2)); + if (values["tool-failure"] && count === 0) assert.ok(mcpTool, `No live MCP tool: ${tools.map((tool) => tool.name)}`); + const output = values["tool-failure"] && count === 0 + ? [{ type: "function_call", id: "fc_projects", call_id: "call_projects", name: namespace ? "list_projects" : mcpTool, ...(namespace && { namespace: "mcp__chatcut" }), arguments: "{}" }] + : count === commandTurn + ? [{ type: "function_call", id: "fc_doctor", call_id: "call_doctor", name, + arguments: JSON.stringify(name === "shell_command" ? { command: hostCommands.get(host), workdir: join(root, host), timeout_ms: 30000 } : { cmd: hostCommands.get(host), workdir: join(root, host), yield_time_ms: 1000, max_output_tokens: 2000 }) }] + : [{ type: "message", id: "msg_done", role: "assistant", content: [{ type: "output_text", text: "Controlled native-host acceptance completed; see local report." }] }]; + const response = { id: `resp_${count}`, object: "response", status: "completed", model: "doctor-fixture", output, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } }; + res.writeHead(200, { "content-type": "text/event-stream" }); + const event = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`); + event("response.created", { response: { ...response, status: "in_progress", output: [] } }); + output.forEach((item, output_index) => { + event("response.output_item.added", { output_index, item }); + event("response.output_item.done", { output_index, item }); + }); + event("response.completed", { response }); + return res.end(); + } + // Claude print mode expands /plugin:skill before requesting this local model. + const mcpTool = body.tools?.find((tool) => /mcp.*list_projects/.test(tool.name))?.name; + if (values["tool-failure"] && count === 0) assert.ok(mcpTool, "Actual Claude must expose initialized MCP read tool"); + const output = values["tool-failure"] && count === 0 + ? [{ type: "tool_use", id: "tool_projects", name: mcpTool, input: {} }] + : count === commandTurn + ? [{ type: "tool_use", id: "tool_doctor", name: "Bash", input: { command: hostCommands.get(host), timeout: 30000, description: "Run local Doctor acceptance" } }] + : [{ type: "text", text: "Controlled native-host acceptance completed; see local report." }]; + const message = { id: `msg_${count}`, type: "message", role: "assistant", model: body.model, content: output, stop_reason: count <= commandTurn ? "tool_use" : "end_turn", stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } }; + if (!body.stream) { res.setHeader("content-type", "application/json"); return res.end(JSON.stringify(message)); } + res.writeHead(200, { "content-type": "text/event-stream" }); + const event = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`); + event("message_start", { message: { ...message, content: [], stop_reason: null } }); + output.forEach((item, index) => { + event("content_block_start", { index, content_block: item.type === "tool_use" ? { ...item, input: {} } : { type: "text", text: "" } }); + event("content_block_delta", { index, delta: item.type === "tool_use" ? { type: "input_json_delta", partial_json: JSON.stringify(item.input) } : { type: "text_delta", text: item.text } }); + event("content_block_stop", { index }); + }); + event("message_delta", { delta: { stop_reason: message.stop_reason, stop_sequence: null }, usage: { output_tokens: 1 } }); + event("message_stop", {}); + res.end(); +} +const server = createServer((req, res) => { + handleRequest(req, res).catch((error) => { + fixtureErrors.push(error.message); + if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ fixtureError: error.message })); + }); +}); +server.listen(0, "127.0.0.1"); +await once(server, "listening"); +const origin = `http://127.0.0.1:${server.address().port}`; +function env(dir) { + const result = {}; + for (const key of ["SystemRoot", "WINDIR", "PATH", "PATHEXT", "TEMP", "TMP", "COMSPEC", "PSModulePath", "ProgramFiles", "ProgramFiles(x86)", "ProgramData"]) + if (process.env[key]) result[key] = process.env[key]; + return { ...result, HOME: dir, USERPROFILE: dir, CODEX_HOME: join(dir, "home"), LOCALAPPDATA: join(dir, "local"), APPDATA: join(dir, "roaming"), CLAUDE_CONFIG_DIR: join(dir, "home"), ANTHROPIC_API_KEY: "controlled-local-fixture", ANTHROPIC_BASE_URL: `${origin}/claude`, DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", ENABLE_TOOL_SEARCH: "false", NO_PROXY: "127.0.0.1,localhost", CODEX_THREAD_ID: "" }; +} +async function setup(host) { + const dir = join(root, host); + await mkdir(join(dir, "home"), { recursive: true }); + const skill = join(dir, host === "codex" ? values["codex-plugin"] ? "marketplace/codex/skills/chatcut-mcp-doctor" : ".agents/skills/chatcut-mcp-doctor" : "plugin/skills/chatcut-mcp-doctor"); + await cp(join(repo, `${host}/skills/chatcut-mcp-doctor`), skill, { recursive: true }); + hostCommands.set(host, `node "${join(skill, "scripts/doctor.mjs").replaceAll("\\", "/")}" --host ${host} --server-url ${origin}/api/external-mcp/mcp --offline --output "${join(dir, "native.mcp-report.json").replaceAll("\\", "/")}"`); + if (values["tool-failure"]) hostCommands.set(host, hostCommands.get(host) + ` --failure-record "${join(dir, "native.mcp-failure.json").replaceAll("\\", "/")}" --host-snapshot "${join(dir, "native.mcp-snapshot.json").replaceAll("\\", "/")}"`); + return { dir, skill, childEnv: env(dir) }; +} +async function cli(exe, args, context, label, expected = 0, onEvent) { + let result; + try { + const promise = run(exe, args, { cwd: context.dir, env: context.childEnv, timeout: 45000, maxBuffer: 4 * 1024 * 1024 }); + if (onEvent) { + const lines = createInterface({ input: promise.child.stdout }); + lines.on("line", (line) => { try { onEvent(JSON.parse(line)); } catch {} }); + } + result = { ...(await promise), code: 0 }; + } + catch (error) { result = { stdout: error.stdout, stderr: error.stderr, code: error.code }; } + await writeFile(join(context.dir, `${label}.json`), JSON.stringify({ args, ...result }, null, 2)); + if (expected !== null) assert.equal(result.code, expected, `${label}: ${result.stderr}`); + return result; +} +async function registration(host, exe, context) { + async function verifyLookup(label, result, lookupResult, expectedFinding) { + const path = join(context.dir, `${label}.mcp-snapshot.json`); + const output = join(context.dir, `${label}.mcp-report.json`); + const snapshot = validateSnapshot({ schemaVersion: 1, observedAt: new Date().toISOString(), observations: [{ + stage: "host_registration", operation: "lookup_registered_server", source: "cli_configuration", + outcome: result.code === 0 ? "success" : "failed", lookupResult, observedAt: null, + message: result.stderr || result.stdout, + }] }, null); + await writeFile(path, JSON.stringify(snapshot, null, 2)); + await cli(process.execPath, [join(context.skill, "scripts/doctor.mjs"), "--host", host, "--host-snapshot", path, "--offline", "--output", output], context, `${label}-doctor`); + const report = JSON.parse(await readFile(output)); + const findings = diagnose(report).findings; + assert.equal(findings.some((item) => item.kind === "not_registered"), lookupResult === "not_found"); + if (expectedFinding) assert.ok(findings.some((item) => item.kind === expectedFinding)); + return { lookupResult, findings, report: output }; + } + let unreadable; + if (host === "codex") { + // An actual parser failure in this task's isolated home, without changing ACLs. + await writeFile(join(context.dir, "home/config.toml"), "invalid = ["); + const failed = await cli(exe, ["mcp", "get", "chatcut"], context, "registration-parse-failed", 1); + unreadable = await verifyLookup("registration-parse-failed", failed, "unavailable", "registration_lookup_unavailable"); + await writeFile(join(context.dir, "home/config.toml"), ""); + } + const missing = await cli(exe, ["mcp", "get", "chatcut"], context, "registration-missing", 1); + // Exact native no-entry replies verified for these installed CLI versions. + // A generic exit 1 or text containing 'not found' is insufficient. + assert.equal(missing.stdout.trim(), ""); + assert.equal(missing.stderr.trim(), host === "codex" + ? "Error: No MCP server named 'chatcut' found." + : 'No MCP server named "chatcut". Run `claude mcp add` to add one.'); + const absent = await verifyLookup("registration-missing", missing, "not_found", "not_registered"); + const args = host === "codex" ? ["mcp", "add", "chatcut", "--url", `${origin}/api/external-mcp/mcp`] : ["mcp", "add", "--transport", "http", "--scope", "local", "chatcut", `${origin}/api/external-mcp/mcp`]; + await cli(exe, args, context, "registration-added"); + const recheck = await cli(exe, ["mcp", "get", "chatcut"], context, "registration-recheck"); + assert.ok(recheck.stdout.includes(`${origin}/api/external-mcp/mcp`)); + const present = await verifyLookup("registration-recheck", recheck, "found"); + await cli(exe, host === "codex" ? ["mcp", "remove", "chatcut"] : ["mcp", "remove", "--scope", "local", "chatcut"], context, "registration-rollback"); + await cli(exe, ["mcp", "get", "chatcut"], context, "registration-rollback-recheck", 1); + registrationRecords.push({ host, absent, present, ...(unreadable && { unreadable }), rollbackVerified: true }); +} +try { + if (values.codex) { + const context = await setup("codex"); + await cli(values.codex, ["--version"], context, "version"); + await registration("codex", values.codex, context); + if (!values["registration-only"]) { + await writeFile(join(context.dir, "home/config.toml"), `model="doctor-fixture"\nmodel_provider="fixture"\napproval_policy="never"\nsandbox_mode="danger-full-access"\n[model_providers.fixture]\nname="local acceptance fixture"\nbase_url="${origin}/codex"\nwire_api="responses"\nrequires_openai_auth=false\n[mcp_servers.chatcut]\nurl="${origin}/api/external-mcp/mcp"\nstartup_timeout_sec=2\n`); + if (values["codex-plugin"]) { + await mkdir(join(context.dir, "marketplace/.agents/plugins"), { recursive: true }); + await mkdir(join(context.dir, "marketplace/codex/.codex-plugin"), { recursive: true }); + const manifest = JSON.parse(await readFile(join(repo, "codex/.codex-plugin/plugin.json"))); + delete manifest.mcpServers; // MCP is the isolated manual fixture above, never production. + await writeFile(join(context.dir, "marketplace/codex/.codex-plugin/plugin.json"), JSON.stringify(manifest)); + await writeFile(join(context.dir, "marketplace/.agents/plugins/marketplace.json"), JSON.stringify({ name: "doctor-local", plugins: [{ name: "chatcut", source: { source: "local", path: "./codex" } }] })); + await cli(values.codex, ["plugin", "marketplace", "add", join(context.dir, "marketplace"), "--json"], context, "marketplace-added"); + await cli(values.codex, ["plugin", "add", "chatcut@doctor-local", "--json"], context, "plugin-installed"); + } + const child = spawn(values.codex, ["app-server", "--stdio"], { cwd: context.dir, env: context.childEnv }); + const lines = createInterface({ input: child.stdout }); + const pending = new Map(); + const events = []; + let serial = 0; + let stderr = ""; + let failureSaved; + nativeEvidence.set("codex", new Promise((ok) => failureSaved = ok)); + if (!values["tool-failure"]) hostCommands.set("codex", hostCommands.get("codex") + ` --failure-record "${join(context.dir, "native.mcp-failure.json").replaceAll("\\", "/")}"`); + child.stderr.on("data", (chunk) => stderr += chunk); + lines.on("line", (line) => { + try { + const message = JSON.parse(line); events.push(message); + if (message.method === "mcpServer/startupStatus/updated" && message.params.status === "failed") { + writeFile(join(context.dir, "native.mcp-failure.json"), JSON.stringify({ schemaVersion: 1, source: "host_runtime_event", serverName: "chatcut", occurredAt: null, conversationId: message.params.threadId, operation: "initialize", error: { message: message.params.error } }, null, 2)).then(failureSaved); + } + if (message.id !== undefined && pending.has(message.id)) { + const { ok, fail } = pending.get(message.id); pending.delete(message.id); + if (message.error) fail(Error(JSON.stringify(message.error))); else ok(message.result); + } + } catch { /* native non-JSON stdout retained by stderr/status evidence */ } + }); + const request = (method, params) => new Promise((ok, fail) => { + const id = ++serial; + const timer = setTimeout(() => fail(Error(`Timeout: ${method}; ${stderr}`)), 15000); + pending.set(id, { ok: (value) => { clearTimeout(timer); ok(value); }, fail: (error) => { clearTimeout(timer); fail(error); } }); + child.stdin.write(JSON.stringify({ id, method, params }) + "\n"); + }); + try { + await request("initialize", { clientInfo: { name: "doctor-isolated-acceptance", version: "1.0" }, capabilities: { experimentalApi: true } }); + child.stdin.write('{"method":"initialized"}\n'); + const catalog = await request("skills/list", { cwds: [context.dir], forceReload: true }); + await writeFile(join(context.dir, "skill-catalog.json"), JSON.stringify(catalog, null, 2)); + const found = catalog.data.flatMap((item) => item.skills).find((item) => ["chatcut-mcp-doctor", "chatcut:chatcut-mcp-doctor"].includes(item.name)); + assert.ok(found, "native skill catalog must discover standalone Doctor"); + hostCommands.set("codex", hostCommands.get("codex").replace(join(context.skill, "scripts/doctor.mjs").replaceAll("\\", "/"), join(found.path, "../scripts/doctor.mjs").replaceAll("\\", "/"))); + const thread = await request("thread/start", { cwd: context.dir, ephemeral: true, approvalPolicy: "never", sandbox: "danger-full-access" }); + await request("turn/start", { threadId: thread.thread.id, input: [{ type: "text", text: "$chatcut-mcp-doctor Diagnose the controlled broken ChatCut MCP locally; no upload." }, { type: "skill", name: found.name, path: found.path }] }); + const deadline = Date.now() + 35000; + while (!events.some((item) => item.method === "turn/completed") && Date.now() < deadline) await new Promise((ok) => setTimeout(ok, 100)); + assert.ok(events.some((item) => item.method === "turn/completed"), stderr); + if (!values["tool-failure"]) assert.ok(events.some((item) => item.method === "mcpServer/startupStatus/updated" && item.params.status === "failed"), "actual host must report controlled startup failure"); + assert.ok(requests.some((item) => item.host === "codex" && JSON.stringify(item.body.input).includes("# ChatCut MCP Doctor")), "native skill input must expand full Skill into provider request"); + assert.equal(JSON.parse(await readFile(join(context.dir, "native.mcp-report.json"))).upload.status, "skipped_offline"); + assert.ok(JSON.parse(await readFile(join(context.dir, "native.mcp-report.json"))).originalFailure.message.includes(values["tool-failure"] ? "CONTROLLED_TOOL_FAILURE" : "CONTROLLED_HOST_STARTUP_FAILURE")); + records.push({ host: "codex", packaging: values["codex-plugin"] ? "local marketplace plugin" : "project standalone Skill", discovered: true, invoked: true, reportWrittenByNativeTool: true, scenario: values["tool-failure"] ? "post_connection_tool_failure" : "startup_failure", provider: "deterministic loopback fixture; not model quality evaluation" }); + } finally { + child.kill(); lines.close(); + await writeFile(join(context.dir, "appserver-events.json"), JSON.stringify(events, null, 2)); + await writeFile(join(context.dir, "appserver-stderr.txt"), stderr); + } + } + } + if (values.claude) { + const context = await setup("claude"); + await cli(values.claude, ["--version"], context, "version"); + await registration("claude", values.claude, context); + if (!values["registration-only"]) { + await mkdir(join(context.dir, "plugin/.claude-plugin"), { recursive: true }); + await writeFile(join(context.dir, "plugin/.claude-plugin/plugin.json"), JSON.stringify({ name: "chatcut", version: "0.0.0-local", skills: "./skills/", mcpServers: { chatcut: { type: "http", url: `${origin}/api/external-mcp/mcp` } } })); + let statusSaved; + nativeEvidence.set("claude", new Promise((ok) => statusSaved = ok)); + if (!values["tool-failure"]) hostCommands.set("claude", hostCommands.get("claude") + ` --host-snapshot "${join(context.dir, "native.mcp-snapshot.json").replaceAll("\\", "/")}"`); + const result = await cli(values.claude, ["--bare", "--plugin-dir", join(context.dir, "plugin"), "--strict-mcp-config", "--mcp-config", JSON.stringify({ mcpServers: { chatcut: { type: "http", url: `${origin}/api/external-mcp/mcp` } } }), "--setting-sources", "", "--allowedTools", "Bash(node *)", "mcp__chatcut__list_projects", "--output-format", "stream-json", "--verbose", "--no-session-persistence", "-p", "/chatcut:chatcut-mcp-doctor Diagnose this controlled broken ChatCut MCP locally; no upload."], context, "native-invocation", 0, (event) => { + if (!values["tool-failure"] && event.type === "system" && event.subtype === "init") + writeFile(join(context.dir, "native.mcp-snapshot.json"), JSON.stringify({ schemaVersion: 1, observedAt: new Date().toISOString(), hostVersion: event.claude_code_version, session: { source: "current_session", connectionStatus: event.mcp_servers.some((item) => item.name === "chatcut" && item.status === "failed") ? "failed" : "unknown", authStatus: "unknown", visibleToolCount: null }, observations: [] }, null, 2)).then(statusSaved); + }); + const events = result.stdout.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)); + const init = events.find((item) => item.type === "system" && item.subtype === "init"); + assert.ok(init?.slash_commands.includes("chatcut:chatcut-mcp-doctor"), "native Claude catalog must expose plugin namespaced command"); + assert.ok(requests.some((item) => item.host === "claude" && JSON.stringify(item.body.messages ?? []).includes("# ChatCut MCP Doctor")), "native slash invocation must expand full Skill"); + assert.ok(init.mcp_servers.some((item) => item.name === "chatcut" && (values["tool-failure"] ? item.status === "connected" : item.status !== "connected")), "actual host connection state must match scenario"); + assert.equal(JSON.parse(await readFile(join(context.dir, "native.mcp-report.json"))).upload.status, "skipped_offline"); + records.push({ host: "claude", discovered: true, invoked: true, reportWrittenByNativeTool: true, scenario: values["tool-failure"] ? "post_connection_tool_failure" : "startup_failure", provider: "deterministic loopback fixture; not model quality evaluation" }); + } + } +} finally { + server.closeAllConnections(); server.close(); + await writeFile(join(root, "provider-requests.json"), JSON.stringify(requests, null, 2)); + await writeFile(join(root, "acceptance.json"), JSON.stringify({ records, registrationRecords, mcpCalls, fixtureErrors, diagnosticNetworkPolicy: "offline", providerAndMcpTargets: "loopback", wholeHostNetworkEgressInstrumented: false, globalSettingsChanged: false }, null, 2)); +} +console.log(JSON.stringify({ evidence: root, records, mcpCalls })); diff --git a/tests/mcp-doctor.test.mjs b/tests/mcp-doctor.test.mjs index fd251f7..19c178b 100644 --- a/tests/mcp-doctor.test.mjs +++ b/tests/mcp-doctor.test.mjs @@ -413,15 +413,21 @@ for (const [host, folder, manifest] of [ const output = join(dir, "report.json"); await writeFile(original, "CONTROLLED FIXTURE: original metadata connection failed"); let posts = 0; + let initializeClientVersion; + let uploadedCollectorVersion; const server = createServer(async (req, res) => { if (req.url === "/telemetry/events") { const parts = []; for await (const part of req) parts.push(part); const events = JSON.parse(Buffer.concat(parts).toString()); assert.equal(events[0].name, "mcp.doctor.report_fragment"); + uploadedCollectorVersion = JSON.parse(events.map((event) => event.properties.attributes.mcpDoctor.fragment).join("")).collectorVersion; posts++; res.end('{"ok":true}'); } else if (req.url === "/api/external-mcp/mcp") { + const parts = []; + for await (const part of req) parts.push(part); + initializeClientVersion = JSON.parse(Buffer.concat(parts).toString()).params.clientInfo.version; res.writeHead(401, { "www-authenticate": 'Bearer resource_metadata="' + @@ -470,6 +476,9 @@ for (const [host, folder, manifest] of [ assert.equal(receipt.upload.status, "ingest_accepted"); assert.equal(receipt.upload.storageVerified, false); const report = JSON.parse(await readFile(output)); + assert.equal(report.collectorVersion, "1.3.0"); + assert.equal(initializeClientVersion, report.collectorVersion); + assert.equal(uploadedCollectorVersion, report.collectorVersion); assert.equal(report.host, host); assert.equal(report.hostVersion, "fixture-1.0"); assert.equal( @@ -722,7 +731,7 @@ test("App fallback remains bounded and does not promote host notices into the or codexLogDir: join(dir, "empty-tui"), output: join(dir, "report.json"), }); - assert.equal(report.collectorVersion, "1.2.3"); + assert.equal(report.collectorVersion, "1.3.0"); assert.equal( report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log").length, 30, diff --git a/tests/observed-diagnosis.test.mjs b/tests/observed-diagnosis.test.mjs new file mode 100644 index 0000000..d9e0aa0 --- /dev/null +++ b/tests/observed-diagnosis.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { diagnose, renderPacket } from "../codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs"; + +// Reconstructed, sanitized fixtures for observed native message/stage shapes. +// They do not identify users, associate server records or reproduce a live cause. +const makeReport = () => ({ + schemaVersion: 2, diagnosticId: "00000000-0000-0000-0000-000000000024", + collectorVersion: "1.2.3", host: "codex", + originalFailure: { status: "unavailable", occurredAt: null }, + hostLogs: { entries: [], gaps: [] }, gaps: [], probes: [], wireProbes: [], +}); +const issuerWarning = 'MCP server startup failed server_name="chatcut" error=OAuth refresh credentials for server chatcut are missing an authorization server issuer; authorization required: OAuth authorization required'; +const nativeWarning = () => ({ + source: "codex_runtime_sqlite", level: "WARN", target: "codex_mcp::rmcp_client", + occurredAt: "2026-10-24T01:00:00Z", message: issuerWarning, +}); + +test("exact native issuer warning retains historical auth requirement without inventing an original attempt", () => { + const input = makeReport(); + input.hostLogs.entries = [nativeWarning()]; + input.hostSnapshot = { session: { connectionStatus: "connected", authStatus: "authenticated", visibleToolCount: 65 } }; + const before = structuredClone(input); + const result = diagnose(input); + const fact = result.findings.find((item) => item.kind === "host_reauthorization_required_unassociated"); + assert.deepEqual(fact.evidence, ["hostLogs.entries[0]"]); + assert.equal(fact.observed.occurredAt, input.hostLogs.entries[0].occurredAt); + assert.equal(fact.observed.originalAttemptMatch, "unconfirmed"); + assert.match(fact.next, /current authentication status first/); + assert.ok(!result.findings.some((item) => item.kind === "no_incident_demonstrated")); + assert.equal(result.rootCause, "unconfirmed"); + assert.equal(result.recoveryVerified, false); + const packet = renderPacket(input, result); + assert.ok(packet.summary.includes("OAuth refresh credentials for server chatcut are missing an authorization server issuer")); + assert.ok(!packet.draft.includes("OAuth refresh credentials for server chatcut are missing an authorization server issuer")); + assert.deepEqual(input, before); +}); + +test("generic warnings and changed source, target or native message do not establish reauthorization", () => { + for (const extra of [ + { source: "unknown" }, { target: "another_module" }, { level: "INFO" }, + { message: "OAuth token may need a refresh" }, + { message: `${issuerWarning}; quoted by a tool` }, + ]) { + const input = makeReport(); + input.hostLogs.entries = [{ ...nativeWarning(), ...extra }]; + assert.ok(!diagnose(input).findings.some((item) => item.kind === "host_reauthorization_required_unassociated")); + } +}); + +test("historical tool_call and tool_discovery stage names preserve their observed boundary", () => { + for (const [stage, kind] of [["tool_call", "tool_call_failed"], ["tool_discovery", "startup_or_discovery_failed"]]) { + const input = makeReport(); + input.hostSnapshot = { observations: [{ stage, outcome: "failed", source: "current_session", message: "native failure" }] }; + const result = diagnose(input); + assert.ok(result.findings.some((item) => item.kind === kind && item.evidence.includes("hostSnapshot.observations[0]"))); + assert.ok(!result.findings.some((item) => item.kind === "observed_stage_failed")); + assert.equal(result.recoveryVerified, false); + } +}); + +test("oauth attempt keeps its supplied time and does not attribute a timeout to network or registration", () => { + const input = makeReport(); + const occurredAt = "2026-10-24T04:00:00Z"; + input.originalFailure = { status: "recorded", operation: "connect", occurredAt, message: "OAuth authorization timed out" }; + input.hostSnapshot = { observations: [{ stage: "oauth", operation: "authorization", outcome: "failed", source: "runtime_log", observedAt: occurredAt, message: "OAuth authorization timed out" }] }; + const result = diagnose(input); + const fact = result.findings.find((item) => item.kind === "authentication_attempt_failed"); + assert.equal(fact.observed.observedAt, occurredAt); + assert.equal(fact.observed.stage, "oauth"); + assert.ok(!result.findings.some((item) => ["not_registered", "later_network_or_metadata_check_failed"].includes(item.kind))); + assert.equal(input.originalFailure.occurredAt, occurredAt); +}); + +test("connected browser_render failure remains a tool execution fact", () => { + const input = makeReport(); + const message = "The render tree was unmounted before this frame's assets were collected"; + input.hostSnapshot = { + session: { connectionStatus: "connected", authStatus: "authenticated", visibleToolCount: 65 }, + observations: [{ stage: "browser_render", operation: "preview_timeline", source: "current_session", outcome: "failed", message }], + }; + const result = diagnose(input); + const fact = result.findings.find((item) => item.kind === "tool_execution_failed"); + assert.equal(fact.observed.connectionStatus, "connected"); + assert.equal(fact.observed.messageExcerpt, message); + assert.ok(!result.findings.some((item) => ["startup_or_discovery_failed", "authentication_required", "not_registered"].includes(item.kind))); + assert.equal(result.recoveryVerified, false); +}); + +test("native startup time stays separate from unknown original time; later health and gaps remain bounded", () => { + const input = makeReport(); + input.hostLogs.entries = [{ + source: "codex_app_log", occurredAt: "2026-10-24T03:00:00Z", + message: '2026-10-24T03:00:00Z info [AppServerConnection] mcp_server_startup_status_updated error="native failure" server=chatcut status=failed threadId=null', + }]; + input.hostLogs.gaps = [{ source: "codex_runtime_sqlite", reason: "unreadable" }, { source: "codex_tui_log", reason: "omitted_ranges" }]; + input.entryProbe = { outcome: "authentication_challenge", status: 401 }; + input.probes = [{ outcome: "metadata_read", status: 200 }]; + const result = diagnose(input); + const fact = result.findings.find((item) => item.kind === "host_runtime_error_unassociated"); + assert.equal(fact.observed.occurredAt, input.hostLogs.entries[0].occurredAt); + assert.equal(input.originalFailure.occurredAt, null); + assert.deepEqual(result.gaps, input.hostLogs.gaps); + assert.equal(result.recoveryVerified, false); + input.hostLogs.entries = []; + input.originalFailure = { status: "recorded", source: "agent_supplied_host_tool_result", operation: "import_media", message: "Timed out after 300 seconds", occurredAt: null }; + const timeout = diagnose(input); + assert.ok(timeout.findings.some((item) => item.kind === "tool_call_failed")); + assert.ok(!JSON.stringify(timeout.findings).includes("session_request_in_use")); + assert.equal(timeout.rootCause, "unconfirmed"); + assert.equal(timeout.recoveryVerified, false); +}); diff --git a/tests/standalone-doctor.test.mjs b/tests/standalone-doctor.test.mjs new file mode 100644 index 0000000..9942dd1 --- /dev/null +++ b/tests/standalone-doctor.test.mjs @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { once } from "node:events"; +import { cp, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; +import { collectHostLogs, redact, runDoctor, submitReport } from "../codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; +import { diagnose, renderPacket } from "../codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs"; + +const exec = promisify(execFile); +const skill = fileURLToPath(new URL("../codex/skills/chatcut-mcp-doctor/", import.meta.url)); +const noLogs = async () => ({ entries: [], gaps: [{ source: "fixture", reason: "logs_unavailable" }] }); +async function temp(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-standalone-doctor-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} +async function service(t, handler) { + const server = createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => { server.closeAllConnections(); server.close(); }); + return `http://127.0.0.1:${server.address().port}`; +} +const cli = (path, args, dir) => exec(process.execPath, [path, ...args], { + env: { ...process.env, USERPROFILE: dir, HOME: dir, CODEX_HOME: dir, LOCALAPPDATA: dir, CLAUDE_CONFIG_DIR: dir, CODEX_THREAD_ID: "" }, + timeout: 30000, +}); + +test("standalone copied skill runs offline without a parent plugin or a live MCP and emits all feedback files", async (t) => { + const dir = await temp(t); + await cp(skill, join(dir, "doctor"), { recursive: true }); + let requests = 0; + const origin = await service(t, (_, res) => { requests++; res.end("{}"); }); + const output = join(dir, "offline.mcp-report.json"); + await cli(join(dir, "doctor/scripts/doctor.mjs"), ["--host", "codex", "--server-url", `${origin}/api/external-mcp/mcp`, "--offline", "--upload", "--output", output], dir); + const report = JSON.parse(await readFile(output)); + assert.equal(requests, 0); + assert.equal(report.collectorVersion, "1.3.0"); + assert.equal(report.upload.status, "skipped_offline"); + assert.equal(report.probes.length, 0); + assert.ok(report.gaps.some((gap) => gap.source === "plugin_version")); + for (const suffix of [".diagnosis.json", ".summary.md", ".issue-draft.md"]) assert.ok((await readFile(output + suffix)).length > 0); + const before = await readFile(output, "utf8"); + await cli(join(dir, "doctor/scripts/doctor.mjs"), ["--report", output], dir); + assert.equal(await readFile(output, "utf8"), before); + assert.equal(requests, 0); +}); + +test("normal anonymous 401 plus valid metadata demonstrates no incident or authenticated recovery", async (t) => { + const dir = await temp(t); + let origin; + origin = await service(t, (req, res) => { + res.setHeader("content-type", "application/json"); + if (req.method === "POST") { + res.writeHead(401, { "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/api/external-mcp/mcp"` }); + res.end('{"error":"unauthorized"}'); + } else if (req.url.includes("oauth-protected-resource")) + res.end(JSON.stringify({ resource: `${origin}/api/external-mcp/mcp`, authorization_servers: [origin] })); + else res.end(JSON.stringify({ issuer: origin, authorization_endpoint: `${origin}/authorize`, token_endpoint: `${origin}/token`, response_types_supported: ["code"], code_challenge_methods_supported: ["S256"] })); + }); + const { report } = await runDoctor({ host: "codex", serverUrl: `${origin}/api/external-mcp/mcp`, output: join(dir, "normal.mcp-report.json") }, { collectLogs: noLogs }); + assert.equal(report.entryProbe.outcome, "authentication_challenge"); + assert.ok(report.probes.every((probe) => probe.outcome === "metadata_read")); + const result = diagnose(report); + assert.equal(result.findings.some((item) => item.severity === "attention"), false); + assert.equal(result.recoveryVerified, false); +}); + +test("startup, post-connection tool failure, missing registration and truncated logs keep distinct evidence", async (t) => { + const dir = await temp(t); + for (const [stage, operation, source, expected] of [ + ["initialize", "initialize", "runtime_log", "startup_or_discovery_failed"], + ["tools_call", "import_media", "current_session", "tool_call_failed"], + ["host_registration", "lookup_registered_server", "cli_configuration", "not_registered"], + ]) { + const snapshot = join(dir, `${stage}.mcp-snapshot.json`); + await writeFile(snapshot, JSON.stringify({ schemaVersion: 1, observedAt: new Date().toISOString(), observations: [{ stage, operation, source, observedAt: null, outcome: "failed", ...(stage === "host_registration" && { lookupResult: "not_found" }), message: stage === "host_registration" ? "Completed scoped lookup explicitly reports no entry" : "Exact native error E_UNKNOWN" }] })); + const failure = join(dir, `${stage}.mcp-failure.json`); + await writeFile(failure, JSON.stringify({ schemaVersion: 1, source: "host_tool_result", serverName: "chatcut", occurredAt: null, operation, callId: "original-local-id", error: { message: "busy after 300s", code: "E_UNKNOWN", cause: { code: "E_NATIVE" } } })); + const { report } = await runDoctor({ host: "claude", hostSnapshot: snapshot, failureRecord: failure, output: join(dir, `${stage}.mcp-report.json`) }, { + collectLogs: async () => ({ entries: [], gaps: [{ source: "host_logs", reason: "truncated", omittedBytes: 12 }] }), + }); + const result = diagnose(report); + assert.ok(result.findings.some((item) => item.kind === expected)); + assert.ok(result.gaps.some((gap) => gap.reason === "truncated" && gap.omittedBytes === 12)); + assert.equal(report.originalFailure.error.cause.code, "E_NATIVE"); + assert.equal(report.originalFailure.occurredAt, null); + assert.equal(result.rootCause, "unconfirmed"); + assert.equal(result.recoveryVerified, false); + assert.ok(!JSON.stringify(result.findings).includes("clear locks")); + } +}); + +test("unsupported endpoint yields a local report without retargeting or any requests", async (t) => { + const dir = await temp(t); + const { report } = await runDoctor({ host: "codex", serverUrl: "https://unrecognized.invalid/api/external-mcp/mcp", upload: true, output: join(dir, "unsupported.mcp-report.json") }, { + collectLogs: noLogs, fetchImpl: () => assert.fail("must not send"), + }); + assert.equal(report.serverUrl, null); + assert.equal(report.upload.status, "skipped_endpoint_unavailable"); + assert.ok(report.gaps.some((gap) => gap.reason.startsWith("unsupported_endpoint"))); +}); + +test("failed ingest can resend the same saved report once, without probes or report mutations", async (t) => { + const dir = await temp(t); + let posts = 0; + let reject = true; + let received; + const origin = await service(t, (req, res) => { + assert.equal(req.url, "/telemetry/events"); + assert.equal(req.headers.authorization, undefined); + posts++; + let body = ""; + req.on("data", (chunk) => body += chunk); + req.on("end", () => { received = JSON.parse(body); res.writeHead(reject ? 503 : 200); res.end(reject ? "busy" : '{"ok":true}'); }); + }); + const output = join(dir, "retry.mcp-report.json"); + await runDoctor({ host: "codex", serverUrl: `${origin}/api/external-mcp/mcp`, offline: true, output }, { collectLogs: noLogs }); + const before = await readFile(output, "utf8"); + await cli(join(skill, "scripts/doctor.mjs"), ["--upload-report", output], dir); + assert.equal(JSON.parse(await readFile(output + ".upload.json")).receipt.status, "failed"); + reject = false; + await cli(join(skill, "scripts/doctor.mjs"), ["--upload-report", output], dir); + assert.equal(JSON.parse(await readFile(output + ".upload.json")).receipt.storageVerified, false); + assert.equal(JSON.parse(await readFile(output + ".upload.json")).receipt.status, "ingest_accepted"); + assert.equal(received[0].properties.attributes.mcpDoctor.diagnosticId, JSON.parse(before).diagnosticId); + assert.equal(await readFile(output, "utf8"), before); + await assert.rejects(cli(join(skill, "scripts/doctor.mjs"), ["--upload-report", output], dir), /already acknowledged/); + assert.equal(posts, 2); +}); + +test("private issue draft excludes local IDs, full stacks, paths, credential values and log dumps", async (t) => { + const dir = await temp(t); + const { report } = await runDoctor({ host: "codex", output: join(dir, "privacy.mcp-report.json") }, { collectLogs: noLogs }); + report.originalFailure = { status: "recorded", occurredAt: null, message: "E_UNKNOWN token=abc123 at C:\\Users\\alice\\secret.txt", callId: "private-call-id", stack: "private-full-stack" }; + report.hostLogs.entries.push({ message: "private-log-dump" }); + const { draft } = renderPacket(report, diagnose(report)); + for (const secret of ["abc123", "alice", "private-call-id", "private-full-stack", "private-log-dump"]) assert.ok(!draft.includes(secret)); + assert.ok(draft.includes("E_UNKNOWN")); +}); + +test("MCP session credentials are redacted while local IDs remain private report evidence only", async (t) => { + const dir = await temp(t); + const raw = 'Mcp-Session-Id: opaque-secret\n{"mcp_session_id":"second-secret"}\ncallId=local-call conversationId=local-chat'; + const { report } = await runDoctor({ host: "codex", output: join(dir, "ids.mcp-report.json") }, { collectLogs: noLogs }); + report.originalFailure = { status: "recorded", message: redact(raw), callId: "local-call", conversationId: "local-chat" }; + const { draft } = renderPacket(report, diagnose(report)); + for (const value of ["opaque-secret", "second-secret", "local-call", "local-chat"]) assert.ok(!draft.includes(value)); + assert.equal(report.originalFailure.callId, "local-call"); + assert.ok(redact(raw).includes("callId=local-call")); + for (const opaque of ["!opaque-secret", "opaque:secret", "opaque;secret", "opaque\\secret"]) + assert.ok(!redact(`Mcp-Session-Id: ${opaque}\nError E_KEEP`).includes(opaque)); + for (let code = 0x21; code <= 0x7e; code++) { + const opaque = `opaque${String.fromCharCode(code)}SECRET_SUFFIX`; + const document = JSON.stringify({ "Mcp-Session-Id": opaque, code: "E_KEEP" }); + for (const text of [`Mcp-Session-Id: ${opaque}\nError E_KEEP`, document, `Response: ${JSON.stringify(document)}`, `Response: ${JSON.stringify(JSON.stringify(document))}`]) { + assert.ok(!redact(text).includes("SECRET_SUFFIX")); + assert.ok(redact(text).includes("E_KEEP")); + } + } +}); + +test("foreign upload receipts cannot claim delivery for another report", async (t) => { + const dir = await temp(t); + const output = join(dir, "foreign.mcp-report.json"); + await runDoctor({ host: "codex", output }, { collectLogs: noLogs }); + const source = await readFile(output, "utf8"); + await writeFile(output + ".upload.json", JSON.stringify({ schemaVersion: 1, diagnosticId: "00000000-0000-0000-0000-000000000000", sourceReportSha256: "0".repeat(64), receipt: { status: "ingest_accepted", storageVerified: false } })); + for (const flag of ["--report", "--upload-report"]) + await assert.rejects(cli(join(skill, "scripts/doctor.mjs"), [flag, output], dir), /does not match/); + assert.equal(await readFile(output, "utf8"), source); +}); + +test("invalid disabled snapshots remain disabled through offline collection and later report submission", async (t) => { + const dir = await temp(t); + const snapshot = join(dir, "disabled.mcp-snapshot.json"); + await writeFile(snapshot, JSON.stringify({ schemaVersion: 99, configuration: { enabled: false } })); + const { report } = await runDoctor({ host: "codex", serverUrl: "https://api.chatcut.io/api/external-mcp/mcp", hostSnapshot: snapshot, offline: true, output: join(dir, "disabled.mcp-report.json") }, { collectLogs: noLogs, fetchImpl: () => assert.fail("disabled collection sends nothing") }); + assert.equal(report.hostSnapshot, undefined); + assert.equal(report.upload.status, "skipped_intentionally_disabled"); + assert.equal((await submitReport(report, () => assert.fail("disabled report sends nothing"))).status, "skipped_intentionally_disabled"); +}); + +test("SQLite and text runtime records exclude content echoes from permitted MCP modules", async (t) => { + const dir = await temp(t); + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(join(dir, "logs_2.sqlite")); + db.exec("CREATE TABLE logs(id INTEGER,ts INTEGER,level TEXT,target TEXT,feedback_log_body TEXT)"); + const messages = [ + 'start{server_name="chatcut"}: tool call: {"method":"tools/call","params":{"arguments":{"prompt":"PRIVATE_PROMPT_SENTINEL"}}}', + 'start{server_name="chatcut"}: tool result: {"result":{"content":[{"text":"PRIVATE_RESULT_SENTINEL"}]}}', + 'start{server_name="chatcut"}: tool call: {"params":{"arguments":{"prompt":"}: PRIVATE_PROMPT_SENTINEL"}}}', + 'start{server_name="chatcut"}: tool result: {"result":{"content":[{"text":"}: PRIVATE_RESULT_SENTINEL"}]}}', + 'start{server_name="chatcut"}: Unfamiliar error E_KEEP_UNKNOWN cause ECONNRESET', + ]; + const add = db.prepare("INSERT INTO logs VALUES(?,?,?,?,?)"); + messages.forEach((message, index) => add.run(index + 1, Math.floor(Date.now() / 1000), "WARN", "codex_mcp::client", message)); + db.close(); + await writeFile(join(dir, "codex-tui.log"), messages.map((message) => `${new Date().toISOString()} WARN codex_mcp::client: ${message}`).join("\n")); + const logs = await collectHostLogs("codex", { codexHome: dir, codexSqliteHome: dir, codexLogDir: dir, codexAppLogDir: dir }); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_PROMPT_SENTINEL")); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_RESULT_SENTINEL")); + assert.equal(logs.entries.filter((entry) => entry.message.includes("E_KEEP_UNKNOWN")).length, 2); +}); + +test("both packages ship identical standalone executable resources and references", async () => { + async function compare(relative = "") { + for (const entry of await readdir(join(skill, relative), { withFileTypes: true })) { + if (entry.name === "agents") continue; + const path = join(relative, entry.name); + if (entry.isDirectory()) await compare(path); + else assert.deepEqual(await readFile(join(skill, path)), await readFile(new URL(`../claude/skills/chatcut-mcp-doctor/${path.replaceAll("\\", "/")}`, import.meta.url))); + } + } + await compare(); +});