From aed83678c2cfa817d6ccc155f14a0b2123db5592 Mon Sep 17 00:00:00 2001 From: IanYet Date: Fri, 23 Oct 2026 17:20:21 +0800 Subject: [PATCH] fix(plugin): preserve original MCP failure evidence --- claude/.claude-plugin/plugin.json | 2 +- .../chatcut-plugin-basics-claude/SKILL.md | 2 + .../references/connection-diagnostics.md | 56 ++- .../scripts/mcp-doctor.mjs | 177 ++++++++- .../scripts/mcp-evidence.mjs | 31 ++ .../scripts/mcp-failure.mjs | 83 +++++ codex/.codex-plugin/plugin.json | 2 +- codex/skills/chatcut-plugin-basics/SKILL.md | 2 + .../references/connection-diagnostics.md | 56 ++- .../scripts/mcp-doctor.mjs | 177 ++++++++- .../scripts/mcp-evidence.mjs | 31 ++ .../scripts/mcp-failure.mjs | 83 +++++ docs/mcp-doctor.md | 14 + tests/mcp-doctor.test.mjs | 2 +- tests/mcp-failure-coverage.test.mjs | 335 ++++++++++++++++++ 15 files changed, 1018 insertions(+), 35 deletions(-) create mode 100644 claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs create mode 100644 codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs create mode 100644 tests/mcp-failure-coverage.test.mjs diff --git a/claude/.claude-plugin/plugin.json b/claude/.claude-plugin/plugin.json index 2f4d544..8353226 100644 --- a/claude/.claude-plugin/plugin.json +++ b/claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "chatcut", "displayName": "ChatCut", - "version": "1.10.21", + "version": "1.10.22", "description": "ChatCut plugin for editable video editing and creation workflows from Claude Code.", "author": { "name": "ChatCut", diff --git a/claude/skills/chatcut-plugin-basics-claude/SKILL.md b/claude/skills/chatcut-plugin-basics-claude/SKILL.md index 6746cbd..7007f0c 100644 --- a/claude/skills/chatcut-plugin-basics-claude/SKILL.md +++ b/claude/skills/chatcut-plugin-basics-claude/SKILL.md @@ -30,6 +30,8 @@ When an existing connection or OAuth attempt reports an error, or tools remain a Also use connection diagnostics when previously available tools disappear or the host reports an unfamiliar MCP/protocol error. Capture the current-host snapshot described in that reference; preserve unknown errors and stages. +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. + 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 9cff88a..c133f82 100644 --- a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md +++ b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md @@ -2,7 +2,7 @@ Use this branch when a registered hosted ChatCut connection fails, including OAuth failing before a browser opens, or authentication completing without usable -tools. Preserve evidence before reconnecting or restarting. This version gathers +tools, and failures of already available hosted tools. Preserve evidence before reconnecting or restarting. This version gathers evidence; it does not repair configuration, credentials, network settings, or the original connection. @@ -104,6 +104,49 @@ Only include ChatCut tool names; keep tool arguments and results out. ## Run once for the reported failure +### Preserve the failed call before independent checks + +When the host exposes the original failed tool result or runtime event, the Agent +copies only its error and available identifiers into a new dedicated +`.mcp-failure.json`. Do this before retries, reconnects or later probes. +Do not read a conversation transcript or collect tool arguments/results to build +it. Use the host's existing error metadata and supported read-only runtime logs; +the user does not fill this record. Keep the full original error message, code, +cause and stack when available, including unfamiliar failures. + +```json +{ + "schemaVersion": 1, + "source": "host_tool_result", + "serverName": "chatcut", + "occurredAt": null, + "conversationId": null, + "callId": null, + "operation": "list_projects", + "requestId": null, + "error": { "message": "Exact original error; add original code/cause/stack when available" } +} +``` + +Supported sources are `host_tool_result`, `host_runtime_event`, and +`captured_stderr`. `occurredAt` is the failed event's actual timestamp with +timezone, when exposed; never substitute the current time, file modification +time, snapshot capture or later probe time. Omit unknown optional IDs or leave +them null. A local `callId` and `conversationId` identify client-side work and +are useful even if the request never reaches ChatCut; they are not server +request IDs. Copy a `requestId` only when the original failed call actually +provides it. All supplied facts remain labelled as Agent-supplied evidence. + +Prefer `--failure-record ` to `--original-error` when +this structured source is available; choose one input. The collector centers +its bounded log window on the supplied failure time and uses its conversation +ID when available. A missing time remains an explicit gap. For an exec caller, +use already retained stderr; discarded stderr cannot be recovered by this Skill. +Do not start another authenticated session to manufacture replacement evidence. + +If only the original error text is accessible, use the text-file fallback below. +Do not call a report with only startup-status notices a captured tool failure. + 1. Obtain the existing server's registered URL through the host. Preserve its environment. The helper accepts ChatCut production/beta/dev endpoints and loopback development servers; it reports unsupported endpoints rather than @@ -153,6 +196,17 @@ records from three 128 KiB tails. Missing/rotated/denied logs remain explicit gaps. The known Windows MXC `LOCALAPPDATA` sandbox redirect falls back to the real user's `AppData/Local` without changing permissions. An already supplied alternative App log root can be selected with `--codex-app-log-dir`; do not scan browser profiles or session transcripts. + +The report names selected/omitted native files and byte ranges that were not read. +For an existing reported gap, use `--codex-app-log-file ` +to select at most three known native files. With exactly one selected file, +`--codex-app-log-offset ` reads at most 128 KiB from +that segment; unread ranges remain explicit. Preserve the original failure's +known time/window. Do not repeatedly upload replacement reports or scan entire +log history to fill an unknown failure time. App startup notices cover startup; +they do not cover every later tool-call failure. SQLite/native log extraction +retains available local conversation/call IDs without promoting them to original +server request IDs or independently confirmed attempt associations. For a separately chosen future reproduction, Codex's explicit `log_dir` setting can enable its native plaintext log. Do not change global settings or relaunch the user's host automatically. Existing logs cannot be reconstructed retroactively. 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 f55721e..513ba4a 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -8,6 +8,7 @@ import { connect as connectTls } from "node:tls"; import { isIP } from "node:net"; import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; import { probeMetadataWire } from "./mcp-wire.mjs"; +import { failureFromRecord } from "./mcp-failure.mjs"; const READ_LIMIT = 128 * 1024; const UPLOAD_LIMIT = 256 * 1024; @@ -252,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.2" }, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.3" }, }, }), }); @@ -374,6 +375,21 @@ function logMessage(body) { return (marker < 0 ? body : body.slice(marker + 2)).trim(); } +function runtimeContext(body) { + const boundary = body.lastIndexOf("}:"); + if (boundary < 0) return {}; + const context = body.slice(0, boundary); + const value = (field) => + [ + ...context.matchAll( + new RegExp(`(?:^|[\\s{])${field}="?([A-Za-z0-9_.-]{1,256})(?:"|[\\s}:]|$)`, "g"), + ), + ].at(-1)?.[1]; + const conversationId = value("thread_id") ?? value("thread\\.id") ?? value("conversation\\.id"); + const callId = value("call_id"); + return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; +} + function hostedTextRecord(line) { if (/^\s/.test(line)) return false; let body = line.replace( @@ -448,13 +464,34 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { const handle = await open(path, "r"); try { const info = await handle.stat(); - const start = Math.max(0, info.size - READ_LIMIT); - const buffer = Buffer.alloc(Math.min(info.size, READ_LIMIT)); + const start = window.readOffset ?? Math.max(0, info.size - READ_LIMIT); + if (!Number.isSafeInteger(start) || start < 0 || start > info.size) + throw new Error("Log segment offset must point inside the selected existing file"); + const buffer = Buffer.alloc(Math.min(info.size - start, READ_LIMIT)); const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); if (start) { - lines.shift(); - gaps.push({ source, reason: "only_log_tail_read", bytesBeforeWindow: start }); + const previous = Buffer.alloc(1); + await handle.read(previous, 0, 1, start - 1); + if (previous[0] !== 10) lines.shift(); + gaps.push({ + source, + reason: "only_log_tail_read", + sourceFile: basename(path), + bytesBeforeWindow: start, + omittedBytes: start, + }); + } + if (start + bytesRead < info.size) { + if (!buffer.subarray(0, bytesRead).toString("utf8").endsWith("\n")) lines.pop(); + gaps.push({ + source, + reason: "only_log_segment_read", + sourceFile: basename(path), + offset: start, + bytesRead, + remainingBytes: info.size - start - bytesRead, + }); } const entries = []; for (let i = 0; i < lines.length; i++) { @@ -483,6 +520,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]), originalAttemptMatch: "unconfirmed", }); } @@ -497,7 +535,14 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { } } -async function codexAppLogs(root, gaps, window, conversationId) { +async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { + if ( + readOffset !== undefined && + (selectedFiles.length !== 1 || !Number.isSafeInteger(readOffset) || readOffset < 0) + ) + throw new Error( + "A segment offset requires exactly one selected native App log file and a nonnegative integer", + ); const files = []; const dates = new Set(); for (const time of [window.since * 1000, window.until * 1000]) { @@ -530,13 +575,25 @@ async function codexAppLogs(root, gaps, window, conversationId) { if (scanned > 200 || Date.now() > deadline) break; } files.sort((a, b) => b.modified - a.modified); + if (selectedFiles.length) { + if (selectedFiles.length > 3) + throw new Error("Select at most three existing Codex App log files"); + files.length = 0; + for (const path of [...new Set(selectedFiles)]) { + if (!/^codex-desktop-.*\.log$/.test(basename(path))) + throw new Error("Select only existing native codex-desktop-*.log files"); + const info = await stat(path); + if (!info.isFile()) throw new Error("Codex App log selection must be a file"); + files.push({ path, modified: info.mtimeMs }); + } + } const entries = []; let filesRead = 0; for (const file of files.slice(0, 3)) { try { // App logs mix unrelated UI activity with runtime notices, so accept only this native event. entries.push( - ...(await textLog(file.path, "codex_app_log", gaps, window, (line) => { + ...(await textLog(file.path, "codex_app_log", gaps, { ...window, readOffset }, (line) => { if ( !/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test( line, @@ -555,7 +612,12 @@ async function codexAppLogs(root, gaps, window, conversationId) { } } if (files.length > 3) - gaps.push({ source: "codex_app_log", reason: "log_file_limit", omitted: files.length - 3 }); + gaps.push({ + source: "codex_app_log", + reason: "log_file_limit", + omitted: files.length - 3, + omittedFiles: files.slice(3, 13).map((file) => basename(file.path)), + }); if (entries.length > 30) gaps.push({ source: "codex_app_log", @@ -572,6 +634,12 @@ async function codexAppLogs(root, gaps, window, conversationId) { filesRead, maximumTailBytesPerFile: READ_LIMIT, currentConversationFilterApplied: Boolean(conversationId), + ...(conversationId && { conversationId }), + selectedFileMode: selectedFiles.length > 0, + ...(readOffset !== undefined && { selectedByteOffset: readOffset }), + filesSelected: files.slice(0, 3).map((file) => basename(file.path)), + observationScope: + "hosted_mcp_startup_notices_only; tool_request_failures_need_original_failure_record_or_matching_runtime_logs", }, }; } @@ -631,14 +699,26 @@ export async function collectHostLogs(host, options = {}) { const { DatabaseSync } = await import("node:sqlite"); db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); db.exec("PRAGMA query_only=ON"); + const hasThreadId = db + .prepare("PRAGMA table_info(logs)") + .all() + .some((column) => column.name === "thread_id"); const rows = db .prepare( - "SELECT id,ts,level,target,feedback_log_body 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", + `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, ) .all(since, until); for (const row of rows) { const message = logMessage(row.feedback_log_body ?? ""); const original = row.feedback_log_body ?? ""; + const context = runtimeContext(original); + if (row.thread_id) context.conversationId = row.thread_id; + if ( + options.conversationId && + context.conversationId && + context.conversationId !== options.conversationId + ) + continue; if (!CHATCUT_MESSAGE.test(original) || /server_name="?chatcut_desktop/.test(original)) continue; if ( @@ -653,6 +733,7 @@ export async function collectHostLogs(host, options = {}) { target: row.target, level: row.level, message: redact(message), + ...context, originalAttemptMatch: "unconfirmed", }); if (entries.length === 30) break; @@ -701,7 +782,14 @@ export async function collectHostLogs(host, options = {}) { ) : null); if (appLogRoot) { - const result = await codexAppLogs(appLogRoot, gaps, { since, until }, options.conversationId); + const result = await codexAppLogs( + appLogRoot, + gaps, + { since, until }, + options.conversationId, + options.codexAppLogFiles, + options.codexAppLogOffset, + ); entries.push(...result.entries); appLog = { ...result.scan, @@ -800,6 +888,8 @@ export function fragmentEvents(report) { export async function runDoctor(options, dependencies = {}) { if (!["codex", "claude"].includes(options.host)) throw new Error("--host must be codex or claude"); + if (options.originalError && options.failureRecord) + throw new Error("Choose one original failure input: --failure-record or --original-error"); const endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; if ( options.occurredAt && @@ -812,7 +902,7 @@ export async function runDoctor(options, dependencies = {}) { const diagnosticId = randomUUID(); const report = { schemaVersion: 2, - collectorVersion: "1.2.2", + collectorVersion: "1.2.3", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", @@ -841,6 +931,9 @@ export async function runDoctor(options, dependencies = {}) { originalFailure: { evidenceKind: "original_failure", occurredAt: options.occurredAt ?? null, + timeSource: options.occurredAt + ? "command_line; agent_supplied_not_independently_verified" + : "unavailable", source: "agent_supplied_error_file", status: "unavailable", }, @@ -917,18 +1010,63 @@ export async function runDoctor(options, dependencies = {}) { error: errorDetails(error), }); } - } else + } else if (!options.failureRecord) report.gaps.push({ source: "original_failure", reason: "not_provided; do_not_invent_original_error", }); + if (options.failureRecord) { + try { + if (!basename(String(options.failureRecord)).endsWith(".mcp-failure.json")) + throw new Error( + "Use a dedicated .mcp-failure.json file, never a config or session transcript", + ); + if ((await stat(options.failureRecord)).size > 64 * 1024) + throw new Error("Failure record exceeds 64 KiB; complete source retained without reading"); + report.originalFailure = clean( + failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), + ); + if (!report.originalFailure.occurredAt && options.occurredAt) { + report.originalFailure.occurredAt = options.occurredAt; + report.originalFailure.timeSource = + "command_line; agent_supplied_not_independently_verified"; + } + if ( + options.occurredAt && + report.originalFailure.occurredAt && + Date.parse(options.occurredAt) !== Date.parse(report.originalFailure.occurredAt) + ) { + report.originalFailure.timeConflict = { + failureRecord: report.originalFailure.occurredAt, + commandLine: options.occurredAt, + }; + report.originalFailure.occurredAt = null; + report.originalFailure.timeSource = "conflicting_supplied_times"; + report.gaps.push({ + source: "original_failure", + reason: "conflicting_supplied_failure_times", + }); + } + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "failure_record_unavailable", + error: errorDetails(error), + }); + } + } try { report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { - occurredAt: options.occurredAt, + occurredAt: report.originalFailure.occurredAt, codexSqliteHome: options.codexSqliteHome, codexLogDir: options.codexLogDir, codexAppLogDir: options.codexAppLogDir, - conversationId: report.hostSnapshot?.session?.conversationId ?? process.env.CODEX_THREAD_ID, + codexAppLogFiles: options.codexAppLogFiles, + codexAppLogOffset: options.codexAppLogOffset, + conversationId: + report.originalFailure.conversationId ?? + report.hostSnapshot?.session?.conversationId ?? + process.env.CODEX_THREAD_ID, }); } catch (error) { report.hostLogs = { @@ -1098,10 +1236,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur "host-version": { type: "string" }, "host-snapshot": { type: "string" }, "original-error": { type: "string" }, + "failure-record": { type: "string" }, "occurred-at": { type: "string" }, "codex-sqlite-home": { type: "string" }, "codex-log-dir": { type: "string" }, "codex-app-log-dir": { type: "string" }, + "codex-app-log-file": { type: "string", multiple: true }, + "codex-app-log-offset": { type: "string" }, output: { type: "string" }, upload: { type: "boolean", default: false }, "wire-evidence": { type: "boolean", default: true }, @@ -1143,7 +1284,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 ] [--original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--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-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--upload]", ); } else { const { path, report } = await runDoctor({ @@ -1152,10 +1293,16 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur hostVersion: values["host-version"], hostSnapshot: values["host-snapshot"], originalError: values["original-error"], + failureRecord: values["failure-record"], occurredAt: values["occurred-at"], codexSqliteHome: values["codex-sqlite-home"], codexLogDir: values["codex-log-dir"], codexAppLogDir: values["codex-app-log-dir"], + codexAppLogFiles: values["codex-app-log-file"], + codexAppLogOffset: + values["codex-app-log-offset"] === undefined + ? undefined + : Number(values["codex-app-log-offset"]), output: values.output, upload: values.upload, wireEvidence: values["wire-evidence"], 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 057904f..da0ed7c 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs @@ -211,9 +211,20 @@ export function evidenceSummary(report) { : [], ) : []; + if (report.originalFailure.status === "recorded" && report.originalFailure.requestId) + originalRequests.push({ + requestId: report.originalFailure.requestId, + source: report.originalFailure.source, + }); if (!originalRequests.length) gaps.push("original_request_id_unavailable"); if (!snapshot?.pluginVersion) gaps.push("actual_loaded_plugin_version_unavailable"); if (!report.hostLogs.entries.length) gaps.push("matching_host_runtime_logs_unavailable"); + const appNotices = report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log"); + const failedAppNotices = appNotices.filter( + (entry) => [...entry.message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed", + ); + if (appNotices.length === report.hostLogs.entries.length && !failedAppNotices.length) + gaps.push("matching_host_failure_logs_unavailable"); const stages = STAGES.map((stage) => ({ stage, observations: observations.filter((item) => item.stage === stage), @@ -226,6 +237,8 @@ export function evidenceSummary(report) { entryIndex: index, stage: stageHint(entry.message), interpretation: "text_hint_only; attempt_match_unconfirmed", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), })); return { stages, @@ -237,6 +250,24 @@ export function evidenceSummary(report) { provenance: "agent_supplied_confirmation; not_independently_verified; never_inferred_from_time_or_other_ids", }, + originalLocalCall: report.originalFailure.callId + ? { + callId: report.originalFailure.callId, + conversationId: report.originalFailure.conversationId ?? null, + operation: report.originalFailure.operation ?? null, + provenance: + "original_failure_record; agent_supplied_not_independently_verified; local_call_id_is_not_server_request_id", + } + : null, + hostLogCoverage: { + records: report.hostLogs.entries.length, + appStartupRecords: appNotices.length, + failedAppStartupRecords: failedAppNotices.length, + originalToolFailureRecord: + report.originalFailure.source === "agent_supplied_host_tool_result", + qualification: + "startup notices are not tool-request failure evidence; log presence alone does not prove original failure capture", + }, gaps, independentChecks: "entryProbe/probes/wireProbes/transportContext are later requests, never original stage completions; server observation is before Node framing, not proof of downstream wire validity", diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs new file mode 100644 index 0000000..ef9e338 --- /dev/null +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs @@ -0,0 +1,83 @@ +const SOURCES = ["host_tool_result", "host_runtime_event", "captured_stderr"]; +const FIELDS = [ + "schemaVersion", + "source", + "serverName", + "occurredAt", + "conversationId", + "callId", + "operation", + "requestId", + "error", +]; + +export function validateFailureRecord(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + Object.keys(input).some((key) => !FIELDS.includes(key)) + ) + throw new Error( + "Use one dedicated failure record, without tool arguments, results or conversation content", + ); + if ( + input.schemaVersion !== 1 || + !SOURCES.includes(input.source) || + !["chatcut", "plugin:chatcut:chatcut"].includes(input.serverName) + ) + throw new Error( + "Failure record must identify the hosted ChatCut server and original error source", + ); + if ( + input.occurredAt !== null && + (typeof input.occurredAt !== "string" || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( + input.occurredAt, + ) || + !Number.isFinite(Date.parse(input.occurredAt))) + ) + throw new Error( + "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", + ); + for (const key of ["conversationId", "callId", "operation", "requestId"]) + if ( + input[key] !== undefined && + input[key] !== null && + (typeof input[key] !== "string" || !/^[A-Za-z0-9_.:/-]{1,256}$/.test(input[key])) + ) + throw new Error( + `Invalid original failure ${key}; never substitute a credential or a later probe ID`, + ); + if ( + !input.error || + typeof input.error !== "object" || + Array.isArray(input.error) || + typeof input.error.message !== "string" || + !input.error.message.trim() + ) + throw new Error( + "Failure record needs the original nonempty error.message; preserve its code, cause and stack when available", + ); + return input; +} + +export function failureFromRecord(input) { + const record = validateFailureRecord(input); + return { + evidenceKind: "original_failure", + status: "recorded", + source: `agent_supplied_${record.source}`, + occurredAt: record.occurredAt, + timeSource: record.occurredAt + ? "host_failure_record; agent_supplied_not_independently_verified" + : "unavailable", + message: record.error.message, + error: record.error, + ...(record.conversationId && { conversationId: record.conversationId }), + ...(record.callId && { callId: record.callId }), + ...(record.operation && { operation: record.operation }), + ...(record.requestId && { requestId: record.requestId }), + association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", + }; +} diff --git a/codex/.codex-plugin/plugin.json b/codex/.codex-plugin/plugin.json index 6e07427..2871135 100644 --- a/codex/.codex-plugin/plugin.json +++ b/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "chatcut", - "version": "1.10.19", + "version": "1.10.20", "description": "ChatCut Codex plugin for editable video editing and creation workflows from Codex.", "author": { "name": "ChatCut", diff --git a/codex/skills/chatcut-plugin-basics/SKILL.md b/codex/skills/chatcut-plugin-basics/SKILL.md index 2689615..63a949b 100644 --- a/codex/skills/chatcut-plugin-basics/SKILL.md +++ b/codex/skills/chatcut-plugin-basics/SKILL.md @@ -30,6 +30,8 @@ When an existing connection or OAuth attempt reports an error, or tools remain a Also use connection diagnostics when previously available tools disappear or the host reports an unfamiliar MCP/protocol error. Capture the current-host snapshot described in that reference; preserve unknown errors and stages. +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. + 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 9cff88a..c133f82 100644 --- a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md +++ b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md @@ -2,7 +2,7 @@ Use this branch when a registered hosted ChatCut connection fails, including OAuth failing before a browser opens, or authentication completing without usable -tools. Preserve evidence before reconnecting or restarting. This version gathers +tools, and failures of already available hosted tools. Preserve evidence before reconnecting or restarting. This version gathers evidence; it does not repair configuration, credentials, network settings, or the original connection. @@ -104,6 +104,49 @@ Only include ChatCut tool names; keep tool arguments and results out. ## Run once for the reported failure +### Preserve the failed call before independent checks + +When the host exposes the original failed tool result or runtime event, the Agent +copies only its error and available identifiers into a new dedicated +`.mcp-failure.json`. Do this before retries, reconnects or later probes. +Do not read a conversation transcript or collect tool arguments/results to build +it. Use the host's existing error metadata and supported read-only runtime logs; +the user does not fill this record. Keep the full original error message, code, +cause and stack when available, including unfamiliar failures. + +```json +{ + "schemaVersion": 1, + "source": "host_tool_result", + "serverName": "chatcut", + "occurredAt": null, + "conversationId": null, + "callId": null, + "operation": "list_projects", + "requestId": null, + "error": { "message": "Exact original error; add original code/cause/stack when available" } +} +``` + +Supported sources are `host_tool_result`, `host_runtime_event`, and +`captured_stderr`. `occurredAt` is the failed event's actual timestamp with +timezone, when exposed; never substitute the current time, file modification +time, snapshot capture or later probe time. Omit unknown optional IDs or leave +them null. A local `callId` and `conversationId` identify client-side work and +are useful even if the request never reaches ChatCut; they are not server +request IDs. Copy a `requestId` only when the original failed call actually +provides it. All supplied facts remain labelled as Agent-supplied evidence. + +Prefer `--failure-record ` to `--original-error` when +this structured source is available; choose one input. The collector centers +its bounded log window on the supplied failure time and uses its conversation +ID when available. A missing time remains an explicit gap. For an exec caller, +use already retained stderr; discarded stderr cannot be recovered by this Skill. +Do not start another authenticated session to manufacture replacement evidence. + +If only the original error text is accessible, use the text-file fallback below. +Do not call a report with only startup-status notices a captured tool failure. + 1. Obtain the existing server's registered URL through the host. Preserve its environment. The helper accepts ChatCut production/beta/dev endpoints and loopback development servers; it reports unsupported endpoints rather than @@ -153,6 +196,17 @@ records from three 128 KiB tails. Missing/rotated/denied logs remain explicit gaps. The known Windows MXC `LOCALAPPDATA` sandbox redirect falls back to the real user's `AppData/Local` without changing permissions. An already supplied alternative App log root can be selected with `--codex-app-log-dir`; do not scan browser profiles or session transcripts. + +The report names selected/omitted native files and byte ranges that were not read. +For an existing reported gap, use `--codex-app-log-file ` +to select at most three known native files. With exactly one selected file, +`--codex-app-log-offset ` reads at most 128 KiB from +that segment; unread ranges remain explicit. Preserve the original failure's +known time/window. Do not repeatedly upload replacement reports or scan entire +log history to fill an unknown failure time. App startup notices cover startup; +they do not cover every later tool-call failure. SQLite/native log extraction +retains available local conversation/call IDs without promoting them to original +server request IDs or independently confirmed attempt associations. For a separately chosen future reproduction, Codex's explicit `log_dir` setting can enable its native plaintext log. Do not change global settings or relaunch the user's host automatically. Existing logs cannot be reconstructed retroactively. diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs index f55721e..513ba4a 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -8,6 +8,7 @@ import { connect as connectTls } from "node:tls"; import { isIP } from "node:net"; import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; import { probeMetadataWire } from "./mcp-wire.mjs"; +import { failureFromRecord } from "./mcp-failure.mjs"; const READ_LIMIT = 128 * 1024; const UPLOAD_LIMIT = 256 * 1024; @@ -252,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.2" }, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.3" }, }, }), }); @@ -374,6 +375,21 @@ function logMessage(body) { return (marker < 0 ? body : body.slice(marker + 2)).trim(); } +function runtimeContext(body) { + const boundary = body.lastIndexOf("}:"); + if (boundary < 0) return {}; + const context = body.slice(0, boundary); + const value = (field) => + [ + ...context.matchAll( + new RegExp(`(?:^|[\\s{])${field}="?([A-Za-z0-9_.-]{1,256})(?:"|[\\s}:]|$)`, "g"), + ), + ].at(-1)?.[1]; + const conversationId = value("thread_id") ?? value("thread\\.id") ?? value("conversation\\.id"); + const callId = value("call_id"); + return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; +} + function hostedTextRecord(line) { if (/^\s/.test(line)) return false; let body = line.replace( @@ -448,13 +464,34 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { const handle = await open(path, "r"); try { const info = await handle.stat(); - const start = Math.max(0, info.size - READ_LIMIT); - const buffer = Buffer.alloc(Math.min(info.size, READ_LIMIT)); + const start = window.readOffset ?? Math.max(0, info.size - READ_LIMIT); + if (!Number.isSafeInteger(start) || start < 0 || start > info.size) + throw new Error("Log segment offset must point inside the selected existing file"); + const buffer = Buffer.alloc(Math.min(info.size - start, READ_LIMIT)); const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); if (start) { - lines.shift(); - gaps.push({ source, reason: "only_log_tail_read", bytesBeforeWindow: start }); + const previous = Buffer.alloc(1); + await handle.read(previous, 0, 1, start - 1); + if (previous[0] !== 10) lines.shift(); + gaps.push({ + source, + reason: "only_log_tail_read", + sourceFile: basename(path), + bytesBeforeWindow: start, + omittedBytes: start, + }); + } + if (start + bytesRead < info.size) { + if (!buffer.subarray(0, bytesRead).toString("utf8").endsWith("\n")) lines.pop(); + gaps.push({ + source, + reason: "only_log_segment_read", + sourceFile: basename(path), + offset: start, + bytesRead, + remainingBytes: info.size - start - bytesRead, + }); } const entries = []; for (let i = 0; i < lines.length; i++) { @@ -483,6 +520,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]), originalAttemptMatch: "unconfirmed", }); } @@ -497,7 +535,14 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { } } -async function codexAppLogs(root, gaps, window, conversationId) { +async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { + if ( + readOffset !== undefined && + (selectedFiles.length !== 1 || !Number.isSafeInteger(readOffset) || readOffset < 0) + ) + throw new Error( + "A segment offset requires exactly one selected native App log file and a nonnegative integer", + ); const files = []; const dates = new Set(); for (const time of [window.since * 1000, window.until * 1000]) { @@ -530,13 +575,25 @@ async function codexAppLogs(root, gaps, window, conversationId) { if (scanned > 200 || Date.now() > deadline) break; } files.sort((a, b) => b.modified - a.modified); + if (selectedFiles.length) { + if (selectedFiles.length > 3) + throw new Error("Select at most three existing Codex App log files"); + files.length = 0; + for (const path of [...new Set(selectedFiles)]) { + if (!/^codex-desktop-.*\.log$/.test(basename(path))) + throw new Error("Select only existing native codex-desktop-*.log files"); + const info = await stat(path); + if (!info.isFile()) throw new Error("Codex App log selection must be a file"); + files.push({ path, modified: info.mtimeMs }); + } + } const entries = []; let filesRead = 0; for (const file of files.slice(0, 3)) { try { // App logs mix unrelated UI activity with runtime notices, so accept only this native event. entries.push( - ...(await textLog(file.path, "codex_app_log", gaps, window, (line) => { + ...(await textLog(file.path, "codex_app_log", gaps, { ...window, readOffset }, (line) => { if ( !/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test( line, @@ -555,7 +612,12 @@ async function codexAppLogs(root, gaps, window, conversationId) { } } if (files.length > 3) - gaps.push({ source: "codex_app_log", reason: "log_file_limit", omitted: files.length - 3 }); + gaps.push({ + source: "codex_app_log", + reason: "log_file_limit", + omitted: files.length - 3, + omittedFiles: files.slice(3, 13).map((file) => basename(file.path)), + }); if (entries.length > 30) gaps.push({ source: "codex_app_log", @@ -572,6 +634,12 @@ async function codexAppLogs(root, gaps, window, conversationId) { filesRead, maximumTailBytesPerFile: READ_LIMIT, currentConversationFilterApplied: Boolean(conversationId), + ...(conversationId && { conversationId }), + selectedFileMode: selectedFiles.length > 0, + ...(readOffset !== undefined && { selectedByteOffset: readOffset }), + filesSelected: files.slice(0, 3).map((file) => basename(file.path)), + observationScope: + "hosted_mcp_startup_notices_only; tool_request_failures_need_original_failure_record_or_matching_runtime_logs", }, }; } @@ -631,14 +699,26 @@ export async function collectHostLogs(host, options = {}) { const { DatabaseSync } = await import("node:sqlite"); db = new DatabaseSync(databasePath, { readOnly: true, timeout: 1000 }); db.exec("PRAGMA query_only=ON"); + const hasThreadId = db + .prepare("PRAGMA table_info(logs)") + .all() + .some((column) => column.name === "thread_id"); const rows = db .prepare( - "SELECT id,ts,level,target,feedback_log_body 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", + `SELECT id,ts,level,target,feedback_log_body${hasThreadId ? ",thread_id" : ""} FROM logs WHERE ts>=? AND ts<=? AND (target LIKE 'codex_mcp%' OR target LIKE 'rmcp%' OR target LIKE 'codex_plugins%' OR target LIKE 'codex_core::plugins%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, ) .all(since, until); for (const row of rows) { const message = logMessage(row.feedback_log_body ?? ""); const original = row.feedback_log_body ?? ""; + const context = runtimeContext(original); + if (row.thread_id) context.conversationId = row.thread_id; + if ( + options.conversationId && + context.conversationId && + context.conversationId !== options.conversationId + ) + continue; if (!CHATCUT_MESSAGE.test(original) || /server_name="?chatcut_desktop/.test(original)) continue; if ( @@ -653,6 +733,7 @@ export async function collectHostLogs(host, options = {}) { target: row.target, level: row.level, message: redact(message), + ...context, originalAttemptMatch: "unconfirmed", }); if (entries.length === 30) break; @@ -701,7 +782,14 @@ export async function collectHostLogs(host, options = {}) { ) : null); if (appLogRoot) { - const result = await codexAppLogs(appLogRoot, gaps, { since, until }, options.conversationId); + const result = await codexAppLogs( + appLogRoot, + gaps, + { since, until }, + options.conversationId, + options.codexAppLogFiles, + options.codexAppLogOffset, + ); entries.push(...result.entries); appLog = { ...result.scan, @@ -800,6 +888,8 @@ export function fragmentEvents(report) { export async function runDoctor(options, dependencies = {}) { if (!["codex", "claude"].includes(options.host)) throw new Error("--host must be codex or claude"); + if (options.originalError && options.failureRecord) + throw new Error("Choose one original failure input: --failure-record or --original-error"); const endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; if ( options.occurredAt && @@ -812,7 +902,7 @@ export async function runDoctor(options, dependencies = {}) { const diagnosticId = randomUUID(); const report = { schemaVersion: 2, - collectorVersion: "1.2.2", + collectorVersion: "1.2.3", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", @@ -841,6 +931,9 @@ export async function runDoctor(options, dependencies = {}) { originalFailure: { evidenceKind: "original_failure", occurredAt: options.occurredAt ?? null, + timeSource: options.occurredAt + ? "command_line; agent_supplied_not_independently_verified" + : "unavailable", source: "agent_supplied_error_file", status: "unavailable", }, @@ -917,18 +1010,63 @@ export async function runDoctor(options, dependencies = {}) { error: errorDetails(error), }); } - } else + } else if (!options.failureRecord) report.gaps.push({ source: "original_failure", reason: "not_provided; do_not_invent_original_error", }); + if (options.failureRecord) { + try { + if (!basename(String(options.failureRecord)).endsWith(".mcp-failure.json")) + throw new Error( + "Use a dedicated .mcp-failure.json file, never a config or session transcript", + ); + if ((await stat(options.failureRecord)).size > 64 * 1024) + throw new Error("Failure record exceeds 64 KiB; complete source retained without reading"); + report.originalFailure = clean( + failureFromRecord(JSON.parse(await readFile(options.failureRecord, "utf8"))), + ); + if (!report.originalFailure.occurredAt && options.occurredAt) { + report.originalFailure.occurredAt = options.occurredAt; + report.originalFailure.timeSource = + "command_line; agent_supplied_not_independently_verified"; + } + if ( + options.occurredAt && + report.originalFailure.occurredAt && + Date.parse(options.occurredAt) !== Date.parse(report.originalFailure.occurredAt) + ) { + report.originalFailure.timeConflict = { + failureRecord: report.originalFailure.occurredAt, + commandLine: options.occurredAt, + }; + report.originalFailure.occurredAt = null; + report.originalFailure.timeSource = "conflicting_supplied_times"; + report.gaps.push({ + source: "original_failure", + reason: "conflicting_supplied_failure_times", + }); + } + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "failure_record_unavailable", + error: errorDetails(error), + }); + } + } try { report.hostLogs = await (dependencies.collectLogs ?? collectHostLogs)(options.host, { - occurredAt: options.occurredAt, + occurredAt: report.originalFailure.occurredAt, codexSqliteHome: options.codexSqliteHome, codexLogDir: options.codexLogDir, codexAppLogDir: options.codexAppLogDir, - conversationId: report.hostSnapshot?.session?.conversationId ?? process.env.CODEX_THREAD_ID, + codexAppLogFiles: options.codexAppLogFiles, + codexAppLogOffset: options.codexAppLogOffset, + conversationId: + report.originalFailure.conversationId ?? + report.hostSnapshot?.session?.conversationId ?? + process.env.CODEX_THREAD_ID, }); } catch (error) { report.hostLogs = { @@ -1098,10 +1236,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur "host-version": { type: "string" }, "host-snapshot": { type: "string" }, "original-error": { type: "string" }, + "failure-record": { type: "string" }, "occurred-at": { type: "string" }, "codex-sqlite-home": { type: "string" }, "codex-log-dir": { type: "string" }, "codex-app-log-dir": { type: "string" }, + "codex-app-log-file": { type: "string", multiple: true }, + "codex-app-log-offset": { type: "string" }, output: { type: "string" }, upload: { type: "boolean", default: false }, "wire-evidence": { type: "boolean", default: true }, @@ -1143,7 +1284,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 ] [--original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--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-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--upload]", ); } else { const { path, report } = await runDoctor({ @@ -1152,10 +1293,16 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur hostVersion: values["host-version"], hostSnapshot: values["host-snapshot"], originalError: values["original-error"], + failureRecord: values["failure-record"], occurredAt: values["occurred-at"], codexSqliteHome: values["codex-sqlite-home"], codexLogDir: values["codex-log-dir"], codexAppLogDir: values["codex-app-log-dir"], + codexAppLogFiles: values["codex-app-log-file"], + codexAppLogOffset: + values["codex-app-log-offset"] === undefined + ? undefined + : Number(values["codex-app-log-offset"]), output: values.output, upload: values.upload, wireEvidence: values["wire-evidence"], diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs index 057904f..da0ed7c 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs @@ -211,9 +211,20 @@ export function evidenceSummary(report) { : [], ) : []; + if (report.originalFailure.status === "recorded" && report.originalFailure.requestId) + originalRequests.push({ + requestId: report.originalFailure.requestId, + source: report.originalFailure.source, + }); if (!originalRequests.length) gaps.push("original_request_id_unavailable"); if (!snapshot?.pluginVersion) gaps.push("actual_loaded_plugin_version_unavailable"); if (!report.hostLogs.entries.length) gaps.push("matching_host_runtime_logs_unavailable"); + const appNotices = report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log"); + const failedAppNotices = appNotices.filter( + (entry) => [...entry.message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed", + ); + if (appNotices.length === report.hostLogs.entries.length && !failedAppNotices.length) + gaps.push("matching_host_failure_logs_unavailable"); const stages = STAGES.map((stage) => ({ stage, observations: observations.filter((item) => item.stage === stage), @@ -226,6 +237,8 @@ export function evidenceSummary(report) { entryIndex: index, stage: stageHint(entry.message), interpretation: "text_hint_only; attempt_match_unconfirmed", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), })); return { stages, @@ -237,6 +250,24 @@ export function evidenceSummary(report) { provenance: "agent_supplied_confirmation; not_independently_verified; never_inferred_from_time_or_other_ids", }, + originalLocalCall: report.originalFailure.callId + ? { + callId: report.originalFailure.callId, + conversationId: report.originalFailure.conversationId ?? null, + operation: report.originalFailure.operation ?? null, + provenance: + "original_failure_record; agent_supplied_not_independently_verified; local_call_id_is_not_server_request_id", + } + : null, + hostLogCoverage: { + records: report.hostLogs.entries.length, + appStartupRecords: appNotices.length, + failedAppStartupRecords: failedAppNotices.length, + originalToolFailureRecord: + report.originalFailure.source === "agent_supplied_host_tool_result", + qualification: + "startup notices are not tool-request failure evidence; log presence alone does not prove original failure capture", + }, gaps, independentChecks: "entryProbe/probes/wireProbes/transportContext are later requests, never original stage completions; server observation is before Node framing, not proof of downstream wire validity", diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs new file mode 100644 index 0000000..ef9e338 --- /dev/null +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs @@ -0,0 +1,83 @@ +const SOURCES = ["host_tool_result", "host_runtime_event", "captured_stderr"]; +const FIELDS = [ + "schemaVersion", + "source", + "serverName", + "occurredAt", + "conversationId", + "callId", + "operation", + "requestId", + "error", +]; + +export function validateFailureRecord(input) { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + Object.keys(input).some((key) => !FIELDS.includes(key)) + ) + throw new Error( + "Use one dedicated failure record, without tool arguments, results or conversation content", + ); + if ( + input.schemaVersion !== 1 || + !SOURCES.includes(input.source) || + !["chatcut", "plugin:chatcut:chatcut"].includes(input.serverName) + ) + throw new Error( + "Failure record must identify the hosted ChatCut server and original error source", + ); + if ( + input.occurredAt !== null && + (typeof input.occurredAt !== "string" || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( + input.occurredAt, + ) || + !Number.isFinite(Date.parse(input.occurredAt))) + ) + throw new Error( + "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", + ); + for (const key of ["conversationId", "callId", "operation", "requestId"]) + if ( + input[key] !== undefined && + input[key] !== null && + (typeof input[key] !== "string" || !/^[A-Za-z0-9_.:/-]{1,256}$/.test(input[key])) + ) + throw new Error( + `Invalid original failure ${key}; never substitute a credential or a later probe ID`, + ); + if ( + !input.error || + typeof input.error !== "object" || + Array.isArray(input.error) || + typeof input.error.message !== "string" || + !input.error.message.trim() + ) + throw new Error( + "Failure record needs the original nonempty error.message; preserve its code, cause and stack when available", + ); + return input; +} + +export function failureFromRecord(input) { + const record = validateFailureRecord(input); + return { + evidenceKind: "original_failure", + status: "recorded", + source: `agent_supplied_${record.source}`, + occurredAt: record.occurredAt, + timeSource: record.occurredAt + ? "host_failure_record; agent_supplied_not_independently_verified" + : "unavailable", + message: record.error.message, + error: record.error, + ...(record.conversationId && { conversationId: record.conversationId }), + ...(record.callId && { callId: record.callId }), + ...(record.operation && { operation: record.operation }), + ...(record.requestId && { requestId: record.requestId }), + association: "dedicated_original_failure_record; agent_supplied_not_independently_verified", + }; +} diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index 8a01cfd..4d0c7e9 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -12,6 +12,20 @@ need a runtime with `node:sqlite`; missing support is an explicit evidence gap. ## Evidence and delivery contract +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 +stays unknown. A local call ID is not a server request ID. Startup-only App +records leave the tool failure evidence gap open. Bounded file selection now +names omitted files/ranges and supports a known native file/128 KiB segment for +targeted follow-up. Native SQLite/text trace context retains available local +identifiers. These changes cannot recover discarded stderr or unavailable logs +from another machine. See the diagnostic reference for the narrow input schema. + +Release acceptance covers failed startup, post-connection tool failure, requests +that do not reach the server, and unavailable/truncated logs. Full upload and +hash verification is separate from proving the original fault's root cause. + `schemaVersion:2` reports contain `diagnosticId`, `collectedAt`, declared host and version source, collector/package/runtime/platform versions, registered cloud server URL, `originalFailure`, `hostSnapshot` when available, `hostLogs`, `entryProbe`, diff --git a/tests/mcp-doctor.test.mjs b/tests/mcp-doctor.test.mjs index 22fa00a..fd251f7 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.2"); + assert.equal(report.collectorVersion, "1.2.3"); assert.equal( report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log").length, 30, diff --git a/tests/mcp-failure-coverage.test.mjs b/tests/mcp-failure-coverage.test.mjs new file mode 100644 index 0000000..6840ba1 --- /dev/null +++ b/tests/mcp-failure-coverage.test.mjs @@ -0,0 +1,335 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:net"; +import { mkdtemp, mkdir, readFile, rm, writeFile, utimes } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { + collectHostLogs, + runDoctor, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; +import { validateFailureRecord } from "../codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs"; + +async function temporary(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-failure-coverage-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} +const noLogs = async () => ({ entries: [], gaps: [] }); +const time = "2026-10-23T08:01:12.345Z"; +const record = (extra = {}) => ({ + schemaVersion: 1, + source: "host_tool_result", + serverName: "chatcut", + occurredAt: time, + conversationId: "failure-thread", + callId: "call_original", + operation: "list_projects", + error: { + message: "Original MCP transport failure", + code: "E_UNFAMILIAR", + cause: { message: "socket reset", code: "ECONNRESET" }, + }, + ...extra, +}); + +for (const [host, operation, message] of [ + ["codex", "initialize", "MCP server startup failed: HTTP 503"], + [ + "codex", + "import_media", + "timed out awaiting tools/call after 300s; another request is still running", + ], + ["claude", "list_projects", "Original unfamiliar tool failure E_CC_982"], +]) + test(`${host} ${operation}: original failure time/call/cause survive absent logs and local-only collection`, async (t) => { + const dir = await temporary(t); + const failureRecord = join(dir, "original.mcp-failure.json"); + await writeFile( + failureRecord, + JSON.stringify( + record({ + operation, + error: { + message, + code: "E_UNFAMILIAR", + cause: { message: "native cause", code: "E_NATIVE" }, + }, + }), + ), + ); + let logOptions; + const { report } = await runDoctor( + { host, failureRecord, output: join(dir, "report.json") }, + { + collectLogs: async (_, options) => { + logOptions = options; + return noLogs(); + }, + fetchImpl: () => { + throw Error("No endpoint: a new network request is forbidden"); + }, + }, + ); + assert.equal(report.originalFailure.occurredAt, time); + assert.equal(logOptions.occurredAt, time); + assert.equal(logOptions.conversationId, "failure-thread"); + assert.equal(report.originalFailure.error.cause.code, "E_NATIVE"); + assert.equal(report.originalFailure.message, message); + assert.equal(report.evidenceSummary.originalLocalCall.callId, "call_original"); + assert.equal(report.evidenceSummary.originalRequestCorrelation.status, "unconfirmed"); + assert.ok(report.evidenceSummary.gaps.includes("original_request_id_unavailable")); + assert.equal(report.evidenceSummary.recoveryVerified, false); + }); + +test("a real refused connection retains the native cause without inventing a server request ID", async (t) => { + const dir = await temporary(t); + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + let failure; + try { + await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(1000) }); + } catch (error) { + failure = error; + } + assert.equal(failure.cause.code, "ECONNREFUSED"); + const failureRecord = join(dir, "refused.mcp-failure.json"); + await writeFile( + failureRecord, + JSON.stringify( + record({ + occurredAt: null, + error: { + name: failure.name, + message: failure.message, + cause: { message: failure.cause.message, code: failure.cause.code }, + }, + }), + ), + ); + const { report } = await runDoctor( + { host: "codex", failureRecord, output: join(dir, "report.json") }, + { collectLogs: noLogs }, + ); + assert.equal(report.originalFailure.error.cause.code, "ECONNREFUSED"); + assert.equal(report.originalFailure.occurredAt, null); + assert.equal(report.originalFailure.requestId, undefined); + assert.ok(report.evidenceSummary.gaps.includes("original_failure_time_unavailable")); +}); + +test("Windows startup-only records cannot close the tool failure evidence gap", async (t) => { + const dir = await temporary(t); + const { report } = await runDoctor( + { host: "codex", output: join(dir, "report.json") }, + { + collectLogs: async () => ({ + entries: ["starting", "cancelled", "ready"].map((status) => ({ + source: "codex_app_log", + message: `status=${status} threadId=failure-thread`, + })), + gaps: [], + }), + }, + ); + assert.equal(report.evidenceSummary.hostLogCoverage.appStartupRecords, 3); + assert.equal(report.evidenceSummary.hostLogCoverage.failedAppStartupRecords, 0); + assert.ok(report.evidenceSummary.gaps.includes("matching_host_failure_logs_unavailable")); + assert.equal(report.originalFailure.occurredAt, null); +}); + +test("bounded App collection names omitted files and allows a targeted native-file follow-up", async (t) => { + const dir = await temporary(t); + const now = Date.now(); + const dateDir = join(dir, new Date(now).toISOString().slice(0, 10).replaceAll("-", "/")); + await mkdir(dateDir, { recursive: true }); + let selected; + for (let i = 0; i < 4; i++) { + const path = join(dateDir, `codex-desktop-file-${i}.log`); + await writeFile( + path, + `${new Date(now).toISOString()} info [AppServerConnection] mcp_server_startup_status_updated error="E_FILE_${i}" failureReason=null hostId=local server=chatcut status=failed threadId=failure-thread\n`, + ); + await utimes(path, new Date(now - i * 1000), new Date(now - i * 1000)); + if (i === 3) selected = path; + } + const options = { + home: dir, + codexHome: dir, + codexAppLogDir: dir, + conversationId: "failure-thread", + now, + }; + const initial = await collectHostLogs("codex", options); + const gap = initial.gaps.find((g) => g.reason === "log_file_limit"); + assert.deepEqual(gap.omittedFiles, ["codex-desktop-file-3.log"]); + assert.ok(!initial.entries.some((e) => e.message.includes("E_FILE_3"))); + const followup = await collectHostLogs("codex", { ...options, codexAppLogFiles: [selected] }); + assert.equal(followup.appLog.selectedFileMode, true); + assert.deepEqual(followup.appLog.filesSelected, ["codex-desktop-file-3.log"]); + assert.equal(followup.appLog.conversationId, "failure-thread"); + assert.ok(followup.entries.some((e) => e.message.includes("E_FILE_3"))); + await assert.rejects( + collectHostLogs("codex", { ...options, codexAppLogFiles: [join(dir, "credentials.json")] }), + /./, + ); +}); + +test("dedicated records preserve unknown errors, redact credentials, and keep conflicting timestamps unresolved", async (t) => { + const dir = await temporary(t); + const failureRecord = join(dir, "secret.mcp-failure.json"); + await writeFile( + failureRecord, + JSON.stringify( + record({ + error: { + message: + "Unknown tool_call transcript E_NEW_183; Authorization: Bearer private-canary-123", + code: "E_NEW_183", + cause: { message: "original cause remains", access_token: "private-token-canary" }, + }, + }), + ), + ); + const { report } = await runDoctor( + { + host: "codex", + failureRecord, + occurredAt: "2026-10-23T08:02:00Z", + output: join(dir, "report.json"), + }, + { collectLogs: noLogs }, + ); + assert.equal(report.originalFailure.occurredAt, null); + assert.ok(report.gaps.some((g) => g.reason === "conflicting_supplied_failure_times")); + assert.match(report.originalFailure.message, /tool_call transcript E_NEW_183/); + assert.match(report.originalFailure.error.cause.message, /original cause remains/); + assert.doesNotMatch(JSON.stringify(report), /private-canary-123|private-token-canary/); + assert.throws( + () => validateFailureRecord(record({ arguments: { private: true } })), + /dedicated failure record/, + ); + assert.throws( + () => validateFailureRecord(record({ serverName: "chatcut_desktop" })), + /hosted ChatCut/, + ); +}); + +test("both host packages ship the dedicated failure reader", async () => { + assert.equal( + await readFile( + new URL("../codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs", import.meta.url), + "utf8", + ), + await readFile( + new URL( + "../claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs", + import.meta.url, + ), + "utf8", + ), + ); +}); + +test("a known App segment can recover a native failure outside the tail without reading the entire file", async (t) => { + const dir = await temporary(t); + const now = Date.now(); + const app = join(dir, "codex-desktop-selected.log"); + const error = `${new Date(now).toISOString()} info [AppServerConnection] mcp_server_startup_status_updated error="E_PREFIX_ORIGINAL" failureReason=null hostId=local server=chatcut status=failed threadId=failure-thread\n`; + await writeFile(app, error + "irrelevant UI record\n".repeat(16000)); + const options = { + home: dir, + codexHome: dir, + codexAppLogDir: dir, + codexAppLogFiles: [app], + conversationId: "failure-thread", + now, + }; + const tail = await collectHostLogs("codex", options); + assert.ok(!tail.entries.some((e) => e.message.includes("E_PREFIX_ORIGINAL"))); + const tailGap = tail.gaps.find((g) => g.reason === "only_log_tail_read"); + assert.equal(tailGap.sourceFile, "codex-desktop-selected.log"); + assert.ok(tailGap.omittedBytes > 0); + const first = await collectHostLogs("codex", { ...options, codexAppLogOffset: 0 }); + assert.ok(first.entries.some((e) => e.message.includes("E_PREFIX_ORIGINAL"))); + assert.equal(first.appLog.selectedByteOffset, 0); + assert.ok( + first.gaps.some( + (g) => + g.reason === "only_log_segment_read" && g.bytesRead <= 128 * 1024 && g.remainingBytes > 0, + ), + ); +}); + +test("native runtime trace call/thread identifiers survive extraction without becoming a cloud request ID", async (t) => { + const { DatabaseSync } = await import("node:sqlite"); + const dir = await temporary(t); + const db = new DatabaseSync(join(dir, "logs_2.sqlite")); + db.exec( + "CREATE TABLE logs(id INTEGER, ts INTEGER, level TEXT, target TEXT, feedback_log_body TEXT, thread_id TEXT)", + ); + const add = db.prepare("INSERT INTO logs VALUES(?,?,?,?,?,?)"); + add.run( + 1, + Math.floor(Date.now() / 1000), + "ERROR", + "codex_mcp::rmcp_client", + "session{thread_id=failure-thread}:invoke{server_name=chatcut call_id=call_native}: Original native error E_CAUSE_381", + "failure-thread", + ); + add.run( + 2, + Math.floor(Date.now() / 1000), + "ERROR", + "codex_mcp::rmcp_client", + "session{thread_id=other-thread}:invoke{server_name=chatcut call_id=call_other}: Private other-thread failure", + "other-thread", + ); + db.close(); + const logs = await collectHostLogs("codex", { codexHome: dir, conversationId: "failure-thread" }); + const selected = logs.entries.find((e) => e.source === "codex_runtime_sqlite"); + assert.equal(selected.conversationId, "failure-thread"); + assert.equal(selected.callId, "call_native"); + assert.equal(selected.requestId, undefined); + assert.equal(selected.originalAttemptMatch, "unconfirmed"); + assert.match(selected.message, /E_CAUSE_381/); + assert.doesNotMatch(JSON.stringify(logs), /Private other-thread failure/); +}); + +for (const [host, location] of [ + ["codex", "codex/skills/chatcut-plugin-basics"], + ["claude", "claude/skills/chatcut-plugin-basics-claude"], +]) + test(`${host} published CLI accepts the original failure record without contacting a server`, async (t) => { + const dir = await temporary(t); + const failureRecord = join(dir, "cli.mcp-failure.json"); + const output = join(dir, "cli-report.json"); + await writeFile(failureRecord, JSON.stringify(record())); + 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.occurredAt, time); + assert.equal(report.originalFailure.callId, "call_original"); + assert.equal(report.originalFailure.error.cause.code, "ECONNRESET"); + assert.equal(report.upload.status, "skipped_endpoint_unavailable"); + assert.equal(report.probes.length, 0); + });