From d77ff3c7137be31be1a62d123c0a0b4442c8ce6e Mon Sep 17 00:00:00 2001 From: IanYet Date: Sat, 24 Oct 2026 21:12:10 +0800 Subject: [PATCH 1/3] fix: release bounded MCP collection improvements without Doctor --- claude/.claude-plugin/plugin.json | 4 +- .../chatcut-plugin-basics-claude/SKILL.md | 2 + .../references/connection-diagnostics.md | 35 +++ .../scripts/mcp-doctor.mjs | 207 ++++++++++++++++-- .../scripts/mcp-evidence.mjs | 20 ++ codex/.codex-plugin/plugin.json | 2 +- codex/.mcp.json | 2 +- codex/skills/chatcut-plugin-basics/SKILL.md | 2 + .../references/connection-diagnostics.md | 35 +++ .../scripts/mcp-doctor.mjs | 207 ++++++++++++++++-- .../scripts/mcp-evidence.mjs | 20 ++ docs/mcp-doctor.md | 12 + tests/collection-quality.test.mjs | 109 +++++++++ tests/fixtures/owned-codex-mcp-tool-error.log | 1 + tests/mcp-doctor.test.mjs | 14 +- tests/native-failure-index.test.mjs | 146 ++++++++++++ tests/real-native-log.test.mjs | 56 +++++ tests/windows-db-fallback.test.mjs | 86 ++++++++ 18 files changed, 909 insertions(+), 51 deletions(-) create mode 100644 tests/collection-quality.test.mjs create mode 100644 tests/fixtures/owned-codex-mcp-tool-error.log create mode 100644 tests/native-failure-index.test.mjs create mode 100644 tests/real-native-log.test.mjs create mode 100644 tests/windows-db-fallback.test.mjs diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index 395d78e..5a33e65 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "chatcut", "displayName": "ChatCut", - "version": "1.10.23", + "version": "1.10.25", "description": "ChatCut plugin for editable video editing and creation workflows from Claude Code.", "author": { "name": "ChatCut", @@ -37,7 +37,7 @@ "headers": { "x-chatcut-mcp-client": "claude_code", "x-chatcut-mcp-surface": "embedded-preview", - "x-chatcut-plugin-version": "1.10.23" + "x-chatcut-plugin-version": "1.10.25" } } }, diff --git a/claude/skills/chatcut-plugin-basics-claude/SKILL.md b/claude/skills/chatcut-plugin-basics-claude/SKILL.md index 89fa6ff..a8b6563 100644 --- a/claude/skills/chatcut-plugin-basics-claude/SKILL.md +++ b/claude/skills/chatcut-plugin-basics-claude/SKILL.md @@ -32,6 +32,8 @@ Also use connection diagnostics when previously available tools disappear or the For a failed hosted ChatCut tool request after connection, immediately preserve the original tool error and any available failure time, conversation ID and local call ID in the dedicated failure record described there. The Agent gathers these facts; do not ask the user to manually reconstruct them. Startup-status notices and later successful checks do not establish what happened to that tool request. +Save the visible error before reading runtime databases, including unfamiliar error messages and cause. A failed Windows database read does not invalidate that error or prevent local collection with `--offline`. Keep independent bounded App evidence separate and check `evidenceSummary.hostLogCoverage` for retained and missing sources. Do not require prior stderr setup, database export or permission changes. A startup error hidden from the Agent still needs an accessible source. + 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 index c133f82..fe82f27 100644 --- a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md +++ b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md @@ -106,6 +106,15 @@ Only include ChatCut tool names; keep tool arguments and results out. ### Preserve the failed call before independent checks +Database reads are supplementary. Save the currently visible error first, then +continue local collection with `--offline` even when SQLite is unavailable. +This option sends no probes or upload, including when `--upload` is also given. +`evidenceSummary.hostLogCoverage` distinguishes the retained original error, +database failures and remaining sources. Existing App logs remain independent. +An already visible tool error needs no prior stderr setup; an unexposed startup +error still needs an accessible source. Never copy a live database, parse its +WAL separately, ignore locks or widen permissions to make collection pass. + When the host exposes the original failed tool result or runtime event, the Agent copies only its error and available identifiers into a new dedicated `.mcp-failure.json`. Do this before retries, reconnects or later probes. @@ -147,6 +156,24 @@ Do not start another authenticated session to manufacture replacement evidence. If only the original error text is accessible, use the text-file fallback below. Do not call a report with only startup-status notices a captured tool failure. +Save actual error fields rather than reconstructing a raw tool-result envelope. +The collector records a valid Codex `CODEX_THREAD_ID` in `collectionContext` as +the current collecting thread. It does not fill the original failure's thread +automatically. When the failure just happened in this same chat, that known +thread may be supplied in its failure record with the source explained. Never +use the diagnostic command's `CODEX_TOOL_CALL_ID` as the failed MCP call's ID. + +With a known original/session thread, `hostLogs.nativeFailedCalls` selects at +most ten failed hosted ChatCut MCP items in the bounded window from the native +`thread_history_1.sqlite` index. Only IDs, lifecycle times and error fields are +projected; whole history items, conversations, arguments and successful results +are not collected. An error above 128 KiB is explicitly omitted; the complete +local report is never trimmed to fit the 256 KiB upload limit. `nativeItemId` +is a native MCP item identity, distinct from Responses call IDs, RPC IDs and +server request IDs. `startedAtMs`/`completedAtMs` are lifecycle times, not the +original `occurredAt`. Matches remain unconfirmed; compare known thread, tool +and exact error without promoting nearby times to proof of association. + 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 @@ -184,10 +211,18 @@ Codex SQLite locations follow `CODEX_SQLITE_HOME` when present. If the current host already supplies its actual `sqlite_home` or `log_dir`, pass that directory with `--codex-sqlite-home` or `--codex-log-dir`; do not read whole config files to find it. These options select existing log locations and do not enable logging. +If the caller already retained a dedicated native stderr stream, pass its +existing `.codex-stderr.log` with `--codex-stderr-file`. The helper reads only +its bounded ChatCut log tail, marks emission time as `loggedAt`, and does not +fill original failure time or association. This source is optional; it does +not capture a stream or recover discarded stderr. On Windows, SQLite error 14 can coexist with readable WAL files in a read-only sandbox. The report preserves the original error and bounded file-access facts; it does not prove a specific security product caused the restriction. Never copy the whole log database, ignore its WAL with immutable mode, or relax permissions. +Both SQLite readers preserve the failing operation and error, then continue +other sources; a close failure does not replace the primary error. Missing +native IDs and lifecycle times remain missing when the database is unreadable. On Windows, the helper also reads the existing Codex App startup-status notices under `%LOCALAPPDATA%/Codex/Logs/YYYY/MM/DD`. This is independent of TUI logging. It selects only `AppServerConnection` startup notices for the hosted ChatCut diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs index 513ba4a..bf76c45 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -24,8 +24,33 @@ function redactFieldValue(_match, prefix, value) { return prefix + (quote ? quote + "[REDACTED]" + quote : "[REDACTED]"); } -export function redact(text) { - return String(text) +export function redact(text, jsonDepth = 0) { + let value = String(text); + // A host may quote/stringify an entire JSON response inside its error. Decode + // only JSON string tokens containing this credential label, redact their full + // value, then restore the quoting. A partial escaped-field regex can leak the + // suffix after an escaped quote/backslash. Input collection is size bounded. + if (jsonDepth < 16) value = value.replace(/"(?:\\.|[^"\\\r\n])*"/g, (token) => { + try { + const decoded = JSON.parse(token); + if (/mcp[-_]session[-_]id/i.test(decoded)) { + const sanitized = redact(decoded, jsonDepth + 1); + if (sanitized !== decoded) return JSON.stringify(sanitized); + } + } catch { /* Non-JSON diagnostic text is retained for the normal rules. */ } + return token; + }); + return value + .replace(/\b(Mcp-Session-Id[ \t]*:[ \t]*)\S+/gi, "$1[REDACTED]") + .replace( + /(["']mcp[-_]session[-_]id["']\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*')/gi, + redactFieldValue, + ) + .replace( + /\b((?:mcp[-_]session[-_]id)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^\s"'<>]+)/gi, + redactFieldValue, + ) + .replace(/((?:\\*["'])mcp[-_]session[-_]id(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, "$1[REDACTED]") .replace( /\bBearer\s+(?!(?:token|required)\b|<|(?:realm|scope|error(?:_description|_uri)?|resource_metadata)\s*=)[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]", @@ -55,7 +80,7 @@ export function redact(text) { 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( + /^(?:mcp[-_]session[-_]id|access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( key, ) ? "[REDACTED]" @@ -253,7 +278,7 @@ export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { params: { protocolVersion: "2025-11-25", capabilities: {}, - clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.3" }, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.4" }, }, }), }); @@ -390,8 +415,11 @@ function runtimeContext(body) { return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; } -function hostedTextRecord(line) { +function hostedTextRecord(line, nativeLevel) { if (/^\s/.test(line)) return false; + // Inspect explicit echo prefixes before stripping trace context: a payload + // itself can contain "}:" and must not become a diagnostic via lastIndex. + if (/(?:^|\}:\s*)(?:tool (?:call|result)\s*:|(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[])/i.test(line)) return false; let body = line.replace( /^\d{4}-\d{2}-\d{2}T\S+\s+(?:(?:\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\]|TRACE|DEBUG|INFO|WARN|ERROR)\s+)?/i, "", @@ -399,7 +427,7 @@ function hostedTextRecord(line) { const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); if (module) { if ( - !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::)/.test(module[1]) + !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::|codex_core::mcp_tool_call$)/.test(module[1]) ) return false; body = body.slice(module[0].length); @@ -417,9 +445,23 @@ function hostedTextRecord(line) { const server = context.match(/\bserver_name=["']?([\w:.-]+)/)?.[1] ?? scope?.[1] ?? scope?.[2] ?? scope?.[3]; if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; - if (!CHATCUT_MESSAGE.test(body)) return false; - if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) return false; - const message = scope ? body.slice(scope[0].length) : body; + const header = line.match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i); + const level = String(nativeLevel ?? header?.[1] ?? header?.[2]).toUpperCase(); + const nativeToolError = module?.[1] === "codex_core::mcp_tool_call" && + ["ERROR", "WARN"].includes(level) && + /^MCP tool call error:\s*["']?tool call error: tool call failed for `(?:chatcut|plugin:chatcut:chatcut)\/[A-Za-z0-9_.:-]+`/.test(body); + if (module?.[1] === "codex_core::mcp_tool_call" && !nativeToolError) return false; + if (!CHATCUT_MESSAGE.test(body) && !nativeToolError) return false; + if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) { + // A trusted native ERROR/WARN can describe a failed tools/call without + // echoing its content. SQLite supplies its separate native level column. + if (!module || !["ERROR", "WARN"].includes(level)) return false; + // Inspect the unstripped body: a payload can itself contain "}:". + const contentCheck = body.replace(/\\+(?=["'])/g, ""); + if (/["'](?:params|arguments)["']\s*:\s*[{[]|["'](?:prompt|transcript)["']\s*:\s*["'{[]|["']result["']\s*:\s*\{[\s\S]*?["']content["']\s*:\s*\[|["']role["']\s*:\s*["'](?:user|assistant|system|developer)["']|["']type["']\s*:\s*["'](?:function_call(?:_output)?|tool_call(?:_output)?|tool_result|user(?:_message)?|assistant(?:_message)?)["']/i.test(contentCheck)) return false; + if (/(?:^|\}:\s*)(?:prompt|transcript)\s*:/i.test(body)) return false; + } + const message = scope ? body.slice(scope[0].length) : logMessage(body); // Runtime error contents can name tools; only explicit echo records are excluded. if (TEXT_ECHO.test(message)) return false; if ( @@ -507,7 +549,11 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { !TEXT_ECHO.test(lines[i + 1].trimStart()) ) block.push(lines[++i]); + if (block.length > 1 && !select(block.join("\n"))) continue; const occurredAt = logTime(block[0]); + const context = runtimeContext(block[0]); + if (window.conversationId && context.conversationId && context.conversationId !== window.conversationId) + continue; if ( occurredAt && (Date.parse(occurredAt) < window.since * 1000 || @@ -520,7 +566,7 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { occurredAt, timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", message: redact(block.join("\n")), - ...runtimeContext(block[0]), + ...context, originalAttemptMatch: "unconfirmed", }); } @@ -679,11 +725,80 @@ async function inspectSqliteAccess(path) { }; } +async function nativeFailedMcpItems(sqliteRoot, conversationId, window, gaps) { + const source = "codex_native_failed_mcp_items"; + if (!conversationId) { + gaps.push({ source, reason: "known_conversation_id_unavailable; no_history_query" }); + return []; + } + let db; + const databasePath = join(sqliteRoot, "thread_history_1.sqlite"); + let operation = "locate_database"; + try { + await stat(databasePath); + operation = "load_sqlite_module"; + const { DatabaseSync } = await import("node:sqlite"); + operation = "open_database"; + db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); + operation = "set_query_only"; + db.exec("PRAGMA query_only=ON"); + operation = "inspect_schema"; + const columns = db.prepare("PRAGMA table_info(thread_items)").all().map(column => column.name); + if (!["thread_id", "turn_id", "item_id", "item_type", "item_json", "started_at_ms", "completed_at_ms"].every(name => columns.includes(name))) { + gaps.push({ source, reason: "unsupported_schema; no_conversation_rows_read" }); + return []; + } + // Only scalar/error projections leave SQLite; never select whole item_json, + // chat roles, arguments, successful results or rollout contents. + const field = name => `json_extract(CASE WHEN json_valid(item_json) THEN item_json ELSE '{}' END,'$.${name}')`; + const error = field("error"); + operation = "prepare_failed_items_query"; + const query = db.prepare(`SELECT thread_id,turn_id,item_id,started_at_ms,completed_at_ms, + ${field("server")} AS server,${field("tool")} AS operation, + CASE WHEN length(CAST(${error} AS BLOB))<=${READ_LIMIT} THEN ${error} END AS error_json, + length(CAST(${error} AS BLOB)) AS error_bytes + FROM thread_items WHERE thread_id=? AND item_type='mcpToolCall' + AND ((started_at_ms>=? AND started_at_ms<=?) OR (completed_at_ms>=? AND completed_at_ms<=?)) + AND ${field("type")}='mcpToolCall' AND ${field("server")} IN ('chatcut','plugin:chatcut:chatcut') + AND ${field("status")}='failed' ORDER BY completed_at_ms DESC,item_id LIMIT 11`); + operation = "select_failed_mcp_items"; + const rows = query.all(conversationId, window.since * 1000, window.until * 1000, window.since * 1000, window.until * 1000); + if (rows.length > 10) gaps.push({ source, reason: "native_failure_record_limit", limit: 10 }); + if (!rows.length) gaps.push({ source, reason: "no_failed_mcp_items_in_bounded_window" }); + return rows.slice(0, 10).map(row => { + if (row.error_bytes > READ_LIMIT) gaps.push({ source, reason: "native_error_size_limit", nativeItemId: row.item_id, limitBytes: READ_LIMIT }); + let error = null; + if (row.error_json) { + try { error = JSON.parse(row.error_json); } + catch { error = { message: String(row.error_json) }; } + } + return { + source, sourceFile: "thread_history_1.sqlite", conversationId: row.thread_id, turnId: row.turn_id, + nativeItemId: row.item_id, idKind: "codex_native_mcp_item_id; not_responses_call_id_rpc_id_or_server_request_id", + serverName: row.server, operation: row.operation, status: "failed", + startedAtMs: row.started_at_ms, completedAtMs: row.completed_at_ms, + timeSource: "native_thread_items_lifecycle_columns; not_original_occurredAt_or_log_time", + error, ...(row.error_bytes > READ_LIMIT && { errorCaptureLimited: true }), originalAttemptMatch: "unconfirmed", + }; + }); + } catch (error) { + gaps.push({ source, reason: "unavailable", operation, error: errorDetails(error), + ...(platform() === "win32" && error?.errcode === 14 + ? { accessEvidence: await inspectSqliteAccess(databasePath) } : {}), + }); + return []; + } finally { + try { db?.close(); } + catch (error) { gaps.push({ source, reason: "close_failed", operation: "close_database", error: errorDetails(error) }); } + } +} + export async function collectHostLogs(host, options = {}) { const userHome = options.home ?? homedir(); const gaps = []; const entries = []; let appLog; + let nativeFailedCalls; 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); @@ -694,20 +809,29 @@ export async function collectHostLogs(host, options = {}) { const logRoot = options.codexLogDir ?? join(root, "log"); const databasePath = join(sqliteRoot, "logs_2.sqlite"); let db; + let operation = "locate_database"; try { await stat(databasePath); + operation = "load_sqlite_module"; const { DatabaseSync } = await import("node:sqlite"); + operation = "open_database"; db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); + operation = "set_query_only"; db.exec("PRAGMA query_only=ON"); + operation = "inspect_schema"; const hasThreadId = db .prepare("PRAGMA table_info(logs)") .all() .some((column) => column.name === "thread_id"); - const rows = db - .prepare( - `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, - ) - .all(since, until); + const threadFilter = hasThreadId && options.conversationId + ? " AND (thread_id IS NULL OR thread_id='' OR thread_id=?)" : ""; + operation = "prepare_runtime_logs_query"; + const query = db.prepare( + `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=?${threadFilter} AND (target LIKE 'codex_mcp%' OR target LIKE 'codex_rmcp_client%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%' OR target='codex_core::mcp_tool_call') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, + ); + operation = "select_runtime_logs"; + const rows = query.all(since, until, ...(threadFilter ? [options.conversationId] : [])); + operation = "filter_runtime_logs"; for (const row of rows) { const message = logMessage(row.feedback_log_body ?? ""); const original = row.feedback_log_body ?? ""; @@ -719,7 +843,8 @@ export async function collectHostLogs(host, options = {}) { context.conversationId !== options.conversationId ) continue; - if (!CHATCUT_MESSAGE.test(original) || /server_name="?chatcut_desktop/.test(original)) + if (!hostedTextRecord(`${row.target}: ${original}`, row.level) || + !hostedTextRecord(`MCP server "chatcut" ${message}`)) continue; if ( !["ERROR", "WARN"].includes(row.level) && @@ -744,6 +869,7 @@ export async function collectHostLogs(host, options = {}) { gaps.push({ source: "codex_runtime_sqlite", reason: "unavailable", + operation, error: errorDetails(error), locationSource: options.codexSqliteHome ? "explicit_host_location" @@ -755,18 +881,35 @@ export async function collectHostLogs(host, options = {}) { : {}), }); } finally { - db?.close(); + try { db?.close(); } + catch (error) { gaps.push({ source: "codex_runtime_sqlite", reason: "close_failed", operation: "close_database", error: errorDetails(error) }); } } + nativeFailedCalls = await nativeFailedMcpItems(sqliteRoot, options.conversationId, { since, until }, gaps); try { entries.push( ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { since, until, + conversationId: options.conversationId, })), ); } catch (error) { gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); } + if (options.codexStderrFile) { + try { + if (!basename(options.codexStderrFile).endsWith(".codex-stderr.log")) + throw new Error("Use an existing dedicated .codex-stderr.log native capture, never config/auth/session files"); + const captured = await textLog(options.codexStderrFile, "codex_stderr_log", gaps, { + since, until, conversationId: options.conversationId, + }); + entries.push(...captured.map(({ occurredAt, ...entry }) => ({ + ...entry, loggedAt: occurredAt, timestampKind: "native_log_time_not_original_failure_time", + }))); + } catch (error) { + gaps.push({ source: "codex_stderr_log", reason: "unavailable", error: errorDetails(error) }); + } + } const appData = options.localAppData ?? process.env.LOCALAPPDATA; const sandboxAppData = !options.localAppData && @@ -830,12 +973,13 @@ export async function collectHostLogs(host, options = {}) { gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); } } - if (!entries.length) + if (!entries.length && !nativeFailedCalls?.length) gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); return clean({ entries, gaps, ...(appLog ? { appLog } : {}), + ...(nativeFailedCalls ? { nativeFailedCalls } : {}), window: { since: new Date(since * 1000).toISOString(), until: new Date(until * 1000).toISOString(), @@ -899,16 +1043,24 @@ export async function runDoctor(options, dependencies = {}) { throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); } const fetchImpl = dependencies.fetchImpl ?? fetch; + const threadEnv = options.host === "codex" ? process.env.CODEX_THREAD_ID : undefined; + const collectionConversationId = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(threadEnv ?? "") + ? threadEnv : null; const diagnosticId = randomUUID(); const report = { schemaVersion: 2, - collectorVersion: "1.2.3", + collectorVersion: "1.2.4", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", host: options.host, hostVersion: options.hostVersion ?? null, hostVersionSource: options.hostVersion ? "agent_supplied" : "unavailable", + collectionContext: { + conversationId: collectionConversationId, + source: collectionConversationId ? "CODEX_THREAD_ID" : "unavailable", + association: "current_collection_thread; original_failed_attempt_unconfirmed", + }, environment: { platform: platform(), architecture: arch(), @@ -1063,10 +1215,11 @@ export async function runDoctor(options, dependencies = {}) { codexAppLogDir: options.codexAppLogDir, codexAppLogFiles: options.codexAppLogFiles, codexAppLogOffset: options.codexAppLogOffset, + codexStderrFile: options.codexStderrFile, conversationId: report.originalFailure.conversationId ?? report.hostSnapshot?.session?.conversationId ?? - process.env.CODEX_THREAD_ID, + collectionConversationId, }); } catch (error) { report.hostLogs = { @@ -1078,11 +1231,13 @@ export async function runDoctor(options, dependencies = {}) { disabledBySnapshot || report.hostSnapshot?.configuration?.enabled === false || report.hostSnapshot?.session?.connectionStatus === "disabled"; - if (disabled || !endpoint) { + if (options.offline || disabled || !endpoint) { report.gaps.push({ source: "probes", reason: disabled ? "host_reports_intentionally_disabled; no_requests_sent" + : options.offline + ? "offline_requested; no_requests_sent" : "registered_endpoint_unavailable; no_requests_sent", }); } else { @@ -1189,6 +1344,8 @@ export async function runDoctor(options, dependencies = {}) { report.upload = { status: disabled ? "skipped_intentionally_disabled" + : options.offline + ? "skipped_offline" : !endpoint ? "skipped_endpoint_unavailable" : options.upload @@ -1200,7 +1357,7 @@ export async function runDoctor(options, dependencies = {}) { const save = async (flag) => writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); await save("wx"); - if (options.upload && !disabled && endpoint) { + if (options.upload && !options.offline && !disabled && endpoint) { try { const events = fragmentEvents(report); const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { @@ -1240,11 +1397,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur "occurred-at": { type: "string" }, "codex-sqlite-home": { type: "string" }, "codex-log-dir": { type: "string" }, + "codex-stderr-file": { type: "string" }, "codex-app-log-dir": { type: "string" }, "codex-app-log-file": { type: "string", multiple: true }, "codex-app-log-offset": { type: "string" }, output: { type: "string" }, upload: { type: "boolean", default: false }, + offline: { type: "boolean", default: false }, "wire-evidence": { type: "boolean", default: true }, "create-snapshot": { type: "string" }, "validate-snapshot": { type: "string" }, @@ -1284,7 +1443,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); } else if (values.help) { console.log( - "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--upload]", + "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-stderr-file ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--offline] [--upload]", ); } else { const { path, report } = await runDoctor({ @@ -1297,6 +1456,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur occurredAt: values["occurred-at"], codexSqliteHome: values["codex-sqlite-home"], codexLogDir: values["codex-log-dir"], + codexStderrFile: values["codex-stderr-file"], codexAppLogDir: values["codex-app-log-dir"], codexAppLogFiles: values["codex-app-log-file"], codexAppLogOffset: @@ -1305,6 +1465,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur : Number(values["codex-app-log-offset"]), output: values.output, upload: values.upload, + offline: values.offline, wireEvidence: values["wire-evidence"], }); console.log( diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs index da0ed7c..b0ab995 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs @@ -240,10 +240,23 @@ export function evidenceSummary(report) { ...(entry.conversationId && { conversationId: entry.conversationId }), ...(entry.callId && { callId: entry.callId }), })); + const databaseReadFailures = (report.hostLogs.gaps ?? []).filter(gap => + ["codex_runtime_sqlite", "codex_native_failed_mcp_items"].includes(gap.source) && + ["unavailable", "close_failed"].includes(gap.reason)).map(gap => ({ + source: gap.source, operation: gap.operation ?? null, + code: gap.error?.code ?? null, errcode: gap.error?.errcode ?? null, + })); return { stages, otherObservations: unknown, logHints, + collectionContext: report.collectionContext ?? null, + nativeFailedCalls: (report.hostLogs.nativeFailedCalls ?? []).map((item, index) => ({ + evidence: `hostLogs.nativeFailedCalls[${index}]`, source: item.source, + nativeItemId: item.nativeItemId, idKind: item.idKind, conversationId: item.conversationId, + operation: item.operation, startedAtMs: item.startedAtMs, completedAtMs: item.completedAtMs, + originalAttemptMatch: "unconfirmed", + })), originalRequestCorrelation: { status: originalRequests.length ? "agent_reported_same_attempt" : "unconfirmed", observations: originalRequests, @@ -260,6 +273,13 @@ export function evidenceSummary(report) { } : null, hostLogCoverage: { + originalErrorRetained: report.originalFailure.status === "recorded", + originalErrorRetainedWithoutDatabase: report.originalFailure.status === "recorded" && databaseReadFailures.length > 0, + databaseReadFailures, + runtimeCollectionFailed: (report.hostLogs.gaps ?? []).some(gap => gap.source === "host_logs" && gap.reason === "collector_failed"), + nativeFailedMcpItems: report.hostLogs.nativeFailedCalls?.length ?? 0, + retainedStderrRecords: report.hostLogs.entries.filter(entry => entry.source === "codex_stderr_log").length, + priorStderrCaptureRequired: false, records: report.hostLogs.entries.length, appStartupRecords: appNotices.length, failedAppStartupRecords: failedAppNotices.length, diff --git a/codex/.codex-plugin/plugin.json b/codex/.codex-plugin/plugin.json index 5d170b4..bb9eda4 100644 --- a/codex/.codex-plugin/plugin.json +++ b/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "chatcut", - "version": "1.10.21", + "version": "1.10.23", "description": "ChatCut Codex plugin for editable video editing and creation workflows from Codex.", "author": { "name": "ChatCut", diff --git a/codex/.mcp.json b/codex/.mcp.json index c02fca1..1c15b6e 100644 --- a/codex/.mcp.json +++ b/codex/.mcp.json @@ -3,7 +3,7 @@ "chatcut": { "http_headers": { "x-chatcut-mcp-surface": "codex", - "x-chatcut-plugin-version": "1.10.21" + "x-chatcut-plugin-version": "1.10.23" }, "oauth_resource": "https://api.chatcut.io/api/external-mcp/mcp", "url": "https://api.chatcut.io/api/external-mcp/mcp" diff --git a/codex/skills/chatcut-plugin-basics/SKILL.md b/codex/skills/chatcut-plugin-basics/SKILL.md index e6ec7b3..5937684 100644 --- a/codex/skills/chatcut-plugin-basics/SKILL.md +++ b/codex/skills/chatcut-plugin-basics/SKILL.md @@ -32,6 +32,8 @@ Also use connection diagnostics when previously available tools disappear or the For a failed hosted ChatCut tool request after connection, immediately preserve the original tool error and any available failure time, conversation ID and local call ID in the dedicated failure record described there. The Agent gathers these facts; do not ask the user to manually reconstruct them. Startup-status notices and later successful checks do not establish what happened to that tool request. +Save the visible error before reading runtime databases, including unfamiliar error messages and cause. A failed Windows database read does not invalidate that error or prevent local collection with `--offline`. Keep independent bounded App evidence separate and check `evidenceSummary.hostLogCoverage` for retained and missing sources. Do not require prior stderr setup, database export or permission changes. A startup error hidden from the Agent still needs an accessible source. + 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 index c133f82..fe82f27 100644 --- a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md +++ b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md @@ -106,6 +106,15 @@ Only include ChatCut tool names; keep tool arguments and results out. ### Preserve the failed call before independent checks +Database reads are supplementary. Save the currently visible error first, then +continue local collection with `--offline` even when SQLite is unavailable. +This option sends no probes or upload, including when `--upload` is also given. +`evidenceSummary.hostLogCoverage` distinguishes the retained original error, +database failures and remaining sources. Existing App logs remain independent. +An already visible tool error needs no prior stderr setup; an unexposed startup +error still needs an accessible source. Never copy a live database, parse its +WAL separately, ignore locks or widen permissions to make collection pass. + When the host exposes the original failed tool result or runtime event, the Agent copies only its error and available identifiers into a new dedicated `.mcp-failure.json`. Do this before retries, reconnects or later probes. @@ -147,6 +156,24 @@ Do not start another authenticated session to manufacture replacement evidence. If only the original error text is accessible, use the text-file fallback below. Do not call a report with only startup-status notices a captured tool failure. +Save actual error fields rather than reconstructing a raw tool-result envelope. +The collector records a valid Codex `CODEX_THREAD_ID` in `collectionContext` as +the current collecting thread. It does not fill the original failure's thread +automatically. When the failure just happened in this same chat, that known +thread may be supplied in its failure record with the source explained. Never +use the diagnostic command's `CODEX_TOOL_CALL_ID` as the failed MCP call's ID. + +With a known original/session thread, `hostLogs.nativeFailedCalls` selects at +most ten failed hosted ChatCut MCP items in the bounded window from the native +`thread_history_1.sqlite` index. Only IDs, lifecycle times and error fields are +projected; whole history items, conversations, arguments and successful results +are not collected. An error above 128 KiB is explicitly omitted; the complete +local report is never trimmed to fit the 256 KiB upload limit. `nativeItemId` +is a native MCP item identity, distinct from Responses call IDs, RPC IDs and +server request IDs. `startedAtMs`/`completedAtMs` are lifecycle times, not the +original `occurredAt`. Matches remain unconfirmed; compare known thread, tool +and exact error without promoting nearby times to proof of association. + 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 @@ -184,10 +211,18 @@ Codex SQLite locations follow `CODEX_SQLITE_HOME` when present. If the current host already supplies its actual `sqlite_home` or `log_dir`, pass that directory with `--codex-sqlite-home` or `--codex-log-dir`; do not read whole config files to find it. These options select existing log locations and do not enable logging. +If the caller already retained a dedicated native stderr stream, pass its +existing `.codex-stderr.log` with `--codex-stderr-file`. The helper reads only +its bounded ChatCut log tail, marks emission time as `loggedAt`, and does not +fill original failure time or association. This source is optional; it does +not capture a stream or recover discarded stderr. On Windows, SQLite error 14 can coexist with readable WAL files in a read-only sandbox. The report preserves the original error and bounded file-access facts; it does not prove a specific security product caused the restriction. Never copy the whole log database, ignore its WAL with immutable mode, or relax permissions. +Both SQLite readers preserve the failing operation and error, then continue +other sources; a close failure does not replace the primary error. Missing +native IDs and lifecycle times remain missing when the database is unreadable. On Windows, the helper also reads the existing Codex App startup-status notices under `%LOCALAPPDATA%/Codex/Logs/YYYY/MM/DD`. This is independent of TUI logging. It selects only `AppServerConnection` startup notices for the hosted ChatCut diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs index 513ba4a..bf76c45 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -24,8 +24,33 @@ function redactFieldValue(_match, prefix, value) { return prefix + (quote ? quote + "[REDACTED]" + quote : "[REDACTED]"); } -export function redact(text) { - return String(text) +export function redact(text, jsonDepth = 0) { + let value = String(text); + // A host may quote/stringify an entire JSON response inside its error. Decode + // only JSON string tokens containing this credential label, redact their full + // value, then restore the quoting. A partial escaped-field regex can leak the + // suffix after an escaped quote/backslash. Input collection is size bounded. + if (jsonDepth < 16) value = value.replace(/"(?:\\.|[^"\\\r\n])*"/g, (token) => { + try { + const decoded = JSON.parse(token); + if (/mcp[-_]session[-_]id/i.test(decoded)) { + const sanitized = redact(decoded, jsonDepth + 1); + if (sanitized !== decoded) return JSON.stringify(sanitized); + } + } catch { /* Non-JSON diagnostic text is retained for the normal rules. */ } + return token; + }); + return value + .replace(/\b(Mcp-Session-Id[ \t]*:[ \t]*)\S+/gi, "$1[REDACTED]") + .replace( + /(["']mcp[-_]session[-_]id["']\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*')/gi, + redactFieldValue, + ) + .replace( + /\b((?:mcp[-_]session[-_]id)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^\s"'<>]+)/gi, + redactFieldValue, + ) + .replace(/((?:\\*["'])mcp[-_]session[-_]id(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, "$1[REDACTED]") .replace( /\bBearer\s+(?!(?:token|required)\b|<|(?:realm|scope|error(?:_description|_uri)?|resource_metadata)\s*=)[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]", @@ -55,7 +80,7 @@ export function redact(text) { 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( + /^(?:mcp[-_]session[-_]id|access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( key, ) ? "[REDACTED]" @@ -253,7 +278,7 @@ export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { params: { protocolVersion: "2025-11-25", capabilities: {}, - clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.3" }, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.4" }, }, }), }); @@ -390,8 +415,11 @@ function runtimeContext(body) { return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; } -function hostedTextRecord(line) { +function hostedTextRecord(line, nativeLevel) { if (/^\s/.test(line)) return false; + // Inspect explicit echo prefixes before stripping trace context: a payload + // itself can contain "}:" and must not become a diagnostic via lastIndex. + if (/(?:^|\}:\s*)(?:tool (?:call|result)\s*:|(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[])/i.test(line)) return false; let body = line.replace( /^\d{4}-\d{2}-\d{2}T\S+\s+(?:(?:\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\]|TRACE|DEBUG|INFO|WARN|ERROR)\s+)?/i, "", @@ -399,7 +427,7 @@ function hostedTextRecord(line) { const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); if (module) { if ( - !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::)/.test(module[1]) + !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::|codex_core::mcp_tool_call$)/.test(module[1]) ) return false; body = body.slice(module[0].length); @@ -417,9 +445,23 @@ function hostedTextRecord(line) { const server = context.match(/\bserver_name=["']?([\w:.-]+)/)?.[1] ?? scope?.[1] ?? scope?.[2] ?? scope?.[3]; if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; - if (!CHATCUT_MESSAGE.test(body)) return false; - if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) return false; - const message = scope ? body.slice(scope[0].length) : body; + const header = line.match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i); + const level = String(nativeLevel ?? header?.[1] ?? header?.[2]).toUpperCase(); + const nativeToolError = module?.[1] === "codex_core::mcp_tool_call" && + ["ERROR", "WARN"].includes(level) && + /^MCP tool call error:\s*["']?tool call error: tool call failed for `(?:chatcut|plugin:chatcut:chatcut)\/[A-Za-z0-9_.:-]+`/.test(body); + if (module?.[1] === "codex_core::mcp_tool_call" && !nativeToolError) return false; + if (!CHATCUT_MESSAGE.test(body) && !nativeToolError) return false; + if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) { + // A trusted native ERROR/WARN can describe a failed tools/call without + // echoing its content. SQLite supplies its separate native level column. + if (!module || !["ERROR", "WARN"].includes(level)) return false; + // Inspect the unstripped body: a payload can itself contain "}:". + const contentCheck = body.replace(/\\+(?=["'])/g, ""); + if (/["'](?:params|arguments)["']\s*:\s*[{[]|["'](?:prompt|transcript)["']\s*:\s*["'{[]|["']result["']\s*:\s*\{[\s\S]*?["']content["']\s*:\s*\[|["']role["']\s*:\s*["'](?:user|assistant|system|developer)["']|["']type["']\s*:\s*["'](?:function_call(?:_output)?|tool_call(?:_output)?|tool_result|user(?:_message)?|assistant(?:_message)?)["']/i.test(contentCheck)) return false; + if (/(?:^|\}:\s*)(?:prompt|transcript)\s*:/i.test(body)) return false; + } + const message = scope ? body.slice(scope[0].length) : logMessage(body); // Runtime error contents can name tools; only explicit echo records are excluded. if (TEXT_ECHO.test(message)) return false; if ( @@ -507,7 +549,11 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { !TEXT_ECHO.test(lines[i + 1].trimStart()) ) block.push(lines[++i]); + if (block.length > 1 && !select(block.join("\n"))) continue; const occurredAt = logTime(block[0]); + const context = runtimeContext(block[0]); + if (window.conversationId && context.conversationId && context.conversationId !== window.conversationId) + continue; if ( occurredAt && (Date.parse(occurredAt) < window.since * 1000 || @@ -520,7 +566,7 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { occurredAt, timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", message: redact(block.join("\n")), - ...runtimeContext(block[0]), + ...context, originalAttemptMatch: "unconfirmed", }); } @@ -679,11 +725,80 @@ async function inspectSqliteAccess(path) { }; } +async function nativeFailedMcpItems(sqliteRoot, conversationId, window, gaps) { + const source = "codex_native_failed_mcp_items"; + if (!conversationId) { + gaps.push({ source, reason: "known_conversation_id_unavailable; no_history_query" }); + return []; + } + let db; + const databasePath = join(sqliteRoot, "thread_history_1.sqlite"); + let operation = "locate_database"; + try { + await stat(databasePath); + operation = "load_sqlite_module"; + const { DatabaseSync } = await import("node:sqlite"); + operation = "open_database"; + db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); + operation = "set_query_only"; + db.exec("PRAGMA query_only=ON"); + operation = "inspect_schema"; + const columns = db.prepare("PRAGMA table_info(thread_items)").all().map(column => column.name); + if (!["thread_id", "turn_id", "item_id", "item_type", "item_json", "started_at_ms", "completed_at_ms"].every(name => columns.includes(name))) { + gaps.push({ source, reason: "unsupported_schema; no_conversation_rows_read" }); + return []; + } + // Only scalar/error projections leave SQLite; never select whole item_json, + // chat roles, arguments, successful results or rollout contents. + const field = name => `json_extract(CASE WHEN json_valid(item_json) THEN item_json ELSE '{}' END,'$.${name}')`; + const error = field("error"); + operation = "prepare_failed_items_query"; + const query = db.prepare(`SELECT thread_id,turn_id,item_id,started_at_ms,completed_at_ms, + ${field("server")} AS server,${field("tool")} AS operation, + CASE WHEN length(CAST(${error} AS BLOB))<=${READ_LIMIT} THEN ${error} END AS error_json, + length(CAST(${error} AS BLOB)) AS error_bytes + FROM thread_items WHERE thread_id=? AND item_type='mcpToolCall' + AND ((started_at_ms>=? AND started_at_ms<=?) OR (completed_at_ms>=? AND completed_at_ms<=?)) + AND ${field("type")}='mcpToolCall' AND ${field("server")} IN ('chatcut','plugin:chatcut:chatcut') + AND ${field("status")}='failed' ORDER BY completed_at_ms DESC,item_id LIMIT 11`); + operation = "select_failed_mcp_items"; + const rows = query.all(conversationId, window.since * 1000, window.until * 1000, window.since * 1000, window.until * 1000); + if (rows.length > 10) gaps.push({ source, reason: "native_failure_record_limit", limit: 10 }); + if (!rows.length) gaps.push({ source, reason: "no_failed_mcp_items_in_bounded_window" }); + return rows.slice(0, 10).map(row => { + if (row.error_bytes > READ_LIMIT) gaps.push({ source, reason: "native_error_size_limit", nativeItemId: row.item_id, limitBytes: READ_LIMIT }); + let error = null; + if (row.error_json) { + try { error = JSON.parse(row.error_json); } + catch { error = { message: String(row.error_json) }; } + } + return { + source, sourceFile: "thread_history_1.sqlite", conversationId: row.thread_id, turnId: row.turn_id, + nativeItemId: row.item_id, idKind: "codex_native_mcp_item_id; not_responses_call_id_rpc_id_or_server_request_id", + serverName: row.server, operation: row.operation, status: "failed", + startedAtMs: row.started_at_ms, completedAtMs: row.completed_at_ms, + timeSource: "native_thread_items_lifecycle_columns; not_original_occurredAt_or_log_time", + error, ...(row.error_bytes > READ_LIMIT && { errorCaptureLimited: true }), originalAttemptMatch: "unconfirmed", + }; + }); + } catch (error) { + gaps.push({ source, reason: "unavailable", operation, error: errorDetails(error), + ...(platform() === "win32" && error?.errcode === 14 + ? { accessEvidence: await inspectSqliteAccess(databasePath) } : {}), + }); + return []; + } finally { + try { db?.close(); } + catch (error) { gaps.push({ source, reason: "close_failed", operation: "close_database", error: errorDetails(error) }); } + } +} + export async function collectHostLogs(host, options = {}) { const userHome = options.home ?? homedir(); const gaps = []; const entries = []; let appLog; + let nativeFailedCalls; 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); @@ -694,20 +809,29 @@ export async function collectHostLogs(host, options = {}) { const logRoot = options.codexLogDir ?? join(root, "log"); const databasePath = join(sqliteRoot, "logs_2.sqlite"); let db; + let operation = "locate_database"; try { await stat(databasePath); + operation = "load_sqlite_module"; const { DatabaseSync } = await import("node:sqlite"); + operation = "open_database"; db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); + operation = "set_query_only"; db.exec("PRAGMA query_only=ON"); + operation = "inspect_schema"; const hasThreadId = db .prepare("PRAGMA table_info(logs)") .all() .some((column) => column.name === "thread_id"); - const rows = db - .prepare( - `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, - ) - .all(since, until); + const threadFilter = hasThreadId && options.conversationId + ? " AND (thread_id IS NULL OR thread_id='' OR thread_id=?)" : ""; + operation = "prepare_runtime_logs_query"; + const query = db.prepare( + `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=?${threadFilter} AND (target LIKE 'codex_mcp%' OR target LIKE 'codex_rmcp_client%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%' OR target='codex_core::mcp_tool_call') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, + ); + operation = "select_runtime_logs"; + const rows = query.all(since, until, ...(threadFilter ? [options.conversationId] : [])); + operation = "filter_runtime_logs"; for (const row of rows) { const message = logMessage(row.feedback_log_body ?? ""); const original = row.feedback_log_body ?? ""; @@ -719,7 +843,8 @@ export async function collectHostLogs(host, options = {}) { context.conversationId !== options.conversationId ) continue; - if (!CHATCUT_MESSAGE.test(original) || /server_name="?chatcut_desktop/.test(original)) + if (!hostedTextRecord(`${row.target}: ${original}`, row.level) || + !hostedTextRecord(`MCP server "chatcut" ${message}`)) continue; if ( !["ERROR", "WARN"].includes(row.level) && @@ -744,6 +869,7 @@ export async function collectHostLogs(host, options = {}) { gaps.push({ source: "codex_runtime_sqlite", reason: "unavailable", + operation, error: errorDetails(error), locationSource: options.codexSqliteHome ? "explicit_host_location" @@ -755,18 +881,35 @@ export async function collectHostLogs(host, options = {}) { : {}), }); } finally { - db?.close(); + try { db?.close(); } + catch (error) { gaps.push({ source: "codex_runtime_sqlite", reason: "close_failed", operation: "close_database", error: errorDetails(error) }); } } + nativeFailedCalls = await nativeFailedMcpItems(sqliteRoot, options.conversationId, { since, until }, gaps); try { entries.push( ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { since, until, + conversationId: options.conversationId, })), ); } catch (error) { gaps.push({ source: "codex_tui_log", reason: "unavailable", error: errorDetails(error) }); } + if (options.codexStderrFile) { + try { + if (!basename(options.codexStderrFile).endsWith(".codex-stderr.log")) + throw new Error("Use an existing dedicated .codex-stderr.log native capture, never config/auth/session files"); + const captured = await textLog(options.codexStderrFile, "codex_stderr_log", gaps, { + since, until, conversationId: options.conversationId, + }); + entries.push(...captured.map(({ occurredAt, ...entry }) => ({ + ...entry, loggedAt: occurredAt, timestampKind: "native_log_time_not_original_failure_time", + }))); + } catch (error) { + gaps.push({ source: "codex_stderr_log", reason: "unavailable", error: errorDetails(error) }); + } + } const appData = options.localAppData ?? process.env.LOCALAPPDATA; const sandboxAppData = !options.localAppData && @@ -830,12 +973,13 @@ export async function collectHostLogs(host, options = {}) { gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); } } - if (!entries.length) + if (!entries.length && !nativeFailedCalls?.length) gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); return clean({ entries, gaps, ...(appLog ? { appLog } : {}), + ...(nativeFailedCalls ? { nativeFailedCalls } : {}), window: { since: new Date(since * 1000).toISOString(), until: new Date(until * 1000).toISOString(), @@ -899,16 +1043,24 @@ export async function runDoctor(options, dependencies = {}) { throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); } const fetchImpl = dependencies.fetchImpl ?? fetch; + const threadEnv = options.host === "codex" ? process.env.CODEX_THREAD_ID : undefined; + const collectionConversationId = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(threadEnv ?? "") + ? threadEnv : null; const diagnosticId = randomUUID(); const report = { schemaVersion: 2, - collectorVersion: "1.2.3", + collectorVersion: "1.2.4", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", host: options.host, hostVersion: options.hostVersion ?? null, hostVersionSource: options.hostVersion ? "agent_supplied" : "unavailable", + collectionContext: { + conversationId: collectionConversationId, + source: collectionConversationId ? "CODEX_THREAD_ID" : "unavailable", + association: "current_collection_thread; original_failed_attempt_unconfirmed", + }, environment: { platform: platform(), architecture: arch(), @@ -1063,10 +1215,11 @@ export async function runDoctor(options, dependencies = {}) { codexAppLogDir: options.codexAppLogDir, codexAppLogFiles: options.codexAppLogFiles, codexAppLogOffset: options.codexAppLogOffset, + codexStderrFile: options.codexStderrFile, conversationId: report.originalFailure.conversationId ?? report.hostSnapshot?.session?.conversationId ?? - process.env.CODEX_THREAD_ID, + collectionConversationId, }); } catch (error) { report.hostLogs = { @@ -1078,11 +1231,13 @@ export async function runDoctor(options, dependencies = {}) { disabledBySnapshot || report.hostSnapshot?.configuration?.enabled === false || report.hostSnapshot?.session?.connectionStatus === "disabled"; - if (disabled || !endpoint) { + if (options.offline || disabled || !endpoint) { report.gaps.push({ source: "probes", reason: disabled ? "host_reports_intentionally_disabled; no_requests_sent" + : options.offline + ? "offline_requested; no_requests_sent" : "registered_endpoint_unavailable; no_requests_sent", }); } else { @@ -1189,6 +1344,8 @@ export async function runDoctor(options, dependencies = {}) { report.upload = { status: disabled ? "skipped_intentionally_disabled" + : options.offline + ? "skipped_offline" : !endpoint ? "skipped_endpoint_unavailable" : options.upload @@ -1200,7 +1357,7 @@ export async function runDoctor(options, dependencies = {}) { const save = async (flag) => writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); await save("wx"); - if (options.upload && !disabled && endpoint) { + if (options.upload && !options.offline && !disabled && endpoint) { try { const events = fragmentEvents(report); const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { @@ -1240,11 +1397,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur "occurred-at": { type: "string" }, "codex-sqlite-home": { type: "string" }, "codex-log-dir": { type: "string" }, + "codex-stderr-file": { type: "string" }, "codex-app-log-dir": { type: "string" }, "codex-app-log-file": { type: "string", multiple: true }, "codex-app-log-offset": { type: "string" }, output: { type: "string" }, upload: { type: "boolean", default: false }, + offline: { type: "boolean", default: false }, "wire-evidence": { type: "boolean", default: true }, "create-snapshot": { type: "string" }, "validate-snapshot": { type: "string" }, @@ -1284,7 +1443,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); } else if (values.help) { console.log( - "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--upload]", + "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-stderr-file ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--offline] [--upload]", ); } else { const { path, report } = await runDoctor({ @@ -1297,6 +1456,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur occurredAt: values["occurred-at"], codexSqliteHome: values["codex-sqlite-home"], codexLogDir: values["codex-log-dir"], + codexStderrFile: values["codex-stderr-file"], codexAppLogDir: values["codex-app-log-dir"], codexAppLogFiles: values["codex-app-log-file"], codexAppLogOffset: @@ -1305,6 +1465,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur : Number(values["codex-app-log-offset"]), output: values.output, upload: values.upload, + offline: values.offline, wireEvidence: values["wire-evidence"], }); console.log( diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs index da0ed7c..b0ab995 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs @@ -240,10 +240,23 @@ export function evidenceSummary(report) { ...(entry.conversationId && { conversationId: entry.conversationId }), ...(entry.callId && { callId: entry.callId }), })); + const databaseReadFailures = (report.hostLogs.gaps ?? []).filter(gap => + ["codex_runtime_sqlite", "codex_native_failed_mcp_items"].includes(gap.source) && + ["unavailable", "close_failed"].includes(gap.reason)).map(gap => ({ + source: gap.source, operation: gap.operation ?? null, + code: gap.error?.code ?? null, errcode: gap.error?.errcode ?? null, + })); return { stages, otherObservations: unknown, logHints, + collectionContext: report.collectionContext ?? null, + nativeFailedCalls: (report.hostLogs.nativeFailedCalls ?? []).map((item, index) => ({ + evidence: `hostLogs.nativeFailedCalls[${index}]`, source: item.source, + nativeItemId: item.nativeItemId, idKind: item.idKind, conversationId: item.conversationId, + operation: item.operation, startedAtMs: item.startedAtMs, completedAtMs: item.completedAtMs, + originalAttemptMatch: "unconfirmed", + })), originalRequestCorrelation: { status: originalRequests.length ? "agent_reported_same_attempt" : "unconfirmed", observations: originalRequests, @@ -260,6 +273,13 @@ export function evidenceSummary(report) { } : null, hostLogCoverage: { + originalErrorRetained: report.originalFailure.status === "recorded", + originalErrorRetainedWithoutDatabase: report.originalFailure.status === "recorded" && databaseReadFailures.length > 0, + databaseReadFailures, + runtimeCollectionFailed: (report.hostLogs.gaps ?? []).some(gap => gap.source === "host_logs" && gap.reason === "collector_failed"), + nativeFailedMcpItems: report.hostLogs.nativeFailedCalls?.length ?? 0, + retainedStderrRecords: report.hostLogs.entries.filter(entry => entry.source === "codex_stderr_log").length, + priorStderrCaptureRequired: false, records: report.hostLogs.entries.length, appStartupRecords: appNotices.length, failedAppStartupRecords: failedAppNotices.length, diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index 4d0c7e9..961a824 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -12,6 +12,18 @@ need a runtime with `node:sqlite`; missing support is an explicit evidence gap. ## Evidence and delivery contract +Collector 1.2.4 keeps the complete collector in the existing basics skills. +It adds bounded Codex failed-MCP index projections, current collecting-thread +context, explicit already-retained stderr, native tool-error formats and SQL +thread scoping before the row limit. Original, lifecycle and log times stay +separate; no native identity is promoted to a server request ID. SQLite errors +retain the failing operation while original errors and independent sources +continue. `evidenceSummary.hostLogCoverage` makes these gaps explicit, and +`--offline` suppresses all probes/uploads. Already visible errors need no +preconfigured stderr. Hidden startup errors with no accessible source remain +unavailable. MCP session credentials in error text are redacted. This change +adds no separate Doctor Skill, diagnosis/repair rules or publication workflow. + Collector 1.2.3 adds a dedicated `.mcp-failure.json` input for the original host tool/runtime error, its available failure time and local conversation/call IDs. The Skill preserves this evidence before retries; a missing failure timestamp diff --git a/tests/collection-quality.test.mjs b/tests/collection-quality.test.mjs new file mode 100644 index 0000000..81040bd --- /dev/null +++ b/tests/collection-quality.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { collectHostLogs } from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; + +// Owned native-format fixtures test actual collector I/O, not natural failures +// or a model's autonomous collection ability. +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-collection-quality-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return { dir, now: Date.now() }; +} +function database(dir) { + 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,thread_id TEXT)"); + return db; +} +const options = ({ dir, now }) => ({ codexHome: dir, codexSqliteHome: dir, codexLogDir: dir, codexAppLogDir: dir, now, conversationId: "current-thread" }); + +test("SQLite includes the already-supported codex_rmcp_client native module and excludes other services", async (t) => { + const f = await fixture(t), db = database(f.dir); + const add = db.prepare("INSERT INTO logs VALUES(?,?,?,?,?,?)"); + add.run(1, Math.floor(f.now / 1000), "ERROR", "codex_rmcp_client::rmcp_client", 'MCP server startup failed server_name="chatcut" error=E_NATIVE_RMCP', "current-thread"); + add.run(2, Math.floor(f.now / 1000), "ERROR", "codex_rmcp_client::rmcp_client", 'MCP server startup failed server_name="other" error=PRIVATE_OTHER_SERVICE', "current-thread"); + db.close(); + const logs = await collectHostLogs("codex", options(f)); + assert.ok(logs.entries.some(e => e.source === "codex_runtime_sqlite" && e.message.includes("E_NATIVE_RMCP"))); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_OTHER_SERVICE")); +}); + +test("native ERROR/WARN tools/call diagnostics keep their local IDs and unknown cause without collecting echoes", async (t) => { + const f = await fixture(t), db = database(f.dir), lines = []; + const add = db.prepare("INSERT INTO logs VALUES(?,?,?,?,?,?)"); + let id = 0; + for (const level of ["ERROR", "WARN"]) { + const context = 'request{thread_id="current-thread" call_id="local-call" server_name="chatcut" method="tools/call"}: '; + const bodies = [ + 'Transport send error: E_UNFAMILIAR_CAUSE token=PRIVATE_TOKEN error={"type":"TlsError","code":"ECONNRESET","method":"tools/call","content":["native diagnostic detail"],"cause":{"message":"connection closed"}}', + 'tool call: {"params":{"arguments":{"prompt":"}: PRIVATE_ECHO"}}}', + 'tool result: {"result":{"content":[{"text":"}: PRIVATE_ECHO"}]}}', + '{"method":"tools/call","params":{"arguments":{"prompt":"}: PRIVATE_ECHO"}}}', + 'Received response: {"result":{"content":[{"text":"}: PRIVATE_ECHO"}]}}', + '{"type":"user_message","message":"}: PRIVATE_ECHO"}', + 'Transport failure details:\n {"result":{"content":[{"text":"}: PRIVATE_ECHO"}]}}', + 'Transport failure details: "{\\"type\\":\\"user_message\\",\\"message\\":\\"}: PRIVATE_ECHO\\"}"', + 'prompt: PRIVATE_ECHO', 'transcript: PRIVATE_ECHO', + ]; + for (const body of bodies) { + add.run(++id, Math.floor(f.now / 1000), level, "codex_mcp::rmcp_client", context + body, "current-thread"); + lines.push(`${new Date(f.now).toISOString()} ${level} codex_mcp::rmcp_client: ${context}${body}`); + } + } + // An INFO trace without an error diagnostic still cannot expose tool content. + lines.push(`${new Date(f.now).toISOString()} INFO rmcp::client: request{server_name=chatcut method="tools/call"}: PRIVATE_ECHO`); + db.close(); + await writeFile(join(f.dir, "codex-tui.log"), lines.join("\n")); + const logs = await collectHostLogs("codex", options(f)); + const errors = logs.entries.filter(e => e.message.includes("E_UNFAMILIAR_CAUSE")); + assert.equal(errors.length, 4, "Both native severities and both sources must survive"); + assert.ok(errors.every(e => e.message.includes('"type":"TlsError"') && e.message.includes('"cause":{"message":"connection closed"}'))); + assert.ok(errors.every(e => e.callId === "local-call" && e.conversationId === "current-thread" && e.originalAttemptMatch === "unconfirmed")); + assert.ok(errors.every(e => !e.requestId)); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_ECHO")); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_TOKEN")); +}); + +test("Codex TUI excludes explicit other conversations but retains unknown association", async (t) => { + const f = await fixture(t); + const prefix = `${new Date(f.now).toISOString()} ERROR codex_mcp::rmcp_client: `; + await writeFile(join(f.dir, "codex-tui.log"), [ + `${prefix}request{thread_id=other-thread call_id=foreign-call server_name=chatcut}: PRIVATE_FOREIGN_ERROR`, + `${prefix}request{thread_id=current-thread call_id=current-call server_name=chatcut}: E_CURRENT`, + `${prefix}MCP server "chatcut": E_THREAD_UNKNOWN`, + ].join("\n")); + const logs = await collectHostLogs("codex", options(f)); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_FOREIGN_ERROR")); + assert.ok(!JSON.stringify(logs).includes("foreign-call")); + assert.ok(logs.entries.some(e => e.message.includes("E_CURRENT") && e.callId === "current-call")); + assert.ok(logs.entries.some(e => e.message.includes("E_THREAD_UNKNOWN") && !e.conversationId && e.originalAttemptMatch === "unconfirmed")); + const unscoped = await collectHostLogs("codex", { ...options(f), conversationId: undefined }); + assert.ok(unscoped.entries.some(e => e.conversationId === "other-thread" && e.originalAttemptMatch === "unconfirmed")); +}); + +test("SQLite scopes before LIMIT when thread_id exists and preserves unknown or legacy thread evidence", async (t) => { + for (const hasThread of [true, false]) { + const f = await fixture(t), db = new DatabaseSync(join(f.dir, "logs_2.sqlite")); + db.exec(`CREATE TABLE logs(id INTEGER,ts INTEGER,level TEXT,target TEXT,feedback_log_body TEXT${hasThread ? ",thread_id TEXT" : ""})`); + const insert = db.prepare(`INSERT INTO logs VALUES(?,?,?,?,?${hasThread ? ",?" : ""})`); + let id = 0; + const add = (age, thread, body) => insert.run(++id, Math.floor(f.now / 1000) - age, "ERROR", "codex_mcp::rmcp_client", body, ...(hasThread ? [thread] : [])); + add(500, "current-thread", 'request{server_name=chatcut thread_id=current-thread call_id=current-call}: E_CURRENT_BEFORE_LIMIT'); + add(499, null, 'MCP server "chatcut": E_NULL_THREAD'); + add(498, "", 'MCP server "chatcut": E_EMPTY_THREAD'); + // Legacy schemas still use explicit trace IDs after the bounded query. + // Only schemas with a native column can scope before LIMIT. + const foreignCount = hasThread ? 200 : 1; + for (let i = 0; i < foreignCount; i++) + add(200 - i, "other-thread", 'request{server_name=chatcut thread_id=other-thread}: PRIVATE_FOREIGN'); + db.close(); + const logs = await collectHostLogs("codex", options(f)); + assert.ok(logs.entries.some(e => e.message.includes("E_CURRENT_BEFORE_LIMIT"))); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_FOREIGN")); + for (const marker of ["E_NULL_THREAD", "E_EMPTY_THREAD"]) + assert.ok(logs.entries.some(e => e.message.includes(marker) && !e.conversationId && e.originalAttemptMatch === "unconfirmed")); + } +}); diff --git a/tests/fixtures/owned-codex-mcp-tool-error.log b/tests/fixtures/owned-codex-mcp-tool-error.log new file mode 100644 index 0000000..faca88e --- /dev/null +++ b/tests/fixtures/owned-codex-mcp-tool-error.log @@ -0,0 +1 @@ +2026-10-24T09:37:03.806685Z WARN codex_core::mcp_tool_call: MCP tool call error: "tool call error: tool call failed for `chatcut/list_projects`\n\nCaused by:\n Mcp error: -32041: Owned read-only project lookup failed: E_NATIVE_READ_ONLY_724({\"cause\":{\"code\":\"E_OWNED_SOURCE\",\"message\":\"Controlled original cause; no operation was written\"}})" diff --git a/tests/mcp-doctor.test.mjs b/tests/mcp-doctor.test.mjs index fd251f7..7407e6d 100644 --- a/tests/mcp-doctor.test.mjs +++ b/tests/mcp-doctor.test.mjs @@ -722,7 +722,7 @@ test("App fallback remains bounded and does not promote host notices into the or codexLogDir: join(dir, "empty-tui"), output: join(dir, "report.json"), }); - assert.equal(report.collectorVersion, "1.2.3"); + assert.equal(report.collectorVersion, "1.2.4"); assert.equal( report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log").length, 30, @@ -770,3 +770,15 @@ test("self-contained host packages ship identical helpers and diagnostic referen ); } }); + +test("MCP session credentials in native errors are redacted across punctuation and nested JSON strings", () => { + for (let code = 0x21; code <= 0x7e; code++) { + const opaque = `opaque${String.fromCharCode(code)}SECRET_SUFFIX`; + const document = JSON.stringify({ "Mcp-Session-Id": opaque, code: "E_KEEP" }); + for (const text of [`Mcp-Session-Id: ${opaque}\nError E_KEEP`, document, `Response: ${JSON.stringify(document)}`, `Response: ${JSON.stringify(JSON.stringify(document))}`]) { + assert.ok(!redact(text).includes("SECRET_SUFFIX")); + assert.ok(redact(text).includes("E_KEEP")); + } + } + assert.ok(redact("Mcp-Session-Id: private\ncallId=local-call").includes("callId=local-call")); +}); diff --git a/tests/native-failure-index.test.mjs b/tests/native-failure-index.test.mjs new file mode 100644 index 0000000..4558919 --- /dev/null +++ b/tests/native-failure-index.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { collectHostLogs, runDoctor, fragmentEvents } from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-native-index-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} +const now = Date.parse("2026-10-24T10:30:00Z"); +const current = "00000000-0000-0000-0000-000000000123"; +const options = dir => ({ codexHome: dir, codexSqliteHome: dir, codexLogDir: dir, codexAppLogDir: dir, conversationId: current, now }); +function database(dir) { + const db = new DatabaseSync(join(dir, "thread_history_1.sqlite")); + db.exec("CREATE TABLE thread_items(thread_id TEXT,turn_id TEXT,item_id TEXT,item_type TEXT,created_at_ms INTEGER,started_at_ms INTEGER,completed_at_ms INTEGER,item_json TEXT)"); + return db; +} +const native = extra => ({ type: "mcpToolCall", server: "chatcut", tool: "list_projects", status: "failed", + error: { message: "Unfamiliar original error token=PRIVATE_TOKEN", code: -32041, cause: { message: "E_NATIVE_CAUSE" } }, + arguments: { prompt: "PRIVATE_ARGUMENTS" }, result: { content: [{ text: "PRIVATE_RESULT" }] }, ...extra }); + +test("native index projects only scoped failed MCP metadata and keeps lifecycle/ID kinds separate", async t => { + const dir = await fixture(t), db = database(dir), add = db.prepare("INSERT INTO thread_items VALUES(?,?,?,?,?,?,?,?)"); + const insert = (id, value, extra = {}) => add.run(extra.thread ?? current, "native-turn", id, extra.type ?? "mcpToolCall", now, + extra.started === undefined ? now - 1000 : extra.started, extra.completed === undefined ? now - 900 : extra.completed, typeof value === "string" ? value : JSON.stringify(value)); + insert("native-item", native()); + insert("foreign", native({ error: { message: "PRIVATE_FOREIGN" } }), { thread: "other-thread" }); + insert("success", native({ status: "completed", error: { message: "PRIVATE_SUCCESS" } })); + insert("other-server", native({ server: "other", error: { message: "PRIVATE_OTHER_SERVER" } })); + insert("desktop", native({ server: "chatcut_desktop", error: { message: "PRIVATE_DESKTOP" } })); + insert("user", { type: "userMessage", text: "PRIVATE_USER" }, { type: "userMessage" }); + insert("assistant", { type: "agentMessage", text: "PRIVATE_ASSISTANT" }, { type: "agentMessage" }); + insert("mismatched-type", native({ type: "agentMessage", error: { message: "PRIVATE_BAD_TYPE" } })); + insert("no-lifecycle", native({ error: { message: "PRIVATE_UNKNOWN_TIME" } }), { started: null, completed: null }); + insert("old", native({ error: { message: "PRIVATE_OLD" } }), { started: now - 7200000, completed: now - 7100000 }); + insert("malformed", "not JSON PRIVATE_BAD_JSON"); + db.close(); + const logs = await collectHostLogs("codex", options(dir)); + assert.equal(logs.nativeFailedCalls.length, 1); + const item = logs.nativeFailedCalls[0]; + assert.equal(item.nativeItemId, "native-item"); + assert.match(item.idKind, /codex_native_mcp_item_id/); + assert.equal(item.startedAtMs, now - 1000); + assert.equal(item.completedAtMs, now - 900); + assert.equal(item.occurredAt, undefined); + assert.equal(item.callId, undefined); + assert.equal(item.requestId, undefined); + assert.equal(item.originalAttemptMatch, "unconfirmed"); + assert.equal(item.error.cause.message, "E_NATIVE_CAUSE"); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_")); + const unknown = await collectHostLogs("codex", { ...options(dir), conversationId: undefined }); + assert.equal(unknown.nativeFailedCalls.length, 0); + assert.ok(unknown.gaps.some(g => g.reason === "known_conversation_id_unavailable; no_history_query")); + const injected = await collectHostLogs("codex", { ...options(dir), conversationId: "' OR 1=1 --" }); + assert.equal(injected.nativeFailedCalls.length, 0); +}); + +test("record/error limits and unsupported schemas remain explicit without replacing the original error", async t => { + const dir = await fixture(t), db = database(dir), add = db.prepare("INSERT INTO thread_items VALUES(?,?,?,?,?,?,?,?)"); + for (let i = 0; i < 12; i++) add.run(current, "turn", `native-${i}`, "mcpToolCall", now, now - 1000, now - i, + JSON.stringify(native({ error: { message: i === 0 ? "X".repeat(140000) : i === 1 ? "X".repeat(20000) + "E_FULL_TAIL" : "E_NATIVE" } }))); + db.close(); + const logs = await collectHostLogs("codex", options(dir)); + assert.equal(logs.nativeFailedCalls.length, 10); + assert.ok(logs.gaps.some(g => g.reason === "native_failure_record_limit")); + assert.ok(logs.gaps.some(g => g.reason === "native_error_size_limit")); + assert.equal(logs.nativeFailedCalls.find(i => i.nativeItemId === "native-0").error, null); + assert.ok(logs.nativeFailedCalls.find(i => i.nativeItemId === "native-1").error.message.endsWith("E_FULL_TAIL")); + const unsupportedDir = await fixture(t), old = new DatabaseSync(join(unsupportedDir, "thread_history_1.sqlite")); + old.exec("CREATE TABLE thread_items(thread_id TEXT,item_json TEXT)"); old.close(); + const unsupported = await collectHostLogs("codex", options(unsupportedDir)); + assert.ok(unsupported.gaps.some(g => g.reason === "unsupported_schema; no_conversation_rows_read")); +}); + +test("native query errors retain their failing operation and local evidence is not trimmed to fit upload", async t => { + const dir = await fixture(t), db = new DatabaseSync(join(dir, "thread_history_1.sqlite")); + db.exec(`CREATE VIEW thread_items AS SELECT '${current}' AS thread_id,'turn' AS turn_id,'native' AS item_id, + 'mcpToolCall' AS item_type,${now - 1000} AS started_at_ms,${now - 900} AS completed_at_ms, + abs(-9223372036854775808) AS item_json`); + db.close(); + const runtime = new DatabaseSync(join(dir, "logs_2.sqlite")); + runtime.exec("CREATE TABLE logs(id INTEGER,ts INTEGER,level TEXT,target TEXT)"); + runtime.close(); + const broken = await collectHostLogs("codex", options(dir)); + assert.ok(broken.gaps.some(g => g.source === "codex_native_failed_mcp_items" && g.operation === "select_failed_mcp_items" && g.error?.message)); + assert.ok(broken.gaps.some(g => g.source === "codex_runtime_sqlite" && g.operation === "prepare_runtime_logs_query" && g.error?.message.includes("feedback_log_body"))); + const largeDir = await fixture(t), large = database(largeDir); + const insert = large.prepare("INSERT INTO thread_items VALUES(?,?,?,?,?,?,?,?)"); + for (let i = 0; i < 3; i++) insert.run(current, "turn", `native-${i}`, "mcpToolCall", now, now - 1000, now - i, + JSON.stringify(native({ error: { message: "X".repeat(100000) + "E_UNTRIMMED_TAIL" } }))); + large.close(); + const logs = await collectHostLogs("codex", options(largeDir)); + const { report, path } = await runDoctor({ host: "codex", serverUrl: "http://127.0.0.1:9/api/external-mcp/mcp", offline: true, output: join(largeDir, "large.json") }, { collectLogs: async () => logs }); + assert.ok(report.hostLogs.nativeFailedCalls.every(i => i.error.message.endsWith("E_UNTRIMMED_TAIL"))); + assert.ok((await readFile(path, "utf8")).length > 256 * 1024); + assert.throws(() => fragmentEvents(report), /upload budget; complete local report retained/); + assert.ok(report.hostLogs.nativeFailedCalls.every(i => i.error.message.endsWith("E_UNTRIMMED_TAIL"))); +}); + +test("current collection context and native item references never fill original fields", async t => { + const dir = await fixture(t), before = process.env.CODEX_THREAD_ID; + process.env.CODEX_THREAD_ID = current; + t.after(() => { if (before === undefined) delete process.env.CODEX_THREAD_ID; else process.env.CODEX_THREAD_ID = before; }); + const failure = join(dir, "original.mcp-failure.json"); + await writeFile(failure, JSON.stringify({ schemaVersion: 1, source: "host_tool_result", serverName: "chatcut", occurredAt: null, + operation: "list_projects", error: { message: "E_ORIGINAL" } })); + let query; + const { report, path } = await runDoctor({ host: "codex", failureRecord: failure, offline: true, output: join(dir, "out.json") }, { + collectLogs: async (_, value) => { query = value; return { entries: [], gaps: [], nativeFailedCalls: [{ source: "codex_native_failed_mcp_items", + nativeItemId: "PRIVATE_NATIVE_ID", idKind: "codex_native_mcp_item_id", conversationId: current, turnId: "PRIVATE_TURN_ID", + operation: "list_projects", startedAtMs: now - 1000, completedAtMs: now - 900, error: { message: "E_ORIGINAL" } }] }; }, + fetchImpl: () => assert.fail("Offline capture sends no request"), + }); + assert.equal(report.collectionContext.conversationId, current); + assert.equal(report.collectionContext.source, "CODEX_THREAD_ID"); + assert.equal(query.conversationId, current); + assert.equal(report.originalFailure.occurredAt, null); + assert.equal(report.originalFailure.conversationId, undefined); + assert.equal(report.originalFailure.callId, undefined); + assert.equal(report.evidenceSummary.originalLocalCall, null); + assert.equal(JSON.parse(await readFile(path, "utf8")).collectionContext.conversationId, current); + assert.equal(report.evidenceSummary.nativeFailedCalls[0].nativeItemId, "PRIVATE_NATIVE_ID"); + assert.equal(report.evidenceSummary.nativeFailedCalls[0].originalAttemptMatch, "unconfirmed"); +}); + +test("an explicit retained native stderr tail preserves log time and errors without importing echoes or original fields", async t => { + const dir = await fixture(t), path = join(dir, "owned.codex-stderr.log"); + const line = '2026-10-24T10:29:59.123456Z WARN codex_mcp::rmcp_client: MCP server startup failed server_name="chatcut" error=HTTP503 E_REAL_NATIVE token=PRIVATE_TOKEN'; + await writeFile(path, "unrelated log record\n".repeat(8000) + line + "\n" + + '2026-10-24T10:29:59Z ERROR codex_mcp::rmcp_client: request{server_name=chatcut}: tool result: {"content":[{"text":"PRIVATE_ECHO"}]}\n'); + const logs = await collectHostLogs("codex", { ...options(dir), codexStderrFile: path }); + const found = logs.entries.filter(e => e.source === "codex_stderr_log"); + assert.equal(found.length, 1); + assert.equal(found[0].loggedAt, "2026-10-24T10:29:59.123Z"); + assert.equal(found[0].occurredAt, undefined); + assert.equal(found[0].originalAttemptMatch, "unconfirmed"); + assert.ok(found[0].message.includes("E_REAL_NATIVE")); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_")); + assert.ok(logs.gaps.some(g => g.source === "codex_stderr_log" && g.reason === "only_log_tail_read")); + const bad = await collectHostLogs("codex", { ...options(dir), codexStderrFile: join(dir, "auth.json") }); + assert.ok(bad.gaps.some(g => g.source === "codex_stderr_log" && g.reason === "unavailable")); +}); diff --git a/tests/real-native-log.test.mjs b/tests/real-native-log.test.mjs new file mode 100644 index 0000000..c2873d6 --- /dev/null +++ b/tests/real-native-log.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { collectHostLogs } from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; + +// Recorded native stderr from an owned real-model Codex CLI call against a +// local read-only MCP service. TUI/SQLite below are component replay containers, +// not claims that this event was persisted there by that native session. +const line = (await readFile(new URL("./fixtures/owned-codex-mcp-tool-error.log", import.meta.url), "utf8")).trimEnd(); +const now = Date.parse("2026-10-24T09:37:10Z"); +const occurredAt = "2026-10-24T09:37:03.806Z"; +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-native-error-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} +const options = dir => ({ codexHome: dir, codexSqliteHome: dir, codexLogDir: dir, codexAppLogDir: dir, now }); + +test("the exact owned native MCP tool error survives text and SQLite component replay", async t => { + const dir = await fixture(t); + await writeFile(join(dir, "codex-tui.log"), line + "\n"); + 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 body = line.split("codex_core::mcp_tool_call: ")[1]; + db.prepare("INSERT INTO logs VALUES(?,?,?,?,?)").run(1, now / 1000 - 7, "WARN", "codex_core::mcp_tool_call", body); + db.close(); + const logs = await collectHostLogs("codex", options(dir)); + const text = logs.entries.find(e => e.source === "codex_tui_log"); + const sqlite = logs.entries.find(e => e.source === "codex_runtime_sqlite"); + assert.equal(text.message, line); + assert.equal(text.occurredAt, occurredAt); + assert.equal(sqlite.message, body); + for (const entry of [text, sqlite]) { + assert.ok(entry.message.includes("-32041") && entry.message.includes("E_OWNED_SOURCE")); + assert.equal(entry.callId, undefined); + assert.equal(entry.conversationId, undefined); + assert.equal(entry.requestId, undefined); + assert.equal(entry.originalAttemptMatch, "unconfirmed"); + } +}); + +test("exact native module support excludes other services, arbitrary core logs and content echoes", async t => { + const dir = await fixture(t); + await writeFile(join(dir, "codex-tui.log"), [ + line.replace("chatcut/list_projects", "chatcut_desktop/list_projects"), + line.replace("chatcut/list_projects", "other/list_projects"), + line.replace("codex_core::mcp_tool_call", "codex_core::unrelated"), + line.replace("WARN", "INFO"), + '2026-10-24T09:37:03Z WARN codex_core::mcp_tool_call: tool result server_name=chatcut {"content":[{"text":"PRIVATE_ECHO"}]}', + '2026-10-24T09:37:03Z WARN codex_core::mcp_tool_call: Unrelated message server_name=chatcut PRIVATE_ECHO', + ].join("\n")); + assert.equal((await collectHostLogs("codex", options(dir))).entries.length, 0); +}); diff --git a/tests/windows-db-fallback.test.mjs b/tests/windows-db-fallback.test.mjs new file mode 100644 index 0000000..664d5b9 --- /dev/null +++ b/tests/windows-db-fallback.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { runDoctor } from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; + +const error = { message: "Unfamiliar provider error E_FALLBACK_NEW: transcript/tool_call are native error words", + code: "E_UNFAMILIAR_1024", cause: { type: "OpaqueNativeError", message: "Keep this original cause" } }; + +test("the bundled basics CLI saves an offline report even when upload is also requested", async t => { + const { dir, failure } = await fixture(t), output = join(dir, "cli.mcp-report.json"); + const helper = fileURLToPath(new URL("../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs", import.meta.url)); + await promisify(execFile)(process.execPath, [helper, "--host", "claude", "--server-url", "http://127.0.0.1:1/api/external-mcp/mcp", "--failure-record", failure, "--output", output, "--offline", "--upload"]); + const report = JSON.parse(await readFile(output, "utf8")); + assert.equal(report.collectorVersion, "1.2.4"); + assert.deepEqual(report.originalFailure.error, error); + assert.equal(report.upload.status, "skipped_offline"); + assert.equal(report.entryProbe, undefined); + assert.equal(report.probes.length, 0); + assert.ok(report.gaps.some(g => g.reason === "offline_requested; no_requests_sent")); +}); +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-db-fallback-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const failure = join(dir, "original.mcp-failure.json"); + await writeFile(failure, JSON.stringify({ schemaVersion: 1, source: "host_tool_result", serverName: "chatcut", + occurredAt: null, operation: "list_projects", error })); + return { dir, failure }; +} +// Windows error14 is supplied as an owned unit fixture here. Real MXC access +// failure and native-source continuation are verified separately, not simulated +// by changing permissions, corrupting a user database or copying its WAL. +const dbGaps = ["codex_runtime_sqlite", "codex_native_failed_mcp_items"].map(source => ({ + source, reason: "unavailable", operation: "select_failed_mcp_items", + error: { code: "ERR_SQLITE_ERROR", errcode: 14, message: "unable to open database file" }, +})); + +test("database errors do not replace the visible original error or independent App evidence", async t => { + const { dir, failure } = await fixture(t); + const { report, path } = await runDoctor({ host: "codex", failureRecord: failure, offline: true, output: join(dir, "fallback.mcp-report.json") }, { + fetchImpl: () => assert.fail("No fallback network requests"), + collectLogs: async () => ({ entries: [{ source: "codex_app_log", occurredAt: "2026-10-24T10:00:00Z", + message: '2026-10-24T10:00:00Z warning [AppServerConnection] mcp_server_startup_status_updated error="PRIVATE_APP_ERROR" server=chatcut status=failed threadId=null', + originalAttemptMatch: "unconfirmed" }], gaps: dbGaps, nativeFailedCalls: [] }), + }); + assert.deepEqual(report.originalFailure.error, error); + assert.equal(report.originalFailure.occurredAt, null); + assert.equal(report.originalFailure.callId, undefined); + assert.equal(report.originalFailure.requestId, undefined); + const coverage = report.evidenceSummary.hostLogCoverage; + assert.equal(coverage.originalErrorRetainedWithoutDatabase, true); + assert.equal(coverage.databaseReadFailures.length, 2); + assert.ok(coverage.databaseReadFailures.every(g => g.errcode === 14)); + assert.equal(coverage.appStartupRecords, 1); + assert.equal(coverage.failedAppStartupRecords, 1); + assert.equal(coverage.priorStderrCaptureRequired, false); + assert.equal(coverage.retainedStderrRecords, 0); + assert.equal(report.upload.status, "skipped_offline"); + assert.deepEqual(JSON.parse(await readFile(path, "utf8")).originalFailure.error, error); +}); + +test("an unexpected runtime collector exception still produces the saved original error and explicit coverage", async t => { + const { dir, failure } = await fixture(t); + const { report, path } = await runDoctor({ host: "codex", failureRecord: failure, offline: true, output: join(dir, "collector-failed.mcp-report.json") }, { + collectLogs: async () => { throw Object.assign(Error("Unexpected runtime read failure"), { code: "ERR_SQLITE_ERROR", errcode: 14 }); }, + }); + assert.deepEqual(report.originalFailure.error, error); + assert.equal(report.evidenceSummary.hostLogCoverage.originalErrorRetained, true); + assert.equal(report.evidenceSummary.hostLogCoverage.runtimeCollectionFailed, true); + assert.equal(report.hostLogs.gaps[0].error.errcode, 14); + assert.ok((await readFile(path, "utf8")).includes("E_FALLBACK_NEW")); +}); + +test("database failure without an exposed original error is not called successful original capture", async t => { + const { dir } = await fixture(t); + const { report } = await runDoctor({ host: "codex", offline: true, output: join(dir, "unknown.mcp-report.json") }, { + collectLogs: async () => ({ entries: [], gaps: dbGaps, nativeFailedCalls: [] }), + }); + assert.equal(report.evidenceSummary.hostLogCoverage.originalErrorRetained, false); + assert.equal(report.evidenceSummary.hostLogCoverage.originalErrorRetainedWithoutDatabase, false); + assert.equal(report.originalFailure.occurredAt, null); +}); From 0130207b11ed3020b45dba65857bc802ce0881c0 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 16:32:53 +0800 Subject: [PATCH 2/3] fix: preserve original MCP errors when optional metadata is invalid --- claude/.claude-plugin/plugin.json | 4 +- .../scripts/mcp-doctor.mjs | 6 +- .../scripts/mcp-evidence.mjs | 2 + .../scripts/mcp-failure.mjs | 49 ++++-- codex/.codex-plugin/plugin.json | 2 +- codex/.mcp.json | 2 +- .../scripts/mcp-doctor.mjs | 6 +- .../scripts/mcp-evidence.mjs | 2 + .../scripts/mcp-failure.mjs | 49 ++++-- tests/mcp-doctor.test.mjs | 2 +- tests/mcp-failure-metadata.test.mjs | 159 ++++++++++++++++++ 11 files changed, 244 insertions(+), 39 deletions(-) create mode 100644 tests/mcp-failure-metadata.test.mjs diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index 395d78e..151276b 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "chatcut", "displayName": "ChatCut", - "version": "1.10.23", + "version": "1.10.26", "description": "ChatCut plugin for editable video editing and creation workflows from Claude Code.", "author": { "name": "ChatCut", @@ -37,7 +37,7 @@ "headers": { "x-chatcut-mcp-client": "claude_code", "x-chatcut-mcp-surface": "embedded-preview", - "x-chatcut-plugin-version": "1.10.23" + "x-chatcut-plugin-version": "1.10.26" } } }, diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs index 513ba4a..ee1159f 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -253,7 +253,7 @@ export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { params: { protocolVersion: "2025-11-25", capabilities: {}, - clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.3" }, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.5" }, }, }), }); @@ -902,7 +902,7 @@ export async function runDoctor(options, dependencies = {}) { const diagnosticId = randomUUID(); const report = { schemaVersion: 2, - collectorVersion: "1.2.3", + collectorVersion: "1.2.5", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", @@ -1026,6 +1026,8 @@ export async function runDoctor(options, dependencies = {}) { report.originalFailure = clean( failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), ); + for (const gap of report.originalFailure.metadataGaps ?? []) + report.gaps.push({ source: "original_failure", ...gap }); if (!report.originalFailure.occurredAt && options.occurredAt) { report.originalFailure.occurredAt = options.occurredAt; report.originalFailure.timeSource = diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs index da0ed7c..0e1fea9 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs @@ -200,6 +200,8 @@ export function evidenceSummary(report) { 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"); + for (const gap of report.originalFailure.metadataGaps ?? []) + gaps.push(`original_failure_${gap.field}_rejected`); const originalRequests = report.originalFailure.status === "recorded" ? observations.flatMap((item, observationIndex) => diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs index ef9e338..cb1c8f2 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs @@ -10,6 +10,16 @@ const FIELDS = [ "requestId", "error", ]; +const OPTIONAL_IDENTIFIERS = ["conversationId", "callId", "operation", "requestId"]; +const validIdentifier = (value) => + value === undefined || + value === null || + (typeof value === "string" && /^[A-Za-z0-9_.:/-]{1,256}$/.test(value)); +const validTime = (value) => + value === null || + (typeof value === "string" && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && + Number.isFinite(Date.parse(value))); export function validateFailureRecord(input) { if ( @@ -29,23 +39,12 @@ export function validateFailureRecord(input) { throw new Error( "Failure record must identify the hosted ChatCut server and original error source", ); - if ( - input.occurredAt !== null && - (typeof input.occurredAt !== "string" || - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( - input.occurredAt, - ) || - !Number.isFinite(Date.parse(input.occurredAt))) - ) + if (!validTime(input.occurredAt)) throw new Error( "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", ); - for (const key of ["conversationId", "callId", "operation", "requestId"]) - if ( - input[key] !== undefined && - input[key] !== null && - (typeof input[key] !== "string" || !/^[A-Za-z0-9_.:/-]{1,256}$/.test(input[key])) - ) + for (const key of OPTIONAL_IDENTIFIERS) + if (!validIdentifier(input[key])) throw new Error( `Invalid original failure ${key}; never substitute a credential or a later probe ID`, ); @@ -63,7 +62,26 @@ export function validateFailureRecord(input) { } export function failureFromRecord(input) { - const record = validateFailureRecord(input); + const candidate = + input && typeof input === "object" && !Array.isArray(input) ? { ...input } : input; + const metadataGaps = []; + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + if (!validTime(candidate.occurredAt)) { + candidate.occurredAt = null; + metadataGaps.push({ + field: "occurredAt", + reason: "invalid_optional_metadata; value_omitted", + }); + } + for (const field of OPTIONAL_IDENTIFIERS) { + if (!validIdentifier(candidate[field])) { + // Invalid association hints must not discard the independent original error. + delete candidate[field]; + metadataGaps.push({ field, reason: "invalid_optional_metadata; value_omitted" }); + } + } + } + const record = validateFailureRecord(candidate); return { evidenceKind: "original_failure", status: "recorded", @@ -78,6 +96,7 @@ export function failureFromRecord(input) { ...(record.callId && { callId: record.callId }), ...(record.operation && { operation: record.operation }), ...(record.requestId && { requestId: record.requestId }), + ...(metadataGaps.length && { metadataGaps }), association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", }; } diff --git a/codex/.codex-plugin/plugin.json b/codex/.codex-plugin/plugin.json index 5d170b4..79b88c4 100644 --- a/codex/.codex-plugin/plugin.json +++ b/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "chatcut", - "version": "1.10.21", + "version": "1.10.24", "description": "ChatCut Codex plugin for editable video editing and creation workflows from Codex.", "author": { "name": "ChatCut", diff --git a/codex/.mcp.json b/codex/.mcp.json index c02fca1..ec6d288 100644 --- a/codex/.mcp.json +++ b/codex/.mcp.json @@ -3,7 +3,7 @@ "chatcut": { "http_headers": { "x-chatcut-mcp-surface": "codex", - "x-chatcut-plugin-version": "1.10.21" + "x-chatcut-plugin-version": "1.10.24" }, "oauth_resource": "https://api.chatcut.io/api/external-mcp/mcp", "url": "https://api.chatcut.io/api/external-mcp/mcp" diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs index 513ba4a..ee1159f 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -253,7 +253,7 @@ export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { params: { protocolVersion: "2025-11-25", capabilities: {}, - clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.3" }, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.5" }, }, }), }); @@ -902,7 +902,7 @@ export async function runDoctor(options, dependencies = {}) { const diagnosticId = randomUUID(); const report = { schemaVersion: 2, - collectorVersion: "1.2.3", + collectorVersion: "1.2.5", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", @@ -1026,6 +1026,8 @@ export async function runDoctor(options, dependencies = {}) { report.originalFailure = clean( failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), ); + for (const gap of report.originalFailure.metadataGaps ?? []) + report.gaps.push({ source: "original_failure", ...gap }); if (!report.originalFailure.occurredAt && options.occurredAt) { report.originalFailure.occurredAt = options.occurredAt; report.originalFailure.timeSource = diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs index da0ed7c..0e1fea9 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs @@ -200,6 +200,8 @@ export function evidenceSummary(report) { 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"); + for (const gap of report.originalFailure.metadataGaps ?? []) + gaps.push(`original_failure_${gap.field}_rejected`); const originalRequests = report.originalFailure.status === "recorded" ? observations.flatMap((item, observationIndex) => diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs index ef9e338..cb1c8f2 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs @@ -10,6 +10,16 @@ const FIELDS = [ "requestId", "error", ]; +const OPTIONAL_IDENTIFIERS = ["conversationId", "callId", "operation", "requestId"]; +const validIdentifier = (value) => + value === undefined || + value === null || + (typeof value === "string" && /^[A-Za-z0-9_.:/-]{1,256}$/.test(value)); +const validTime = (value) => + value === null || + (typeof value === "string" && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && + Number.isFinite(Date.parse(value))); export function validateFailureRecord(input) { if ( @@ -29,23 +39,12 @@ export function validateFailureRecord(input) { throw new Error( "Failure record must identify the hosted ChatCut server and original error source", ); - if ( - input.occurredAt !== null && - (typeof input.occurredAt !== "string" || - !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( - input.occurredAt, - ) || - !Number.isFinite(Date.parse(input.occurredAt))) - ) + if (!validTime(input.occurredAt)) throw new Error( "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", ); - for (const key of ["conversationId", "callId", "operation", "requestId"]) - if ( - input[key] !== undefined && - input[key] !== null && - (typeof input[key] !== "string" || !/^[A-Za-z0-9_.:/-]{1,256}$/.test(input[key])) - ) + for (const key of OPTIONAL_IDENTIFIERS) + if (!validIdentifier(input[key])) throw new Error( `Invalid original failure ${key}; never substitute a credential or a later probe ID`, ); @@ -63,7 +62,26 @@ export function validateFailureRecord(input) { } export function failureFromRecord(input) { - const record = validateFailureRecord(input); + const candidate = + input && typeof input === "object" && !Array.isArray(input) ? { ...input } : input; + const metadataGaps = []; + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + if (!validTime(candidate.occurredAt)) { + candidate.occurredAt = null; + metadataGaps.push({ + field: "occurredAt", + reason: "invalid_optional_metadata; value_omitted", + }); + } + for (const field of OPTIONAL_IDENTIFIERS) { + if (!validIdentifier(candidate[field])) { + // Invalid association hints must not discard the independent original error. + delete candidate[field]; + metadataGaps.push({ field, reason: "invalid_optional_metadata; value_omitted" }); + } + } + } + const record = validateFailureRecord(candidate); return { evidenceKind: "original_failure", status: "recorded", @@ -78,6 +96,7 @@ export function failureFromRecord(input) { ...(record.callId && { callId: record.callId }), ...(record.operation && { operation: record.operation }), ...(record.requestId && { requestId: record.requestId }), + ...(metadataGaps.length && { metadataGaps }), association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", }; } diff --git a/tests/mcp-doctor.test.mjs b/tests/mcp-doctor.test.mjs index fd251f7..92742a5 100644 --- a/tests/mcp-doctor.test.mjs +++ b/tests/mcp-doctor.test.mjs @@ -722,7 +722,7 @@ test("App fallback remains bounded and does not promote host notices into the or codexLogDir: join(dir, "empty-tui"), output: join(dir, "report.json"), }); - assert.equal(report.collectorVersion, "1.2.3"); + assert.equal(report.collectorVersion, "1.2.5"); assert.equal( report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log").length, 30, diff --git a/tests/mcp-failure-metadata.test.mjs b/tests/mcp-failure-metadata.test.mjs new file mode 100644 index 0000000..db2f128 --- /dev/null +++ b/tests/mcp-failure-metadata.test.mjs @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { + failureFromRecord, + validateFailureRecord, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs"; + +const time = "2026-10-25T08:01:12.345Z"; +const original = () => ({ + schemaVersion: 1, + source: "host_tool_result", + serverName: "chatcut", + occurredAt: time, + conversationId: "original-thread", + callId: "original-call", + operation: "list_projects", + requestId: "original-request", + error: { + message: "Original unfamiliar E_ORIGINAL_492", + code: "E_ORIGINAL_492", + cause: { message: "native socket failure", code: "ECONNRESET" }, + }, +}); + +for (const field of ["conversationId", "callId", "operation", "requestId"]) + test(`rejecting optional ${field} preserves independent failure evidence`, () => { + for (const value of [ + "codex mcp login chatcut", + { access_token: "private-canary" }, + 42, + "x".repeat(257), + ]) { + const input = { ...original(), [field]: value }; + const before = structuredClone(input); + assert.throws(() => validateFailureRecord(input), /Invalid original failure/); + const failure = failureFromRecord(input); + assert.equal(failure.status, "recorded"); + assert.deepEqual(failure.error, input.error); + assert.equal(failure.occurredAt, time); + assert.equal(failure[field], undefined); + for (const key of ["conversationId", "callId", "operation", "requestId"]) + if (key !== field) assert.equal(failure[key], input[key]); + assert.deepEqual(failure.metadataGaps, [ + { field, reason: "invalid_optional_metadata; value_omitted" }, + ]); + assert.deepEqual(input, before); + assert.doesNotMatch(JSON.stringify(failure), /private-canary|codex mcp login/); + } + }); + +test("invalid or absent failure time remains unknown while the error survives", () => { + for (const occurredAt of [ + undefined, + "yesterday", + "2026-10-25T08:00:00", + "2026-99-99T08:00:00Z", + ]) { + const input = { ...original(), occurredAt }; + assert.throws(() => validateFailureRecord(input), /occurredAt/); + const failure = failureFromRecord(input); + assert.equal(failure.occurredAt, null); + assert.equal(failure.timeSource, "unavailable"); + assert.deepEqual(failure.error, input.error); + assert.equal(failure.metadataGaps[0].field, "occurredAt"); + } + const valid = original(); + assert.equal(validateFailureRecord(valid), valid); + assert.equal(failureFromRecord(valid).metadataGaps, undefined); + assert.equal(failureFromRecord({ ...valid, occurredAt: null }).metadataGaps, undefined); +}); + +test("optional metadata recovery still rejects transcripts, wrong sources, and missing errors", () => { + for (const input of [ + null, + [], + { ...original(), arguments: { private: "must-not-read" }, operation: "bad hint" }, + { ...original(), source: "conversation", operation: "bad hint" }, + { ...original(), serverName: "chatcut_desktop", operation: "bad hint" }, + { ...original(), error: { message: "" }, operation: "bad hint" }, + ]) + assert.throws(() => failureFromRecord(input)); +}); + +for (const [host, location] of [ + ["codex", "codex/skills/chatcut-plugin-basics"], + ["claude", "claude/skills/chatcut-plugin-basics-claude"], +]) + test(`${host} shipped CLI saves sanitized failure evidence and explicit summary gaps`, async (t) => { + const dir = await mkdtemp(join(tmpdir(), "chatcut-metadata-regression-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const module = await import(new URL(`../${location}/scripts/mcp-doctor.mjs`, import.meta.url)); + for (const occurredAt of [time, "invalid-time-private-canary"]) { + const suffix = occurredAt === time ? "known-time" : "unknown-time"; + const input = { + ...original(), + occurredAt, + operation: "codex mcp login chatcut", + requestId: "Bearer private-request-canary", + error: { + ...original().error, + message: "E_ORIGINAL_492 Authorization: Bearer private-error-canary", + cause: { message: "original native cause", access_token: "private-cause-canary" }, + }, + }; + const failureRecord = join(dir, `${suffix}.mcp-failure.json`); + const output = join(dir, `${suffix}.json`); + await writeFile(failureRecord, JSON.stringify(input)); + const cli = fileURLToPath(new URL(`../${location}/scripts/mcp-doctor.mjs`, import.meta.url)); + await promisify(execFile)( + process.execPath, + [cli, "--host", host, "--failure-record", failureRecord, "--output", output], + { + env: { + ...process.env, + CODEX_HOME: dir, + CODEX_SQLITE_HOME: dir, + LOCALAPPDATA: dir, + CLAUDE_CONFIG_DIR: dir, + }, + timeout: 10000, + }, + ); + const report = JSON.parse(await readFile(output, "utf8")); + assert.equal(report.originalFailure.status, "recorded"); + assert.match(report.originalFailure.message, /E_ORIGINAL_492/); + assert.equal(report.originalFailure.error.cause.message, "original native cause"); + assert.equal(report.originalFailure.occurredAt, occurredAt === time ? time : null); + assert.equal(report.originalFailure.conversationId, "original-thread"); + assert.equal(report.originalFailure.callId, "original-call"); + assert.equal(report.originalFailure.operation, undefined); + assert.equal(report.originalFailure.requestId, undefined); + assert.equal(report.evidenceSummary.originalLocalCall.callId, "original-call"); + assert.equal(report.evidenceSummary.originalLocalCall.operation, null); + assert.equal(report.evidenceSummary.originalRequestCorrelation.status, "unconfirmed"); + assert.ok(report.evidenceSummary.gaps.includes("original_failure_operation_rejected")); + assert.ok(report.evidenceSummary.gaps.includes("original_request_id_unavailable")); + assert.ok(!report.evidenceSummary.gaps.includes("original_failure_unavailable")); + if (occurredAt !== time) + assert.ok(report.evidenceSummary.gaps.includes("original_failure_time_unavailable")); + assert.ok( + report.gaps.some((gap) => gap.source === "original_failure" && gap.field === "operation"), + ); + assert.ok(!report.gaps.some((gap) => gap.reason === "failure_record_unavailable")); + assert.equal(report.probes.length, 0); + assert.equal(report.upload.status, "skipped_endpoint_unavailable"); + assert.doesNotMatch(JSON.stringify(report), /private-[a-z-]*canary|codex mcp login/); + const fragments = module.fragmentEvents(report); + const reassembled = JSON.parse( + fragments.map((event) => event.properties.attributes.mcpDoctor.fragment).join(""), + ); + assert.deepEqual(reassembled, report); + } + }); From f321b5792fd7fee5ee6a0da2e87c0cdd8f73a219 Mon Sep 17 00:00:00 2001 From: IanYet Date: Mon, 26 Oct 2026 12:37:38 +0800 Subject: [PATCH 3/3] Preserve original response evidence and checkpoint collection --- .../references/connection-diagnostics.md | 54 ++- .../scripts/mcp-doctor.mjs | 114 ++++- .../scripts/mcp-evidence.mjs | 120 +++++- .../scripts/mcp-failure.mjs | 100 ++++- .../references/connection-diagnostics.md | 54 ++- .../scripts/mcp-doctor.mjs | 114 ++++- .../scripts/mcp-evidence.mjs | 120 +++++- .../scripts/mcp-failure.mjs | 100 ++++- docs/mcp-doctor.md | 24 ++ tests/collection-input-resilience.test.mjs | 393 ++++++++++++++++++ 10 files changed, 1157 insertions(+), 36 deletions(-) create mode 100644 tests/collection-input-resilience.test.mjs diff --git a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md index c1907e1..58d583c 100644 --- a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md +++ b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md @@ -102,6 +102,17 @@ 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. +Preflight remains strict. During collection, an invalid optional version, +configuration or session group is omitted with a named gap while independent +valid groups survive. Each observation is checked separately; invalid auxiliary +time/ID fields remain unknown without discarding its valid error message. An +unsupported source, extra payload field or malformed core observation is omitted. +Invalid outer schema/capture time still rejects the snapshot. Never insert the +package version as a missing actual host version. A static registration lookup +may use `stage: host_registration`, `operation: lookup_registered_server`, +`source: cli_configuration` or `host_settings`, and `lookupResult: found`, +`not_found` or `unavailable`; it does not establish current tools or authentication. + ## Automatically retained host failures The helper keeps admitted native failure records ahead of normal notices within @@ -172,6 +183,38 @@ are useful even if the request never reaches ChatCut; they are not server request IDs. Copy a `requestId` only when the original failed call actually provides it. All supplied facts remain labelled as Agent-supplied evidence. +When that same original error exposes an HTTP response, preserve the available +status, headers and complete error body in optional `httpResponse`: + +```json +{ + "source": "host_original_error_response", + "status": 502, + "headers": { "X-Request-ID": "actual-original-response-id", "content-type": "application/json" }, + "body": "Exact host-exposed error response body", + "bodyComplete": true +} +``` + +`status` accepts an integer from 100 through 599; header values are strings or +arrays of strings. Omit unavailable fields. Set `bodyComplete` only when the +source establishes completeness, otherwise omit it or use null. This field is +for the original failed response, never a later request or raw tool arguments/ +successful results. Preserve an exposed HTML/text response as well as JSON. +The helper redacts credential header values and actual secrets in error text; +ordinary fields, the rest of the error and causal details survive. A unique, +valid original `X-Request-ID` is retained with its Agent-supplied provenance. +Contradictory original IDs remain explicitly unresolved; a malformed header ID +does not supply a replacement. Local call/native-item IDs never become server IDs. +Invalid optional HTTP fields or association hints are omitted with value-free +gaps while a valid original error remains recorded. The helper cannot recover +response bytes that the host discarded before exposing the error. + +Dedicated original-error and failure-record input files are bounded to 128 KiB. +An oversized input is explicitly omitted with its byte count and source-retained +gap; its complete local file is left unchanged. Collected reports above the +256 KiB upload budget remain local and are not silently trimmed. + Prefer `--failure-record ` to `--original-error` when this structured source is available; choose one input. The collector centers its bounded log window on the supplied failure time and uses its conversation @@ -342,7 +385,16 @@ the cause. A disabled connection sends neither probes nor an upload. a successful finish does not prove valid framing after Node/ALB/local HTTPS inspection, nor receipt by Codex. Different requests may have different paths. -The local JSON is written before any upload. `ingest_accepted` means that the +The helper first writes the original evidence to the new local report before +host-log collection, then checkpoints available logs before independent network +checks. These checkpoints have `collectionStatus: in_progress` and a collection +stage; they are incomplete evidence and cannot be fragmented or auto-uploaded. +Updates atomically replace the previous complete checkpoint. A completed run +keeps the same diagnostic ID, marks `collectionStatus: complete`, and attempts +at most one upload. An interrupted run leaves its last saved local checkpoint; +do not treat it as a complete diagnostic or rerun merely to manufacture a report. + +`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. diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs index befbdc2..d1a4ed2 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -1,5 +1,14 @@ import { randomUUID, createHash } from "node:crypto"; -import { open, opendir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { + open, + opendir, + readFile, + readdir, + rename, + stat, + unlink, + 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"; @@ -11,6 +20,7 @@ import { inspectMetadata, isHostFailure, logTime, + snapshotFromRecord, validateSnapshot, } from "./mcp-evidence.mjs"; import { probeMetadataWire } from "./mcp-wire.mjs"; @@ -33,14 +43,18 @@ function redactFieldValue(_match, prefix, value) { export function redact(text, jsonDepth = 0) { let value = String(text); // A host may quote/stringify an entire JSON response inside its error. Decode - // only JSON string tokens containing this credential label, redact their full + // only JSON string tokens containing credential labels, redact their full // value, then restore the quoting. A partial escaped-field regex can leak the // suffix after an escaped quote/backslash. Input collection is size bounded. if (jsonDepth < 16) value = value.replace(/"(?:\\.|[^"\\\r\n])*"/g, (token) => { try { const decoded = JSON.parse(token); - if (/mcp[-_]session[-_]id/i.test(decoded)) { + if ( + /mcp[-_]session[-_]id|access_?token|refresh_?token|id_?token|session_?token|client_?secret|api[_-]?key|authorization|cookie/i.test( + decoded, + ) + ) { const sanitized = redact(decoded, jsonDepth + 1); if (sanitized !== decoded) return JSON.stringify(sanitized); } @@ -92,7 +106,7 @@ export function redact(text, jsonDepth = 0) { function clean(value) { return JSON.parse( JSON.stringify(value, (key, item) => - /^(?:mcp[-_]session[-_]id|access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( + /^(?:mcp[-_]session[-_]id|(?:proxy[-_])?authorization|(?:x[-_])?(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|token)|cookie|set-cookie)$/i.test( key, ) ? "[REDACTED]" @@ -1134,6 +1148,10 @@ export async function collectHostLogs(host, options = {}) { } export function fragmentEvents(report) { + if (report.collectionStatus === "in_progress") + throw new Error( + "Incomplete collection checkpoint cannot be uploaded; original local evidence remains available", + ); const json = JSON.stringify(clean(report)); if (Buffer.byteLength(json) > UPLOAD_LIMIT) throw new Error("Report exceeds upload budget; complete local report retained"); @@ -1258,7 +1276,28 @@ export async function runDoctor(options, dependencies = {}) { disabledBySnapshot = snapshot?.configuration?.enabled === false || snapshot?.session?.connectionStatus === "disabled"; - report.hostSnapshot = clean(validateSnapshot(snapshot, endpoint)); + const captured = snapshotFromRecord(snapshot, endpoint); + report.hostSnapshot = clean(captured.snapshot); + if (report.hostSnapshot.session?.conversationId?.includes("[REDACTED]")) { + delete report.hostSnapshot.session.conversationId; + captured.metadataGaps.push({ + field: "session.conversationId", + reason: "credential_removed_from_snapshot_metadata", + }); + } + for (const [index, observation] of (report.hostSnapshot.observations ?? []).entries()) { + if (observation.requestId?.includes("[REDACTED]")) { + delete observation.requestId; + delete observation.originalAttemptMatch; + captured.metadataGaps.push({ + field: `observations[${index}].requestId`, + reason: "credential_removed_from_snapshot_metadata", + }); + } + } + report.gaps.push( + ...captured.metadataGaps.map((gap) => ({ source: "host_snapshot", ...gap })), + ); report.hostSnapshotSource = "agent_supplied; see each field source; not_independently_verified"; if (!report.hostVersion && report.hostSnapshot.hostVersion) { @@ -1296,8 +1335,18 @@ export async function runDoctor(options, dependencies = {}) { ); } 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"); + if (info.size > READ_LIMIT) { + report.gaps.push({ + source: "original_failure", + reason: "original_error_input_size_limit", + maximumBytes: READ_LIMIT, + sourceBytes: info.size, + completeLocalSourceRetained: true, + }); + throw new Error( + "Original error file exceeds 128 KiB; complete source file retained without reading", + ); + } const message = await readFile(options.originalError, "utf8"); if (!message.trim()) throw new Error("Original error file is empty; original failure remains unavailable"); @@ -1321,11 +1370,29 @@ export async function runDoctor(options, dependencies = {}) { throw new Error( "Use a dedicated .mcp-failure.json file, never a config or session transcript", ); - if ((await stat(options.failureRecord)).size > 64 * 1024) - throw new Error("Failure record exceeds 64 KiB; complete source retained without reading"); + const info = await stat(options.failureRecord); + if (info.size > READ_LIMIT) { + report.gaps.push({ + source: "original_failure", + reason: "failure_record_input_size_limit", + maximumBytes: READ_LIMIT, + sourceBytes: info.size, + completeLocalSourceRetained: true, + }); + throw new Error("Failure record exceeds 128 KiB; complete source retained without reading"); + } report.originalFailure = clean( failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), ); + for (const field of ["conversationId", "callId", "operation", "requestId"]) { + if (report.originalFailure[field]?.includes("[REDACTED]")) { + delete report.originalFailure[field]; + (report.originalFailure.metadataGaps ??= []).push({ + field, + reason: "credential_removed_from_optional_metadata", + }); + } + } for (const gap of report.originalFailure.metadataGaps ?? []) report.gaps.push({ source: "original_failure", ...gap }); if (!report.originalFailure.occurredAt && options.occurredAt) { @@ -1357,6 +1424,25 @@ export async function runDoctor(options, dependencies = {}) { }); } } + const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); + const save = async (flag) => { + const json = JSON.stringify(clean(report), null, 2) + "\n"; + if (flag === "wx") return writeFile(path, json, { flag, mode: 0o600 }); + const pending = `${path}.writing-${diagnosticId}`; + try { + await writeFile(pending, json, { flag: "wx", mode: 0o600 }); + await rename(pending, path); + } catch (error) { + await unlink(pending).catch(() => {}); + throw error; + } + }; + report.collectionStatus = "in_progress"; + report.collectionStage = "host_logs"; + report.hostLogs = { entries: [], gaps: [{ source: "host_logs", reason: "collection_pending" }] }; + report.evidenceSummary = evidenceSummary(report); + report.upload = { status: "not_attempted_collection_incomplete", storageVerified: false }; + await save("wx"); try { report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { occurredAt: report.originalFailure.occurredAt, @@ -1377,6 +1463,9 @@ export async function runDoctor(options, dependencies = {}) { gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], }; } + report.collectionStage = "independent_checks"; + report.evidenceSummary = evidenceSummary(report); + await save("w"); const disabled = disabledBySnapshot || report.hostSnapshot?.configuration?.enabled === false || @@ -1491,6 +1580,8 @@ export async function runDoctor(options, dependencies = {}) { } } report.evidenceSummary = evidenceSummary(report); + report.collectionStatus = "complete"; + report.collectionStage = "complete"; report.upload = { status: disabled ? "skipped_intentionally_disabled" @@ -1503,10 +1594,7 @@ export async function runDoctor(options, dependencies = {}) { : "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"); + await save("w"); if (options.upload && !options.offline && !disabled && endpoint) { try { const events = fragmentEvents(report); diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs index f020513..9af54e7 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs @@ -135,8 +135,20 @@ export function validateSnapshot(input, endpoint) { "protocolCode", "message", "originalAttemptMatch", + "lookupResult", ]); source(item.source); + if ( + item.lookupResult !== undefined && + (item.stage !== "host_registration" || + item.operation !== "lookup_registered_server" || + !["cli_configuration", "host_settings"].includes(item.source) || + !["not_found", "found", "unavailable"].includes(item.lookupResult) || + (item.lookupResult === "not_found" && item.outcome === "unknown")) + ) + throw new Error( + "lookupResult requires an explicit scoped host registration observation; it does not establish active session availability", + ); if ( !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || !["success", "failed", "unknown"].includes(item.outcome) || @@ -172,6 +184,91 @@ export function validateSnapshot(input, endpoint) { return input; } +export function snapshotFromRecord(input, endpoint) { + keys(input, [ + "schemaVersion", + "observedAt", + "hostVersion", + "pluginVersion", + "configuration", + "session", + "observations", + ]); + const base = { schemaVersion: input.schemaVersion, observedAt: input.observedAt }; + validateSnapshot(base, endpoint); + const snapshot = { ...base }; + const metadataGaps = []; + const reject = (field) => + metadataGaps.push({ field, reason: "invalid_optional_snapshot_evidence; value_omitted" }); + for (const field of ["hostVersion", "pluginVersion", "configuration", "session"]) { + if (input[field] === undefined) continue; + try { + if (["configuration", "session"].includes(field) && !object(input[field])) + throw new Error("Snapshot group must be an object"); + validateSnapshot({ ...base, [field]: input[field] }, endpoint); + snapshot[field] = input[field]; + } catch { + reject(field); + } + } + if (input.observations !== undefined) { + if (!Array.isArray(input.observations) || input.observations.length > 32) + reject("observations"); + else { + snapshot.observations = []; + for (const [index, item] of input.observations.entries()) { + const path = `observations[${index}]`; + if (!object(item)) { + reject(path); + continue; + } + const candidate = { ...item }; + if (candidate.observedAt !== null && !instant(candidate.observedAt)) { + candidate.observedAt = null; + reject(path + ".observedAt"); + } + for (const field of ["operation", "requestId", "message"]) { + if (candidate[field] !== undefined && typeof candidate[field] !== "string") { + delete candidate[field]; + reject(path + "." + field); + } + } + if ( + candidate.requestId !== undefined && + !/^[A-Za-z0-9_.:/-]{1,256}$/.test(candidate.requestId) + ) { + delete candidate.requestId; + reject(path + ".requestId"); + } + for (const field of ["httpStatus", "protocolCode"]) { + if (candidate[field] !== undefined && !Number.isSafeInteger(candidate[field])) { + delete candidate[field]; + reject(path + "." + field); + } + } + if ( + candidate.originalAttemptMatch !== undefined && + (!["confirmed", "unconfirmed"].includes(candidate.originalAttemptMatch) || + (candidate.originalAttemptMatch === "confirmed" && + (!candidate.requestId || + candidate.outcome !== "failed" || + !["current_session", "runtime_log"].includes(candidate.source)))) + ) { + delete candidate.originalAttemptMatch; + reject(path + ".originalAttemptMatch"); + } + try { + validateSnapshot({ ...base, observations: [candidate] }, endpoint); + snapshot.observations.push(candidate); + } catch { + reject(path); + } + } + } + } + return { snapshot, metadataGaps }; +} + export function logTime(line) { const match = line.match(TIMESTAMP)?.[0]; return match && Number.isFinite(Date.parse(match)) ? new Date(match).toISOString() : null; @@ -275,6 +372,8 @@ export function evidenceSummary(report) { const observations = snapshot?.observations ?? []; const gaps = []; if (!snapshot) gaps.push("current_host_snapshot_unavailable"); + if (snapshot && (report.gaps ?? []).some((gap) => gap.source === "host_snapshot" && gap.field)) + gaps.push("current_host_snapshot_partial"); if (!snapshot?.configuration) gaps.push("registration_state_unavailable"); if (!snapshot?.session || snapshot.session.visibleToolCount === null) gaps.push("current_session_tools_unavailable"); @@ -283,7 +382,7 @@ export function evidenceSummary(report) { for (const gap of report.originalFailure.metadataGaps ?? []) gaps.push(`original_failure_${gap.field}_rejected`); const originalRequests = - report.originalFailure.status === "recorded" + report.originalFailure.status === "recorded" && !report.originalFailure.requestIdConflict ? observations.flatMap((item, observationIndex) => item.requestId && item.originalAttemptMatch === "confirmed" && @@ -293,12 +392,23 @@ export function evidenceSummary(report) { : [], ) : []; - if (report.originalFailure.status === "recorded" && report.originalFailure.requestId) + if ( + report.originalFailure.status === "recorded" && + report.originalFailure.requestId && + !report.originalFailure.requestIdConflict + ) originalRequests.push({ requestId: report.originalFailure.requestId, source: report.originalFailure.source, + requestIdSource: + report.originalFailure.requestIdSource ?? + "failure_record; agent_supplied_not_independently_verified", }); if (!originalRequests.length) gaps.push("original_request_id_unavailable"); + const requestIdConflict = + report.originalFailure.requestIdConflict || + new Set(originalRequests.map((item) => item.requestId)).size > 1; + if (requestIdConflict) gaps.push("original_request_id_conflict"); if (!snapshot?.pluginVersion) gaps.push("actual_loaded_plugin_version_unavailable"); if (!report.hostLogs.entries.length) gaps.push("matching_host_runtime_logs_unavailable"); const appNotices = report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log"); @@ -352,7 +462,11 @@ export function evidenceSummary(report) { originalAttemptMatch: "unconfirmed", })), originalRequestCorrelation: { - status: originalRequests.length ? "agent_reported_same_attempt" : "unconfirmed", + status: requestIdConflict + ? "conflicting_agent_supplied_request_ids" + : originalRequests.length + ? "agent_reported_same_attempt" + : "unconfirmed", observations: originalRequests, provenance: "agent_supplied_confirmation; not_independently_verified; never_inferred_from_time_or_other_ids", diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs index cb1c8f2..c472e8d 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs @@ -8,6 +8,7 @@ const FIELDS = [ "callId", "operation", "requestId", + "httpResponse", "error", ]; const OPTIONAL_IDENTIFIERS = ["conversationId", "callId", "operation", "requestId"]; @@ -20,6 +21,51 @@ const validTime = (value) => (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value))); +const HTTP_FIELDS = ["source", "status", "headers", "body", "bodyComplete"]; +const object = (value) => value && typeof value === "object" && !Array.isArray(value); +const validHeader = (value) => + typeof value === "string" || + (Array.isArray(value) && value.every((item) => typeof item === "string")); + +function normalizeHttpResponse(input, gaps) { + const reject = (field, reason = "invalid_optional_http_metadata; value_omitted") => + gaps.push({ field: `httpResponse.${field}`, reason }); + if (!object(input) || input.source !== "host_original_error_response") { + gaps.push({ + field: "httpResponse", + reason: "original_http_response_source_unconfirmed; value_omitted", + }); + return undefined; + } + const result = { source: input.source }; + if (Object.keys(input).some((key) => !HTTP_FIELDS.includes(key))) reject("unsupported_fields"); + if (input.status !== undefined) { + if (Number.isInteger(input.status) && input.status >= 100 && input.status <= 599) + result.status = input.status; + else reject("status"); + } + if (input.headers !== undefined) { + if (object(input.headers)) { + result.headers = Object.create(null); + for (const [name, value] of Object.entries(input.headers)) { + if (/^[!#$%&'*+.^_`|~A-Za-z0-9-]+$/.test(name) && validHeader(value)) + result.headers[name] = value; + else reject("headers"); + } + } else reject("headers"); + } + if (input.body !== undefined) { + if (typeof input.body === "string") result.body = input.body; + else reject("body"); + } + result.bodyComplete = null; + if (input.bodyComplete !== undefined && input.bodyComplete !== null) { + if (typeof input.bodyComplete === "boolean" && result.body !== undefined) + result.bodyComplete = input.bodyComplete; + else reject("bodyComplete"); + } + return result; +} export function validateFailureRecord(input) { if ( @@ -58,6 +104,14 @@ export function validateFailureRecord(input) { throw new Error( "Failure record needs the original nonempty error.message; preserve its code, cause and stack when available", ); + if (input.httpResponse !== undefined) { + const gaps = []; + normalizeHttpResponse(input.httpResponse, gaps); + if (gaps.length) + throw new Error( + "Invalid original failure httpResponse; preserve only an exposed original error response", + ); + } return input; } @@ -66,6 +120,11 @@ export function failureFromRecord(input) { input && typeof input === "object" && !Array.isArray(input) ? { ...input } : input; const metadataGaps = []; if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + if (candidate.httpResponse !== undefined) { + const response = normalizeHttpResponse(candidate.httpResponse, metadataGaps); + if (response) candidate.httpResponse = response; + else delete candidate.httpResponse; + } if (!validTime(candidate.occurredAt)) { candidate.occurredAt = null; metadataGaps.push({ @@ -82,6 +141,42 @@ export function failureFromRecord(input) { } } const record = validateFailureRecord(candidate); + const headerIds = Object.entries(record.httpResponse?.headers ?? {}) + .filter(([name]) => name.toLowerCase() === "x-request-id") + .flatMap(([, value]) => (Array.isArray(value) ? value : [value])); + const validHeaderIds = [ + ...new Set(headerIds.filter((value) => typeof value === "string" && validIdentifier(value))), + ]; + const invalidHeaderId = headerIds.some((value) => !validIdentifier(value)); + if (invalidHeaderId) + metadataGaps.push({ + field: "requestId", + reason: "invalid_original_response_request_id; not_used_for_association", + }); + let requestId = record.requestId; + let requestIdSource = requestId + ? "failure_record; agent_supplied_not_independently_verified" + : undefined; + let requestIdConflict; + if ( + validHeaderIds.length > 1 || + (requestId && validHeaderIds.some((value) => value !== requestId)) + ) { + requestIdConflict = { + failureRecord: requestId ?? null, + originalResponseHeaders: validHeaderIds, + }; + requestId = undefined; + requestIdSource = "conflicting_original_request_ids; none_selected"; + metadataGaps.push({ + field: "requestId", + reason: "conflicting_original_request_ids; none_selected", + }); + } else if (validHeaderIds.length === 1 && !invalidHeaderId) { + requestId = validHeaderIds[0]; + requestIdSource = + "original_failure_response_x_request_id; agent_supplied_not_independently_verified"; + } return { evidenceKind: "original_failure", status: "recorded", @@ -95,7 +190,10 @@ export function failureFromRecord(input) { ...(record.conversationId && { conversationId: record.conversationId }), ...(record.callId && { callId: record.callId }), ...(record.operation && { operation: record.operation }), - ...(record.requestId && { requestId: record.requestId }), + ...(requestId && { requestId }), + ...(requestIdSource && { requestIdSource }), + ...(requestIdConflict && { requestIdConflict }), + ...(record.httpResponse && { httpResponse: record.httpResponse }), ...(metadataGaps.length && { metadataGaps }), association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", }; diff --git a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md index c1907e1..58d583c 100644 --- a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md +++ b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md @@ -102,6 +102,17 @@ 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. +Preflight remains strict. During collection, an invalid optional version, +configuration or session group is omitted with a named gap while independent +valid groups survive. Each observation is checked separately; invalid auxiliary +time/ID fields remain unknown without discarding its valid error message. An +unsupported source, extra payload field or malformed core observation is omitted. +Invalid outer schema/capture time still rejects the snapshot. Never insert the +package version as a missing actual host version. A static registration lookup +may use `stage: host_registration`, `operation: lookup_registered_server`, +`source: cli_configuration` or `host_settings`, and `lookupResult: found`, +`not_found` or `unavailable`; it does not establish current tools or authentication. + ## Automatically retained host failures The helper keeps admitted native failure records ahead of normal notices within @@ -172,6 +183,38 @@ are useful even if the request never reaches ChatCut; they are not server request IDs. Copy a `requestId` only when the original failed call actually provides it. All supplied facts remain labelled as Agent-supplied evidence. +When that same original error exposes an HTTP response, preserve the available +status, headers and complete error body in optional `httpResponse`: + +```json +{ + "source": "host_original_error_response", + "status": 502, + "headers": { "X-Request-ID": "actual-original-response-id", "content-type": "application/json" }, + "body": "Exact host-exposed error response body", + "bodyComplete": true +} +``` + +`status` accepts an integer from 100 through 599; header values are strings or +arrays of strings. Omit unavailable fields. Set `bodyComplete` only when the +source establishes completeness, otherwise omit it or use null. This field is +for the original failed response, never a later request or raw tool arguments/ +successful results. Preserve an exposed HTML/text response as well as JSON. +The helper redacts credential header values and actual secrets in error text; +ordinary fields, the rest of the error and causal details survive. A unique, +valid original `X-Request-ID` is retained with its Agent-supplied provenance. +Contradictory original IDs remain explicitly unresolved; a malformed header ID +does not supply a replacement. Local call/native-item IDs never become server IDs. +Invalid optional HTTP fields or association hints are omitted with value-free +gaps while a valid original error remains recorded. The helper cannot recover +response bytes that the host discarded before exposing the error. + +Dedicated original-error and failure-record input files are bounded to 128 KiB. +An oversized input is explicitly omitted with its byte count and source-retained +gap; its complete local file is left unchanged. Collected reports above the +256 KiB upload budget remain local and are not silently trimmed. + Prefer `--failure-record ` to `--original-error` when this structured source is available; choose one input. The collector centers its bounded log window on the supplied failure time and uses its conversation @@ -342,7 +385,16 @@ the cause. A disabled connection sends neither probes nor an upload. a successful finish does not prove valid framing after Node/ALB/local HTTPS inspection, nor receipt by Codex. Different requests may have different paths. -The local JSON is written before any upload. `ingest_accepted` means that the +The helper first writes the original evidence to the new local report before +host-log collection, then checkpoints available logs before independent network +checks. These checkpoints have `collectionStatus: in_progress` and a collection +stage; they are incomplete evidence and cannot be fragmented or auto-uploaded. +Updates atomically replace the previous complete checkpoint. A completed run +keeps the same diagnostic ID, marks `collectionStatus: complete`, and attempts +at most one upload. An interrupted run leaves its last saved local checkpoint; +do not treat it as a complete diagnostic or rerun merely to manufacture a report. + +`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. diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs index befbdc2..d1a4ed2 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -1,5 +1,14 @@ import { randomUUID, createHash } from "node:crypto"; -import { open, opendir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { + open, + opendir, + readFile, + readdir, + rename, + stat, + unlink, + 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"; @@ -11,6 +20,7 @@ import { inspectMetadata, isHostFailure, logTime, + snapshotFromRecord, validateSnapshot, } from "./mcp-evidence.mjs"; import { probeMetadataWire } from "./mcp-wire.mjs"; @@ -33,14 +43,18 @@ function redactFieldValue(_match, prefix, value) { export function redact(text, jsonDepth = 0) { let value = String(text); // A host may quote/stringify an entire JSON response inside its error. Decode - // only JSON string tokens containing this credential label, redact their full + // only JSON string tokens containing credential labels, redact their full // value, then restore the quoting. A partial escaped-field regex can leak the // suffix after an escaped quote/backslash. Input collection is size bounded. if (jsonDepth < 16) value = value.replace(/"(?:\\.|[^"\\\r\n])*"/g, (token) => { try { const decoded = JSON.parse(token); - if (/mcp[-_]session[-_]id/i.test(decoded)) { + if ( + /mcp[-_]session[-_]id|access_?token|refresh_?token|id_?token|session_?token|client_?secret|api[_-]?key|authorization|cookie/i.test( + decoded, + ) + ) { const sanitized = redact(decoded, jsonDepth + 1); if (sanitized !== decoded) return JSON.stringify(sanitized); } @@ -92,7 +106,7 @@ export function redact(text, jsonDepth = 0) { function clean(value) { return JSON.parse( JSON.stringify(value, (key, item) => - /^(?:mcp[-_]session[-_]id|access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)$/i.test( + /^(?:mcp[-_]session[-_]id|(?:proxy[-_])?authorization|(?:x[-_])?(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|token)|cookie|set-cookie)$/i.test( key, ) ? "[REDACTED]" @@ -1134,6 +1148,10 @@ export async function collectHostLogs(host, options = {}) { } export function fragmentEvents(report) { + if (report.collectionStatus === "in_progress") + throw new Error( + "Incomplete collection checkpoint cannot be uploaded; original local evidence remains available", + ); const json = JSON.stringify(clean(report)); if (Buffer.byteLength(json) > UPLOAD_LIMIT) throw new Error("Report exceeds upload budget; complete local report retained"); @@ -1258,7 +1276,28 @@ export async function runDoctor(options, dependencies = {}) { disabledBySnapshot = snapshot?.configuration?.enabled === false || snapshot?.session?.connectionStatus === "disabled"; - report.hostSnapshot = clean(validateSnapshot(snapshot, endpoint)); + const captured = snapshotFromRecord(snapshot, endpoint); + report.hostSnapshot = clean(captured.snapshot); + if (report.hostSnapshot.session?.conversationId?.includes("[REDACTED]")) { + delete report.hostSnapshot.session.conversationId; + captured.metadataGaps.push({ + field: "session.conversationId", + reason: "credential_removed_from_snapshot_metadata", + }); + } + for (const [index, observation] of (report.hostSnapshot.observations ?? []).entries()) { + if (observation.requestId?.includes("[REDACTED]")) { + delete observation.requestId; + delete observation.originalAttemptMatch; + captured.metadataGaps.push({ + field: `observations[${index}].requestId`, + reason: "credential_removed_from_snapshot_metadata", + }); + } + } + report.gaps.push( + ...captured.metadataGaps.map((gap) => ({ source: "host_snapshot", ...gap })), + ); report.hostSnapshotSource = "agent_supplied; see each field source; not_independently_verified"; if (!report.hostVersion && report.hostSnapshot.hostVersion) { @@ -1296,8 +1335,18 @@ export async function runDoctor(options, dependencies = {}) { ); } 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"); + if (info.size > READ_LIMIT) { + report.gaps.push({ + source: "original_failure", + reason: "original_error_input_size_limit", + maximumBytes: READ_LIMIT, + sourceBytes: info.size, + completeLocalSourceRetained: true, + }); + throw new Error( + "Original error file exceeds 128 KiB; complete source file retained without reading", + ); + } const message = await readFile(options.originalError, "utf8"); if (!message.trim()) throw new Error("Original error file is empty; original failure remains unavailable"); @@ -1321,11 +1370,29 @@ export async function runDoctor(options, dependencies = {}) { throw new Error( "Use a dedicated .mcp-failure.json file, never a config or session transcript", ); - if ((await stat(options.failureRecord)).size > 64 * 1024) - throw new Error("Failure record exceeds 64 KiB; complete source retained without reading"); + const info = await stat(options.failureRecord); + if (info.size > READ_LIMIT) { + report.gaps.push({ + source: "original_failure", + reason: "failure_record_input_size_limit", + maximumBytes: READ_LIMIT, + sourceBytes: info.size, + completeLocalSourceRetained: true, + }); + throw new Error("Failure record exceeds 128 KiB; complete source retained without reading"); + } report.originalFailure = clean( failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), ); + for (const field of ["conversationId", "callId", "operation", "requestId"]) { + if (report.originalFailure[field]?.includes("[REDACTED]")) { + delete report.originalFailure[field]; + (report.originalFailure.metadataGaps ??= []).push({ + field, + reason: "credential_removed_from_optional_metadata", + }); + } + } for (const gap of report.originalFailure.metadataGaps ?? []) report.gaps.push({ source: "original_failure", ...gap }); if (!report.originalFailure.occurredAt && options.occurredAt) { @@ -1357,6 +1424,25 @@ export async function runDoctor(options, dependencies = {}) { }); } } + const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); + const save = async (flag) => { + const json = JSON.stringify(clean(report), null, 2) + "\n"; + if (flag === "wx") return writeFile(path, json, { flag, mode: 0o600 }); + const pending = `${path}.writing-${diagnosticId}`; + try { + await writeFile(pending, json, { flag: "wx", mode: 0o600 }); + await rename(pending, path); + } catch (error) { + await unlink(pending).catch(() => {}); + throw error; + } + }; + report.collectionStatus = "in_progress"; + report.collectionStage = "host_logs"; + report.hostLogs = { entries: [], gaps: [{ source: "host_logs", reason: "collection_pending" }] }; + report.evidenceSummary = evidenceSummary(report); + report.upload = { status: "not_attempted_collection_incomplete", storageVerified: false }; + await save("wx"); try { report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { occurredAt: report.originalFailure.occurredAt, @@ -1377,6 +1463,9 @@ export async function runDoctor(options, dependencies = {}) { gaps: [{ source: "host_logs", reason: "collector_failed", error: errorDetails(error) }], }; } + report.collectionStage = "independent_checks"; + report.evidenceSummary = evidenceSummary(report); + await save("w"); const disabled = disabledBySnapshot || report.hostSnapshot?.configuration?.enabled === false || @@ -1491,6 +1580,8 @@ export async function runDoctor(options, dependencies = {}) { } } report.evidenceSummary = evidenceSummary(report); + report.collectionStatus = "complete"; + report.collectionStage = "complete"; report.upload = { status: disabled ? "skipped_intentionally_disabled" @@ -1503,10 +1594,7 @@ export async function runDoctor(options, dependencies = {}) { : "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"); + await save("w"); if (options.upload && !options.offline && !disabled && endpoint) { try { const events = fragmentEvents(report); diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs index f020513..9af54e7 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs @@ -135,8 +135,20 @@ export function validateSnapshot(input, endpoint) { "protocolCode", "message", "originalAttemptMatch", + "lookupResult", ]); source(item.source); + if ( + item.lookupResult !== undefined && + (item.stage !== "host_registration" || + item.operation !== "lookup_registered_server" || + !["cli_configuration", "host_settings"].includes(item.source) || + !["not_found", "found", "unavailable"].includes(item.lookupResult) || + (item.lookupResult === "not_found" && item.outcome === "unknown")) + ) + throw new Error( + "lookupResult requires an explicit scoped host registration observation; it does not establish active session availability", + ); if ( !/^[a-z][a-z0-9_./:-]{0,127}$/.test(item.stage) || !["success", "failed", "unknown"].includes(item.outcome) || @@ -172,6 +184,91 @@ export function validateSnapshot(input, endpoint) { return input; } +export function snapshotFromRecord(input, endpoint) { + keys(input, [ + "schemaVersion", + "observedAt", + "hostVersion", + "pluginVersion", + "configuration", + "session", + "observations", + ]); + const base = { schemaVersion: input.schemaVersion, observedAt: input.observedAt }; + validateSnapshot(base, endpoint); + const snapshot = { ...base }; + const metadataGaps = []; + const reject = (field) => + metadataGaps.push({ field, reason: "invalid_optional_snapshot_evidence; value_omitted" }); + for (const field of ["hostVersion", "pluginVersion", "configuration", "session"]) { + if (input[field] === undefined) continue; + try { + if (["configuration", "session"].includes(field) && !object(input[field])) + throw new Error("Snapshot group must be an object"); + validateSnapshot({ ...base, [field]: input[field] }, endpoint); + snapshot[field] = input[field]; + } catch { + reject(field); + } + } + if (input.observations !== undefined) { + if (!Array.isArray(input.observations) || input.observations.length > 32) + reject("observations"); + else { + snapshot.observations = []; + for (const [index, item] of input.observations.entries()) { + const path = `observations[${index}]`; + if (!object(item)) { + reject(path); + continue; + } + const candidate = { ...item }; + if (candidate.observedAt !== null && !instant(candidate.observedAt)) { + candidate.observedAt = null; + reject(path + ".observedAt"); + } + for (const field of ["operation", "requestId", "message"]) { + if (candidate[field] !== undefined && typeof candidate[field] !== "string") { + delete candidate[field]; + reject(path + "." + field); + } + } + if ( + candidate.requestId !== undefined && + !/^[A-Za-z0-9_.:/-]{1,256}$/.test(candidate.requestId) + ) { + delete candidate.requestId; + reject(path + ".requestId"); + } + for (const field of ["httpStatus", "protocolCode"]) { + if (candidate[field] !== undefined && !Number.isSafeInteger(candidate[field])) { + delete candidate[field]; + reject(path + "." + field); + } + } + if ( + candidate.originalAttemptMatch !== undefined && + (!["confirmed", "unconfirmed"].includes(candidate.originalAttemptMatch) || + (candidate.originalAttemptMatch === "confirmed" && + (!candidate.requestId || + candidate.outcome !== "failed" || + !["current_session", "runtime_log"].includes(candidate.source)))) + ) { + delete candidate.originalAttemptMatch; + reject(path + ".originalAttemptMatch"); + } + try { + validateSnapshot({ ...base, observations: [candidate] }, endpoint); + snapshot.observations.push(candidate); + } catch { + reject(path); + } + } + } + } + return { snapshot, metadataGaps }; +} + export function logTime(line) { const match = line.match(TIMESTAMP)?.[0]; return match && Number.isFinite(Date.parse(match)) ? new Date(match).toISOString() : null; @@ -275,6 +372,8 @@ export function evidenceSummary(report) { const observations = snapshot?.observations ?? []; const gaps = []; if (!snapshot) gaps.push("current_host_snapshot_unavailable"); + if (snapshot && (report.gaps ?? []).some((gap) => gap.source === "host_snapshot" && gap.field)) + gaps.push("current_host_snapshot_partial"); if (!snapshot?.configuration) gaps.push("registration_state_unavailable"); if (!snapshot?.session || snapshot.session.visibleToolCount === null) gaps.push("current_session_tools_unavailable"); @@ -283,7 +382,7 @@ export function evidenceSummary(report) { for (const gap of report.originalFailure.metadataGaps ?? []) gaps.push(`original_failure_${gap.field}_rejected`); const originalRequests = - report.originalFailure.status === "recorded" + report.originalFailure.status === "recorded" && !report.originalFailure.requestIdConflict ? observations.flatMap((item, observationIndex) => item.requestId && item.originalAttemptMatch === "confirmed" && @@ -293,12 +392,23 @@ export function evidenceSummary(report) { : [], ) : []; - if (report.originalFailure.status === "recorded" && report.originalFailure.requestId) + if ( + report.originalFailure.status === "recorded" && + report.originalFailure.requestId && + !report.originalFailure.requestIdConflict + ) originalRequests.push({ requestId: report.originalFailure.requestId, source: report.originalFailure.source, + requestIdSource: + report.originalFailure.requestIdSource ?? + "failure_record; agent_supplied_not_independently_verified", }); if (!originalRequests.length) gaps.push("original_request_id_unavailable"); + const requestIdConflict = + report.originalFailure.requestIdConflict || + new Set(originalRequests.map((item) => item.requestId)).size > 1; + if (requestIdConflict) gaps.push("original_request_id_conflict"); if (!snapshot?.pluginVersion) gaps.push("actual_loaded_plugin_version_unavailable"); if (!report.hostLogs.entries.length) gaps.push("matching_host_runtime_logs_unavailable"); const appNotices = report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log"); @@ -352,7 +462,11 @@ export function evidenceSummary(report) { originalAttemptMatch: "unconfirmed", })), originalRequestCorrelation: { - status: originalRequests.length ? "agent_reported_same_attempt" : "unconfirmed", + status: requestIdConflict + ? "conflicting_agent_supplied_request_ids" + : originalRequests.length + ? "agent_reported_same_attempt" + : "unconfirmed", observations: originalRequests, provenance: "agent_supplied_confirmation; not_independently_verified; never_inferred_from_time_or_other_ids", diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs index cb1c8f2..c472e8d 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs @@ -8,6 +8,7 @@ const FIELDS = [ "callId", "operation", "requestId", + "httpResponse", "error", ]; const OPTIONAL_IDENTIFIERS = ["conversationId", "callId", "operation", "requestId"]; @@ -20,6 +21,51 @@ const validTime = (value) => (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value))); +const HTTP_FIELDS = ["source", "status", "headers", "body", "bodyComplete"]; +const object = (value) => value && typeof value === "object" && !Array.isArray(value); +const validHeader = (value) => + typeof value === "string" || + (Array.isArray(value) && value.every((item) => typeof item === "string")); + +function normalizeHttpResponse(input, gaps) { + const reject = (field, reason = "invalid_optional_http_metadata; value_omitted") => + gaps.push({ field: `httpResponse.${field}`, reason }); + if (!object(input) || input.source !== "host_original_error_response") { + gaps.push({ + field: "httpResponse", + reason: "original_http_response_source_unconfirmed; value_omitted", + }); + return undefined; + } + const result = { source: input.source }; + if (Object.keys(input).some((key) => !HTTP_FIELDS.includes(key))) reject("unsupported_fields"); + if (input.status !== undefined) { + if (Number.isInteger(input.status) && input.status >= 100 && input.status <= 599) + result.status = input.status; + else reject("status"); + } + if (input.headers !== undefined) { + if (object(input.headers)) { + result.headers = Object.create(null); + for (const [name, value] of Object.entries(input.headers)) { + if (/^[!#$%&'*+.^_`|~A-Za-z0-9-]+$/.test(name) && validHeader(value)) + result.headers[name] = value; + else reject("headers"); + } + } else reject("headers"); + } + if (input.body !== undefined) { + if (typeof input.body === "string") result.body = input.body; + else reject("body"); + } + result.bodyComplete = null; + if (input.bodyComplete !== undefined && input.bodyComplete !== null) { + if (typeof input.bodyComplete === "boolean" && result.body !== undefined) + result.bodyComplete = input.bodyComplete; + else reject("bodyComplete"); + } + return result; +} export function validateFailureRecord(input) { if ( @@ -58,6 +104,14 @@ export function validateFailureRecord(input) { throw new Error( "Failure record needs the original nonempty error.message; preserve its code, cause and stack when available", ); + if (input.httpResponse !== undefined) { + const gaps = []; + normalizeHttpResponse(input.httpResponse, gaps); + if (gaps.length) + throw new Error( + "Invalid original failure httpResponse; preserve only an exposed original error response", + ); + } return input; } @@ -66,6 +120,11 @@ export function failureFromRecord(input) { input && typeof input === "object" && !Array.isArray(input) ? { ...input } : input; const metadataGaps = []; if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + if (candidate.httpResponse !== undefined) { + const response = normalizeHttpResponse(candidate.httpResponse, metadataGaps); + if (response) candidate.httpResponse = response; + else delete candidate.httpResponse; + } if (!validTime(candidate.occurredAt)) { candidate.occurredAt = null; metadataGaps.push({ @@ -82,6 +141,42 @@ export function failureFromRecord(input) { } } const record = validateFailureRecord(candidate); + const headerIds = Object.entries(record.httpResponse?.headers ?? {}) + .filter(([name]) => name.toLowerCase() === "x-request-id") + .flatMap(([, value]) => (Array.isArray(value) ? value : [value])); + const validHeaderIds = [ + ...new Set(headerIds.filter((value) => typeof value === "string" && validIdentifier(value))), + ]; + const invalidHeaderId = headerIds.some((value) => !validIdentifier(value)); + if (invalidHeaderId) + metadataGaps.push({ + field: "requestId", + reason: "invalid_original_response_request_id; not_used_for_association", + }); + let requestId = record.requestId; + let requestIdSource = requestId + ? "failure_record; agent_supplied_not_independently_verified" + : undefined; + let requestIdConflict; + if ( + validHeaderIds.length > 1 || + (requestId && validHeaderIds.some((value) => value !== requestId)) + ) { + requestIdConflict = { + failureRecord: requestId ?? null, + originalResponseHeaders: validHeaderIds, + }; + requestId = undefined; + requestIdSource = "conflicting_original_request_ids; none_selected"; + metadataGaps.push({ + field: "requestId", + reason: "conflicting_original_request_ids; none_selected", + }); + } else if (validHeaderIds.length === 1 && !invalidHeaderId) { + requestId = validHeaderIds[0]; + requestIdSource = + "original_failure_response_x_request_id; agent_supplied_not_independently_verified"; + } return { evidenceKind: "original_failure", status: "recorded", @@ -95,7 +190,10 @@ export function failureFromRecord(input) { ...(record.conversationId && { conversationId: record.conversationId }), ...(record.callId && { callId: record.callId }), ...(record.operation && { operation: record.operation }), - ...(record.requestId && { requestId: record.requestId }), + ...(requestId && { requestId }), + ...(requestIdSource && { requestIdSource }), + ...(requestIdConflict && { requestIdConflict }), + ...(record.httpResponse && { httpResponse: record.httpResponse }), ...(metadataGaps.length && { metadataGaps }), association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", }; diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index ad7b15a..63eb1be 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -29,6 +29,30 @@ preconfigured stderr. Hidden startup errors with no accessible source remain unavailable. MCP session credentials in error text are redacted. This change adds no separate Doctor Skill, diagnosis/repair rules or publication workflow. +The same collection candidate additionally preserves optional original HTTP +response evidence through the dedicated failure record. It retains supplied +status, headers, full available error body and completeness, while redacting +credential fields and actual secrets. It never obtains this evidence from later +probes. An original response X-Request-ID has explicit Agent-supplied provenance; +conflicting original IDs remain unresolved. Invalid optional response/association +fields do not discard a valid original error. Dedicated original inputs are +bounded to 128 KiB; oversized input stays unchanged locally with an explicit gap. +The 256 KiB report upload budget still applies without silently trimming errors. + +Snapshot collection salvages valid independent optional groups and observations +with named, value-free gaps. Strict preflight remains available; unsupported +sources/payload fields and malformed core observations remain excluded. Invalid +outer schema/capture time still rejects the snapshot. Static `host_registration` +lookup facts do not prove active tools, authentication or recovery. + +Before host-log collection the helper saves an `in_progress` checkpoint containing +the original supplied error; before independent checks it saves available logs. +Later saves use a task-specific same-directory temporary file and atomic rename +to preserve the previous checkpoint if a write is interrupted. Partial reports +cannot be fragmented/uploaded. Normal completion uses the same diagnostic ID and +at most one upload. This protects evidence retention, not automatic retry or +recovery. Neither host log event time nor checkpoint time fills original time. + Collector 1.2.3 adds a dedicated `.mcp-failure.json` input for the original host tool/runtime error, its available failure time and local conversation/call IDs. The Skill preserves this evidence before retries; a missing failure timestamp diff --git a/tests/collection-input-resilience.test.mjs b/tests/collection-input-resilience.test.mjs new file mode 100644 index 0000000..1642196 --- /dev/null +++ b/tests/collection-input-resilience.test.mjs @@ -0,0 +1,393 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { failureFromRecord } from "../codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs"; +import { + snapshotFromRecord, + validateSnapshot, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs"; + +const instant = "2026-10-26T03:00:00.123Z"; +const original = (extra) => ({ + schemaVersion: 1, + source: "host_tool_result", + serverName: "chatcut", + occurredAt: instant, + conversationId: "original-thread", + callId: "original-call", + operation: "browser_execute", + error: { + message: "Original browser task failed E_BROWSER_126", + code: "E_BROWSER_126", + cause: { message: "native cause remains", code: "ECONNRESET" }, + }, + ...extra, +}); +const response = (extra) => ({ + source: "host_original_error_response", + status: 502, + headers: { + "X-Request-ID": "original-server-request", + "content-type": "application/json", + "Mcp-Session-Id": "PRIVATE_SESSION", + Cookie: "sid=PRIVATE_COOKIE", + "Set-Cookie": "sid=PRIVATE_SET_COOKIE", + Authorization: "Bearer PRIVATE_AUTH", + "Proxy-Authorization": "Basic PRIVATE_PROXY_AUTH", + "X-Api-Key": "PRIVATE_API_KEY", + }, + body: JSON.stringify({ + error: { + message: "Full response original cause", + code: "E_HTTP_ORIGINAL", + access_token: "PRIVATE_BODY_TOKEN", + }, + }), + bodyComplete: true, + ...extra, +}); +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-input-resilience-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +test("HTTP evidence is optional and contradicting original request IDs remain unresolved", () => { + const accepted = failureFromRecord(original({ httpResponse: response() })); + assert.equal(accepted.requestId, "original-server-request"); + assert.match(accepted.requestIdSource, /^original_failure_response_x_request_id/); + assert.equal(accepted.httpResponse.status, 502); + for (const headers of [ + { "X-Request-ID": "different-request" }, + { "x-request-id": ["first-request", "second-request"] }, + ]) { + const conflict = failureFromRecord( + original({ requestId: "record-request", httpResponse: response({ headers }) }), + ); + assert.equal(conflict.requestId, undefined); + assert.ok(conflict.requestIdConflict); + assert.ok( + conflict.metadataGaps.some( + (gap) => gap.reason === "conflicting_original_request_ids; none_selected", + ), + ); + } + for (const httpResponse of [ + "PRIVATE_BAD_SHAPE", + response({ source: "later_probe", headers: { "X-Request-ID": "LATER_REQUEST" } }), + ]) { + const rejected = failureFromRecord(original({ httpResponse })); + assert.equal(rejected.status, "recorded"); + assert.equal(rejected.httpResponse, undefined); + assert.equal(rejected.requestId, undefined); + assert.ok(!JSON.stringify(rejected).includes("PRIVATE_BAD_SHAPE")); + assert.ok(!JSON.stringify(rejected).includes("LATER_REQUEST")); + } + const partial = failureFromRecord( + original({ + httpResponse: response({ + status: "bad", + headers: { valid: "retained", invalid: { token: "PRIVATE_INVALID_HEADER" } }, + bodyComplete: "bad", + }), + }), + ); + assert.equal(partial.error.cause.code, "ECONNRESET"); + assert.equal(partial.httpResponse.status, undefined); + assert.equal(partial.httpResponse.headers.valid, "retained"); + assert.equal(partial.httpResponse.headers.invalid, undefined); + assert.equal(partial.httpResponse.bodyComplete, null); + assert.ok(!JSON.stringify(partial.metadataGaps).includes("PRIVATE_")); + const ambiguous = failureFromRecord( + original({ + httpResponse: response({ headers: { "X-Request-ID": ["valid-id", "Bearer PRIVATE_TOKEN"] } }), + }), + ); + assert.equal(ambiguous.requestId, undefined); + assert.ok( + ambiguous.metadataGaps.some( + (gap) => gap.reason === "invalid_original_response_request_id; not_used_for_association", + ), + ); + const explicit = failureFromRecord( + original({ + requestId: "record-request", + httpResponse: response({ headers: { "X-Request-ID": "Bearer PRIVATE_TOKEN" } }), + }), + ); + assert.equal(explicit.requestId, "record-request"); + const specialHeader = failureFromRecord( + original({ + httpResponse: response({ headers: JSON.parse('{"__proto__":["retained-header"]}') }), + }), + ); + assert.deepEqual(specialHeader.httpResponse.headers.__proto__, ["retained-header"]); +}); + +test("snapshot collection retains independent valid groups and observations with explicit gaps", () => { + const good = { + stage: "browser_execution", + operation: "browser_execute", + source: "current_session", + observedAt: instant, + outcome: "failed", + message: "E_BROWSER_ORIGINAL", + }; + const snapshot = { + schemaVersion: 1, + observedAt: instant, + configuration: { + source: "host_settings", + serverName: "other", + url: "https://other.invalid", + enabled: true, + }, + session: { + source: "cli_configuration", + connectionStatus: "connected", + authStatus: "authenticated", + visibleToolCount: 10, + }, + observations: [ + good, + { + ...good, + observedAt: "bad PRIVATE_TIME", + operation: { private: true }, + requestId: "Bearer PRIVATE_TOKEN", + originalAttemptMatch: "confirmed", + }, + { ...good, source: "other_server", message: "PRIVATE_OTHER_SERVER" }, + { ...good, arguments: { prompt: "PRIVATE_TOOL_ARGUMENTS" } }, + ], + }; + assert.throws(() => validateSnapshot(snapshot)); + const { snapshot: kept, metadataGaps } = snapshotFromRecord(snapshot); + assert.equal(kept.configuration, undefined); + assert.equal(kept.session, undefined); + assert.equal(kept.observations.length, 2); + assert.equal(kept.observations[0].message, good.message); + assert.equal(kept.observations[1].observedAt, null); + assert.equal(kept.observations[1].requestId, undefined); + assert.equal(kept.observations[1].originalAttemptMatch, undefined); + assert.ok(!JSON.stringify({ kept, metadataGaps }).includes("PRIVATE_")); + assert.throws(() => snapshotFromRecord({ ...snapshot, credential: "PRIVATE_TOP_LEVEL" })); + assert.throws(() => snapshotFromRecord({ ...snapshot, schemaVersion: 2 })); + for (const invalid of [null, false, 0, ""]) { + const result = snapshotFromRecord({ + schemaVersion: 1, + observedAt: instant, + configuration: invalid, + session: invalid, + observations: [good], + }); + assert.equal(result.snapshot.configuration, undefined); + assert.equal(result.snapshot.session, undefined); + assert.equal(result.snapshot.observations.length, 1); + assert.equal(result.metadataGaps.length, 2); + } + const registration = { + ...good, + stage: "host_registration", + operation: "lookup_registered_server", + source: "cli_configuration", + outcome: "success", + lookupResult: "found", + }; + assert.equal( + validateSnapshot({ schemaVersion: 1, observedAt: instant, observations: [registration] }) + .observations[0].lookupResult, + "found", + ); +}); + +for (const [host, folder] of [ + ["codex", "codex/skills/chatcut-plugin-basics"], + ["claude", "claude/skills/chatcut-plugin-basics-claude"], +]) { + const module = await import(new URL(`../${folder}/scripts/mcp-doctor.mjs`, import.meta.url)); + test(`${host}: saved partial snapshot keeps valid errors and flags conflicting original IDs`, async (t) => { + const dir = await fixture(t), + failureRecord = join(dir, "original.mcp-failure.json"), + hostSnapshot = join(dir, "host.mcp-snapshot.json"); + await writeFile(failureRecord, JSON.stringify(original({ httpResponse: response() }))); + await writeFile( + hostSnapshot, + JSON.stringify({ + schemaVersion: 1, + observedAt: instant, + configuration: { + source: "host_settings", + serverName: "other", + url: "https://other.invalid", + enabled: true, + }, + hostVersion: { private: "PRIVATE_VERSION" }, + observations: [ + { + stage: "browser_execution", + operation: "browser_execute", + source: "current_session", + observedAt: null, + outcome: "failed", + requestId: "different-original-id", + originalAttemptMatch: "confirmed", + message: "E_VALID_BROWSER_ERROR", + }, + { + stage: "initialize", + source: "other_server", + observedAt: instant, + outcome: "failed", + message: "PRIVATE_OTHER_SERVER", + }, + ], + }), + ); + const output = join(dir, "report.json"); + const { report } = await module.runDoctor( + { host, hostSnapshot, failureRecord, output, offline: true }, + { collectLogs: async () => ({ entries: [], gaps: [] }) }, + ); + assert.equal(report.originalFailure.status, "recorded"); + assert.equal(report.hostSnapshot.observations.length, 1); + assert.equal(report.hostSnapshot.observations[0].message, "E_VALID_BROWSER_ERROR"); + assert.equal(report.hostSnapshot.observations[0].observedAt, null); + assert.equal(report.hostVersion, null); + assert.ok(report.evidenceSummary.gaps.includes("current_host_snapshot_partial")); + assert.ok(report.evidenceSummary.gaps.includes("original_request_id_conflict")); + assert.equal( + report.evidenceSummary.originalRequestCorrelation.status, + "conflicting_agent_supplied_request_ids", + ); + assert.ok(!JSON.stringify(report).includes("PRIVATE_")); + assert.deepEqual(JSON.parse(await readFile(output, "utf8")), report); + }); + test(`${host}: full supplied HTTP error response survives optional-field rejection and precise redaction`, async (t) => { + const dir = await fixture(t), + input = join(dir, "original.mcp-failure.json"), + output = join(dir, "report.json"); + const fullBody = JSON.stringify({ + message: "X".repeat(70000) + " E_FULL_BODY_TAIL", + access_token: 'PRIVATE_ESCAPED_"TOKEN\\SUFFIX', + }); + await writeFile( + input, + JSON.stringify( + original({ operation: "invalid operation", httpResponse: response({ body: fullBody }) }), + ), + ); + const { report } = await module.runDoctor( + { host, failureRecord: input, output, offline: true }, + { collectLogs: async () => ({ entries: [], gaps: [] }) }, + ); + assert.equal(report.originalFailure.status, "recorded"); + assert.equal(report.originalFailure.error.cause.code, "ECONNRESET"); + assert.equal(report.originalFailure.occurredAt, instant); + assert.equal(report.originalFailure.callId, "original-call"); + assert.equal(report.originalFailure.operation, undefined); + assert.equal(report.originalFailure.httpResponse.status, 502); + assert.equal(report.originalFailure.httpResponse.bodyComplete, true); + assert.match(report.originalFailure.httpResponse.body, /E_FULL_BODY_TAIL/); + assert.equal(report.originalFailure.httpResponse.headers["content-type"], "application/json"); + assert.equal(report.originalFailure.requestId, "original-server-request"); + assert.equal( + report.evidenceSummary.originalRequestCorrelation.status, + "agent_reported_same_attempt", + ); + assert.ok(!JSON.stringify(report).includes("PRIVATE_")); + const fragments = module.fragmentEvents(report); + assert.deepEqual( + JSON.parse(fragments.map((event) => event.properties.attributes.mcpDoctor.fragment).join("")), + report, + ); + assert.equal(report.collectionStatus, "complete"); + }); + test(`${host}: original and host logs are checkpointed before independent network checks`, async (t) => { + const dir = await fixture(t), + input = join(dir, "original.mcp-failure.json"), + output = join(dir, "report.json"); + await writeFile(input, JSON.stringify(original())); + let firstId, + posts = 0, + probes = 0; + const { report } = await module.runDoctor( + { + host, + failureRecord: input, + output, + serverUrl: "http://127.0.0.1:9/api/external-mcp/mcp", + upload: true, + }, + { + collectLogs: async () => { + const checkpoint = JSON.parse(await readFile(output, "utf8")); + assert.equal(checkpoint.originalFailure.error.code, "E_BROWSER_126"); + assert.equal(checkpoint.collectionStatus, "in_progress"); + assert.equal(checkpoint.collectionStage, "host_logs"); + assert.equal(checkpoint.upload.status, "not_attempted_collection_incomplete"); + assert.throws( + () => module.fragmentEvents(checkpoint), + /Incomplete collection checkpoint/, + ); + firstId = checkpoint.diagnosticId; + return { + entries: [ + { + source: "claude_debug_log", + message: 'MCP server "chatcut": Error E_HOST_ORIGINAL', + occurredAt: instant, + }, + ], + gaps: [], + }; + }, + fetchImpl: async (url, options) => { + const checkpoint = JSON.parse(await readFile(output, "utf8")); + assert.equal(checkpoint.diagnosticId, firstId); + assert.equal(checkpoint.hostLogs.entries[0].message.includes("E_HOST_ORIGINAL"), true); + if (url.endsWith("/telemetry/events")) { + posts++; + assert.equal(checkpoint.collectionStatus, "complete"); + assert.equal(checkpoint.upload.status, "pending"); + return new Response('{"ok":true}'); + } + probes++; + assert.equal(checkpoint.collectionStatus, "in_progress"); + assert.equal(checkpoint.collectionStage, "independent_checks"); + return new Response("{}", { status: 401 }); + }, + }, + ); + assert.ok(probes > 0); + assert.equal(posts, 1); + assert.equal(report.diagnosticId, firstId); + assert.equal(report.collectionStatus, "complete"); + }); + test(`${host}: over-budget original sources are retained locally and never silently truncated`, async (t) => { + const dir = await fixture(t), + input = join(dir, "original.mcp-failure.json"); + const bytes = JSON.stringify( + original({ + httpResponse: response({ body: "X".repeat(140000) + " E_UNTRIMMED_SOURCE_TAIL" }), + }), + ); + await writeFile(input, bytes); + const { report } = await module.runDoctor( + { host, failureRecord: input, output: join(dir, "report.json"), offline: true }, + { collectLogs: async () => ({ entries: [], gaps: [] }) }, + ); + assert.equal(await readFile(input, "utf8"), bytes); + assert.equal(report.originalFailure.status, "unavailable"); + assert.ok( + report.gaps.some( + (gap) => + gap.reason === "failure_record_input_size_limit" && + gap.completeLocalSourceRetained === true && + gap.maximumBytes === 128 * 1024, + ), + ); + }); +}