diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1b9af4b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,11 @@ +name: Plugin helpers +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: node --test tests/*.test.mjs diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index 769c5ad..91b67c7 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "chatcut", "displayName": "ChatCut", - "version": "1.10.19", + "version": "1.10.20", "description": "ChatCut plugin for editable video editing and creation workflows from Claude Code.", "author": { "name": "ChatCut", diff --git a/claude/README.md b/claude/README.md index 4b0e2a0..e315f1c 100644 --- a/claude/README.md +++ b/claude/README.md @@ -64,6 +64,20 @@ Local imports use one of two bundled Node.js helpers: Only import media you have permission to use. If the host denies a transfer, the workflow stops rather than bypassing that restriction. +For hosted MCP connection failures, the Agent can run the bundled diagnostic +helper with Node.js 18+. It reads bounded ChatCut connection log records and +checks the anonymous MCP entry and public OAuth metadata responses, preserving the original failure +separately from later probes. A local report is saved first. When network +permissions permit diagnostic reporting, the Agent can send the redacted report +to ChatCut's telemetry endpoint without MCP login. Credential stores and +conversation transcripts are not read. Response bodies are not retained as +separate fields; original parser errors may quote response text. This does not +repair or replace the connection, and it does not monitor every connection attempt. +The Agent can attach a small, source-labelled current-host snapshot. Failed +HTTPS checks may additionally collect a bounded direct TLS certificate/protocol +observation, explicitly distinct from the host's connection route. +See [the evidence and delivery contract](../docs/mcp-doctor.md). + ## Links - [Documentation](https://chatcut.io/claude) diff --git a/claude/skills/chatcut-plugin-basics-claude/SKILL.md b/claude/skills/chatcut-plugin-basics-claude/SKILL.md index ba10346..6746cbd 100644 --- a/claude/skills/chatcut-plugin-basics-claude/SKILL.md +++ b/claude/skills/chatcut-plugin-basics-claude/SKILL.md @@ -1,6 +1,6 @@ --- name: chatcut-plugin-basics-claude -description: "Hosted ChatCut plugin sessions only (the `chatcut` server, namespaced `plugin:chatcut:chatcut`). If the conversation is driving ChatCut Desktop (a `chatcut_desktop*` MCP server), load this skill only when the user explicitly chooses the plugin/web surface — desktop sessions otherwise ship their own instructions and tools. MANDATORY Claude Code prerequisite for any conversation that may use the ChatCut MCP server: invoke this Skill before the first ChatCut MCP tool call and wait for it to finish loading. Also invoke it whenever video editing or creation should remain editable in ChatCut, even if the user does not mention ChatCut. Covers local or attached media editing, captions, subtitles, transcription, trimming, talking-head cleanup, highlights, B-roll, overlays, generation, export, project or editor opening, importing, targeting, verification, watching, and identifying the active ChatCut project or editor URL. Also invoke it when ChatCut tools appear missing, unavailable, or not connected: that is normally an unauthenticated MCP server rather than a broken install, and this skill says what to tell the user." +description: "Hosted ChatCut plugin only (`chatcut` or `plugin:chatcut:chatcut`). MANDATORY Claude Code prerequisite: invoke this Skill before the first hosted ChatCut MCP tool call and wait for loading. Also invoke when video editing or creation should remain editable in ChatCut, even if the user does not mention ChatCut. Covers project/editor access, local media import, timeline editing, captions, transcription, talking-head cleanup, B-roll, overlays, motion graphics, generation, audio, translation, export, and verification. Use when hosted tools are missing, unavailable, unauthenticated, or fail to connect; includes connection evidence diagnostics. For `chatcut_desktop*`, follow Desktop instructions unless the user explicitly selects the hosted plugin/web surface." --- # ChatCut Plugin Basics (Claude Code) @@ -26,6 +26,10 @@ 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. + +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. + 1. **Server not registered:** Offer to register the Hosted MCP server directly at user scope. Do not install, reinstall, repair, or enable a plugin to obtain the server: ```sh diff --git a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md new file mode 100644 index 0000000..c5ee5b8 --- /dev/null +++ b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md @@ -0,0 +1,157 @@ +# 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. 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. + +## 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. + +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`, and `message`. 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 + +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. +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. + +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. + +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-plugin-basics-claude/scripts/mcp-doctor.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs new file mode 100644 index 0000000..33fbbe2 --- /dev/null +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -0,0 +1,862 @@ +import { randomUUID, createHash } from "node:crypto"; +import { open, 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"; + +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; + +export function redact(text) { + return String(text) + .replace(/\bBearer\s+(?!(?:token|required)\b|<)[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]+/gi, "$1[REDACTED]") + .replace(/\bchatcut_live_[A-Za-z0-9_-]{20,}\b/g, "[REDACTED]") + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]") + .replace( + /([?&](?:code|state|token|access_token|refresh_token|id_token|client_secret|code_verifier|client_assertion|api[_-]?key|signature|sig|x-amz-(?:signature|credential|security-token))=)[^&\s"<>]+/gi, + "$1[REDACTED]", + ) + .replace( + /\b((?:access_?token|refresh_?token|id_?token|client_?secret|api[_-]?key)\s*[:=]\s*)[A-Za-z0-9._~+/=-]+/gi, + "$1[REDACTED]", + ) + .replace( + /("(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)"\s*:\s*")[^"]*/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", "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.1.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(); +} + +async function textLog(path, source, gaps, window) { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + const start = Math.max(0, info.size - READ_LIMIT); + const buffer = Buffer.alloc(Math.min(info.size, 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) { + lines.shift(); + gaps.push({ source, reason: "only_log_tail_read", bytesBeforeWindow: start }); + } + const entries = []; + for (let i = 0; i < lines.length; i++) { + if ( + !CHATCUT_MESSAGE.test(lines[i]) || + /chatcut_desktop|function_call|tool_call|tool result|user prompt|transcript|"method"\s*:\s*"tools\/call"/i.test( + lines[i], + ) + ) + continue; + const block = [lines[i]]; + while (i + 1 < lines.length && /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1])) + 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")), + 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(); + } +} + +export async function collectHostLogs(host, options = {}) { + const userHome = options.home ?? homedir(); + const gaps = []; + const entries = []; + 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.floor(Math.min(now, center + 15 * 60_000) / 1000); + if (host === "codex") { + const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); + let db; + try { + await stat(join(root, "logs_2.sqlite")); + const { DatabaseSync } = await import("node:sqlite"); + db = new DatabaseSync(join(root, "logs_2.sqlite"), { readOnly: true, timeout: 1000 }); + db.exec("PRAGMA query_only=ON"); + const rows = db + .prepare( + "SELECT id,ts,level,target,feedback_log_body 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 ?? ""; + 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), + 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), + }); + } finally { + db?.close(); + } + try { + entries.push( + ...(await textLog(join(root, "log", "codex-tui.log"), "codex_tui_log", gaps, { + since, + until, + })), + ); + } catch (error) { + gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); + } + } 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, + 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"); + 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.1.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, + source: "agent_supplied_error_file", + status: "unavailable", + }, + probes: [], + 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", + }; + 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"); + report.hostSnapshot = clean( + validateSnapshot(JSON.parse(await readFile(options.hostSnapshot, "utf8")), 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), + }); + } + } + 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"); + report.originalFailure.message = redact(await readFile(options.originalError, "utf8")); + report.originalFailure.status = "recorded"; + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "unavailable", + error: errorDetails(error), + }); + } + } else + report.gaps.push({ + source: "original_failure", + reason: "not_provided; do_not_invent_original_error", + }); + try { + report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { + occurredAt: options.occurredAt, + }); + } catch (error) { + report.hostLogs = { + entries: [], + gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], + }; + } + const disabled = + 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 ( + 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" }, + "occurred-at": { type: "string" }, + output: { type: "string" }, + upload: { type: "boolean", default: false }, + help: { type: "boolean", default: false }, + }, + }); + if (values.help) { + console.log( + "node mcp-doctor.mjs --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--original-error ] [--occurred-at ] [--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"], + occurredAt: values["occurred-at"], + output: values.output, + upload: values.upload, + }); + console.log( + JSON.stringify({ + diagnosticId: report.diagnosticId, + path, + upload: report.upload, + recoveryVerified: false, + }), + ); + } + } catch (error) { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + } +} diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs new file mode 100644 index 0000000..df2a931 --- /dev/null +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs @@ -0,0 +1,289 @@ +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" && TIMESTAMP.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 || !instant(input.observedAt)) + throw new Error("Snapshot requires schemaVersion 1 and observedAt with a timezone"); + 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", + ]); + source(item.source); + if ( + !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || + !["success", "failed", "unknown"].includes(item.outcome) || + !instant(item.observedAt) + ) + throw new Error("Stage observation needs a stage, outcome, and timestamp"); + 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"); + } + } + 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 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", + })); + return { + stages, + otherObservations: unknown, + logHints, + gaps, + independentChecks: + "entryProbe/probes/transportContext are later requests, never original stage completions", + 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/.codex-plugin/plugin.json b/codex/.codex-plugin/plugin.json index bb3e734..b4d79a4 100644 --- a/codex/.codex-plugin/plugin.json +++ b/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "chatcut", - "version": "1.10.17", + "version": "1.10.18", "description": "ChatCut Codex plugin for editable video editing and creation workflows from Codex.", "author": { "name": "ChatCut", diff --git a/codex/skills/chatcut-plugin-basics/SKILL.md b/codex/skills/chatcut-plugin-basics/SKILL.md index 7a8b59d..2689615 100644 --- a/codex/skills/chatcut-plugin-basics/SKILL.md +++ b/codex/skills/chatcut-plugin-basics/SKILL.md @@ -26,6 +26,10 @@ 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. + +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. + 1. **Server not registered:** Offer to register the Hosted MCP server directly. Do not install, reinstall, repair, or enable a plugin to obtain the server: ```sh diff --git a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md new file mode 100644 index 0000000..c5ee5b8 --- /dev/null +++ b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md @@ -0,0 +1,157 @@ +# 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. 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. + +## 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. + +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`, and `message`. 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 + +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. +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. + +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. + +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-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs new file mode 100644 index 0000000..33fbbe2 --- /dev/null +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -0,0 +1,862 @@ +import { randomUUID, createHash } from "node:crypto"; +import { open, 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"; + +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; + +export function redact(text) { + return String(text) + .replace(/\bBearer\s+(?!(?:token|required)\b|<)[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]+/gi, "$1[REDACTED]") + .replace(/\bchatcut_live_[A-Za-z0-9_-]{20,}\b/g, "[REDACTED]") + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[REDACTED]") + .replace( + /([?&](?:code|state|token|access_token|refresh_token|id_token|client_secret|code_verifier|client_assertion|api[_-]?key|signature|sig|x-amz-(?:signature|credential|security-token))=)[^&\s"<>]+/gi, + "$1[REDACTED]", + ) + .replace( + /\b((?:access_?token|refresh_?token|id_?token|client_?secret|api[_-]?key)\s*[:=]\s*)[A-Za-z0-9._~+/=-]+/gi, + "$1[REDACTED]", + ) + .replace( + /("(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)"\s*:\s*")[^"]*/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", "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.1.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(); +} + +async function textLog(path, source, gaps, window) { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + const start = Math.max(0, info.size - READ_LIMIT); + const buffer = Buffer.alloc(Math.min(info.size, 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) { + lines.shift(); + gaps.push({ source, reason: "only_log_tail_read", bytesBeforeWindow: start }); + } + const entries = []; + for (let i = 0; i < lines.length; i++) { + if ( + !CHATCUT_MESSAGE.test(lines[i]) || + /chatcut_desktop|function_call|tool_call|tool result|user prompt|transcript|"method"\s*:\s*"tools\/call"/i.test( + lines[i], + ) + ) + continue; + const block = [lines[i]]; + while (i + 1 < lines.length && /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1])) + 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")), + 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(); + } +} + +export async function collectHostLogs(host, options = {}) { + const userHome = options.home ?? homedir(); + const gaps = []; + const entries = []; + 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.floor(Math.min(now, center + 15 * 60_000) / 1000); + if (host === "codex") { + const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); + let db; + try { + await stat(join(root, "logs_2.sqlite")); + const { DatabaseSync } = await import("node:sqlite"); + db = new DatabaseSync(join(root, "logs_2.sqlite"), { readOnly: true, timeout: 1000 }); + db.exec("PRAGMA query_only=ON"); + const rows = db + .prepare( + "SELECT id,ts,level,target,feedback_log_body 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 ?? ""; + 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), + 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), + }); + } finally { + db?.close(); + } + try { + entries.push( + ...(await textLog(join(root, "log", "codex-tui.log"), "codex_tui_log", gaps, { + since, + until, + })), + ); + } catch (error) { + gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); + } + } 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, + 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"); + 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.1.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, + source: "agent_supplied_error_file", + status: "unavailable", + }, + probes: [], + 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", + }; + 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"); + report.hostSnapshot = clean( + validateSnapshot(JSON.parse(await readFile(options.hostSnapshot, "utf8")), 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), + }); + } + } + 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"); + report.originalFailure.message = redact(await readFile(options.originalError, "utf8")); + report.originalFailure.status = "recorded"; + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "unavailable", + error: errorDetails(error), + }); + } + } else + report.gaps.push({ + source: "original_failure", + reason: "not_provided; do_not_invent_original_error", + }); + try { + report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { + occurredAt: options.occurredAt, + }); + } catch (error) { + report.hostLogs = { + entries: [], + gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], + }; + } + const disabled = + 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 ( + 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" }, + "occurred-at": { type: "string" }, + output: { type: "string" }, + upload: { type: "boolean", default: false }, + help: { type: "boolean", default: false }, + }, + }); + if (values.help) { + console.log( + "node mcp-doctor.mjs --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--original-error ] [--occurred-at ] [--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"], + occurredAt: values["occurred-at"], + output: values.output, + upload: values.upload, + }); + console.log( + JSON.stringify({ + diagnosticId: report.diagnosticId, + path, + upload: report.upload, + recoveryVerified: false, + }), + ); + } + } catch (error) { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + } +} diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs new file mode 100644 index 0000000..df2a931 --- /dev/null +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs @@ -0,0 +1,289 @@ +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" && TIMESTAMP.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 || !instant(input.observedAt)) + throw new Error("Snapshot requires schemaVersion 1 and observedAt with a timezone"); + 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", + ]); + source(item.source); + if ( + !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || + !["success", "failed", "unknown"].includes(item.outcome) || + !instant(item.observedAt) + ) + throw new Error("Stage observation needs a stage, outcome, and timestamp"); + 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"); + } + } + 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 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", + })); + return { + stages, + otherObservations: unknown, + logHints, + gaps, + independentChecks: + "entryProbe/probes/transportContext are later requests, never original stage completions", + 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/docs/mcp-doctor.md b/docs/mcp-doctor.md new file mode 100644 index 0000000..5da481c --- /dev/null +++ b/docs/mcp-doctor.md @@ -0,0 +1,127 @@ +# Hosted MCP evidence report v2 + +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 +the Agent during diagnosis and is not an automatic monitor of all connections. + +The two plugin packages contain identical copies of the helper and its reference +because either package must remain self-contained. Update both; tests check +parity. Node 18+ runs probes and text-log extraction. Optional Codex SQLite reads +need a runtime with `node:sqlite`; missing support is an explicit evidence gap. + +## Evidence and delivery contract + +`schemaVersion:2` reports contain `diagnosticId`, `collectedAt`, declared host and +version source, collector/package/runtime/platform versions, registered cloud server URL, +`originalFailure`, `hostSnapshot` when available, `hostLogs`, `entryProbe`, +`probes`, optional `transportContext`, `evidenceSummary`, `gaps`, and `upload`. + +The Agent supplies a dedicated schema-1 `.mcp-snapshot.json` using the basics +Skill reference. It identifies configuration vs. current-session sources, +connection/auth state, visible ChatCut tools and actual per-stage observations. +Unknown stages and errors are retained. Missing facts remain unknown. +The helper validates the schema but cannot independently verify Agent-supplied +facts. It does not run a new authenticated session or extract credentials. +Missing registered URL permits local-only collection; a disabled hosted server +sends neither diagnostic requests nor an upload. + +The original failure, host log window, and independent probes have separate +provenance. No original request association or recovery is inferred from a later +probe. A timestamp supplied by the Agent is identified as such; missing times +remain null. Normal initial OAuth challenges are not proof of an incident. + +`pluginVersion` comes from the collector's own package manifest, identified by +`pluginVersionSource`; a separately run helper does not prove which plugin +version the host loaded. `collectorVersion` identifies the diagnostic helper. + +The default report is local only. `--upload` sends one unauthenticated POST to +`/telemetry/events` through the existing public telemetry +ingest. It never adds MCP bearer tokens. The body is an array of +`mcp.doctor.report_fragment` events. The existing `eventName` is used for the +canonical event name and each fragment is nested in +`properties.attributes.mcpDoctor`. The legacy `clientEventName` alias is null so +that the endpoint does not introduce an additional flat dataset field. This +envelope was checked against beta ingestion rather than assuming that an HTTP +acknowledgement proves schema compatibility. Each fragment has these fields: + +| Property | Meaning | +| --- | --- | +| `diagnosticId` | One diagnostic execution; never a user ID or original request ID | +| `schemaVersion`, `host`, `scope` | Report format and cloud host grouping | +| `collectedAt` | Collection time preserved in properties by the existing ingest | +| `fragmentIndex`, `fragmentCount` | Zero-based index and expected count | +| `reportSha256` | SHA-256 of the exact reassembled UTF-8 JSON | +| `fragment` | Up to 4096 UTF-8 bytes, split on Unicode code-point boundaries | + +The existing backend logs these with `eventName=mcp.doctor.report_fragment` and +the message `frontend_event`. Read `attributes.mcpDoctor`, or +`parse_json(attributesJson).mcpDoctor` when map fields are disabled. Reassemble by diagnostic ID and +hash, require all distinct indexes from zero through count minus one, concatenate +in index order, verify the hash, then parse JSON. Duplicate or missing indexes +are delivery issues, not additional user failures. A batch acknowledgement is +not proof of final-store completeness. Backend request IDs for the upload or +probes do not correlate the original host failure automatically. + +Raw report uploads above 256 KiB are refused and the complete collected local +report remains available. Each metadata response is bounded to 128 KiB and 3.5 +seconds; exceeding the limit records an incomplete probe rather than success. +The helper makes one anonymous initialize POST, at most five metadata GETs, +and at most one upload, without retries. A normal 401 challenge is recorded as +such. Metadata checks validate resource/issuer consistency and the product's +OAuth-code/PKCE fields rather than only JSON syntax. Targets discovered in +challenge/issuer metadata stay within the explicitly selected origin. +Redirects are not followed. Proxy environment presence is recorded only +as a boolean; no proxy URLs or secrets are collected. Node fetch's network stack +may differ from the host's, so a healthy probe cannot rule out a host-path issue. +On an HTTPS request/body failure, at most one additional direct TLS connection +may record the certificate issuer and TLS protocol. It retains normal +certificate verification, is limited to 3.5 seconds, and is labelled as a +different route from the host/fetch proxy path. This is not antivirus attribution. + +Only dedicated MCP/plugin runtime modules and matching ChatCut records are selected +from Codex's read-only SQLite database, up to 200 candidates / 30 selected rows. +Native `chatcut_desktop` records are excluded. The text fallback reads up to 128 +KiB from a known host log tail; Claude reads at most three recent debug files. +Missing logs, bounded windows, scan limits, or denied reads are explicit gaps. +Timestamped text records outside the requested window are excluded; unknown +times are explicit. Unfamiliar records do not need a known connection-error +keyword to survive extraction. Other-service/native records, tool-call echoes, +and conversation payloads remain excluded. +Credential files and conversation transcripts are never read. Credential-shaped +values in error text are redacted while the remaining error and cause stay +intact. No separate metadata response body is retained; original JSON parser +errors may quote response text and pass through the same credential redactor. + +## Release validation and production observation + +Run `node --test tests/*.test.mjs`. Focused tests exercise a real malformed +chunked response, invalid JSON under HTTP 200, original failure preservation +despite later healthy probes, upload rejection/local persistence, UTF-8 fragment +reassembly, credential redaction, and scoped host log extraction. + +Before release, verify the controlled failure cases and use an explicitly +labelled internal report to check acknowledgement, final-store fragment +completeness, reassembly and hash. This acceptance work must not depend on a +reporter replying or reproducing an old issue. Check normal tool access in the +candidate's supported host before wider distribution. + +After release, inspect the new server auth evidence and the reports produced +when Agents trigger diagnostics. Establish which reports add an observed fact +that changes the next investigation step, and list remaining evidence gaps. +Repeating Mark's already known HTTP 200/chunk error alone is not proof of +diagnostic improvement. Rick's next request also needs the separately owned +backend auth-logging patch to distinguish a missing bearer from a rejected +bearer. A direct user follow-up is useful supplementary evidence, not a release +gate. Neither fixture results nor another client's successful request establish +recovery of those historical cases. + +Track diagnostic completion/gaps and actionable evidence among received reports. +Do not call these all-user connection failure rates. Browser-only hosts and +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. diff --git a/tests/mcp-doctor.test.mjs b/tests/mcp-doctor.test.mjs new file mode 100644 index 0000000..ff56df9 --- /dev/null +++ b/tests/mcp-doctor.test.mjs @@ -0,0 +1,449 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { createServer as createTcpServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { + collectHostLogs, + fragmentEvents, + probeMetadata, + redact, + runDoctor as runDoctorImpl, + serverUrl, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; + +async function temporary(t) { + const path = await mkdtemp(join(tmpdir(), "chatcut-mcp-doctor-test-")); + t.after(() => rm(path, { recursive: true, force: true })); + return path; +} + +async function listen(t, server) { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after( + () => + new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections?.(); + }), + ); + return `http://127.0.0.1:${server.address().port}`; +} + +const noLogs = async () => ({ entries: [], gaps: [{ reason: "fixture_no_logs" }] }); +const runDoctor = (options, dependencies) => + runDoctorImpl(options, { + inspectTransport: async () => ({ status: "fixture; no_network" }), + ...dependencies, + }); +const metadataResponse = () => + new Response( + JSON.stringify({ + issuer: "https://api.chatcut.io", + authorization_endpoint: "https://api.chatcut.io/auth/mcp/authorize", + token_endpoint: "https://api.chatcut.io/auth/mcp/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + resource: "https://api.chatcut.io/api/external-mcp/mcp", + authorization_servers: ["https://api.chatcut.io"], + }), + ); + +test("real HTTP 200 with malformed chunk framing remains a body failure with its original cause", async (t) => { + const server = createTcpServer((socket) => { + socket.once("data", () => + socket.end( + 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n{"issuer":"fixture"}', + ), + ); + socket.on("error", () => {}); + }); + const origin = await listen(t, server); + const result = await probeMetadata(`${origin}/.well-known/oauth-authorization-server`, "fixture"); + assert.equal(result.status, 200); + assert.equal(result.bodyComplete, false); + assert.equal(result.stage, "response_body"); + assert.equal(result.outcome, "failed"); + assert.match( + JSON.stringify(result.error), + /chunk|HPE_INVALID_CHUNK_SIZE|Invalid character in chunk size/i, + ); +}); + +test("HTTP 200 with an HTML interception page records the original JSON parse failure", async () => { + const result = await probeMetadata( + "https://api.chatcut.io/metadata", + "fixture", + async () => new Response("fixture private contents"), + ); + assert.equal(result.status, 200); + assert.equal(result.bodyComplete, true); + assert.equal(result.jsonValid, false); + assert.equal(result.stage, "json_parse"); + assert.equal(result.outcome, "failed"); + assert.equal(result.body, undefined); + assert.equal(result.error.name, "SyntaxError"); +}); + +test("parseable JSON with missing metadata fields is not reported as valid metadata", async () => { + const result = await probeMetadata( + "https://api.chatcut.io/.well-known/oauth-authorization-server", + "fixture", + async () => new Response("{}"), + ); + assert.equal(result.jsonValid, true); + assert.equal(result.outcome, "invalid_metadata_shape"); + assert.equal(result.metadataShape.issuerPresent, false); +}); + +test("original failure survives later healthy probes and an unavailable log collector", async (t) => { + const dir = await temporary(t); + const original = join(dir, "failure.txt"); + await writeFile(original, "OAuth metadata failed\nCaused by: socket ECONNRESET"); + const { report, path } = await runDoctor( + { + host: "codex", + serverUrl: "https://api.chatcut.io/api/external-mcp/mcp", + originalError: original, + output: join(dir, "report.json"), + }, + { + collectLogs: async () => { + throw Object.assign(new Error("read denied"), { code: "EACCES" }); + }, + fetchImpl: metadataResponse, + }, + ); + assert.match(report.originalFailure.message, /socket ECONNRESET/); + assert.equal(report.originalFailure.occurredAt, null); + assert.equal(report.probes.length, 3); + assert.ok( + report.probes.every( + (p) => p.outcome === "metadata_read" && p.originalAttemptMatch === "unconfirmed", + ), + ); + assert.match(report.outcome, /recovery_unverified/); + assert.equal(report.hostLogs.gaps[0].error.code, "EACCES"); + assert.equal( + JSON.parse(await readFile(path)).originalFailure.message, + report.originalFailure.message, + ); +}); + +test("upload rejection retains the prewritten local report and never replaces the original error", async (t) => { + const dir = await temporary(t); + const output = join(dir, "report.json"); + const original = join(dir, "failure.txt"); + await writeFile(original, "Original: auth failed"); + let posts = 0; + const { report } = await runDoctor( + { + host: "claude", + serverUrl: "https://api.chatcut.dev/api/external-mcp/mcp", + originalError: original, + output, + upload: true, + }, + { + collectLogs: noLogs, + fetchImpl: async (url, init) => { + if (url.endsWith("/telemetry/events")) { + posts++; + assert.equal(url, "https://api.chatcut.dev/telemetry/events"); + const local = JSON.parse(await readFile(output)); + assert.match(local.originalFailure.message, /Original: auth failed/); + return new Response("capacity reached", { status: 429 }); + } + return new Response("{}"); + }, + }, + ); + assert.equal(posts, 1); + assert.equal(report.upload.status, "failed"); + assert.match(report.upload.error.message, /429.*capacity reached/); + assert.match(JSON.parse(await readFile(output)).originalFailure.message, /Original: auth failed/); +}); + +test("accepted batch has reconstructable UTF-8 fragments and does not claim final-store delivery", async (t) => { + const dir = await temporary(t); + let uploaded; + const { report } = await runDoctor( + { + host: "codex", + serverUrl: "https://api.chatcut.io/api/external-mcp/mcp", + output: join(dir, "report.json"), + upload: true, + }, + { + collectLogs: async () => ({ entries: [{ message: "原始故障🎬".repeat(1600) }], gaps: [] }), + fetchImpl: async (url, init) => { + if (url.endsWith("/telemetry/events")) { + assert.equal(init.headers.authorization, undefined); + uploaded = JSON.parse(init.body); + return new Response('{"ok":true}'); + } + return new Response("{}"); + }, + }, + ); + assert.equal(report.upload.status, "ingest_accepted"); + assert.equal(report.upload.storageVerified, false); + const json = uploaded + .map((event, index) => { + const payload = event.properties.attributes.mcpDoctor; + assert.equal(payload.fragmentIndex, index); + assert.equal(payload.fragmentCount, uploaded.length); + assert.ok(Buffer.byteLength(payload.fragment) <= 4096); + assert.equal(event.properties.clientEventName, null); + assert.equal(event.properties.eventName, "mcp.doctor.report_fragment"); + return payload.fragment; + }) + .join(""); + assert.equal(createHash("sha256").update(json).digest("hex"), report.upload.reportSha256); + assert.equal(JSON.parse(json).hostLogs.entries[0].message, "原始故障🎬".repeat(1600)); +}); + +test("oversized diagnostics are retained locally instead of silently truncated for upload", async (t) => { + const dir = await temporary(t); + let posts = 0; + const { report, path } = await runDoctor( + { + host: "codex", + serverUrl: "https://api.chatcut.io/api/external-mcp/mcp", + output: join(dir, "report.json"), + upload: true, + }, + { + collectLogs: async () => ({ entries: [{ message: "x".repeat(280_000) }], gaps: [] }), + fetchImpl: async (url) => { + if (url.endsWith("/telemetry/events")) posts++; + return new Response("{}"); + }, + }, + ); + assert.equal(posts, 0); + assert.equal(report.upload.status, "failed"); + assert.match(report.upload.error.message, /upload budget/); + assert.equal(JSON.parse(await readFile(path)).hostLogs.entries[0].message.length, 280_000); +}); + +test("credentials in original errors are removed while error codes and useful detail remain", () => { + const text = + 'ECONNRESET Bearer fixture-secret https://api.chatcut.io/auth?code=secret-code&state=secret-state\nCookie: sid=secret-cookie\n{"access_token":"secret-json"}'; + const output = redact(text); + assert.match(output, /ECONNRESET/); + for (const value of [ + "fixture-secret", + "secret-code", + "secret-state", + "secret-cookie", + "secret-json", + ]) + assert.ok(!output.includes(value)); + assert.equal(redact("Bearer token required"), "Bearer token required"); + assert.equal( + redact("chatcut_managed_startup_error ECONNRESET"), + "chatcut_managed_startup_error ECONNRESET", + ); + for (const field of ["authorization", "accessToken", "refresh_token", "clientSecret", "apiKey"]) { + assert.ok(!redact(JSON.stringify({ [field]: "private-value" })).includes("private-value")); + } +}); + +test("a credential file cannot be used as the original-error input", async (t) => { + const dir = await temporary(t); + const credential = join(dir, "auth.json"); + await writeFile(credential, '{"private":"fixture-credential-value"}'); + const { report } = await runDoctor( + { + host: "codex", + serverUrl: "https://api.chatcut.io/api/external-mcp/mcp", + originalError: credential, + output: join(dir, "report.json"), + }, + { collectLogs: noLogs, fetchImpl: async () => new Response("{}") }, + ); + assert.equal(report.originalFailure.status, "unavailable"); + assert.ok(!JSON.stringify(report).includes("fixture-credential-value")); + assert.match(report.gaps[0].error.message, /dedicated/); +}); + +for (const [host, folder, manifest] of [ + ["codex", "codex/skills/chatcut-plugin-basics", "codex/.codex-plugin/plugin.json"], + ["claude", "claude/skills/chatcut-plugin-basics-claude", "claude/.claude-plugin/plugin.json"], +]) { + test(`${host} packaged CLI writes a complete local report and receives a real loopback ingest acknowledgement`, async (t) => { + const dir = await temporary(t); + const original = join(dir, "original.txt"); + const output = join(dir, "report.json"); + await writeFile(original, "CONTROLLED FIXTURE: original metadata connection failed"); + let posts = 0; + 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"); + posts++; + res.end('{"ok":true}'); + } else if (req.url === "/api/external-mcp/mcp") { + res.writeHead(401, { + "www-authenticate": + 'Bearer resource_metadata="' + + origin + + '/.well-known/oauth-protected-resource/api/external-mcp/mcp"', + }); + res.end('{"jsonrpc":"2.0","error":{"code":-32000,"message":"Authentication required"}}'); + } else + res.end( + JSON.stringify({ + issuer: origin, + resource: origin + "/api/external-mcp/mcp", + authorization_servers: [origin], + authorization_endpoint: origin + "/auth/mcp/authorize", + token_endpoint: origin + "/auth/mcp/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + ); + }); + const origin = await listen(t, server); + const helper = fileURLToPath(new URL(`../${folder}/scripts/mcp-doctor.mjs`, import.meta.url)); + const { stdout } = await promisify(execFile)( + process.execPath, + [ + helper, + "--host", + host, + "--server-url", + `${origin}/api/external-mcp/mcp`, + "--host-version", + "fixture-1.0", + "--original-error", + original, + "--output", + output, + "--upload", + ], + { + windowsHide: true, + timeout: 10_000, + env: { ...process.env, CODEX_HOME: dir, CLAUDE_CONFIG_DIR: dir }, + }, + ); + const receipt = JSON.parse(stdout); + assert.equal(receipt.upload.status, "ingest_accepted"); + assert.equal(receipt.upload.storageVerified, false); + const report = JSON.parse(await readFile(output)); + assert.equal(report.host, host); + assert.equal(report.hostVersion, "fixture-1.0"); + assert.equal( + report.pluginVersion, + JSON.parse(await readFile(new URL(`../${manifest}`, import.meta.url))).version, + ); + assert.equal(posts, 1); + assert.equal(report.probes.length, 3); + assert.match(report.originalFailure.message, /CONTROLLED FIXTURE/); + assert.equal(receipt.recoveryVerified, false); + }); +} + +test("text extraction includes only hosted ChatCut MCP records and their cause continuation", async (t) => { + const dir = await temporary(t); + await mkdir(join(dir, ".claude", "debug"), { recursive: true }); + await writeFile( + join(dir, ".claude", "debug", "fixture.txt"), + [ + 'MCP server "other-service" connection failed with unrelated private data', + 'MCP server "chatcut_desktop" native failure', + 'MCP server "plugin:chatcut:chatcut" connection failed: ETIMEDOUT', + 'MCP server "chatcut" connection failed: ECONNRESET', + " Caused by: socket reset", + "user prompt: unrelated conversation", + 'MCP server "chatcut" Tool result: private project data', + ].join("\n"), + ); + const result = await collectHostLogs("claude", { + home: dir, + claudeConfigDir: join(dir, ".claude"), + }); + assert.ok(result.entries.some((e) => /ECONNRESET.*\n.*socket reset/.test(e.message))); + assert.ok(result.entries.some((e) => /ETIMEDOUT/.test(e.message))); + assert.ok(!JSON.stringify(result).includes("unrelated private data")); + assert.ok(!JSON.stringify(result).includes("unrelated conversation")); + assert.ok(!JSON.stringify(result).includes("native failure")); + assert.ok(!JSON.stringify(result).includes("private project data")); +}); + +test("SQLite extraction is read-only, scoped to runtime modules, and excludes native MCP and tool-call echoes", async (t) => { + const { DatabaseSync } = await import("node:sqlite"); + const dir = await temporary(t); + 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 add = db.prepare("INSERT INTO logs VALUES(?,?,?,?,?)"); + const ts = Math.floor(Date.now() / 1000); + add.run( + 1, + ts, + "WARN", + "codex_mcp::connection_manager", + "start{server_name=chatcut}: failed metadata request: ECONNRESET", + ); + add.run(2, ts, "WARN", "codex_core::tools", "tool output server_name=chatcut quoted old error"); + add.run( + 3, + ts, + "WARN", + "codex_mcp::connection_manager", + "start{server_name=chatcut_desktop}: native error", + ); + db.close(); + const result = await collectHostLogs("codex", { codexHome: dir }); + assert.equal(result.entries.length, 1); + assert.match(result.entries[0].message, /ECONNRESET/); + assert.equal(result.entries[0].rowId, 1); + assert.equal(result.entries[0].originalAttemptMatch, "unconfirmed"); +}); + +test("endpoint validation never silently retargets an unsupported environment or follows embedded credentials", () => { + assert.equal( + serverUrl("https://api.chatcut.dev/api/external-mcp/mcp").hostname, + "api.chatcut.dev", + ); + assert.throws(() => serverUrl("https://attacker.invalid/api/external-mcp/mcp"), /Unsupported/); + assert.throws( + () => serverUrl("https://user:secret@api.chatcut.io/api/external-mcp/mcp"), + /Unsupported/, + ); + assert.throws( + () => serverUrl("https://api.chatcut.io/api/external-mcp/mcp?token=secret"), + /Unsupported/, + ); +}); + +test("self-contained host packages ship identical helpers and diagnostic references", async () => { + for (const file of [ + "scripts/mcp-doctor.mjs", + "scripts/mcp-evidence.mjs", + "references/connection-diagnostics.md", + ]) { + assert.equal( + await readFile( + new URL(`../codex/skills/chatcut-plugin-basics/${file}`, import.meta.url), + "utf8", + ), + await readFile( + new URL(`../claude/skills/chatcut-plugin-basics-claude/${file}`, import.meta.url), + "utf8", + ), + ); + } +}); diff --git a/tests/mcp-evidence.test.mjs b/tests/mcp-evidence.test.mjs new file mode 100644 index 0000000..e33da0c --- /dev/null +++ b/tests/mcp-evidence.test.mjs @@ -0,0 +1,410 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import { createServer } from "node:net"; +import { once } from "node:events"; +import { + collectHostLogs, + inspectTransport, + probeEntry, + probeMetadata, + runDoctor, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; +import { + inspectMetadata, + validateSnapshot, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs"; + +const endpoint = new URL("https://api.chatcut.io/api/external-mcp/mcp"); +const now = "2026-10-22T12:00:00.000Z"; +const metadata = (url) => + new Response( + JSON.stringify( + url.includes("oauth-protected-resource") + ? { + resource: endpoint.href, + authorization_servers: [endpoint.origin], + } + : { + issuer: endpoint.origin, + authorization_endpoint: endpoint.origin + "/auth/mcp/authorize", + token_endpoint: endpoint.origin + "/auth/mcp/token", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }, + ), + ); +const challenge = () => + new Response('{"error":{"code":-32000,"message":"Authentication required"}}', { + status: 401, + headers: { + "www-authenticate": + 'Bearer resource_metadata="' + + endpoint.origin + + '/.well-known/oauth-protected-resource/api/external-mcp/mcp"', + "x-request-id": "fixture-server-id", + }, + }); +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-mcp-evidence-")); + if (!dir.startsWith(join(tmpdir(), "chatcut-mcp-evidence-"))) + throw new Error("Unexpected fixture path"); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} +const dependencies = { + collectLogs: async () => ({ entries: [], gaps: [] }), + inspectTransport: async () => ({ status: "fixture_no_network" }), + fetchImpl: async (url) => (url === endpoint.href ? challenge() : metadata(url)), +}; + +test("issuer-only and wrong-resource documents expose the missing protocol evidence", async () => { + const auth = await probeMetadata( + endpoint.origin + "/.well-known/oauth-authorization-server", + "fixture", + async () => new Response('{"issuer":"https://api.chatcut.io"}'), + ); + assert.equal(auth.outcome, "invalid_metadata"); + for (const field of [ + "authorization_endpoint", + "token_endpoint", + "response_types_supported", + "code_challenge_methods_supported", + ]) + assert.ok(auth.metadataValidation.issues.some((item) => item.field === field)); + const resource = inspectMetadata( + { resource: "https://wrong.example/mcp", authorization_servers: [endpoint.origin] }, + endpoint.origin + "/.well-known/oauth-protected-resource/api/external-mcp/mcp", + ); + assert.equal(resource.valid, false); + assert.ok(resource.issues.some((item) => item.reason === "resource_mismatch")); +}); + +test("unauthenticated entry checks keep normal challenges separate from protocol errors and rate limits", async () => { + const result = await probeEntry(endpoint, "fixture", async (_, init) => { + assert.equal(init.headers.authorization, undefined); + assert.equal(JSON.parse(init.body).method, "initialize"); + return challenge(); + }); + assert.equal(result.outcome, "authentication_challenge"); + assert.equal(result.responseHeaders["x-request-id"], "fixture-server-id"); + const logical = await probeEntry( + endpoint, + "fixture", + async () => + new Response( + '{"jsonrpc":"2.0","error":{"code":-32999,"message":"Previously unseen negotiation failure","data":{"access_token":"private-token"}}}', + ), + ); + assert.equal(logical.status, 200); + assert.equal(logical.outcome, "protocol_error"); + assert.equal(logical.protocolError.code, -32999); + assert.match(logical.protocolError.message, /Previously unseen/); + assert.ok(!JSON.stringify(logical).includes("private-token")); + const limited = await probeEntry( + endpoint, + "fixture", + async () => + new Response("Unfamiliar capacity response", { + status: 429, + headers: { "retry-after": "9" }, + }), + ); + assert.equal(limited.outcome, "http_rejected"); + assert.equal(limited.responseHeaders["retry-after"], "9"); + assert.match(limited.responseError.message, /Unfamiliar/); +}); + +test("current text-log window excludes old errors and retains unclassified diagnostics with unknown times", async (t) => { + const dir = await fixture(t); + await mkdir(join(dir, "log")); + await writeFile( + join(dir, "log", "codex-tui.log"), + [ + '2026-10-01T00:00:00Z WARN MCP server "chatcut" old fixture failure', + '2026-10-22T11:55:00Z ERROR MCP server "chatcut" FLUXION_472: unknown schema negotiation', + 'MCP server "chatcut" completely unfamiliar provider message', + '2026-10-22T11:59:00Z ERROR MCP server "chatcut_desktop" native fixture', + ].join("\n"), + ); + const result = await collectHostLogs("codex", { codexHome: dir, now: Date.parse(now) }); + assert.equal(result.entries.length, 2); + assert.ok(!JSON.stringify(result).includes("old fixture")); + assert.match(result.entries[0].message, /FLUXION_472/); + assert.equal(result.entries[1].occurredAt, null); + assert.ok(result.gaps.some((gap) => gap.reason === "records_with_unknown_time")); +}); + +test("a host snapshot preserves current-session tool absence without treating healthy probes as recovery", async (t) => { + const dir = await fixture(t); + const snapshotPath = join(dir, "current.mcp-snapshot.json"); + await writeFile( + snapshotPath, + JSON.stringify({ + schemaVersion: 1, + observedAt: now, + hostVersion: "fixture-host", + configuration: { + source: "host_settings", + serverName: "chatcut", + url: endpoint.href, + enabled: true, + }, + session: { + source: "current_session", + conversationId: "fixture-chat", + connectionStatus: "failed", + authStatus: "authenticated", + visibleToolCount: 0, + visibleTools: [], + }, + observations: [ + { + stage: "initialize", + operation: "initialize", + source: "runtime_log", + observedAt: now, + outcome: "failed", + requestId: "original-request-if-present", + message: "Original initialize authentication failure", + }, + { + stage: "future_negotiation", + source: "runtime_log", + observedAt: now, + outcome: "failed", + protocolCode: -32999, + message: "Previously unseen failure", + }, + ], + }), + ); + const { report } = await runDoctor( + { + host: "codex", + serverUrl: endpoint.href, + hostSnapshot: snapshotPath, + output: join(dir, "report.json"), + }, + dependencies, + ); + assert.equal(report.hostSnapshot.session.visibleToolCount, 0); + assert.equal(report.hostVersion, "fixture-host"); + assert.equal(report.entryProbe.outcome, "authentication_challenge"); + assert.equal(report.evidenceSummary.recoveryVerified, false); + assert.equal(report.evidenceSummary.otherObservations[0].protocolCode, -32999); + assert.equal( + report.evidenceSummary.stages.find((item) => item.stage === "tools_list").status, + "not_observed", + ); + assert.match( + report.evidenceSummary.stages.find((item) => item.stage === "initialize").observations[0] + .message, + /Original/, + ); +}); + +test("snapshot rejects credentials, wrong endpoints and static-configuration claims about current tools", () => { + const base = { schemaVersion: 1, observedAt: now }; + assert.throws( + () => validateSnapshot({ ...base, access_token: "secret" }, endpoint), + /unsupported fields/, + ); + assert.throws( + () => + validateSnapshot( + { + ...base, + configuration: { + source: "host_settings", + serverName: "chatcut", + url: "https://api.chatcut.dev/api/external-mcp/mcp", + enabled: true, + }, + }, + endpoint, + ), + /differs/, + ); + assert.throws( + () => + validateSnapshot( + { + ...base, + session: { + source: "cli_configuration", + connectionStatus: "connected", + authStatus: "authenticated", + visibleToolCount: 10, + }, + }, + endpoint, + ), + /Static CLI/, + ); +}); + +test("disabled hosted connections produce a local report without any probe or upload", async (t) => { + const dir = await fixture(t); + const snapshotPath = join(dir, "disabled.mcp-snapshot.json"); + await writeFile( + snapshotPath, + JSON.stringify({ + schemaVersion: 1, + observedAt: now, + configuration: { + source: "host_settings", + serverName: "chatcut", + url: endpoint.href, + enabled: false, + }, + }), + ); + const { report, path } = await runDoctor( + { + host: "codex", + serverUrl: endpoint.href, + hostSnapshot: snapshotPath, + upload: true, + output: join(dir, "report.json"), + }, + { + ...dependencies, + fetchImpl: () => { + throw new Error("No network request should run"); + }, + }, + ); + assert.equal(report.probes.length, 0); + assert.equal(report.upload.status, "skipped_intentionally_disabled"); + assert.ok(JSON.parse(await readFile(path)).gaps.some((gap) => gap.source === "probes")); +}); + +test("untrusted challenge metadata cannot redirect diagnosis to a different origin", async (t) => { + const dir = await fixture(t); + const requested = []; + const { report } = await runDoctor( + { + host: "claude", + serverUrl: endpoint.href, + output: join(dir, "report.json"), + }, + { + ...dependencies, + fetchImpl: async (url) => { + requested.push(url); + return url === endpoint.href + ? new Response("{}", { + status: 401, + headers: { + "www-authenticate": + 'Bearer resource_metadata="https://untrusted.example/.well-known/oauth-protected-resource"', + }, + }) + : metadata(url); + }, + }, + ); + assert.ok(requested.every((url) => new URL(url).origin === endpoint.origin)); + assert.ok(report.gaps.some((gap) => gap.source === "challenge_metadata_target")); + assert.ok(report.evidenceSummary.gaps.includes("current_host_snapshot_unavailable")); +}); + +test("an unavailable registered URL still permits a local evidence report with no silent production fallback", async (t) => { + const dir = await fixture(t); + const { report, path } = await runDoctor( + { + host: "codex", + output: join(dir, "report.json"), + upload: true, + }, + { + ...dependencies, + fetchImpl: () => { + throw new Error("No endpoint was selected"); + }, + }, + ); + assert.equal(report.serverUrl, null); + assert.equal(report.upload.status, "skipped_endpoint_unavailable"); + assert.equal(report.probes.length, 0); + assert.ok( + JSON.parse(await readFile(path)).gaps.some((gap) => + /registered_endpoint_unavailable/.test(gap.reason ?? ""), + ), + ); +}); + +test("missing path metadata checks the root fallback without turning later success into original recovery", async (t) => { + const dir = await fixture(t); + const { report } = await runDoctor( + { + host: "codex", + serverUrl: endpoint.href, + output: join(dir, "report.json"), + }, + { + ...dependencies, + fetchImpl: async (url) => { + if (url === endpoint.href) + return new Response("{}", { + status: 401, + headers: { + "www-authenticate": + "Bearer resource_metadata=" + + endpoint.origin + + "/.well-known/oauth-protected-resource", + }, + }); + if (url.endsWith("/oauth-protected-resource/api/external-mcp/mcp")) + return new Response("{}", { status: 404 }); + return metadata(url); + }, + }, + ); + assert.ok( + report.probes.some( + (probe) => + probe.url.endsWith("/oauth-protected-resource") && probe.outcome === "metadata_read", + ), + ); + assert.equal(report.evidenceSummary.recoveryVerified, false); +}); + +test("direct TLS context has a bounded timeout and states that it differs from the host route", async (t) => { + const server = createServer((socket) => { + socket.on("error", () => {}); + socket.resume(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => server.close(resolve))); + const result = await inspectTransport(new URL("https://127.0.0.1:" + server.address().port)); + assert.equal(result.status, "failed"); + assert.equal(result.error.code, "ETIMEDOUT"); + assert.match(result.route, /does_not_reproduce_host/); +}); + +test("an unexpected probe collector failure cannot erase the original incident or prevent a local report", async (t) => { + const dir = await fixture(t); + const original = join(dir, "original.txt"); + await writeFile(original, "Original unfamiliar MCP failure E_ORIGINAL_472"); + const deepDocument = '{"resource":' + "[".repeat(9000) + "0" + "]".repeat(9000) + "}"; + const { report, path } = await runDoctor( + { + host: "codex", + serverUrl: endpoint.href, + originalError: original, + output: join(dir, "report.json"), + }, + { + ...dependencies, + fetchImpl: async (url) => (url === endpoint.href ? challenge() : new Response(deepDocument)), + }, + ); + assert.match(report.originalFailure.message, /E_ORIGINAL_472/); + assert.ok(report.probes.some((probe) => probe.stage === "probe_collector")); + assert.match(JSON.parse(await readFile(path)).originalFailure.message, /E_ORIGINAL_472/); +});