From 360346fe4fa56ab801991968ff7854a2bdfb37e9 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sat, 24 Oct 2026 14:03:44 +0800 Subject: [PATCH 01/16] fix: retain native MCP failures and scope TUI evidence --- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 24 ++++-- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 24 ++++-- tests/collection-quality.test.mjs | 85 +++++++++++++++++++ 3 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 tests/collection-quality.test.mjs diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 326b4fd..d2b09ba 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -415,7 +415,7 @@ 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. @@ -446,7 +446,16 @@ function hostedTextRecord(line) { 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; + if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) { + const header = line.match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i); + // 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(String(nativeLevel ?? header?.[1] ?? header?.[2]).toUpperCase())) 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; @@ -535,7 +544,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 || @@ -548,7 +561,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", }); } @@ -733,7 +746,7 @@ export async function collectHostLogs(host, options = {}) { .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`, + `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 'codex_rmcp_client%' 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) { @@ -747,7 +760,7 @@ export async function collectHostLogs(host, options = {}) { context.conversationId !== options.conversationId ) continue; - if (!hostedTextRecord(`${row.target}: ${original}`) || + if (!hostedTextRecord(`${row.target}: ${original}`, row.level) || !hostedTextRecord(`MCP server "chatcut" ${message}`)) continue; if ( @@ -791,6 +804,7 @@ export async function collectHostLogs(host, options = {}) { ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { since, until, + conversationId: options.conversationId, })), ); } catch (error) { diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 326b4fd..d2b09ba 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -415,7 +415,7 @@ 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. @@ -446,7 +446,16 @@ function hostedTextRecord(line) { 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; + if (/\bmethod=["']?tools\/call(?:["'\s}]|$)/.test(context)) { + const header = line.match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i); + // 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(String(nativeLevel ?? header?.[1] ?? header?.[2]).toUpperCase())) 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; @@ -535,7 +544,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 || @@ -548,7 +561,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", }); } @@ -733,7 +746,7 @@ export async function collectHostLogs(host, options = {}) { .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`, + `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 'codex_rmcp_client%' 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) { @@ -747,7 +760,7 @@ export async function collectHostLogs(host, options = {}) { context.conversationId !== options.conversationId ) continue; - if (!hostedTextRecord(`${row.target}: ${original}`) || + if (!hostedTextRecord(`${row.target}: ${original}`, row.level) || !hostedTextRecord(`MCP server "chatcut" ${message}`)) continue; if ( @@ -791,6 +804,7 @@ export async function collectHostLogs(host, options = {}) { ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { since, until, + conversationId: options.conversationId, })), ); } catch (error) { diff --git a/tests/collection-quality.test.mjs b/tests/collection-quality.test.mjs new file mode 100644 index 0000000..03b1211 --- /dev/null +++ b/tests/collection-quality.test.mjs @@ -0,0 +1,85 @@ +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-mcp-doctor/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")); +}); From fafd2decdda26eaea2a37de97628c09d0f245497 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sat, 24 Oct 2026 14:08:06 +0800 Subject: [PATCH 02/16] fix: scope native log query before record limit --- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 6 +++-- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 6 +++-- tests/collection-quality.test.mjs | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index d2b09ba..546261a 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -744,11 +744,13 @@ export async function collectHostLogs(host, options = {}) { .prepare("PRAGMA table_info(logs)") .all() .some((column) => column.name === "thread_id"); + const threadFilter = hasThreadId && options.conversationId + ? " AND (thread_id IS NULL OR thread_id='' OR 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 'codex_rmcp_client%' 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<=?${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%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, ) - .all(since, until); + .all(since, until, ...(threadFilter ? [options.conversationId] : [])); for (const row of rows) { const message = logMessage(row.feedback_log_body ?? ""); const original = row.feedback_log_body ?? ""; diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index d2b09ba..546261a 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -744,11 +744,13 @@ export async function collectHostLogs(host, options = {}) { .prepare("PRAGMA table_info(logs)") .all() .some((column) => column.name === "thread_id"); + const threadFilter = hasThreadId && options.conversationId + ? " AND (thread_id IS NULL OR thread_id='' OR 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 'codex_rmcp_client%' 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<=?${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%') AND feedback_log_body LIKE '%chatcut%' ORDER BY ts DESC LIMIT 200`, ) - .all(since, until); + .all(since, until, ...(threadFilter ? [options.conversationId] : [])); for (const row of rows) { const message = logMessage(row.feedback_log_body ?? ""); const original = row.feedback_log_body ?? ""; diff --git a/tests/collection-quality.test.mjs b/tests/collection-quality.test.mjs index 03b1211..bdcf9f7 100644 --- a/tests/collection-quality.test.mjs +++ b/tests/collection-quality.test.mjs @@ -83,3 +83,27 @@ test("Codex TUI excludes explicit other conversations but retains unknown associ 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")); + } +}); From 46d50a8c87acb1f81c10d3e8f8a845f2d1bc0a97 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sat, 24 Oct 2026 17:58:03 +0800 Subject: [PATCH 03/16] fix: collect observed native MCP tool failure format --- claude/skills/chatcut-mcp-doctor/SKILL.md | 2 + .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 15 +++-- codex/skills/chatcut-mcp-doctor/SKILL.md | 2 + .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 15 +++-- tests/fixtures/owned-codex-mcp-tool-error.log | 1 + tests/real-native-log.test.mjs | 56 +++++++++++++++++++ 6 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 tests/fixtures/owned-codex-mcp-tool-error.log create mode 100644 tests/real-native-log.test.mjs diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index e27adc4..ae06ff0 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -7,6 +7,8 @@ description: Diagnose hosted ChatCut MCP connection, startup, authentication or This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. +If the host rejects a local command, preserve that exact rejection and the operation it blocked. One denied compound read does not establish that every file tool or the standalone Node helper is prohibited, or that the workspace is read-only. Use only capabilities already permitted by the current host; do not change permissions, switch shells to bypass a rejection, or call collection complete when no report was written. + ## Start here - In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 546261a..33def0c 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -427,7 +427,7 @@ function hostedTextRecord(line, nativeLevel) { 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); @@ -445,12 +445,17 @@ function hostedTextRecord(line, nativeLevel) { 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; + 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)) { - const header = line.match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i); // 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(String(nativeLevel ?? header?.[1] ?? header?.[2]).toUpperCase())) return false; + 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; @@ -748,7 +753,7 @@ export async function collectHostLogs(host, options = {}) { ? " AND (thread_id IS NULL OR thread_id='' OR thread_id=?)" : ""; const rows = 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%') 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<=?${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`, ) .all(since, until, ...(threadFilter ? [options.conversationId] : [])); for (const row of rows) { diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index e27adc4..ae06ff0 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -7,6 +7,8 @@ description: Diagnose hosted ChatCut MCP connection, startup, authentication or This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. +If the host rejects a local command, preserve that exact rejection and the operation it blocked. One denied compound read does not establish that every file tool or the standalone Node helper is prohibited, or that the workspace is read-only. Use only capabilities already permitted by the current host; do not change permissions, switch shells to bypass a rejection, or call collection complete when no report was written. + ## Start here - In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 546261a..33def0c 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -427,7 +427,7 @@ function hostedTextRecord(line, nativeLevel) { 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); @@ -445,12 +445,17 @@ function hostedTextRecord(line, nativeLevel) { 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; + 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)) { - const header = line.match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i); // 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(String(nativeLevel ?? header?.[1] ?? header?.[2]).toUpperCase())) return false; + 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; @@ -748,7 +753,7 @@ export async function collectHostLogs(host, options = {}) { ? " AND (thread_id IS NULL OR thread_id='' OR thread_id=?)" : ""; const rows = 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%') 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<=?${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`, ) .all(since, until, ...(threadFilter ? [options.conversationId] : [])); for (const row of rows) { 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/real-native-log.test.mjs b/tests/real-native-log.test.mjs new file mode 100644 index 0000000..77749af --- /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-mcp-doctor/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); +}); From 16d44692b8e89a8c3e40a61a5791e8b3c889ae17 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sat, 24 Oct 2026 19:12:19 +0800 Subject: [PATCH 04/16] fix: retain bounded native MCP failure context and stderr --- claude/skills/chatcut-mcp-doctor/SKILL.md | 6 + .../references/connection-diagnostics.md | 34 ++++ .../chatcut-mcp-doctor/scripts/diagnosis.mjs | 32 +++- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 102 ++++++++++- .../scripts/mcp-evidence.mjs | 7 + codex/skills/chatcut-mcp-doctor/SKILL.md | 6 + .../references/connection-diagnostics.md | 34 ++++ .../chatcut-mcp-doctor/scripts/diagnosis.mjs | 32 +++- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 102 ++++++++++- .../scripts/mcp-evidence.mjs | 7 + tests/native-failure-index.test.mjs | 162 ++++++++++++++++++ 11 files changed, 510 insertions(+), 14 deletions(-) create mode 100644 tests/native-failure-index.test.mjs diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index ae06ff0..4b6e108 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -19,6 +19,12 @@ If the host rejects a local command, preserve that exact rejection and the opera Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. +Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. + +With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. + +If this invocation's native stderr has already been retained by its caller in a known dedicated `.codex-stderr.log`, use `--codex-stderr-file` to read a bounded ChatCut diagnostic tail. The helper retains native `loggedAt` separately, never as original `occurredAt` or a call ID. This is conditional on a real retained error stream; do not manufacture a log, scan for unknown paths or start another host to replace missing evidence. + Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. ```text diff --git a/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md b/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md index 7d99c81..c2e2b3a 100644 --- a/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md +++ b/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md @@ -230,6 +230,40 @@ MCP error stream. Preserve the original stderr/startup error when it is already visible to the current host. The helper cannot recover discarded parent-process stderr or reconstruct an old exec failure from App logs. +When the caller actually retained this invocation's native stderr, explicitly +select its known dedicated `.codex-stderr.log` with `--codex-stderr-file`. +The same native ChatCut selector reads at most128 KiB and30 records; omitted +bytes and inaccessible files remain gaps. `source: codex_stderr_log` records +`loggedAt` as native log-emission time and keeps original attempt association +unconfirmed. It does not assign original `occurredAt`, lifecycle time or IDs. +No capture file is created, discovered or populated by this option. Unknown or +discarded stderr remains unavailable; another host/session is not a substitute. + +For a known thread, the helper can also query the existing Codex +`thread_history_1.sqlite` failed-MCP index in the same supplied SQLite root. +It selects only native IDs, start/completion columns and `error` projections for +that thread, ChatCut server, `mcpToolCall` type, failed status and the bounded +time window. It returns at most ten items and at most128 KiB per error; limits, +missing/unsupported schemas and denied reads remain gaps. It never returns +whole `item_json`, user/assistant messages, tool arguments or successful results, +and never reads a rollout transcript. `created_at_ms` is not substituted for a +lifecycle or failure time. + +`collectionContext` identifies the current collecting thread from the host's +`CODEX_THREAD_ID`; it is not silently copied into `originalFailure`. +`hostLogs.nativeFailedCalls` preserves native `nativeItemId` with its explicit +kind and `startedAtMs`/`completedAtMs`. These fields are native MCP item identity +and lifecycle evidence, not Responses `call_id`, MCP RPC ID, server request ID, +log-emission time or an automatically known original `occurredAt`. Native facts +can be useful even when the original fields remain unknown. Only compare a +known same thread, matching operation and exact error; duplicates or missing +provenance remain candidates. A failed index read does not erase other evidence. +The gap identifies the failed operation (locate/open/schema/query) and retains +the native error. Windows error14 can occur at SELECT even when ordinary file +reads and SQLite open succeed. No database copy, immutable mode or permission +change is attempted. A report that exceeds the existing upload budget remains +fully saved locally; errors are not shortened merely to make upload fit. + If the current host already exposes native `mcpServer/startupStatus/updated` notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, `runtimeStatus` and `toolsError` from that same thread in the error file and diff --git a/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs index 3d4bdba..7735ea1 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -85,8 +85,28 @@ export function diagnose(report) { messageExcerpt: redact(probe.error?.message ?? "").slice(0, 2000), }); }); + (report.hostLogs?.nativeFailedCalls ?? []).forEach((item, index) => { + add("native_failed_mcp_item_unassociated", [`hostLogs.nativeFailedCalls[${index}]`], + "A failed native MCP item is present in the scoped index. Preserve its native ID kind and start/completion times; compare a known thread, operation and exact error. Do not fill original occurrence time, Responses call ID or server request ID, or assume duplicate/unknown records are the original attempt.", "attention", { + source: item.source, nativeItemId: item.nativeItemId, idKind: item.idKind, + conversationId: item.conversationId, turnId: item.turnId, operation: item.operation, + startedAtMs: item.startedAtMs, completedAtMs: item.completedAtMs, originalAttemptMatch: "unconfirmed", + }); + }); (report.hostLogs?.entries ?? []).forEach((entry, index) => { const message = entry.message ?? ""; + const stderrHeader = entry.source === "codex_stderr_log" && message.match( + /^\d{4}-\d{2}-\d{2}T\S+\s+(WARN|ERROR)\s+(?:codex_mcp::rmcp_client:\s+MCP server startup failed server_name=["']?chatcut["']?\s+error=|codex_core::mcp_tool_call:\s+MCP tool call error:\s*["']?tool call error: tool call failed for `(?:chatcut|plugin:chatcut:chatcut)\/)/, + ); + if (stderrHeader) { + add("host_runtime_error_unassociated", [`hostLogs.entries[${index}]`], + "A retained native stderr failure was collected. Its log-emission time is not the original occurrence time, and its association with the reported attempt remains unconfirmed. Preserve the exact error and missing correlation; no cause, repair or recovery is established.", "attention", { + source: entry.source, level: stderrHeader[1], loggedAt: entry.loggedAt ?? null, + timestampKind: entry.timestampKind, originalAttemptMatch: "unconfirmed", + messageExcerpt: redact(message).slice(0, 2000), + }); + return; + } // One observed native startup response, not a generic keyword/root-cause // classifier. A warning alone or another module/message is insufficient. if (entry.source === "codex_runtime_sqlite" && entry.target === "codex_mcp::rmcp_client" && @@ -108,7 +128,7 @@ export function diagnose(report) { status = [...message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1]; if (status !== "failed") return; } else { - if (!["codex_runtime_sqlite", "codex_tui_log", "claude_debug_log"].includes(entry.source)) return; + if (!["codex_runtime_sqlite", "codex_tui_log", "codex_stderr_log", "claude_debug_log"].includes(entry.source)) return; const nativeErrorLevel = String(entry.level ?? "").toUpperCase() === "ERROR" || /^(?:\d{4}-\d{2}-\d{2}T\S+\s+)?(?:\[ERROR\]|ERROR)(?:\s|$)/i.test(message); if (!nativeErrorLevel) return; @@ -116,7 +136,9 @@ export function diagnose(report) { add("host_runtime_error_unassociated", [`hostLogs.entries[${index}]`], "A bounded native host error was collected, but its association with the original failed attempt is unconfirmed. Inspect the local record and available time/call context; preserve missing or truncated coverage and do not infer a root cause or recovery.", "attention", { source: entry.source, ...(status ? { status } : { level: "ERROR" }), - occurredAt: entry.occurredAt ?? null, + ...(entry.source === "codex_stderr_log" + ? { loggedAt: entry.loggedAt ?? null, timestampKind: entry.timestampKind } + : { occurredAt: entry.occurredAt ?? null }), originalAttemptMatch: "unconfirmed", messageExcerpt: redact(message).slice(0, 2000), }); @@ -154,12 +176,14 @@ function publicExcerpt(text, original = {}) { export function renderPacket(report, diagnosis) { const facts = (includePrivateExcerpts) => diagnosis.findings.map((item) => { const observed = item.observed && Object.fromEntries(Object.entries(item.observed) - .filter(([key]) => includePrivateExcerpts || key !== "messageExcerpt")); + .filter(([key]) => includePrivateExcerpts || !["messageExcerpt", "nativeItemId", "conversationId", "turnId"].includes(key))); return `- ${item.kind}: ${item.next} Evidence: ${item.evidence.join(", ")}${observed ? ` Observed: ${redact(JSON.stringify(observed))}` : ""}`; }).join("\n"); const gaps = diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? gap.error?.message ?? "see local report"}`).join("\n") || "- None recorded; coverage is still bounded."; const common = (includePrivateExcerpts) => `Diagnostic ID: ${report.diagnosticId}\nHost: ${report.host} ${report.hostVersion ?? "version unknown"}\nCollector: ${report.collectorVersion}; package: ${report.pluginVersion ?? "unknown"} (not proof of loaded version)\n\n${facts(includePrivateExcerpts)}\n\nRoot cause: unconfirmed. Original host recovery: unverified.\n`; - const summary = `# ChatCut MCP diagnosis\n\n${common(true)}\n## Evidence boundaries\n\n${Object.values(diagnosis.provenance).map((value) => `- ${value}`).join("\n")}\n\n## Original failure\n\n${redact(JSON.stringify(report.originalFailure, null, 2))}\n\n## Missing evidence\n\n${gaps}\n\n## Feedback\n\nUpload: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. An ingest receipt is not a support ticket or proof of complete storage.\nKeep this report for targeted follow-up; do not rerun collection just to resend it.\n`; + const nativeContext = report.collectionContext || report.hostLogs?.nativeFailedCalls?.length + ? `\n## Current collection context and native failed MCP items\n\nThese are current-thread context and bounded native failure facts; no original occurrence time or request association is filled automatically. Native item IDs are distinct from Responses call IDs, MCP RPC IDs and server request IDs. Native lifecycle times are start/completion times.\n\n${redact(JSON.stringify({ collectionContext: report.collectionContext ?? null, nativeFailedCalls: report.hostLogs?.nativeFailedCalls ?? [] }, null, 2))}\n` : ""; + const summary = `# ChatCut MCP diagnosis\n\n${common(true)}\n## Evidence boundaries\n\n${Object.values(diagnosis.provenance).map((value) => `- ${value}`).join("\n")}\n\n## Original failure\n\n${redact(JSON.stringify(report.originalFailure, null, 2))}\n${nativeContext}\n## Missing evidence\n\n${gaps}\n\n## Feedback\n\nUpload: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. An ingest receipt is not a support ticket or proof of complete storage.\nKeep this report for targeted follow-up; do not rerun collection just to resend it.\n`; // Deliberately excludes host log dumps, local call/conversation IDs, paths and full stacks. const draft = `# ChatCut MCP issue draft — review before sharing\n\n${common(false)}\n## Symptom and reproduction\n\n[Add the user's symptom and minimal safe steps; do not replay a high-impact operation.]\n\n## Original error excerpt\n\n${publicExcerpt(report.originalFailure?.message, report.originalFailure)}\n\nOriginal failure time: ${report.originalFailure?.occurredAt ?? "unknown"}. Later checks are not this attempt.\n\n## Checks and attempted repairs\n\nDoctor collected the original error if available, bounded host logs and later anonymous probes when allowed. No repair is performed by the helper.\n[Record any actual authorized change and the same-host verification result. Leave unperformed steps unperformed.]\n\n## Evidence gaps\n\n${diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? "details retained locally"}`).join("\n")}\n\n## Delivery\n\nDiagnostic ingest: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. No ticket has been opened.\nUse https://chatcut.io/contact for private support. A public issue destination has not been selected; do not publish automatically. Review the excerpt for project names, personal data and secrets before sharing. Keep the full local JSON private unless an appropriate recipient requests it.\n`; return { summary, draft }; diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 33def0c..9f111fc 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -725,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); @@ -806,6 +875,7 @@ export async function collectHostLogs(host, options = {}) { } finally { db?.close(); } + nativeFailedCalls = await nativeFailedMcpItems(sqliteRoot, options.conversationId, { since, until }, gaps); try { entries.push( ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { @@ -817,6 +887,20 @@ export async function collectHostLogs(host, options = {}) { } 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 && @@ -880,12 +964,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(), @@ -977,6 +1062,9 @@ 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, @@ -987,6 +1075,11 @@ export async function runDoctor(options, dependencies = {}) { 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(), @@ -1142,10 +1235,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 = { @@ -1304,6 +1398,7 @@ export async function runCli(args = process.argv.slice(2)) { "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" }, @@ -1349,7 +1444,7 @@ export async function runCli(args = process.argv.slice(2)) { console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); } else if (values.help) { console.log( - "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--offline] [--upload]", + "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({ @@ -1362,6 +1457,7 @@ export async function runCli(args = process.argv.slice(2)) { 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: diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs index 71111f2..eb600ea 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -251,6 +251,13 @@ export function evidenceSummary(report) { 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, diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index ae06ff0..4b6e108 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -19,6 +19,12 @@ If the host rejects a local command, preserve that exact rejection and the opera Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. +Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. + +With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. + +If this invocation's native stderr has already been retained by its caller in a known dedicated `.codex-stderr.log`, use `--codex-stderr-file` to read a bounded ChatCut diagnostic tail. The helper retains native `loggedAt` separately, never as original `occurredAt` or a call ID. This is conditional on a real retained error stream; do not manufacture a log, scan for unknown paths or start another host to replace missing evidence. + Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. ```text diff --git a/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md b/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md index 7d99c81..c2e2b3a 100644 --- a/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md +++ b/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md @@ -230,6 +230,40 @@ MCP error stream. Preserve the original stderr/startup error when it is already visible to the current host. The helper cannot recover discarded parent-process stderr or reconstruct an old exec failure from App logs. +When the caller actually retained this invocation's native stderr, explicitly +select its known dedicated `.codex-stderr.log` with `--codex-stderr-file`. +The same native ChatCut selector reads at most128 KiB and30 records; omitted +bytes and inaccessible files remain gaps. `source: codex_stderr_log` records +`loggedAt` as native log-emission time and keeps original attempt association +unconfirmed. It does not assign original `occurredAt`, lifecycle time or IDs. +No capture file is created, discovered or populated by this option. Unknown or +discarded stderr remains unavailable; another host/session is not a substitute. + +For a known thread, the helper can also query the existing Codex +`thread_history_1.sqlite` failed-MCP index in the same supplied SQLite root. +It selects only native IDs, start/completion columns and `error` projections for +that thread, ChatCut server, `mcpToolCall` type, failed status and the bounded +time window. It returns at most ten items and at most128 KiB per error; limits, +missing/unsupported schemas and denied reads remain gaps. It never returns +whole `item_json`, user/assistant messages, tool arguments or successful results, +and never reads a rollout transcript. `created_at_ms` is not substituted for a +lifecycle or failure time. + +`collectionContext` identifies the current collecting thread from the host's +`CODEX_THREAD_ID`; it is not silently copied into `originalFailure`. +`hostLogs.nativeFailedCalls` preserves native `nativeItemId` with its explicit +kind and `startedAtMs`/`completedAtMs`. These fields are native MCP item identity +and lifecycle evidence, not Responses `call_id`, MCP RPC ID, server request ID, +log-emission time or an automatically known original `occurredAt`. Native facts +can be useful even when the original fields remain unknown. Only compare a +known same thread, matching operation and exact error; duplicates or missing +provenance remain candidates. A failed index read does not erase other evidence. +The gap identifies the failed operation (locate/open/schema/query) and retains +the native error. Windows error14 can occur at SELECT even when ordinary file +reads and SQLite open succeed. No database copy, immutable mode or permission +change is attempted. A report that exceeds the existing upload budget remains +fully saved locally; errors are not shortened merely to make upload fit. + If the current host already exposes native `mcpServer/startupStatus/updated` notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, `runtimeStatus` and `toolsError` from that same thread in the error file and diff --git a/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs index 3d4bdba..7735ea1 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -85,8 +85,28 @@ export function diagnose(report) { messageExcerpt: redact(probe.error?.message ?? "").slice(0, 2000), }); }); + (report.hostLogs?.nativeFailedCalls ?? []).forEach((item, index) => { + add("native_failed_mcp_item_unassociated", [`hostLogs.nativeFailedCalls[${index}]`], + "A failed native MCP item is present in the scoped index. Preserve its native ID kind and start/completion times; compare a known thread, operation and exact error. Do not fill original occurrence time, Responses call ID or server request ID, or assume duplicate/unknown records are the original attempt.", "attention", { + source: item.source, nativeItemId: item.nativeItemId, idKind: item.idKind, + conversationId: item.conversationId, turnId: item.turnId, operation: item.operation, + startedAtMs: item.startedAtMs, completedAtMs: item.completedAtMs, originalAttemptMatch: "unconfirmed", + }); + }); (report.hostLogs?.entries ?? []).forEach((entry, index) => { const message = entry.message ?? ""; + const stderrHeader = entry.source === "codex_stderr_log" && message.match( + /^\d{4}-\d{2}-\d{2}T\S+\s+(WARN|ERROR)\s+(?:codex_mcp::rmcp_client:\s+MCP server startup failed server_name=["']?chatcut["']?\s+error=|codex_core::mcp_tool_call:\s+MCP tool call error:\s*["']?tool call error: tool call failed for `(?:chatcut|plugin:chatcut:chatcut)\/)/, + ); + if (stderrHeader) { + add("host_runtime_error_unassociated", [`hostLogs.entries[${index}]`], + "A retained native stderr failure was collected. Its log-emission time is not the original occurrence time, and its association with the reported attempt remains unconfirmed. Preserve the exact error and missing correlation; no cause, repair or recovery is established.", "attention", { + source: entry.source, level: stderrHeader[1], loggedAt: entry.loggedAt ?? null, + timestampKind: entry.timestampKind, originalAttemptMatch: "unconfirmed", + messageExcerpt: redact(message).slice(0, 2000), + }); + return; + } // One observed native startup response, not a generic keyword/root-cause // classifier. A warning alone or another module/message is insufficient. if (entry.source === "codex_runtime_sqlite" && entry.target === "codex_mcp::rmcp_client" && @@ -108,7 +128,7 @@ export function diagnose(report) { status = [...message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1]; if (status !== "failed") return; } else { - if (!["codex_runtime_sqlite", "codex_tui_log", "claude_debug_log"].includes(entry.source)) return; + if (!["codex_runtime_sqlite", "codex_tui_log", "codex_stderr_log", "claude_debug_log"].includes(entry.source)) return; const nativeErrorLevel = String(entry.level ?? "").toUpperCase() === "ERROR" || /^(?:\d{4}-\d{2}-\d{2}T\S+\s+)?(?:\[ERROR\]|ERROR)(?:\s|$)/i.test(message); if (!nativeErrorLevel) return; @@ -116,7 +136,9 @@ export function diagnose(report) { add("host_runtime_error_unassociated", [`hostLogs.entries[${index}]`], "A bounded native host error was collected, but its association with the original failed attempt is unconfirmed. Inspect the local record and available time/call context; preserve missing or truncated coverage and do not infer a root cause or recovery.", "attention", { source: entry.source, ...(status ? { status } : { level: "ERROR" }), - occurredAt: entry.occurredAt ?? null, + ...(entry.source === "codex_stderr_log" + ? { loggedAt: entry.loggedAt ?? null, timestampKind: entry.timestampKind } + : { occurredAt: entry.occurredAt ?? null }), originalAttemptMatch: "unconfirmed", messageExcerpt: redact(message).slice(0, 2000), }); @@ -154,12 +176,14 @@ function publicExcerpt(text, original = {}) { export function renderPacket(report, diagnosis) { const facts = (includePrivateExcerpts) => diagnosis.findings.map((item) => { const observed = item.observed && Object.fromEntries(Object.entries(item.observed) - .filter(([key]) => includePrivateExcerpts || key !== "messageExcerpt")); + .filter(([key]) => includePrivateExcerpts || !["messageExcerpt", "nativeItemId", "conversationId", "turnId"].includes(key))); return `- ${item.kind}: ${item.next} Evidence: ${item.evidence.join(", ")}${observed ? ` Observed: ${redact(JSON.stringify(observed))}` : ""}`; }).join("\n"); const gaps = diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? gap.error?.message ?? "see local report"}`).join("\n") || "- None recorded; coverage is still bounded."; const common = (includePrivateExcerpts) => `Diagnostic ID: ${report.diagnosticId}\nHost: ${report.host} ${report.hostVersion ?? "version unknown"}\nCollector: ${report.collectorVersion}; package: ${report.pluginVersion ?? "unknown"} (not proof of loaded version)\n\n${facts(includePrivateExcerpts)}\n\nRoot cause: unconfirmed. Original host recovery: unverified.\n`; - const summary = `# ChatCut MCP diagnosis\n\n${common(true)}\n## Evidence boundaries\n\n${Object.values(diagnosis.provenance).map((value) => `- ${value}`).join("\n")}\n\n## Original failure\n\n${redact(JSON.stringify(report.originalFailure, null, 2))}\n\n## Missing evidence\n\n${gaps}\n\n## Feedback\n\nUpload: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. An ingest receipt is not a support ticket or proof of complete storage.\nKeep this report for targeted follow-up; do not rerun collection just to resend it.\n`; + const nativeContext = report.collectionContext || report.hostLogs?.nativeFailedCalls?.length + ? `\n## Current collection context and native failed MCP items\n\nThese are current-thread context and bounded native failure facts; no original occurrence time or request association is filled automatically. Native item IDs are distinct from Responses call IDs, MCP RPC IDs and server request IDs. Native lifecycle times are start/completion times.\n\n${redact(JSON.stringify({ collectionContext: report.collectionContext ?? null, nativeFailedCalls: report.hostLogs?.nativeFailedCalls ?? [] }, null, 2))}\n` : ""; + const summary = `# ChatCut MCP diagnosis\n\n${common(true)}\n## Evidence boundaries\n\n${Object.values(diagnosis.provenance).map((value) => `- ${value}`).join("\n")}\n\n## Original failure\n\n${redact(JSON.stringify(report.originalFailure, null, 2))}\n${nativeContext}\n## Missing evidence\n\n${gaps}\n\n## Feedback\n\nUpload: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. An ingest receipt is not a support ticket or proof of complete storage.\nKeep this report for targeted follow-up; do not rerun collection just to resend it.\n`; // Deliberately excludes host log dumps, local call/conversation IDs, paths and full stacks. const draft = `# ChatCut MCP issue draft — review before sharing\n\n${common(false)}\n## Symptom and reproduction\n\n[Add the user's symptom and minimal safe steps; do not replay a high-impact operation.]\n\n## Original error excerpt\n\n${publicExcerpt(report.originalFailure?.message, report.originalFailure)}\n\nOriginal failure time: ${report.originalFailure?.occurredAt ?? "unknown"}. Later checks are not this attempt.\n\n## Checks and attempted repairs\n\nDoctor collected the original error if available, bounded host logs and later anonymous probes when allowed. No repair is performed by the helper.\n[Record any actual authorized change and the same-host verification result. Leave unperformed steps unperformed.]\n\n## Evidence gaps\n\n${diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? "details retained locally"}`).join("\n")}\n\n## Delivery\n\nDiagnostic ingest: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. No ticket has been opened.\nUse https://chatcut.io/contact for private support. A public issue destination has not been selected; do not publish automatically. Review the excerpt for project names, personal data and secrets before sharing. Keep the full local JSON private unless an appropriate recipient requests it.\n`; return { summary, draft }; diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 33def0c..9f111fc 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -725,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); @@ -806,6 +875,7 @@ export async function collectHostLogs(host, options = {}) { } finally { db?.close(); } + nativeFailedCalls = await nativeFailedMcpItems(sqliteRoot, options.conversationId, { since, until }, gaps); try { entries.push( ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { @@ -817,6 +887,20 @@ export async function collectHostLogs(host, options = {}) { } 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 && @@ -880,12 +964,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(), @@ -977,6 +1062,9 @@ 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, @@ -987,6 +1075,11 @@ export async function runDoctor(options, dependencies = {}) { 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(), @@ -1142,10 +1235,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 = { @@ -1304,6 +1398,7 @@ export async function runCli(args = process.argv.slice(2)) { "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" }, @@ -1349,7 +1444,7 @@ export async function runCli(args = process.argv.slice(2)) { console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); } else if (values.help) { console.log( - "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--offline] [--upload]", + "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({ @@ -1362,6 +1457,7 @@ export async function runCli(args = process.argv.slice(2)) { 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: diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs index 71111f2..eb600ea 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -251,6 +251,13 @@ export function evidenceSummary(report) { 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, diff --git a/tests/native-failure-index.test.mjs b/tests/native-failure-index.test.mjs new file mode 100644 index 0000000..05f0349 --- /dev/null +++ b/tests/native-failure-index.test.mjs @@ -0,0 +1,162 @@ +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, submitReport } from "../codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; +import { diagnose, renderPacket } from "../codex/skills/chatcut-mcp-doctor/scripts/diagnosis.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 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)); + 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); + const upload = await submitReport(report, () => assert.fail("Oversized report is retained locally without upload")); + assert.equal(upload.status, "failed"); + assert.ok(report.hostLogs.nativeFailedCalls.every(i => i.error.message.endsWith("E_UNTRIMMED_TAIL"))); +}); + +test("current collection context is saved without filling original fields or leaking native IDs to issue drafts", 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); + const diagnosis = diagnose(report), packet = renderPacket(report, diagnosis); + assert.ok(diagnosis.findings.some(f => f.kind === "native_failed_mcp_item_unassociated")); + assert.ok(!diagnosis.findings.some(f => f.kind === "no_incident_demonstrated")); + assert.ok(packet.summary.includes("PRIVATE_NATIVE_ID")); + for (const value of [current, "PRIVATE_NATIVE_ID", "PRIVATE_TURN_ID"]) assert.ok(!packet.draft.includes(value)); + assert.equal(diagnosis.recoveryVerified, false); +}); + +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 report = { diagnosticId: "00000000-0000-0000-0000-000000000001", host: "codex", collectorVersion: "1.3.0", + originalFailure: { status: "unavailable", occurredAt: null }, hostLogs: logs, gaps: [] }; + const diagnosis = diagnose(report); + assert.ok(diagnosis.findings.some(f => f.kind === "host_runtime_error_unassociated" && f.observed.level === "WARN" && f.observed.loggedAt === found[0].loggedAt)); + assert.ok(!diagnosis.findings.some(f => f.kind === "no_incident_demonstrated")); + assert.equal(report.originalFailure.occurredAt, null); + assert.ok(!renderPacket(report, diagnosis).draft.includes("E_REAL_NATIVE")); + const healthy = { ...report, hostLogs: { entries: [ + '2026-10-24T10:29:59Z WARN codex_mcp::rmcp_client: MCP server starting server_name="chatcut"', + '2026-10-24T10:29:59Z WARN codex_mcp::rmcp_client: MCP server ready server_name="chatcut"', + '2026-10-24T10:29:59Z WARN codex_mcp::rmcp_client: cached catalog server_name="chatcut"', + '2026-10-24T10:29:59Z WARN codex_mcp::rmcp_client: MCP server startup failed server_name="other" error=E_OTHER', + ].map(message => ({ source: "codex_stderr_log", message })), gaps: [] } }; + assert.ok(!diagnose(healthy).findings.some(f => f.severity === "attention")); + 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")); +}); From 9a04e69220a6fd7a1d55c5e64436b1a898a51054 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sat, 24 Oct 2026 20:16:33 +0800 Subject: [PATCH 05/16] fix: preserve visible MCP errors when native databases fail --- claude/skills/chatcut-mcp-doctor/SKILL.md | 2 + .../references/connection-diagnostics.md | 9 +++ .../chatcut-mcp-doctor/scripts/diagnosis.mjs | 5 +- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 19 +++-- .../scripts/mcp-evidence.mjs | 13 ++++ codex/skills/chatcut-mcp-doctor/SKILL.md | 2 + .../references/connection-diagnostics.md | 9 +++ .../chatcut-mcp-doctor/scripts/diagnosis.mjs | 5 +- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 19 +++-- .../scripts/mcp-evidence.mjs | 13 ++++ tests/native-failure-index.test.mjs | 4 + tests/windows-db-fallback.test.mjs | 77 +++++++++++++++++++ 12 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 tests/windows-db-fallback.test.mjs diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index 4b6e108..3cd5ff8 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -19,6 +19,8 @@ If the host rejects a local command, preserve that exact rejection and the opera Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. +If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. + Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. diff --git a/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md b/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md index c2e2b3a..089a805 100644 --- a/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md +++ b/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md @@ -114,6 +114,15 @@ Only include ChatCut tool names; keep tool arguments and results out. ### Preserve the failed call before independent checks +Database reads are supplementary. When the current tool error is visible, save +its dedicated failure record before attempting runtime sources, then run the +offline helper even if SQLite fails. `hostLogCoverage` explicitly distinguishes +the retained error, database errors and other-source coverage. Existing App logs +remain an independent bounded source. An already visible tool error needs no +prior stderr setup; an unexposed startup error still needs an accessible source. +Never copy a live database, use immutable/nolock mode, discard WAL or widen +permissions to make collection pass. If no original error is exposed, say so. + 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. diff --git a/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs index 7735ea1..ea5ec67 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -180,7 +180,10 @@ export function renderPacket(report, diagnosis) { return `- ${item.kind}: ${item.next} Evidence: ${item.evidence.join(", ")}${observed ? ` Observed: ${redact(JSON.stringify(observed))}` : ""}`; }).join("\n"); const gaps = diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? gap.error?.message ?? "see local report"}`).join("\n") || "- None recorded; coverage is still bounded."; - const common = (includePrivateExcerpts) => `Diagnostic ID: ${report.diagnosticId}\nHost: ${report.host} ${report.hostVersion ?? "version unknown"}\nCollector: ${report.collectorVersion}; package: ${report.pluginVersion ?? "unknown"} (not proof of loaded version)\n\n${facts(includePrivateExcerpts)}\n\nRoot cause: unconfirmed. Original host recovery: unverified.\n`; + const coverage = report.evidenceSummary?.hostLogCoverage; + const coverageLine = typeof coverage?.originalErrorRetained === "boolean" + ? `\nCapture coverage: original error ${coverage.originalErrorRetained ? "retained" : "unavailable"}; runtime collection ${coverage.runtimeCollectionFailed ? "failed" : "completed with the recorded gaps"}; database read failures: ${(coverage.databaseReadFailures ?? []).map(item => `${item.source}/${item.operation ?? "operation unknown"}: ${item.errcode ?? item.code ?? "code unavailable"}`).join(", ") || "none recorded"}; native failed items ${coverage.nativeFailedMcpItems}; App startup records ${coverage.appStartupRecords}; retained stderr records ${coverage.retainedStderrRecords}. An already visible tool error needs no prior stderr capture; an unexposed startup error still needs an accessible source.\n` : ""; + const common = (includePrivateExcerpts) => `Diagnostic ID: ${report.diagnosticId}\nHost: ${report.host} ${report.hostVersion ?? "version unknown"}\nCollector: ${report.collectorVersion}; package: ${report.pluginVersion ?? "unknown"} (not proof of loaded version)\n${coverageLine}\n${facts(includePrivateExcerpts)}\n\nRoot cause: unconfirmed. Original host recovery: unverified.\n`; const nativeContext = report.collectionContext || report.hostLogs?.nativeFailedCalls?.length ? `\n## Current collection context and native failed MCP items\n\nThese are current-thread context and bounded native failure facts; no original occurrence time or request association is filled automatically. Native item IDs are distinct from Responses call IDs, MCP RPC IDs and server request IDs. Native lifecycle times are start/completion times.\n\n${redact(JSON.stringify({ collectionContext: report.collectionContext ?? null, nativeFailedCalls: report.hostLogs?.nativeFailedCalls ?? [] }, null, 2))}\n` : ""; const summary = `# ChatCut MCP diagnosis\n\n${common(true)}\n## Evidence boundaries\n\n${Object.values(diagnosis.provenance).map((value) => `- ${value}`).join("\n")}\n\n## Original failure\n\n${redact(JSON.stringify(report.originalFailure, null, 2))}\n${nativeContext}\n## Missing evidence\n\n${gaps}\n\n## Feedback\n\nUpload: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. An ingest receipt is not a support ticket or proof of complete storage.\nKeep this report for targeted follow-up; do not rerun collection just to resend it.\n`; diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 9f111fc..b8a3c22 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -809,22 +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 threadFilter = hasThreadId && options.conversationId ? " AND (thread_id IS NULL OR thread_id='' OR thread_id=?)" : ""; - const rows = db - .prepare( + 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`, - ) - .all(since, until, ...(threadFilter ? [options.conversationId] : [])); + ); + 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 ?? ""; @@ -862,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" @@ -873,7 +881,8 @@ 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 { diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs index eb600ea..8c7f3a6 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -247,6 +247,12 @@ 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, @@ -274,6 +280,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/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index 4b6e108..3cd5ff8 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -19,6 +19,8 @@ If the host rejects a local command, preserve that exact rejection and the opera Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. +If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. + Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. diff --git a/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md b/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md index c2e2b3a..089a805 100644 --- a/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md +++ b/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md @@ -114,6 +114,15 @@ Only include ChatCut tool names; keep tool arguments and results out. ### Preserve the failed call before independent checks +Database reads are supplementary. When the current tool error is visible, save +its dedicated failure record before attempting runtime sources, then run the +offline helper even if SQLite fails. `hostLogCoverage` explicitly distinguishes +the retained error, database errors and other-source coverage. Existing App logs +remain an independent bounded source. An already visible tool error needs no +prior stderr setup; an unexposed startup error still needs an accessible source. +Never copy a live database, use immutable/nolock mode, discard WAL or widen +permissions to make collection pass. If no original error is exposed, say so. + 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. diff --git a/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs index 7735ea1..ea5ec67 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -180,7 +180,10 @@ export function renderPacket(report, diagnosis) { return `- ${item.kind}: ${item.next} Evidence: ${item.evidence.join(", ")}${observed ? ` Observed: ${redact(JSON.stringify(observed))}` : ""}`; }).join("\n"); const gaps = diagnosis.gaps.map((gap) => `- ${gap.source}: ${gap.reason ?? gap.error?.message ?? "see local report"}`).join("\n") || "- None recorded; coverage is still bounded."; - const common = (includePrivateExcerpts) => `Diagnostic ID: ${report.diagnosticId}\nHost: ${report.host} ${report.hostVersion ?? "version unknown"}\nCollector: ${report.collectorVersion}; package: ${report.pluginVersion ?? "unknown"} (not proof of loaded version)\n\n${facts(includePrivateExcerpts)}\n\nRoot cause: unconfirmed. Original host recovery: unverified.\n`; + const coverage = report.evidenceSummary?.hostLogCoverage; + const coverageLine = typeof coverage?.originalErrorRetained === "boolean" + ? `\nCapture coverage: original error ${coverage.originalErrorRetained ? "retained" : "unavailable"}; runtime collection ${coverage.runtimeCollectionFailed ? "failed" : "completed with the recorded gaps"}; database read failures: ${(coverage.databaseReadFailures ?? []).map(item => `${item.source}/${item.operation ?? "operation unknown"}: ${item.errcode ?? item.code ?? "code unavailable"}`).join(", ") || "none recorded"}; native failed items ${coverage.nativeFailedMcpItems}; App startup records ${coverage.appStartupRecords}; retained stderr records ${coverage.retainedStderrRecords}. An already visible tool error needs no prior stderr capture; an unexposed startup error still needs an accessible source.\n` : ""; + const common = (includePrivateExcerpts) => `Diagnostic ID: ${report.diagnosticId}\nHost: ${report.host} ${report.hostVersion ?? "version unknown"}\nCollector: ${report.collectorVersion}; package: ${report.pluginVersion ?? "unknown"} (not proof of loaded version)\n${coverageLine}\n${facts(includePrivateExcerpts)}\n\nRoot cause: unconfirmed. Original host recovery: unverified.\n`; const nativeContext = report.collectionContext || report.hostLogs?.nativeFailedCalls?.length ? `\n## Current collection context and native failed MCP items\n\nThese are current-thread context and bounded native failure facts; no original occurrence time or request association is filled automatically. Native item IDs are distinct from Responses call IDs, MCP RPC IDs and server request IDs. Native lifecycle times are start/completion times.\n\n${redact(JSON.stringify({ collectionContext: report.collectionContext ?? null, nativeFailedCalls: report.hostLogs?.nativeFailedCalls ?? [] }, null, 2))}\n` : ""; const summary = `# ChatCut MCP diagnosis\n\n${common(true)}\n## Evidence boundaries\n\n${Object.values(diagnosis.provenance).map((value) => `- ${value}`).join("\n")}\n\n## Original failure\n\n${redact(JSON.stringify(report.originalFailure, null, 2))}\n${nativeContext}\n## Missing evidence\n\n${gaps}\n\n## Feedback\n\nUpload: ${report.upload?.status ?? "unknown"}; final storage verified: ${report.upload?.storageVerified === true}. An ingest receipt is not a support ticket or proof of complete storage.\nKeep this report for targeted follow-up; do not rerun collection just to resend it.\n`; diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 9f111fc..b8a3c22 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -809,22 +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 threadFilter = hasThreadId && options.conversationId ? " AND (thread_id IS NULL OR thread_id='' OR thread_id=?)" : ""; - const rows = db - .prepare( + 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`, - ) - .all(since, until, ...(threadFilter ? [options.conversationId] : [])); + ); + 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 ?? ""; @@ -862,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" @@ -873,7 +881,8 @@ 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 { diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs index eb600ea..8c7f3a6 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -247,6 +247,12 @@ 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, @@ -274,6 +280,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/tests/native-failure-index.test.mjs b/tests/native-failure-index.test.mjs index 05f0349..2e5fbb3 100644 --- a/tests/native-failure-index.test.mjs +++ b/tests/native-failure-index.test.mjs @@ -83,8 +83,12 @@ test("native query errors retain their failing operation and local evidence is n '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, diff --git a/tests/windows-db-fallback.test.mjs b/tests/windows-db-fallback.test.mjs new file mode 100644 index 0000000..a8e47f2 --- /dev/null +++ b/tests/windows-db-fallback.test.mjs @@ -0,0 +1,77 @@ +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 { runDoctor } from "../codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; +import { diagnose, renderPacket } from "../codex/skills/chatcut-mcp-doctor/scripts/diagnosis.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" } }; +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); + const packet = renderPacket(report, diagnose(report)); + assert.ok(packet.summary.includes("original error retained") && packet.summary.includes("14")); + assert.ok(!packet.draft.includes("PRIVATE_APP_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); + const packet = renderPacket(report, diagnose(report)); + assert.ok(packet.summary.includes("original error unavailable")); + assert.ok(packet.summary.includes("an unexposed startup error still needs an accessible source")); +}); From 5843bff289d1f0e443e367e99a0d0700e70e6405 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 18:22:47 +0800 Subject: [PATCH 06/16] fix: retain original errors through optional failure metadata rejection --- README.md | 7 +- claude/skills/chatcut-mcp-doctor/SKILL.md | 6 + .../references/discovery.md | 6 +- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 2 + .../scripts/mcp-evidence.mjs | 2 + .../scripts/mcp-failure.mjs | 49 ++++-- codex/skills/chatcut-mcp-doctor/SKILL.md | 6 + .../references/discovery.md | 6 +- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 2 + .../scripts/mcp-evidence.mjs | 2 + .../scripts/mcp-failure.mjs | 49 ++++-- docs/mcp-doctor.md | 8 +- tests/mcp-failure-metadata.test.mjs | 159 ++++++++++++++++++ 13 files changed, 264 insertions(+), 40 deletions(-) create mode 100644 tests/mcp-failure-metadata.test.mjs diff --git a/README.md b/README.md index d05289f..b929049 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,10 @@ Codex's skill picker (`$chatcut:chatcut-mcp-doctor` for the Git plugin, `$chatcut-desktop:chatcut-mcp-doctor` for the existing store identity), or `/chatcut:chatcut-mcp-doctor` in Claude Code. It runs locally even when ChatCut MCP cannot connect and prepares a report, explanation and private feedback draft. -Doctor is included from Codex 1.10.22 / Claude Code plugin 1.10.24; check the -installed version and skill catalog when using an older package. Local diagnosis -does not require ChatCut authentication. +Doctor is an unpublished candidate on this branch. Its manifest versions are +not released minimum versions, and the separate collection-only packages do +not include this entry. Check actual installed contents and the skill catalog. +Local diagnosis does not require ChatCut authentication. See [Doctor discovery/fallback](codex/skills/chatcut-mcp-doctor/references/discovery.md). The host's own `/doctor` command is separate. diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index 3cd5ff8..2a5392b 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -19,6 +19,8 @@ If the host rejects a local command, preserve that exact rejection and the opera Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. +Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. + If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. @@ -49,6 +51,10 @@ Verify the affected boundary in the **same host and scope** after a change. Regi Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. +Read the generated files and finish a usable explanation in the user's language. For Chinese requests, write a separate Chinese note with the actual symptom, exact error excerpt, evidence/source, action actually taken, safe same-host recheck result and delivery result. Fill core facts from observations; do not leave template placeholders for the user. If a later safe call succeeded, say exactly which operation and host/scope succeeded, while keeping the collector's original report immutable and its historical recovery flag separate. Do not claim an unperformed repair, complete recovery, final storage or an opened ticket. + +A protocol-discovery attempt that was rejected and then successfully negotiated by the same native flow can be normal. Preserve that sequence separately; do not present an intermediate negotiation rejection as the user's terminal failure when actual downstream success is established. Unrelated later success does not erase a recorded original tool failure. + ```text node "/scripts/doctor.mjs" --report node "/scripts/doctor.mjs" --upload-report diff --git a/claude/skills/chatcut-mcp-doctor/references/discovery.md b/claude/skills/chatcut-mcp-doctor/references/discovery.md index 38d0259..55594a9 100644 --- a/claude/skills/chatcut-mcp-doctor/references/discovery.md +++ b/claude/skills/chatcut-mcp-doctor/references/discovery.md @@ -37,6 +37,8 @@ For users without a local copy, the official plugin sources/install guidance are [agent-plugin](https://github.com/ChatCut-Inc/agent-plugin), [Codex setup](https://chatcut.io/chatgpt), and [Claude Code setup](https://chatcut.io/claude). Verify a released package actually -contains this Skill before recommending it (Codex 1.10.22 / Claude plugin 1.10.24 -or later). Git source availability does not prove store approval or a host update. +contains this Skill before recommending it. This branch is an unpublished Doctor +candidate; its manifest versions are review placeholders, not released minimum +versions. The separately prepared collection-only packages do not include this +entry. Git source availability does not prove store approval or a host update. Do not download and execute an unreviewed remote shell script. diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index b8a3c22..6ac313b 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -1207,6 +1207,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-mcp-doctor/scripts/mcp-evidence.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs index 8c7f3a6..5a35595 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -207,6 +207,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-mcp-doctor/scripts/mcp-failure.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs index ef9e338..cb1c8f2 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs +++ b/claude/skills/chatcut-mcp-doctor/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/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index 3cd5ff8..2a5392b 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -19,6 +19,8 @@ If the host rejects a local command, preserve that exact rejection and the opera Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. +Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. + If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. @@ -49,6 +51,10 @@ Verify the affected boundary in the **same host and scope** after a change. Regi Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. +Read the generated files and finish a usable explanation in the user's language. For Chinese requests, write a separate Chinese note with the actual symptom, exact error excerpt, evidence/source, action actually taken, safe same-host recheck result and delivery result. Fill core facts from observations; do not leave template placeholders for the user. If a later safe call succeeded, say exactly which operation and host/scope succeeded, while keeping the collector's original report immutable and its historical recovery flag separate. Do not claim an unperformed repair, complete recovery, final storage or an opened ticket. + +A protocol-discovery attempt that was rejected and then successfully negotiated by the same native flow can be normal. Preserve that sequence separately; do not present an intermediate negotiation rejection as the user's terminal failure when actual downstream success is established. Unrelated later success does not erase a recorded original tool failure. + ```text node "/scripts/doctor.mjs" --report node "/scripts/doctor.mjs" --upload-report diff --git a/codex/skills/chatcut-mcp-doctor/references/discovery.md b/codex/skills/chatcut-mcp-doctor/references/discovery.md index 38d0259..55594a9 100644 --- a/codex/skills/chatcut-mcp-doctor/references/discovery.md +++ b/codex/skills/chatcut-mcp-doctor/references/discovery.md @@ -37,6 +37,8 @@ For users without a local copy, the official plugin sources/install guidance are [agent-plugin](https://github.com/ChatCut-Inc/agent-plugin), [Codex setup](https://chatcut.io/chatgpt), and [Claude Code setup](https://chatcut.io/claude). Verify a released package actually -contains this Skill before recommending it (Codex 1.10.22 / Claude plugin 1.10.24 -or later). Git source availability does not prove store approval or a host update. +contains this Skill before recommending it. This branch is an unpublished Doctor +candidate; its manifest versions are review placeholders, not released minimum +versions. The separately prepared collection-only packages do not include this +entry. Git source availability does not prove store approval or a host update. Do not download and execute an unreviewed remote shell script. diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index b8a3c22..6ac313b 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -1207,6 +1207,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-mcp-doctor/scripts/mcp-evidence.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs index 8c7f3a6..5a35595 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -207,6 +207,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-mcp-doctor/scripts/mcp-failure.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs index ef9e338..cb1c8f2 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs +++ b/codex/skills/chatcut-mcp-doctor/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/docs/mcp-doctor.md b/docs/mcp-doctor.md index d398435..8ca046e 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -11,9 +11,11 @@ Use Codex's skill picker / `$chatcut:chatcut-mcp-doctor` (standalone copies use overrides the host's `/doctor`. Its description also enables agent selection for natural-language connection-failure requests. See its [entrypoint](../codex/skills/chatcut-mcp-doctor/SKILL.md) and [fallback guide](../codex/skills/chatcut-mcp-doctor/references/discovery.md). -The Skill is included from Codex 1.10.22 / Claude plugin 1.10.24, with collector -1.3.0. Check the installed version and catalog; Git availability, store approval -and actual host adoption are separate checks. +This is an unpublished Doctor candidate with collector 1.3.0. The candidate +manifest versions do not establish a released minimum version; final versions +remain subject to review. The separate collection-only packages contain no +Doctor entry. Check actual installed contents and catalog; Git availability, +store approval and host adoption are separate checks. The basics scripts are compatibility forwarders; each host package contains one collector implementation under Doctor. The new `scripts/doctor.mjs` 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 081a0437d0ce419e196dbb07f9fe58176c3a0ecc Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 18:57:58 +0800 Subject: [PATCH 07/16] fix: align Doctor report creation and reuse for native agent flows --- claude/skills/chatcut-mcp-doctor/SKILL.md | 15 +++++++++++++++ .../skills/chatcut-mcp-doctor/scripts/doctor.mjs | 10 +++++++++- codex/skills/chatcut-mcp-doctor/SKILL.md | 15 +++++++++++++++ .../skills/chatcut-mcp-doctor/scripts/doctor.mjs | 10 +++++++++- tests/standalone-doctor.test.mjs | 14 ++++++++++++++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index 2a5392b..a758661 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -21,6 +21,21 @@ Preserve the already visible original error before retries, reconnects or change Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. +For a visible failed tool call, the minimal record is below. Create a file named +`original.mcp-failure.json` (the `.mcp-failure.json` suffix is required). Replace +only the message and the actually known tool name; keep unavailable time null. +Read the reference before adding fields. The failure record uses schema **1**, +not report schema 2; `mcp_tool_call` and `errorMessage` are not accepted fields. + +```json +{"schemaVersion":1,"source":"host_tool_result","serverName":"chatcut","occurredAt":null,"operation":"list_projects","error":{"message":"Exact exposed original error"}} +``` + +Do not lose a known tool name by switching to text-only input after a formatting +mistake. Correct the dedicated record using the already saved original result, +without calling the failed tool again. Keep earlier incomplete reports separate; +reuse the final correct report for feedback and one authorized transfer. + If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. diff --git a/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs index ae58b09..7591ad7 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -16,9 +16,14 @@ export async function writePacket(path, report) { return { diagnosticId: report.diagnosticId, report: path, ...files, findings: diagnosis.findings, upload: report.upload, recoveryVerified: false }; } +function dedicatedReportPath(path) { + return /(?:\.mcp-report|chatcut-mcp-doctor-[a-f0-9-]+)\.json$/i.test(basename(path)) || + /^mcp-report(?:-\d+)?\.json$/i.test(basename(path)); +} + async function readReport(path) { // Do not accept config/auth/session files even as a mistakenly supplied input. - if (!/(?:\.mcp-report|chatcut-mcp-doctor-[a-f0-9-]+)\.json$/i.test(basename(path))) + if (!dedicatedReportPath(path)) throw new Error("Use the dedicated .mcp-report.json or default chatcut-mcp-doctor-.json report, never config/auth/session files"); if ((await stat(path)).size > 2 * 1024 * 1024) throw new Error("Report exceeds 2 MiB; retained locally without reading"); const bytes = await readFile(path); @@ -60,6 +65,9 @@ export async function main(args = process.argv.slice(2)) { } result = { path, report }; } else { + const outputIndex = args.indexOf("--output"); + if (outputIndex >= 0 && !dedicatedReportPath(args[outputIndex + 1] ?? "")) + throw new Error("Use --output or mcp-report.json so the saved report can be reused with --report and --upload-report"); if (args.includes("--help")) { console.log("ChatCut MCP Doctor: collector options below also produce a diagnosis, summary and private issue draft. --offline sends no requests. --report rebuilds feedback offline. --upload-report sends that report once without new probes; use only for an authorized retry. No automatic repair or ticket submission."); } diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index 2a5392b..a758661 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -21,6 +21,21 @@ Preserve the already visible original error before retries, reconnects or change Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. +For a visible failed tool call, the minimal record is below. Create a file named +`original.mcp-failure.json` (the `.mcp-failure.json` suffix is required). Replace +only the message and the actually known tool name; keep unavailable time null. +Read the reference before adding fields. The failure record uses schema **1**, +not report schema 2; `mcp_tool_call` and `errorMessage` are not accepted fields. + +```json +{"schemaVersion":1,"source":"host_tool_result","serverName":"chatcut","occurredAt":null,"operation":"list_projects","error":{"message":"Exact exposed original error"}} +``` + +Do not lose a known tool name by switching to text-only input after a formatting +mistake. Correct the dedicated record using the already saved original result, +without calling the failed tool again. Keep earlier incomplete reports separate; +reuse the final correct report for feedback and one authorized transfer. + If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. diff --git a/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs index ae58b09..7591ad7 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -16,9 +16,14 @@ export async function writePacket(path, report) { return { diagnosticId: report.diagnosticId, report: path, ...files, findings: diagnosis.findings, upload: report.upload, recoveryVerified: false }; } +function dedicatedReportPath(path) { + return /(?:\.mcp-report|chatcut-mcp-doctor-[a-f0-9-]+)\.json$/i.test(basename(path)) || + /^mcp-report(?:-\d+)?\.json$/i.test(basename(path)); +} + async function readReport(path) { // Do not accept config/auth/session files even as a mistakenly supplied input. - if (!/(?:\.mcp-report|chatcut-mcp-doctor-[a-f0-9-]+)\.json$/i.test(basename(path))) + if (!dedicatedReportPath(path)) throw new Error("Use the dedicated .mcp-report.json or default chatcut-mcp-doctor-.json report, never config/auth/session files"); if ((await stat(path)).size > 2 * 1024 * 1024) throw new Error("Report exceeds 2 MiB; retained locally without reading"); const bytes = await readFile(path); @@ -60,6 +65,9 @@ export async function main(args = process.argv.slice(2)) { } result = { path, report }; } else { + const outputIndex = args.indexOf("--output"); + if (outputIndex >= 0 && !dedicatedReportPath(args[outputIndex + 1] ?? "")) + throw new Error("Use --output or mcp-report.json so the saved report can be reused with --report and --upload-report"); if (args.includes("--help")) { console.log("ChatCut MCP Doctor: collector options below also produce a diagnosis, summary and private issue draft. --offline sends no requests. --report rebuilds feedback offline. --upload-report sends that report once without new probes; use only for an authorized retry. No automatic repair or ticket submission."); } diff --git a/tests/standalone-doctor.test.mjs b/tests/standalone-doctor.test.mjs index 9942dd1..9233368 100644 --- a/tests/standalone-doctor.test.mjs +++ b/tests/standalone-doctor.test.mjs @@ -14,6 +14,20 @@ import { diagnose, renderPacket } from "../codex/skills/chatcut-mcp-doctor/scrip const exec = promisify(execFile); const skill = fileURLToPath(new URL("../codex/skills/chatcut-mcp-doctor/", import.meta.url)); const noLogs = async () => ({ entries: [], gaps: [{ source: "fixture", reason: "logs_unavailable" }] }); + +test("Doctor reuses its dedicated short report names and rejects unusable output names before collection", async t => { + const dir = await temp(t), helper = join(skill, "scripts/doctor.mjs"); + for (const name of ["mcp-report.json", "mcp-report-2.json"]) { + const output = join(dir, name); + await cli(helper, ["--host", "codex", "--offline", "--output", output], dir); + const before = await readFile(output, "utf8"), id = JSON.parse(before).diagnosticId; + await cli(helper, ["--report", output], dir); + assert.equal(await readFile(output, "utf8"), before); + assert.equal(JSON.parse(await readFile(output + ".diagnosis.json", "utf8")).diagnosticId, id); + } + await assert.rejects(cli(helper, ["--host", "codex", "--offline", "--output", join(dir, "config.json")], dir), /saved report can be reused/); + assert.ok(!(await readdir(dir)).includes("config.json")); +}); async function temp(t) { const dir = await mkdtemp(join(tmpdir(), "chatcut-standalone-doctor-")); t.after(() => rm(dir, { recursive: true, force: true })); From f8f5f3fe87b18f6896e2b28eed8a6883e701acf3 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 19:08:24 +0800 Subject: [PATCH 08/16] docs: require canonical Doctor reports and evidence-based recheck notes --- claude/skills/chatcut-mcp-doctor/SKILL.md | 2 ++ codex/skills/chatcut-mcp-doctor/SKILL.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index a758661..ae3edd5 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -46,6 +46,8 @@ If this invocation's native stderr has already been retained by its caller in a Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. +Only `scripts/doctor.mjs` generates the canonical report, its diagnostic UUID and collection time. Never handwrite a replacement report or invent these values. The agent writes only the dedicated original-error/snapshot inputs and separate explanatory/repair notes. A directory's timestamp is not the failure or repair time; keep unexposed original times null. A successful read of a controlled unauthenticated service proves that read only, not account authentication, permission checks or the contents of a real user's projects. + ```text node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output ``` diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index a758661..ae3edd5 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -46,6 +46,8 @@ If this invocation's native stderr has already been retained by its caller in a Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. +Only `scripts/doctor.mjs` generates the canonical report, its diagnostic UUID and collection time. Never handwrite a replacement report or invent these values. The agent writes only the dedicated original-error/snapshot inputs and separate explanatory/repair notes. A directory's timestamp is not the failure or repair time; keep unexposed original times null. A successful read of a controlled unauthenticated service proves that read only, not account authentication, permission checks or the contents of a real user's projects. + ```text node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output ``` From bd88a7b74e1f68b2798ba0852b1959458faa46f9 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 19:29:16 +0800 Subject: [PATCH 09/16] fix: validate both Doctor output argument forms before collection --- claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs | 5 +++-- codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs | 5 +++-- tests/standalone-doctor.test.mjs | 1 + 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs index 7591ad7..8721508 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -65,8 +65,9 @@ export async function main(args = process.argv.slice(2)) { } result = { path, report }; } else { - const outputIndex = args.indexOf("--output"); - if (outputIndex >= 0 && !dedicatedReportPath(args[outputIndex + 1] ?? "")) + const outputs = args.flatMap((arg, index) => arg === "--output" + ? [args[index + 1] ?? ""] : arg.startsWith("--output=") ? [arg.slice(9)] : []); + if (outputs.some(path => !dedicatedReportPath(path))) throw new Error("Use --output or mcp-report.json so the saved report can be reused with --report and --upload-report"); if (args.includes("--help")) { console.log("ChatCut MCP Doctor: collector options below also produce a diagnosis, summary and private issue draft. --offline sends no requests. --report rebuilds feedback offline. --upload-report sends that report once without new probes; use only for an authorized retry. No automatic repair or ticket submission."); diff --git a/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs index 7591ad7..8721508 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -65,8 +65,9 @@ export async function main(args = process.argv.slice(2)) { } result = { path, report }; } else { - const outputIndex = args.indexOf("--output"); - if (outputIndex >= 0 && !dedicatedReportPath(args[outputIndex + 1] ?? "")) + const outputs = args.flatMap((arg, index) => arg === "--output" + ? [args[index + 1] ?? ""] : arg.startsWith("--output=") ? [arg.slice(9)] : []); + if (outputs.some(path => !dedicatedReportPath(path))) throw new Error("Use --output or mcp-report.json so the saved report can be reused with --report and --upload-report"); if (args.includes("--help")) { console.log("ChatCut MCP Doctor: collector options below also produce a diagnosis, summary and private issue draft. --offline sends no requests. --report rebuilds feedback offline. --upload-report sends that report once without new probes; use only for an authorized retry. No automatic repair or ticket submission."); diff --git a/tests/standalone-doctor.test.mjs b/tests/standalone-doctor.test.mjs index 9233368..c6b250d 100644 --- a/tests/standalone-doctor.test.mjs +++ b/tests/standalone-doctor.test.mjs @@ -26,6 +26,7 @@ test("Doctor reuses its dedicated short report names and rejects unusable output assert.equal(JSON.parse(await readFile(output + ".diagnosis.json", "utf8")).diagnosticId, id); } await assert.rejects(cli(helper, ["--host", "codex", "--offline", "--output", join(dir, "config.json")], dir), /saved report can be reused/); + await assert.rejects(cli(helper, ["--host", "codex", "--offline", `--output=${join(dir, "config.json")}`], dir), /saved report can be reused/); assert.ok(!(await readdir(dir)).includes("config.json")); }); async function temp(t) { From fabe96041531b0932e12564ae25f260bd55e3500 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 19:43:26 +0800 Subject: [PATCH 10/16] fix: route registration evidence through snapshots and flag rejected inputs --- claude/skills/chatcut-mcp-doctor/SKILL.md | 12 ++++++++++++ .../skills/chatcut-mcp-doctor/scripts/diagnosis.mjs | 9 +++++++++ codex/skills/chatcut-mcp-doctor/SKILL.md | 12 ++++++++++++ .../skills/chatcut-mcp-doctor/scripts/diagnosis.mjs | 9 +++++++++ tests/observed-diagnosis.test.mjs | 12 ++++++++++++ 5 files changed, 54 insertions(+) diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index ae3edd5..6fc2b47 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -36,6 +36,18 @@ mistake. Correct the dedicated record using the already saved original result, without calling the failed tool again. Keep earlier incomplete reports separate; reuse the final correct report for feedback and one authorized transfer. +**CLI registration query → snapshot; failed MCP tool → failure record.** A +`codex/claude mcp get` response belongs in snapshot observations, not in a +failure record with `source: cli_configuration`. Generate a fresh snapshot with +`--create-snapshot current.mcp-snapshot.json`, retain its generated capture time, +then add only the observed registration fact. Explicit native absence permits: + +```json +{"stage":"host_registration","operation":"lookup_registered_server","source":"cli_configuration","observedAt":null,"outcome":"failed","lookupResult":"not_found","message":"Exact native no-entry response"} +``` + +Collect it with `--host-snapshot current.mcp-snapshot.json --server-url --offline --output current.mcp-report.json`. Omit `--failure-record` when no original MCP tool/runtime error is exposed. This keeps the CLI message and `not_registered` finding without inventing an original MCP failure/time. The earlier schema-1 example is for an actual failed MCP tool call only. Keep a known selected URL even offline so a later authorized transfer can reuse the report; never substitute a production URL afterwards. + If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. diff --git a/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs index ea5ec67..17b913a 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -11,6 +11,15 @@ export function diagnose(report) { const failed = observations.map((item, index) => ({ ...item, index })) .filter((item) => item.outcome === "failed"); const original = report.originalFailure; + (report.gaps ?? []).forEach((gap, index) => { + if (!((gap.source === "original_failure" && ["failure_record_unavailable", "unavailable"].includes(gap.reason)) || + (gap.source === "host_snapshot" && gap.reason === "unavailable"))) return; + add("evidence_input_unavailable", [`gaps[${index}]`], + "A supplied evidence input could not be used. Preserve its exact error and use the accepted dedicated input schema; CLI registration facts belong in snapshot observations, not MCP failure records. Do not treat this incomplete capture as proof that no incident occurred, guess missing facts or change permissions.", "attention", { + source: gap.source, reason: gap.reason, + messageExcerpt: redact(gap.error?.message ?? "").slice(0, 2000), + }); + }); if (report.upload?.status === "skipped_intentionally_disabled" || snapshot?.configuration?.enabled === false || session?.connectionStatus === "disabled") add("intentionally_disabled", ["hostSnapshot"], "Preserve the disabled choice; do not enable or probe it.", "info"); for (const [index, item] of observations.entries()) { diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index ae3edd5..6fc2b47 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -36,6 +36,18 @@ mistake. Correct the dedicated record using the already saved original result, without calling the failed tool again. Keep earlier incomplete reports separate; reuse the final correct report for feedback and one authorized transfer. +**CLI registration query → snapshot; failed MCP tool → failure record.** A +`codex/claude mcp get` response belongs in snapshot observations, not in a +failure record with `source: cli_configuration`. Generate a fresh snapshot with +`--create-snapshot current.mcp-snapshot.json`, retain its generated capture time, +then add only the observed registration fact. Explicit native absence permits: + +```json +{"stage":"host_registration","operation":"lookup_registered_server","source":"cli_configuration","observedAt":null,"outcome":"failed","lookupResult":"not_found","message":"Exact native no-entry response"} +``` + +Collect it with `--host-snapshot current.mcp-snapshot.json --server-url --offline --output current.mcp-report.json`. Omit `--failure-record` when no original MCP tool/runtime error is exposed. This keeps the CLI message and `not_registered` finding without inventing an original MCP failure/time. The earlier schema-1 example is for an actual failed MCP tool call only. Keep a known selected URL even offline so a later authorized transfer can reuse the report; never substitute a production URL afterwards. + If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. diff --git a/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs index ea5ec67..17b913a 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -11,6 +11,15 @@ export function diagnose(report) { const failed = observations.map((item, index) => ({ ...item, index })) .filter((item) => item.outcome === "failed"); const original = report.originalFailure; + (report.gaps ?? []).forEach((gap, index) => { + if (!((gap.source === "original_failure" && ["failure_record_unavailable", "unavailable"].includes(gap.reason)) || + (gap.source === "host_snapshot" && gap.reason === "unavailable"))) return; + add("evidence_input_unavailable", [`gaps[${index}]`], + "A supplied evidence input could not be used. Preserve its exact error and use the accepted dedicated input schema; CLI registration facts belong in snapshot observations, not MCP failure records. Do not treat this incomplete capture as proof that no incident occurred, guess missing facts or change permissions.", "attention", { + source: gap.source, reason: gap.reason, + messageExcerpt: redact(gap.error?.message ?? "").slice(0, 2000), + }); + }); if (report.upload?.status === "skipped_intentionally_disabled" || snapshot?.configuration?.enabled === false || session?.connectionStatus === "disabled") add("intentionally_disabled", ["hostSnapshot"], "Preserve the disabled choice; do not enable or probe it.", "info"); for (const [index, item] of observations.entries()) { diff --git a/tests/observed-diagnosis.test.mjs b/tests/observed-diagnosis.test.mjs index d9e0aa0..391c97f 100644 --- a/tests/observed-diagnosis.test.mjs +++ b/tests/observed-diagnosis.test.mjs @@ -11,6 +11,18 @@ const makeReport = () => ({ hostLogs: { entries: [], gaps: [] }, gaps: [], probes: [], wireProbes: [], }); const issuerWarning = 'MCP server startup failed server_name="chatcut" error=OAuth refresh credentials for server chatcut are missing an authorization server issuer; authorization required: OAuth authorization required'; + +test("rejected original/snapshot inputs are incomplete evidence rather than a no-incident result", () => { + for (const [source, reason] of [["original_failure", "failure_record_unavailable"], ["host_snapshot", "unavailable"]]) { + const input = makeReport(); + input.gaps = [{ source, reason, error: { message: "Dedicated input source/schema rejected" } }]; + const result = diagnose(input); + assert.ok(result.findings.some(f => f.kind === "evidence_input_unavailable" && f.observed.source === source)); + assert.ok(!result.findings.some(f => f.kind === "no_incident_demonstrated")); + assert.equal(input.originalFailure.occurredAt, null); + assert.equal(result.rootCause, "unconfirmed"); + } +}); const nativeWarning = () => ({ source: "codex_runtime_sqlite", level: "WARN", target: "codex_mcp::rmcp_client", occurredAt: "2026-10-24T01:00:00Z", message: issuerWarning, From d98590568b7e45039021260f692a193554105a3e Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 23:08:53 +0800 Subject: [PATCH 11/16] Make Doctor capture deterministic before Claude responds --- claude/skills/chatcut-mcp-doctor/SKILL.md | 91 ++++-------- .../references/advanced-collection.md | 89 ++++++++++++ .../chatcut-mcp-doctor/scripts/capture.mjs | 129 ++++++++++++++++++ .../chatcut-mcp-doctor/scripts/diagnosis.mjs | 3 +- .../chatcut-mcp-doctor/scripts/doctor.mjs | 2 +- .../scripts/record-recheck.mjs | 39 ++++++ codex/skills/chatcut-mcp-doctor/SKILL.md | 90 ++++-------- .../references/advanced-collection.md | 89 ++++++++++++ .../chatcut-mcp-doctor/scripts/capture.mjs | 129 ++++++++++++++++++ .../chatcut-mcp-doctor/scripts/diagnosis.mjs | 3 +- .../chatcut-mcp-doctor/scripts/doctor.mjs | 2 +- .../scripts/record-recheck.mjs | 39 ++++++ docs/mcp-doctor.md | 14 ++ tests/doctor-capture.test.mjs | 87 ++++++++++++ tests/standalone-doctor.test.mjs | 3 +- 15 files changed, 676 insertions(+), 133 deletions(-) create mode 100644 claude/skills/chatcut-mcp-doctor/references/advanced-collection.md create mode 100644 claude/skills/chatcut-mcp-doctor/scripts/capture.mjs create mode 100644 claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs create mode 100644 codex/skills/chatcut-mcp-doctor/references/advanced-collection.md create mode 100644 codex/skills/chatcut-mcp-doctor/scripts/capture.mjs create mode 100644 codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs create mode 100644 tests/doctor-capture.test.mjs diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index 6fc2b47..27995c6 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -1,94 +1,57 @@ --- name: chatcut-mcp-doctor -description: Diagnose hosted ChatCut MCP connection, startup, authentication or tool-call failures from a working Codex or Claude Code agent, even when ChatCut tools cannot connect. Produce evidence-based findings, repair guidance, rechecks and local feedback materials. Use for ChatCut Doctor or MCP连接错误; not for Desktop ACP or unrelated video editing. +description: Diagnose hosted ChatCut MCP connection, startup, authentication or tool-call failures locally, save a machine report and usable feedback, and guide scoped repair/recheck. Use for ChatCut Doctor or MCP连接错误; not Desktop ACP or unrelated video editing. +allowed-tools: Bash(node "${CLAUDE_SKILL_DIR}/scripts/capture.mjs" --host claude) --- # ChatCut MCP Doctor -This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. +Run locally with Node 18+ even when ChatCut MCP is unavailable. Preserve the already visible original error before any retry or change. Never read credentials, auth files or conversation histories. -If the host rejects a local command, preserve that exact rejection and the operation it blocked. One denied compound read does not establish that every file tool or the standalone Node helper is prohibited, or that the workspace is read-only. Use only capabilities already permitted by the current host; do not change permissions, switch shells to bypass a rejection, or call collection complete when no report was written. +## Capture first -## Start here +A diagnosis is complete only after this invocation's program-generated schema-2 report is saved and its diagnostic ID/path verified. A prose note or an old report is not a substitute. -- In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” -- In Claude Code with the ChatCut plugin, invoke `/chatcut:chatcut-mcp-doctor`. A separately installed project/personal copy uses `/chatcut-mcp-doctor`. -- `/doctor` and `codex doctor` belong to the host; this Skill does not replace them. If the entry is absent or the plugin is old, read [discovery and fallback](references/discovery.md). A browser-only host without local execution cannot run this collector; retain the error and explain that boundary. +The native host runs this bounded local capture before rendering the Skill. It saves a fresh offline report before any model answer: -## Preserve, collect, explain +!`node "${CLAUDE_SKILL_DIR}/scripts/capture.mjs" --host claude` -Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. +If host policy disables the injected command, run that exact local command with already permitted tools. If denied, retain the denial and report collection incomplete; do not bypass host policy. -Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. +The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. Read these outputs, then explain the observed boundary and remaining gaps in the user's language. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. -For a visible failed tool call, the minimal record is below. Create a file named -`original.mcp-failure.json` (the `.mcp-failure.json` suffix is required). Replace -only the message and the actually known tool name; keep unavailable time null. -Read the reference before adding fields. The failure record uses schema **1**, -not report schema 2; `mcp_tool_call` and `errorMessage` are not accepted fields. +If the current invocation already has a visible MCP tool error, save only its exact exposed error text as `original.mcp-error.txt`, then run: -```json -{"schemaVersion":1,"source":"host_tool_result","serverName":"chatcut","occurredAt":null,"operation":"list_projects","error":{"message":"Exact exposed original error"}} -``` - -Do not lose a known tool name by switching to text-only input after a formatting -mistake. Correct the dedicated record using the already saved original result, -without calling the failed tool again. Keep earlier incomplete reports separate; -reuse the final correct report for feedback and one authorized transfer. - -**CLI registration query → snapshot; failed MCP tool → failure record.** A -`codex/claude mcp get` response belongs in snapshot observations, not in a -failure record with `source: cli_configuration`. Generate a fresh snapshot with -`--create-snapshot current.mcp-snapshot.json`, retain its generated capture time, -then add only the observed registration fact. Explicit native absence permits: - -```json -{"stage":"host_registration","operation":"lookup_registered_server","source":"cli_configuration","observedAt":null,"outcome":"failed","lookupResult":"not_found","message":"Exact native no-entry response"} +```text +node "/scripts/capture.mjs" --host --original-error "original.mcp-error.txt" --operation --server-url ``` -Collect it with `--host-snapshot current.mcp-snapshot.json --server-url --offline --output current.mcp-report.json`. Omit `--failure-record` when no original MCP tool/runtime error is exposed. This keeps the CLI message and `not_registered` finding without inventing an original MCP failure/time. The earlier schema-1 example is for an actual failed MCP tool call only. Keep a known selected URL even offline so a later authorized transfer can reuse the report; never substitute a production URL afterwards. +Omit unknown operation/URL. This program formats the dedicated failure record; never handwrite report JSON, schema versions, UUIDs or timestamps. A retained structured `.mcp-failure.json` can instead use `--failure-record` to preserve its code/cause/known IDs. Reuse saved originals; do not recreate a failure. Unknown original time/IDs stay unknown. The snapshot capture time and collecting thread are not original-event evidence. -If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. +## Diagnose and repair -Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. +Keep configuration, original failure, current session and later checks distinct. Do not infer account/permission/service health from prior success, static config or an empty controlled project list. Findings are observed boundaries, not root causes. -With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. +For current native registration evidence, `capture.mjs --host --native-lookup [--cli ]` runs one bounded native `mcp get` and formats its snapshot/report. Use it only when the user permits a connection check: Claude's command can health-check approved servers. It is **not offline**. It projects allowed fields without retaining headers/full CLI output. Timeout, denied/unavailable CLI and unrecognized output remain unavailable; only the exact completed no-entry response supports not_registered. Select `--server-name plugin:chatcut:chatcut` only when that is the actual selected server; never recreate a plugin server as a manual entry. -If this invocation's native stderr has already been retained by its caller in a known dedicated `.codex-stderr.log`, use `--codex-stderr-file` to read a bounded ChatCut diagnostic tail. The helper retains native `loggedAt` separately, never as original `occurredAt` or a call ID. This is conditional on a real retained error stream; do not manufacture a log, scan for unknown paths or start another host to replace missing evidence. +Read [repair and recheck](references/repair-and-recheck.md) before any change. Repair only a demonstrated missing manual registration in the authorized scope; retain prior state, actual command and rollback. No automatic credential/network repair. Local execution denial is evidence, not authorization to change permissions. -Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. +## Recheck and deliver -Only `scripts/doctor.mjs` generates the canonical report, its diagnostic UUID and collection time. Never handwrite a replacement report or invent these values. The agent writes only the dedicated original-error/snapshot inputs and separate explanatory/repair notes. A directory's timestamp is not the failure or repair time; keep unexposed original times null. A successful read of a controlled unauthenticated service proves that read only, not account authentication, permission checks or the contents of a real user's projects. +After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save its exposed result separately as `read.mcp-recheck.txt`, then record it without changing the original report: ```text -node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output +node "/scripts/record-recheck.mjs" --report "" --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" ``` -Use `--host claude` for Claude Code. Omit unavailable optional inputs. If there is no registered URL, omit it; do not silently choose production. `--offline` keeps the registered URL as evidence but sends **no requests**, including upload. Otherwise the collector makes bounded anonymous entry/metadata checks and optional wire checks; an initial anonymous 401 is expected. Use `--upload` for the redacted report when transfer is authorized, after briefly explaining it. An intentionally disabled server is preserved without requests. - -The command writes the existing schema-2 report plus `.diagnosis.json`, `.summary.md` and `.issue-draft.md` sidecars. Read the findings and actual gaps before answering; do not merely echo a category. Findings identify observed boundaries, not root causes. Distinguish original failure, host evidence and later probes. Missing/truncated/denied logs remain gaps. A healthy later probe does not establish recovery. Proxy presence, busy/300-second timeouts and generic transport errors do not justify blaming security software, clearing locks/credentials, restarting or changing timeouts/environments. - -Wire failures/incomplete captures are later **direct-route** checks even if fetch metadata succeeds. Bounded native ERROR records and App `status=failed` notices are visible as unassociated host evidence; they do not fill a missing original error/time/request ID. Ordinary App starting/ready notices do not demonstrate failure. Local summaries show credential-redacted error excerpts; issue drafts keep structured states and evidence pointers without copying native log excerpts. - -## Repair and recheck +The recording is explicitly Agent supplied and does not independently prove account authentication or original recovery. Finish a usable Chinese explanation for Chinese requests: actual symptom/error, evidence, action, recheck result, diagnostic ID/files and delivery status. Generated Chinese notes have the current facts; add actual repair details in a separate note. Do not ask the user to fill JSON or leave core placeholders. -For an observed missing registration or a normal sign-in requirement, read [repair and recheck](references/repair-and-recheck.md). Apply only the minimal change within the user's authorization and actual host/scope. Keep the prior state and rollback. Other findings need evidence-led investigation; this version has no automatic credential/network repair. +Report transfer requires authorization. `node "/scripts/doctor.mjs" --upload-report ""` sends the unchanged report once and saves a SHA-bound receipt. Do not recollect to resend, change its endpoint or repeat acknowledged delivery. Ingest acceptance is not final storage or a ticket. No publishing or messaging without authorization. -Verify the affected boundary in the **same host and scope** after a change. Registration lookup success proves registration only. Auth success requires the host's authenticated state. Tool recovery requires a safe read-only call such as `list_projects` actually succeeding. If the original operation mutates state, costs credits, or cannot be safely repeated, do not replay it; say that its recovery remains unverified. Record the actual action, time/source and verification result in the summary and draft; never label an unperformed repair as tried. Keep the report's original failure immutable and its collector `recoveryVerified:false`; agent recheck results are separate evidence. - -## Feedback without starting over - -Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. - -Read the generated files and finish a usable explanation in the user's language. For Chinese requests, write a separate Chinese note with the actual symptom, exact error excerpt, evidence/source, action actually taken, safe same-host recheck result and delivery result. Fill core facts from observations; do not leave template placeholders for the user. If a later safe call succeeded, say exactly which operation and host/scope succeeded, while keeping the collector's original report immutable and its historical recovery flag separate. Do not claim an unperformed repair, complete recovery, final storage or an opened ticket. - -A protocol-discovery attempt that was rejected and then successfully negotiated by the same native flow can be normal. Preserve that sequence separately; do not present an intermediate negotiation rejection as the user's terminal failure when actual downstream success is established. Unrelated later success does not erase a recorded original tool failure. - -```text -node "/scripts/doctor.mjs" --report -node "/scripts/doctor.mjs" --upload-report -``` +## Entry and advanced evidence -`--report` rebuilds sidecars offline without recollecting. It overwrites derived sidecars, so save any agent-written repair notes separately first. Use `--upload-report` only for an authorized first transfer or a single requested retry after failure; it reuses the original diagnostic ID without new probes, keeps the original report unchanged, and writes a separate upload receipt. Do not repeatedly recollect or resend an acknowledged report. `ingest_accepted` is not verified final storage or an opened support ticket. +Claude plugin public entry: **/chatcut:chatcut-mcp-doctor**. Codex Git plugin: **$chatcut:chatcut-mcp-doctor**; store identity: **$chatcut-desktop:chatcut-mcp-doctor**. Use the exact installed catalog entry. Bare /doctor belongs to the host. -Use [ChatCut contact](https://chatcut.io/contact) for private support. The draft names no public issue repository because that destination is undecided. Do not open an issue, publish evidence, or message anyone without authorization. Root-cause rules and prevalence based on natural incidents remain future work; controlled tests are not user incidents. +- [Discovery and fallback](references/discovery.md): entry missing/old plugin or no local execution. +- [Connection diagnostics](references/connection-diagnostics.md): dedicated evidence inputs, bounded native logs and original IDs. +- [Advanced collection](references/advanced-collection.md): optional existing collector modes, precise log/database coverage and feedback reuse. Load only when needed. diff --git a/claude/skills/chatcut-mcp-doctor/references/advanced-collection.md b/claude/skills/chatcut-mcp-doctor/references/advanced-collection.md new file mode 100644 index 0000000..76fa5bc --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/references/advanced-collection.md @@ -0,0 +1,89 @@ +# ChatCut MCP Doctor + +This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. + +If the host rejects a local command, preserve that exact rejection and the operation it blocked. One denied compound read does not establish that every file tool or the standalone Node helper is prohibited, or that the workspace is read-only. Use only capabilities already permitted by the current host; do not change permissions, switch shells to bypass a rejection, or call collection complete when no report was written. + +## Start here + +- In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” +- In Claude Code with the ChatCut plugin, invoke `/chatcut:chatcut-mcp-doctor`. A separately installed project/personal copy uses `/chatcut-mcp-doctor`. +- `/doctor` and `codex doctor` belong to the host; this Skill does not replace them. If the entry is absent or the plugin is old, read [discovery and fallback](references/discovery.md). A browser-only host without local execution cannot run this collector; retain the error and explain that boundary. + +## Preserve, collect, explain + +Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. + +Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. + +For a visible failed tool call, the minimal record is below. Create a file named +`original.mcp-failure.json` (the `.mcp-failure.json` suffix is required). Replace +only the message and the actually known tool name; keep unavailable time null. +Read the reference before adding fields. The failure record uses schema **1**, +not report schema 2; `mcp_tool_call` and `errorMessage` are not accepted fields. + +```json +{"schemaVersion":1,"source":"host_tool_result","serverName":"chatcut","occurredAt":null,"operation":"list_projects","error":{"message":"Exact exposed original error"}} +``` + +Do not lose a known tool name by switching to text-only input after a formatting +mistake. Correct the dedicated record using the already saved original result, +without calling the failed tool again. Keep earlier incomplete reports separate; +reuse the final correct report for feedback and one authorized transfer. + +**CLI registration query → snapshot; failed MCP tool → failure record.** A +`codex/claude mcp get` response belongs in snapshot observations, not in a +failure record with `source: cli_configuration`. Generate a fresh snapshot with +`--create-snapshot current.mcp-snapshot.json`, retain its generated capture time, +then add only the observed registration fact. Explicit native absence permits: + +```json +{"stage":"host_registration","operation":"lookup_registered_server","source":"cli_configuration","observedAt":null,"outcome":"failed","lookupResult":"not_found","message":"Exact native no-entry response"} +``` + +Collect it with `--host-snapshot current.mcp-snapshot.json --server-url --offline --output current.mcp-report.json`. Omit `--failure-record` when no original MCP tool/runtime error is exposed. This keeps the CLI message and `not_registered` finding without inventing an original MCP failure/time. The earlier schema-1 example is for an actual failed MCP tool call only. Keep a known selected URL even offline so a later authorized transfer can reuse the report; never substitute a production URL afterwards. + +If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. + +Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. + +With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. + +If this invocation's native stderr has already been retained by its caller in a known dedicated `.codex-stderr.log`, use `--codex-stderr-file` to read a bounded ChatCut diagnostic tail. The helper retains native `loggedAt` separately, never as original `occurredAt` or a call ID. This is conditional on a real retained error stream; do not manufacture a log, scan for unknown paths or start another host to replace missing evidence. + +Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. + +Only `scripts/doctor.mjs` generates the canonical report, its diagnostic UUID and collection time. Never handwrite a replacement report or invent these values. The agent writes only the dedicated original-error/snapshot inputs and separate explanatory/repair notes. A directory's timestamp is not the failure or repair time; keep unexposed original times null. A successful read of a controlled unauthenticated service proves that read only, not account authentication, permission checks or the contents of a real user's projects. + +```text +node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output +``` + +Use `--host claude` for Claude Code. Omit unavailable optional inputs. If there is no registered URL, omit it; do not silently choose production. `--offline` keeps the registered URL as evidence but sends **no requests**, including upload. Otherwise the collector makes bounded anonymous entry/metadata checks and optional wire checks; an initial anonymous 401 is expected. Use `--upload` for the redacted report when transfer is authorized, after briefly explaining it. An intentionally disabled server is preserved without requests. + +The command writes the existing schema-2 report plus `.diagnosis.json`, `.summary.md` and `.issue-draft.md` sidecars. Read the findings and actual gaps before answering; do not merely echo a category. Findings identify observed boundaries, not root causes. Distinguish original failure, host evidence and later probes. Missing/truncated/denied logs remain gaps. A healthy later probe does not establish recovery. Proxy presence, busy/300-second timeouts and generic transport errors do not justify blaming security software, clearing locks/credentials, restarting or changing timeouts/environments. + +Wire failures/incomplete captures are later **direct-route** checks even if fetch metadata succeeds. Bounded native ERROR records and App `status=failed` notices are visible as unassociated host evidence; they do not fill a missing original error/time/request ID. Ordinary App starting/ready notices do not demonstrate failure. Local summaries show credential-redacted error excerpts; issue drafts keep structured states and evidence pointers without copying native log excerpts. + +## Repair and recheck + +For an observed missing registration or a normal sign-in requirement, read [repair and recheck](references/repair-and-recheck.md). Apply only the minimal change within the user's authorization and actual host/scope. Keep the prior state and rollback. Other findings need evidence-led investigation; this version has no automatic credential/network repair. + +Verify the affected boundary in the **same host and scope** after a change. Registration lookup success proves registration only. Auth success requires the host's authenticated state. Tool recovery requires a safe read-only call such as `list_projects` actually succeeding. If the original operation mutates state, costs credits, or cannot be safely repeated, do not replay it; say that its recovery remains unverified. Record the actual action, time/source and verification result in the summary and draft; never label an unperformed repair as tried. Keep the report's original failure immutable and its collector `recoveryVerified:false`; agent recheck results are separate evidence. + +## Feedback without starting over + +Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. + +Read the generated files and finish a usable explanation in the user's language. For Chinese requests, write a separate Chinese note with the actual symptom, exact error excerpt, evidence/source, action actually taken, safe same-host recheck result and delivery result. Fill core facts from observations; do not leave template placeholders for the user. If a later safe call succeeded, say exactly which operation and host/scope succeeded, while keeping the collector's original report immutable and its historical recovery flag separate. Do not claim an unperformed repair, complete recovery, final storage or an opened ticket. + +A protocol-discovery attempt that was rejected and then successfully negotiated by the same native flow can be normal. Preserve that sequence separately; do not present an intermediate negotiation rejection as the user's terminal failure when actual downstream success is established. Unrelated later success does not erase a recorded original tool failure. + +```text +node "/scripts/doctor.mjs" --report +node "/scripts/doctor.mjs" --upload-report +``` + +`--report` rebuilds sidecars offline without recollecting. It overwrites derived sidecars, so save any agent-written repair notes separately first. Use `--upload-report` only for an authorized first transfer or a single requested retry after failure; it reuses the original diagnostic ID without new probes, keeps the original report unchanged, and writes a separate upload receipt. Do not repeatedly recollect or resend an acknowledged report. `ingest_accepted` is not verified final storage or an opened support ticket. + +Use [ChatCut contact](https://chatcut.io/contact) for private support. The draft names no public issue repository because that destination is undecided. Do not open an issue, publish evidence, or message anyone without authorization. Root-cause rules and prevalence based on natural incidents remain future work; controlled tests are not user incidents. diff --git a/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs b/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs new file mode 100644 index 0000000..098a3fc --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs @@ -0,0 +1,129 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstat, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs, promisify } from "node:util"; +import { redact, runDoctor, serverUrl } from "./mcp-doctor.mjs"; +import { writePacket } from "./doctor.mjs"; + +const execute = promisify(execFile); +const save = (path, data) => writeFile(path, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 }); +const now = () => new Date().toISOString(); + +async function dedicatedText(path) { + if (!/\.mcp-error\.txt$/i.test(basename(path))) throw new Error("Use a dedicated .mcp-error.txt containing only the exposed original error"); + const info = await lstat(path); + if (!info.isFile() || info.size > 64 * 1024) throw new Error("Original error must be a regular file of at most 64 KiB"); + const text = await readFile(path, "utf8"); + if (!text.trim()) throw new Error("Original error is empty"); + return redact(text); +} + +// This projects one current project file in memory. Never retain headers, +// command/env, unrelated servers, or user/local credential-bearing config. +async function projectRegistration(cwd, name) { + try { + const path = join(cwd, ".mcp.json"), info = await lstat(path); + if (!info.isFile() || info.size > 128 * 1024) throw new Error("project_config_not_regular_or_too_large"); + const config = JSON.parse(await readFile(path, "utf8")); + const item = config?.mcpServers?.[name]; + if (!item) return { lookupResult: "unavailable", message: "The current project .mcp.json has no selected entry. Local, user and plugin scopes and the active session were not queried; absence in the host is unconfirmed." }; + const url = serverUrl(item.url).href; + return { lookupResult: "found", url, message: `Current project .mcp.json contains the selected HTTP registration ${name}. Static configuration only; active tools and authentication are unknown.` }; + } catch (error) { + return { lookupResult: "unavailable", message: error.code === "ENOENT" + ? "The current project .mcp.json is absent. Local, user and plugin scopes and the active session were not queried; absence in the host is unconfirmed." + : `Current project registration projection unavailable (${error.code ?? error.name}). Configuration contents were not retained.` }; + } +} + +export function projectNativeLookup(host, name, result) { + const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.replace(/\x1b\[[0-9;]*m/g, "").trim(); + // Exact completed native no-entry responses only. Nonzero alone is not absence. + const absent = host === "claude" + ? `No MCP server named "${name}". Run \`claude mcp add\` to add one.` + : `Error: No MCP server named '${name}' found.`; + if (result.code === 1 && text === absent) return { lookupResult: "not_found", message: absent }; + if (result.code === 0) { + try { + const config = host === "codex" ? JSON.parse(text) : null; + const raw = config?.transport?.url ?? (host === "claude" ? text.match(/^\s*URL:\s*(\S+)\s*$/m)?.[1] : undefined); + const url = serverUrl(raw).href; + return { lookupResult: "found", url, message: `Native ${host} mcp get completed and returned the selected HTTP registration ${name}. Headers and other configuration fields were discarded. This is not proof of active-session tools or authentication.` }; + } catch { /* Do not echo unrecognized configuration or credentials. */ } + } + return { lookupResult: "unavailable", message: `Native registration lookup did not establish existence or absence (exit ${result.code ?? "unknown"}; ${result.errorCode ?? "output_unrecognized"}). Raw configuration output was not retained.` }; +} + +async function nativeRegistration(host, name, cli) { + const args = ["mcp", "get", name, ...(host === "codex" ? ["--json"] : [])]; + let result; + try { result = { ...await execute(cli ?? host, args, { timeout: 12000, maxBuffer: 128 * 1024, windowsHide: true }), code: 0 }; } + catch (error) { result = { stdout: error.stdout, stderr: error.stderr, code: typeof error.code === "number" ? error.code : null, errorCode: error.killed ? "lookup_timeout" : String(error.code ?? "lookup_failed") }; } + return projectNativeLookup(host, name, result); +} + +export async function capture(options, dependencies = {}) { + if (!["codex", "claude"].includes(options.host)) throw new Error("--host must be codex or claude"); + const name = options.serverName ?? "chatcut"; + if (!["chatcut", "plugin:chatcut:chatcut"].includes(name)) throw new Error("Select the actual ChatCut server name"); + if (options.operation && !/^[A-Za-z0-9_.:/-]{1,256}$/.test(options.operation)) throw new Error("Invalid known operation"); + if (options.originalError && options.failureRecord) throw new Error("Choose the saved original error or an existing dedicated failure record"); + // Validate explicit endpoint before any optional native connection check. + const selected = options.serverUrl ? serverUrl(options.serverUrl).href : null; + const cwd = resolve(options.cwd ?? process.cwd()); + const base = join(cwd, ".chatcut-doctor"); + await mkdir(base, { recursive: true, mode: 0o700 }); + const dir = await mkdtemp(join(base, "capture-")); + const startedAt = now(); + const lookup = options.nativeLookup + ? await (dependencies.nativeRegistration ?? nativeRegistration)(options.host, name, options.cli) + : options.host === "claude" ? await projectRegistration(cwd, name) + : { lookupResult: "unavailable", message: "Offline capture did not invoke native CLI or read user configuration. Active registration, tools and authentication remain unconfirmed." }; + const source = options.nativeLookup ? "cli_configuration" : "host_settings"; + const snapshot = { schemaVersion: 1, observedAt: now(), observations: [{ + stage: "host_registration", operation: "lookup_registered_server", source, + observedAt: now(), outcome: lookup.lookupResult === "found" ? "success" : "failed", + lookupResult: lookup.lookupResult, message: lookup.message, + }] }; + const endpoint = selected ?? lookup.url; + if (lookup.url && (!selected || selected === lookup.url)) snapshot.configuration = { source, serverName: name, url: lookup.url, enabled: null }; + if (selected && lookup.url && selected !== lookup.url) snapshot.observations[0].message += " The explicitly selected endpoint differs; it was preserved without replacing the configured endpoint. No connection or authentication inference is made."; + const snapshotPath = join(dir, "current.mcp-snapshot.json"); + await save(snapshotPath, snapshot); + let failureRecord = options.failureRecord; + if (options.originalError) { + failureRecord = join(dir, "original.mcp-failure.json"); + await save(failureRecord, { schemaVersion: 1, source: "host_tool_result", serverName: name, + occurredAt: null, ...(options.operation && { operation: options.operation }), error: { message: await dedicatedText(options.originalError) } }); + } + const { path, report } = await runDoctor({ host: options.host, serverUrl: endpoint, + hostSnapshot: snapshotPath, failureRecord, offline: true, output: join(dir, "diagnostic.mcp-report.json") }, dependencies); + const packet = await writePacket(path, report); + const zhPath = path + ".zh-CN.md"; + const note = `# ChatCut MCP 本轮诊断\n\n诊断编号:${report.diagnosticId}\n采集时间:${report.collectedAt}(不是原始错误时间)\n宿主:${report.host}\n\n本轮事实:${lookup.message}\n\n原始错误:${report.originalFailure.status === "recorded" ? report.originalFailure.message : "未提供可见的原始 MCP 错误;未编造。"}\n原始时间:${report.originalFailure.occurredAt ?? "未知"};调用 ID:${report.originalFailure.callId ?? "未知"}。\n\n程序已保存本轮 snapshot、schema-2 正式报告及诊断 sidecar。结论:${packet.findings.map(x => x.kind).join("、")}。根因尚未确认。\n实际处理:${options.nativeLookup ? "已运行一次用户允许的原生注册查询;该命令可能附带健康连接检查。" : "仅投影当前工程允许的配置字段并离线采集;没有调用原生 CLI 或发送诊断请求。"} 未修改注册、登录或权限。\n复验:尚未执行安全的原生工具读取;配置存在不代表当前连接或账号健康。后续复验单独记录,原报告不变。\n发送:未上报、未开工单,最终存储未验证。\n缺口:${report.evidenceSummary?.gaps?.join(";") || "详见正式报告"}。\n\n正式报告:${path}\n`; + await writeFile(zhPath, note, { flag: "wx", mode: 0o600 }); + const bytes = await readFile(path), saved = JSON.parse(bytes); + if (saved.diagnosticId !== report.diagnosticId || saved.schemaVersion !== 2) throw new Error("Saved report identity check failed"); + const receipt = { schemaVersion: 1, startedAt, completedAt: now(), ...packet, snapshot: snapshotPath, chineseNote: zhPath, + sourceReportSha256: createHash("sha256").update(bytes).digest("hex"), savedReportVerified: true, + nativeLookupRequested: Boolean(options.nativeLookup), diagnosticHttpRequests: 0, + nativeLookupMayConnect: Boolean(options.nativeLookup), lookupResult: lookup.lookupResult, + originalInput: options.originalError ? resolve(options.originalError) : options.failureRecord ? resolve(options.failureRecord) : null }; + await save(join(dir, "capture-receipt.json"), receipt); + return receipt; +} + +export async function main(args = process.argv.slice(2)) { + const { values } = parseArgs({ args, options: { host: { type: "string" }, "server-name": { type: "string" }, + "server-url": { type: "string" }, "original-error": { type: "string" }, operation: { type: "string" }, + "failure-record": { type: "string" }, "native-lookup": { type: "boolean", default: false }, cli: { type: "string" } } }); + console.log(JSON.stringify(await capture({ host: values.host, serverName: values["server-name"], serverUrl: values["server-url"], + originalError: values["original-error"], operation: values.operation, failureRecord: values["failure-record"], + nativeLookup: values["native-lookup"], cli: values.cli }))); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => { + console.error(JSON.stringify({ captureFailed: true, message: redact(error.message) })); process.exitCode = 1; +}); diff --git a/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs index 17b913a..51ac3af 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -63,7 +63,8 @@ export function diagnose(report) { add("host_connection_failed_stage_unconfirmed", ["hostSnapshot.session"], "The host reports a failed connection. Preserve its native startup/runtime error if exposed; the exact stage and cause are not yet established."); if (session?.authStatus === "authenticated" && session?.visibleToolCount === 0) add("authenticated_tools_absent", ["hostSnapshot.session"], "Authentication inventory does not prove initialization or tools/list success. Preserve the host's discovery status and error before reconnecting."); - if (original?.status === "recorded" && !failed.length) { + // A separate registration lookup gap must not hide the retained tool error. + if (original?.status === "recorded" && !failed.some(item => ["tools_call", "tool_call"].includes(item.stage))) { if (["initialize", "tools/list"].includes(original.operation)) add("startup_or_discovery_failed", ["originalFailure.operation", "originalFailure"], "The original record identifies the failed startup/discovery operation. Preserve its native error; later healthy probes do not establish host recovery."); else if (original.source === "agent_supplied_host_tool_result" && original.operation) diff --git a/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs index 8721508..3001875 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -21,7 +21,7 @@ function dedicatedReportPath(path) { /^mcp-report(?:-\d+)?\.json$/i.test(basename(path)); } -async function readReport(path) { +export async function readReport(path) { // Do not accept config/auth/session files even as a mistakenly supplied input. if (!dedicatedReportPath(path)) throw new Error("Use the dedicated .mcp-report.json or default chatcut-mcp-doctor-.json report, never config/auth/session files"); diff --git a/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs new file mode 100644 index 0000000..1a2f9d3 --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { readReport } from "./doctor.mjs"; +import { redact } from "./mcp-doctor.mjs"; + +export async function recordRecheck(options) { + const path = resolve(options.report); + const { report, sourceReportSha256 } = await readReport(path); + if (!["success", "failed"].includes(options.outcome) || options.operation !== "list_projects" || + typeof options.scope !== "string" || !options.scope.trim() || options.scope.length > 1000) + throw new Error("Record the actual safe list_projects result, outcome and same-host/scope boundary"); + if (!options.result.endsWith(".mcp-recheck.txt")) throw new Error("Use the dedicated .mcp-recheck.txt exposed result only"); + const info = await lstat(options.result); + if (!info.isFile() || info.size > 64 * 1024) throw new Error("Recheck result must be a regular file of at most 64 KiB"); + const result = redact(await readFile(options.result, "utf8")); + if (!result.trim()) throw new Error("Recheck result is empty"); + const observation = { schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, + recordedAt: new Date().toISOString(), observedAt: null, + source: "agent_supplied_native_tool_result; not_independently_verified", host: report.host, + scope: redact(options.scope), operation: options.operation, outcome: options.outcome, message: result, + originalReportUnchanged: true, originalAttemptAssociation: "unconfirmed", accountAuthentication: "unconfirmed" }; + const output = path + ".recheck.json"; + await writeFile(output, JSON.stringify(observation, null, 2) + "\n", { flag: "wx", mode: 0o600 }); + if (createHash("sha256").update(await readFile(path)).digest("hex") !== sourceReportSha256) throw new Error("Original report changed during recheck recording"); + const note = `${path}.recheck.zh-CN.md`; + await writeFile(note, `# 本轮安全复验\n\n诊断编号:${report.diagnosticId}\n宿主/范围:${report.host};${observation.scope}\n实际操作:原生 list_projects;结果:${options.outcome}。\n已暴露结果:${result}\n\n记录时间:${observation.recordedAt};原生事件时间未提供,保持未知。上述事实由 Agent 从实际工具结果单独记录;配置/账号认证及原故障恢复未据此确认。原始报告及其 SHA-256 保持不变。没有上报或开工单。\n`, { flag: "wx", mode: 0o600 }); + return { ...observation, path: output, chineseNote: note }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { report: { type: "string" }, result: { type: "string" }, + outcome: { type: "string" }, operation: { type: "string" }, scope: { type: "string" } } }); + recordRecheck(values).then(x => console.log(JSON.stringify(x))).catch(error => { + console.error(JSON.stringify({ recheckRecordingFailed: true, message: redact(error.message) })); process.exitCode = 1; + }); +} diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index 6fc2b47..a24b853 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -1,94 +1,56 @@ --- name: chatcut-mcp-doctor -description: Diagnose hosted ChatCut MCP connection, startup, authentication or tool-call failures from a working Codex or Claude Code agent, even when ChatCut tools cannot connect. Produce evidence-based findings, repair guidance, rechecks and local feedback materials. Use for ChatCut Doctor or MCP连接错误; not for Desktop ACP or unrelated video editing. +description: Diagnose hosted ChatCut MCP connection, startup, authentication or tool-call failures locally, save a machine report and usable feedback, and guide scoped repair/recheck. Use for ChatCut Doctor or MCP连接错误; not Desktop ACP or unrelated video editing. --- # ChatCut MCP Doctor -This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. +Run locally with Node 18+ even when ChatCut MCP is unavailable. Preserve the already visible original error before any retry or change. Never read credentials, auth files or conversation histories. -If the host rejects a local command, preserve that exact rejection and the operation it blocked. One denied compound read does not establish that every file tool or the standalone Node helper is prohibited, or that the workspace is read-only. Use only capabilities already permitted by the current host; do not change permissions, switch shells to bypass a rejection, or call collection complete when no report was written. +## Capture first -## Start here +A diagnosis is complete only after this invocation's program-generated schema-2 report is saved and its diagnostic ID/path verified. A prose note or an old report is not a substitute. -- In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” -- In Claude Code with the ChatCut plugin, invoke `/chatcut:chatcut-mcp-doctor`. A separately installed project/personal copy uses `/chatcut-mcp-doctor`. -- `/doctor` and `codex doctor` belong to the host; this Skill does not replace them. If the entry is absent or the plugin is old, read [discovery and fallback](references/discovery.md). A browser-only host without local execution cannot run this collector; retain the error and explain that boundary. +Run this single command first; resolve the path from this Skill directory: -## Preserve, collect, explain - -Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. - -Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. - -For a visible failed tool call, the minimal record is below. Create a file named -`original.mcp-failure.json` (the `.mcp-failure.json` suffix is required). Replace -only the message and the actually known tool name; keep unavailable time null. -Read the reference before adding fields. The failure record uses schema **1**, -not report schema 2; `mcp_tool_call` and `errorMessage` are not accepted fields. - -```json -{"schemaVersion":1,"source":"host_tool_result","serverName":"chatcut","occurredAt":null,"operation":"list_projects","error":{"message":"Exact exposed original error"}} +```text +node "/scripts/capture.mjs" --host codex ``` -Do not lose a known tool name by switching to text-only input after a formatting -mistake. Correct the dedicated record using the already saved original result, -without calling the failed tool again. Keep earlier incomplete reports separate; -reuse the final correct report for feedback and one authorized transfer. +The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. Read these outputs, then explain the observed boundary and remaining gaps in the user's language. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. -**CLI registration query → snapshot; failed MCP tool → failure record.** A -`codex/claude mcp get` response belongs in snapshot observations, not in a -failure record with `source: cli_configuration`. Generate a fresh snapshot with -`--create-snapshot current.mcp-snapshot.json`, retain its generated capture time, -then add only the observed registration fact. Explicit native absence permits: +If the current invocation already has a visible MCP tool error, save only its exact exposed error text as `original.mcp-error.txt`, then run: -```json -{"stage":"host_registration","operation":"lookup_registered_server","source":"cli_configuration","observedAt":null,"outcome":"failed","lookupResult":"not_found","message":"Exact native no-entry response"} +```text +node "/scripts/capture.mjs" --host --original-error "original.mcp-error.txt" --operation --server-url ``` -Collect it with `--host-snapshot current.mcp-snapshot.json --server-url --offline --output current.mcp-report.json`. Omit `--failure-record` when no original MCP tool/runtime error is exposed. This keeps the CLI message and `not_registered` finding without inventing an original MCP failure/time. The earlier schema-1 example is for an actual failed MCP tool call only. Keep a known selected URL even offline so a later authorized transfer can reuse the report; never substitute a production URL afterwards. +Omit unknown operation/URL. This program formats the dedicated failure record; never handwrite report JSON, schema versions, UUIDs or timestamps. A retained structured `.mcp-failure.json` can instead use `--failure-record` to preserve its code/cause/known IDs. Reuse saved originals; do not recreate a failure. Unknown original time/IDs stay unknown. The snapshot capture time and collecting thread are not original-event evidence. -If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. +## Diagnose and repair -Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. +Keep configuration, original failure, current session and later checks distinct. Do not infer account/permission/service health from prior success, static config or an empty controlled project list. Findings are observed boundaries, not root causes. -With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. +For current native registration evidence, `capture.mjs --host --native-lookup [--cli ]` runs one bounded native `mcp get` and formats its snapshot/report. Use it only when the user permits a connection check: Claude's command can health-check approved servers. It is **not offline**. It projects allowed fields without retaining headers/full CLI output. Timeout, denied/unavailable CLI and unrecognized output remain unavailable; only the exact completed no-entry response supports not_registered. Select `--server-name plugin:chatcut:chatcut` only when that is the actual selected server; never recreate a plugin server as a manual entry. -If this invocation's native stderr has already been retained by its caller in a known dedicated `.codex-stderr.log`, use `--codex-stderr-file` to read a bounded ChatCut diagnostic tail. The helper retains native `loggedAt` separately, never as original `occurredAt` or a call ID. This is conditional on a real retained error stream; do not manufacture a log, scan for unknown paths or start another host to replace missing evidence. +Read [repair and recheck](references/repair-and-recheck.md) before any change. Repair only a demonstrated missing manual registration in the authorized scope; retain prior state, actual command and rollback. No automatic credential/network repair. Local execution denial is evidence, not authorization to change permissions. -Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. +## Recheck and deliver -Only `scripts/doctor.mjs` generates the canonical report, its diagnostic UUID and collection time. Never handwrite a replacement report or invent these values. The agent writes only the dedicated original-error/snapshot inputs and separate explanatory/repair notes. A directory's timestamp is not the failure or repair time; keep unexposed original times null. A successful read of a controlled unauthenticated service proves that read only, not account authentication, permission checks or the contents of a real user's projects. +After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save its exposed result separately as `read.mcp-recheck.txt`, then record it without changing the original report: ```text -node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output +node "/scripts/record-recheck.mjs" --report "" --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" ``` -Use `--host claude` for Claude Code. Omit unavailable optional inputs. If there is no registered URL, omit it; do not silently choose production. `--offline` keeps the registered URL as evidence but sends **no requests**, including upload. Otherwise the collector makes bounded anonymous entry/metadata checks and optional wire checks; an initial anonymous 401 is expected. Use `--upload` for the redacted report when transfer is authorized, after briefly explaining it. An intentionally disabled server is preserved without requests. - -The command writes the existing schema-2 report plus `.diagnosis.json`, `.summary.md` and `.issue-draft.md` sidecars. Read the findings and actual gaps before answering; do not merely echo a category. Findings identify observed boundaries, not root causes. Distinguish original failure, host evidence and later probes. Missing/truncated/denied logs remain gaps. A healthy later probe does not establish recovery. Proxy presence, busy/300-second timeouts and generic transport errors do not justify blaming security software, clearing locks/credentials, restarting or changing timeouts/environments. - -Wire failures/incomplete captures are later **direct-route** checks even if fetch metadata succeeds. Bounded native ERROR records and App `status=failed` notices are visible as unassociated host evidence; they do not fill a missing original error/time/request ID. Ordinary App starting/ready notices do not demonstrate failure. Local summaries show credential-redacted error excerpts; issue drafts keep structured states and evidence pointers without copying native log excerpts. +The recording is explicitly Agent supplied and does not independently prove account authentication or original recovery. Finish a usable Chinese explanation for Chinese requests: actual symptom/error, evidence, action, recheck result, diagnostic ID/files and delivery status. Generated Chinese notes have the current facts; add actual repair details in a separate note. Do not ask the user to fill JSON or leave core placeholders. -## Repair and recheck +Report transfer requires authorization. `node "/scripts/doctor.mjs" --upload-report ""` sends the unchanged report once and saves a SHA-bound receipt. Do not recollect to resend, change its endpoint or repeat acknowledged delivery. Ingest acceptance is not final storage or a ticket. No publishing or messaging without authorization. -For an observed missing registration or a normal sign-in requirement, read [repair and recheck](references/repair-and-recheck.md). Apply only the minimal change within the user's authorization and actual host/scope. Keep the prior state and rollback. Other findings need evidence-led investigation; this version has no automatic credential/network repair. - -Verify the affected boundary in the **same host and scope** after a change. Registration lookup success proves registration only. Auth success requires the host's authenticated state. Tool recovery requires a safe read-only call such as `list_projects` actually succeeding. If the original operation mutates state, costs credits, or cannot be safely repeated, do not replay it; say that its recovery remains unverified. Record the actual action, time/source and verification result in the summary and draft; never label an unperformed repair as tried. Keep the report's original failure immutable and its collector `recoveryVerified:false`; agent recheck results are separate evidence. - -## Feedback without starting over - -Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. - -Read the generated files and finish a usable explanation in the user's language. For Chinese requests, write a separate Chinese note with the actual symptom, exact error excerpt, evidence/source, action actually taken, safe same-host recheck result and delivery result. Fill core facts from observations; do not leave template placeholders for the user. If a later safe call succeeded, say exactly which operation and host/scope succeeded, while keeping the collector's original report immutable and its historical recovery flag separate. Do not claim an unperformed repair, complete recovery, final storage or an opened ticket. - -A protocol-discovery attempt that was rejected and then successfully negotiated by the same native flow can be normal. Preserve that sequence separately; do not present an intermediate negotiation rejection as the user's terminal failure when actual downstream success is established. Unrelated later success does not erase a recorded original tool failure. - -```text -node "/scripts/doctor.mjs" --report -node "/scripts/doctor.mjs" --upload-report -``` +## Entry and advanced evidence -`--report` rebuilds sidecars offline without recollecting. It overwrites derived sidecars, so save any agent-written repair notes separately first. Use `--upload-report` only for an authorized first transfer or a single requested retry after failure; it reuses the original diagnostic ID without new probes, keeps the original report unchanged, and writes a separate upload receipt. Do not repeatedly recollect or resend an acknowledged report. `ingest_accepted` is not verified final storage or an opened support ticket. +Claude plugin public entry: **/chatcut:chatcut-mcp-doctor**. Codex Git plugin: **$chatcut:chatcut-mcp-doctor**; store identity: **$chatcut-desktop:chatcut-mcp-doctor**. Use the exact installed catalog entry. Bare /doctor belongs to the host. -Use [ChatCut contact](https://chatcut.io/contact) for private support. The draft names no public issue repository because that destination is undecided. Do not open an issue, publish evidence, or message anyone without authorization. Root-cause rules and prevalence based on natural incidents remain future work; controlled tests are not user incidents. +- [Discovery and fallback](references/discovery.md): entry missing/old plugin or no local execution. +- [Connection diagnostics](references/connection-diagnostics.md): dedicated evidence inputs, bounded native logs and original IDs. +- [Advanced collection](references/advanced-collection.md): optional existing collector modes, precise log/database coverage and feedback reuse. Load only when needed. diff --git a/codex/skills/chatcut-mcp-doctor/references/advanced-collection.md b/codex/skills/chatcut-mcp-doctor/references/advanced-collection.md new file mode 100644 index 0000000..76fa5bc --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/references/advanced-collection.md @@ -0,0 +1,89 @@ +# ChatCut MCP Doctor + +This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. + +If the host rejects a local command, preserve that exact rejection and the operation it blocked. One denied compound read does not establish that every file tool or the standalone Node helper is prohibited, or that the workspace is read-only. Use only capabilities already permitted by the current host; do not change permissions, switch shells to bypass a rejection, or call collection complete when no report was written. + +## Start here + +- In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” +- In Claude Code with the ChatCut plugin, invoke `/chatcut:chatcut-mcp-doctor`. A separately installed project/personal copy uses `/chatcut-mcp-doctor`. +- `/doctor` and `codex doctor` belong to the host; this Skill does not replace them. If the entry is absent or the plugin is old, read [discovery and fallback](references/discovery.md). A browser-only host without local execution cannot run this collector; retain the error and explain that boundary. + +## Preserve, collect, explain + +Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. + +Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. + +For a visible failed tool call, the minimal record is below. Create a file named +`original.mcp-failure.json` (the `.mcp-failure.json` suffix is required). Replace +only the message and the actually known tool name; keep unavailable time null. +Read the reference before adding fields. The failure record uses schema **1**, +not report schema 2; `mcp_tool_call` and `errorMessage` are not accepted fields. + +```json +{"schemaVersion":1,"source":"host_tool_result","serverName":"chatcut","occurredAt":null,"operation":"list_projects","error":{"message":"Exact exposed original error"}} +``` + +Do not lose a known tool name by switching to text-only input after a formatting +mistake. Correct the dedicated record using the already saved original result, +without calling the failed tool again. Keep earlier incomplete reports separate; +reuse the final correct report for feedback and one authorized transfer. + +**CLI registration query → snapshot; failed MCP tool → failure record.** A +`codex/claude mcp get` response belongs in snapshot observations, not in a +failure record with `source: cli_configuration`. Generate a fresh snapshot with +`--create-snapshot current.mcp-snapshot.json`, retain its generated capture time, +then add only the observed registration fact. Explicit native absence permits: + +```json +{"stage":"host_registration","operation":"lookup_registered_server","source":"cli_configuration","observedAt":null,"outcome":"failed","lookupResult":"not_found","message":"Exact native no-entry response"} +``` + +Collect it with `--host-snapshot current.mcp-snapshot.json --server-url --offline --output current.mcp-report.json`. Omit `--failure-record` when no original MCP tool/runtime error is exposed. This keeps the CLI message and `not_registered` finding without inventing an original MCP failure/time. The earlier schema-1 example is for an actual failed MCP tool call only. Keep a known selected URL even offline so a later authorized transfer can reuse the report; never substitute a production URL afterwards. + +If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. + +Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. + +With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. + +If this invocation's native stderr has already been retained by its caller in a known dedicated `.codex-stderr.log`, use `--codex-stderr-file` to read a bounded ChatCut diagnostic tail. The helper retains native `loggedAt` separately, never as original `occurredAt` or a call ID. This is conditional on a real retained error stream; do not manufacture a log, scan for unknown paths or start another host to replace missing evidence. + +Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. + +Only `scripts/doctor.mjs` generates the canonical report, its diagnostic UUID and collection time. Never handwrite a replacement report or invent these values. The agent writes only the dedicated original-error/snapshot inputs and separate explanatory/repair notes. A directory's timestamp is not the failure or repair time; keep unexposed original times null. A successful read of a controlled unauthenticated service proves that read only, not account authentication, permission checks or the contents of a real user's projects. + +```text +node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output +``` + +Use `--host claude` for Claude Code. Omit unavailable optional inputs. If there is no registered URL, omit it; do not silently choose production. `--offline` keeps the registered URL as evidence but sends **no requests**, including upload. Otherwise the collector makes bounded anonymous entry/metadata checks and optional wire checks; an initial anonymous 401 is expected. Use `--upload` for the redacted report when transfer is authorized, after briefly explaining it. An intentionally disabled server is preserved without requests. + +The command writes the existing schema-2 report plus `.diagnosis.json`, `.summary.md` and `.issue-draft.md` sidecars. Read the findings and actual gaps before answering; do not merely echo a category. Findings identify observed boundaries, not root causes. Distinguish original failure, host evidence and later probes. Missing/truncated/denied logs remain gaps. A healthy later probe does not establish recovery. Proxy presence, busy/300-second timeouts and generic transport errors do not justify blaming security software, clearing locks/credentials, restarting or changing timeouts/environments. + +Wire failures/incomplete captures are later **direct-route** checks even if fetch metadata succeeds. Bounded native ERROR records and App `status=failed` notices are visible as unassociated host evidence; they do not fill a missing original error/time/request ID. Ordinary App starting/ready notices do not demonstrate failure. Local summaries show credential-redacted error excerpts; issue drafts keep structured states and evidence pointers without copying native log excerpts. + +## Repair and recheck + +For an observed missing registration or a normal sign-in requirement, read [repair and recheck](references/repair-and-recheck.md). Apply only the minimal change within the user's authorization and actual host/scope. Keep the prior state and rollback. Other findings need evidence-led investigation; this version has no automatic credential/network repair. + +Verify the affected boundary in the **same host and scope** after a change. Registration lookup success proves registration only. Auth success requires the host's authenticated state. Tool recovery requires a safe read-only call such as `list_projects` actually succeeding. If the original operation mutates state, costs credits, or cannot be safely repeated, do not replay it; say that its recovery remains unverified. Record the actual action, time/source and verification result in the summary and draft; never label an unperformed repair as tried. Keep the report's original failure immutable and its collector `recoveryVerified:false`; agent recheck results are separate evidence. + +## Feedback without starting over + +Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. + +Read the generated files and finish a usable explanation in the user's language. For Chinese requests, write a separate Chinese note with the actual symptom, exact error excerpt, evidence/source, action actually taken, safe same-host recheck result and delivery result. Fill core facts from observations; do not leave template placeholders for the user. If a later safe call succeeded, say exactly which operation and host/scope succeeded, while keeping the collector's original report immutable and its historical recovery flag separate. Do not claim an unperformed repair, complete recovery, final storage or an opened ticket. + +A protocol-discovery attempt that was rejected and then successfully negotiated by the same native flow can be normal. Preserve that sequence separately; do not present an intermediate negotiation rejection as the user's terminal failure when actual downstream success is established. Unrelated later success does not erase a recorded original tool failure. + +```text +node "/scripts/doctor.mjs" --report +node "/scripts/doctor.mjs" --upload-report +``` + +`--report` rebuilds sidecars offline without recollecting. It overwrites derived sidecars, so save any agent-written repair notes separately first. Use `--upload-report` only for an authorized first transfer or a single requested retry after failure; it reuses the original diagnostic ID without new probes, keeps the original report unchanged, and writes a separate upload receipt. Do not repeatedly recollect or resend an acknowledged report. `ingest_accepted` is not verified final storage or an opened support ticket. + +Use [ChatCut contact](https://chatcut.io/contact) for private support. The draft names no public issue repository because that destination is undecided. Do not open an issue, publish evidence, or message anyone without authorization. Root-cause rules and prevalence based on natural incidents remain future work; controlled tests are not user incidents. diff --git a/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs b/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs new file mode 100644 index 0000000..098a3fc --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs @@ -0,0 +1,129 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstat, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs, promisify } from "node:util"; +import { redact, runDoctor, serverUrl } from "./mcp-doctor.mjs"; +import { writePacket } from "./doctor.mjs"; + +const execute = promisify(execFile); +const save = (path, data) => writeFile(path, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 }); +const now = () => new Date().toISOString(); + +async function dedicatedText(path) { + if (!/\.mcp-error\.txt$/i.test(basename(path))) throw new Error("Use a dedicated .mcp-error.txt containing only the exposed original error"); + const info = await lstat(path); + if (!info.isFile() || info.size > 64 * 1024) throw new Error("Original error must be a regular file of at most 64 KiB"); + const text = await readFile(path, "utf8"); + if (!text.trim()) throw new Error("Original error is empty"); + return redact(text); +} + +// This projects one current project file in memory. Never retain headers, +// command/env, unrelated servers, or user/local credential-bearing config. +async function projectRegistration(cwd, name) { + try { + const path = join(cwd, ".mcp.json"), info = await lstat(path); + if (!info.isFile() || info.size > 128 * 1024) throw new Error("project_config_not_regular_or_too_large"); + const config = JSON.parse(await readFile(path, "utf8")); + const item = config?.mcpServers?.[name]; + if (!item) return { lookupResult: "unavailable", message: "The current project .mcp.json has no selected entry. Local, user and plugin scopes and the active session were not queried; absence in the host is unconfirmed." }; + const url = serverUrl(item.url).href; + return { lookupResult: "found", url, message: `Current project .mcp.json contains the selected HTTP registration ${name}. Static configuration only; active tools and authentication are unknown.` }; + } catch (error) { + return { lookupResult: "unavailable", message: error.code === "ENOENT" + ? "The current project .mcp.json is absent. Local, user and plugin scopes and the active session were not queried; absence in the host is unconfirmed." + : `Current project registration projection unavailable (${error.code ?? error.name}). Configuration contents were not retained.` }; + } +} + +export function projectNativeLookup(host, name, result) { + const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.replace(/\x1b\[[0-9;]*m/g, "").trim(); + // Exact completed native no-entry responses only. Nonzero alone is not absence. + const absent = host === "claude" + ? `No MCP server named "${name}". Run \`claude mcp add\` to add one.` + : `Error: No MCP server named '${name}' found.`; + if (result.code === 1 && text === absent) return { lookupResult: "not_found", message: absent }; + if (result.code === 0) { + try { + const config = host === "codex" ? JSON.parse(text) : null; + const raw = config?.transport?.url ?? (host === "claude" ? text.match(/^\s*URL:\s*(\S+)\s*$/m)?.[1] : undefined); + const url = serverUrl(raw).href; + return { lookupResult: "found", url, message: `Native ${host} mcp get completed and returned the selected HTTP registration ${name}. Headers and other configuration fields were discarded. This is not proof of active-session tools or authentication.` }; + } catch { /* Do not echo unrecognized configuration or credentials. */ } + } + return { lookupResult: "unavailable", message: `Native registration lookup did not establish existence or absence (exit ${result.code ?? "unknown"}; ${result.errorCode ?? "output_unrecognized"}). Raw configuration output was not retained.` }; +} + +async function nativeRegistration(host, name, cli) { + const args = ["mcp", "get", name, ...(host === "codex" ? ["--json"] : [])]; + let result; + try { result = { ...await execute(cli ?? host, args, { timeout: 12000, maxBuffer: 128 * 1024, windowsHide: true }), code: 0 }; } + catch (error) { result = { stdout: error.stdout, stderr: error.stderr, code: typeof error.code === "number" ? error.code : null, errorCode: error.killed ? "lookup_timeout" : String(error.code ?? "lookup_failed") }; } + return projectNativeLookup(host, name, result); +} + +export async function capture(options, dependencies = {}) { + if (!["codex", "claude"].includes(options.host)) throw new Error("--host must be codex or claude"); + const name = options.serverName ?? "chatcut"; + if (!["chatcut", "plugin:chatcut:chatcut"].includes(name)) throw new Error("Select the actual ChatCut server name"); + if (options.operation && !/^[A-Za-z0-9_.:/-]{1,256}$/.test(options.operation)) throw new Error("Invalid known operation"); + if (options.originalError && options.failureRecord) throw new Error("Choose the saved original error or an existing dedicated failure record"); + // Validate explicit endpoint before any optional native connection check. + const selected = options.serverUrl ? serverUrl(options.serverUrl).href : null; + const cwd = resolve(options.cwd ?? process.cwd()); + const base = join(cwd, ".chatcut-doctor"); + await mkdir(base, { recursive: true, mode: 0o700 }); + const dir = await mkdtemp(join(base, "capture-")); + const startedAt = now(); + const lookup = options.nativeLookup + ? await (dependencies.nativeRegistration ?? nativeRegistration)(options.host, name, options.cli) + : options.host === "claude" ? await projectRegistration(cwd, name) + : { lookupResult: "unavailable", message: "Offline capture did not invoke native CLI or read user configuration. Active registration, tools and authentication remain unconfirmed." }; + const source = options.nativeLookup ? "cli_configuration" : "host_settings"; + const snapshot = { schemaVersion: 1, observedAt: now(), observations: [{ + stage: "host_registration", operation: "lookup_registered_server", source, + observedAt: now(), outcome: lookup.lookupResult === "found" ? "success" : "failed", + lookupResult: lookup.lookupResult, message: lookup.message, + }] }; + const endpoint = selected ?? lookup.url; + if (lookup.url && (!selected || selected === lookup.url)) snapshot.configuration = { source, serverName: name, url: lookup.url, enabled: null }; + if (selected && lookup.url && selected !== lookup.url) snapshot.observations[0].message += " The explicitly selected endpoint differs; it was preserved without replacing the configured endpoint. No connection or authentication inference is made."; + const snapshotPath = join(dir, "current.mcp-snapshot.json"); + await save(snapshotPath, snapshot); + let failureRecord = options.failureRecord; + if (options.originalError) { + failureRecord = join(dir, "original.mcp-failure.json"); + await save(failureRecord, { schemaVersion: 1, source: "host_tool_result", serverName: name, + occurredAt: null, ...(options.operation && { operation: options.operation }), error: { message: await dedicatedText(options.originalError) } }); + } + const { path, report } = await runDoctor({ host: options.host, serverUrl: endpoint, + hostSnapshot: snapshotPath, failureRecord, offline: true, output: join(dir, "diagnostic.mcp-report.json") }, dependencies); + const packet = await writePacket(path, report); + const zhPath = path + ".zh-CN.md"; + const note = `# ChatCut MCP 本轮诊断\n\n诊断编号:${report.diagnosticId}\n采集时间:${report.collectedAt}(不是原始错误时间)\n宿主:${report.host}\n\n本轮事实:${lookup.message}\n\n原始错误:${report.originalFailure.status === "recorded" ? report.originalFailure.message : "未提供可见的原始 MCP 错误;未编造。"}\n原始时间:${report.originalFailure.occurredAt ?? "未知"};调用 ID:${report.originalFailure.callId ?? "未知"}。\n\n程序已保存本轮 snapshot、schema-2 正式报告及诊断 sidecar。结论:${packet.findings.map(x => x.kind).join("、")}。根因尚未确认。\n实际处理:${options.nativeLookup ? "已运行一次用户允许的原生注册查询;该命令可能附带健康连接检查。" : "仅投影当前工程允许的配置字段并离线采集;没有调用原生 CLI 或发送诊断请求。"} 未修改注册、登录或权限。\n复验:尚未执行安全的原生工具读取;配置存在不代表当前连接或账号健康。后续复验单独记录,原报告不变。\n发送:未上报、未开工单,最终存储未验证。\n缺口:${report.evidenceSummary?.gaps?.join(";") || "详见正式报告"}。\n\n正式报告:${path}\n`; + await writeFile(zhPath, note, { flag: "wx", mode: 0o600 }); + const bytes = await readFile(path), saved = JSON.parse(bytes); + if (saved.diagnosticId !== report.diagnosticId || saved.schemaVersion !== 2) throw new Error("Saved report identity check failed"); + const receipt = { schemaVersion: 1, startedAt, completedAt: now(), ...packet, snapshot: snapshotPath, chineseNote: zhPath, + sourceReportSha256: createHash("sha256").update(bytes).digest("hex"), savedReportVerified: true, + nativeLookupRequested: Boolean(options.nativeLookup), diagnosticHttpRequests: 0, + nativeLookupMayConnect: Boolean(options.nativeLookup), lookupResult: lookup.lookupResult, + originalInput: options.originalError ? resolve(options.originalError) : options.failureRecord ? resolve(options.failureRecord) : null }; + await save(join(dir, "capture-receipt.json"), receipt); + return receipt; +} + +export async function main(args = process.argv.slice(2)) { + const { values } = parseArgs({ args, options: { host: { type: "string" }, "server-name": { type: "string" }, + "server-url": { type: "string" }, "original-error": { type: "string" }, operation: { type: "string" }, + "failure-record": { type: "string" }, "native-lookup": { type: "boolean", default: false }, cli: { type: "string" } } }); + console.log(JSON.stringify(await capture({ host: values.host, serverName: values["server-name"], serverUrl: values["server-url"], + originalError: values["original-error"], operation: values.operation, failureRecord: values["failure-record"], + nativeLookup: values["native-lookup"], cli: values.cli }))); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => { + console.error(JSON.stringify({ captureFailed: true, message: redact(error.message) })); process.exitCode = 1; +}); diff --git a/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs index 17b913a..51ac3af 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs @@ -63,7 +63,8 @@ export function diagnose(report) { add("host_connection_failed_stage_unconfirmed", ["hostSnapshot.session"], "The host reports a failed connection. Preserve its native startup/runtime error if exposed; the exact stage and cause are not yet established."); if (session?.authStatus === "authenticated" && session?.visibleToolCount === 0) add("authenticated_tools_absent", ["hostSnapshot.session"], "Authentication inventory does not prove initialization or tools/list success. Preserve the host's discovery status and error before reconnecting."); - if (original?.status === "recorded" && !failed.length) { + // A separate registration lookup gap must not hide the retained tool error. + if (original?.status === "recorded" && !failed.some(item => ["tools_call", "tool_call"].includes(item.stage))) { if (["initialize", "tools/list"].includes(original.operation)) add("startup_or_discovery_failed", ["originalFailure.operation", "originalFailure"], "The original record identifies the failed startup/discovery operation. Preserve its native error; later healthy probes do not establish host recovery."); else if (original.source === "agent_supplied_host_tool_result" && original.operation) diff --git a/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs index 8721508..3001875 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -21,7 +21,7 @@ function dedicatedReportPath(path) { /^mcp-report(?:-\d+)?\.json$/i.test(basename(path)); } -async function readReport(path) { +export async function readReport(path) { // Do not accept config/auth/session files even as a mistakenly supplied input. if (!dedicatedReportPath(path)) throw new Error("Use the dedicated .mcp-report.json or default chatcut-mcp-doctor-.json report, never config/auth/session files"); diff --git a/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs new file mode 100644 index 0000000..1a2f9d3 --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { readReport } from "./doctor.mjs"; +import { redact } from "./mcp-doctor.mjs"; + +export async function recordRecheck(options) { + const path = resolve(options.report); + const { report, sourceReportSha256 } = await readReport(path); + if (!["success", "failed"].includes(options.outcome) || options.operation !== "list_projects" || + typeof options.scope !== "string" || !options.scope.trim() || options.scope.length > 1000) + throw new Error("Record the actual safe list_projects result, outcome and same-host/scope boundary"); + if (!options.result.endsWith(".mcp-recheck.txt")) throw new Error("Use the dedicated .mcp-recheck.txt exposed result only"); + const info = await lstat(options.result); + if (!info.isFile() || info.size > 64 * 1024) throw new Error("Recheck result must be a regular file of at most 64 KiB"); + const result = redact(await readFile(options.result, "utf8")); + if (!result.trim()) throw new Error("Recheck result is empty"); + const observation = { schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, + recordedAt: new Date().toISOString(), observedAt: null, + source: "agent_supplied_native_tool_result; not_independently_verified", host: report.host, + scope: redact(options.scope), operation: options.operation, outcome: options.outcome, message: result, + originalReportUnchanged: true, originalAttemptAssociation: "unconfirmed", accountAuthentication: "unconfirmed" }; + const output = path + ".recheck.json"; + await writeFile(output, JSON.stringify(observation, null, 2) + "\n", { flag: "wx", mode: 0o600 }); + if (createHash("sha256").update(await readFile(path)).digest("hex") !== sourceReportSha256) throw new Error("Original report changed during recheck recording"); + const note = `${path}.recheck.zh-CN.md`; + await writeFile(note, `# 本轮安全复验\n\n诊断编号:${report.diagnosticId}\n宿主/范围:${report.host};${observation.scope}\n实际操作:原生 list_projects;结果:${options.outcome}。\n已暴露结果:${result}\n\n记录时间:${observation.recordedAt};原生事件时间未提供,保持未知。上述事实由 Agent 从实际工具结果单独记录;配置/账号认证及原故障恢复未据此确认。原始报告及其 SHA-256 保持不变。没有上报或开工单。\n`, { flag: "wx", mode: 0o600 }); + return { ...observation, path: output, chineseNote: note }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ options: { report: { type: "string" }, result: { type: "string" }, + outcome: { type: "string" }, operation: { type: "string" }, scope: { type: "string" } } }); + recordRecheck(values).then(x => console.log(JSON.stringify(x))).catch(error => { + console.error(JSON.stringify({ recheckRecordingFailed: true, message: redact(error.message) })); process.exitCode = 1; + }); +} diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index 8ca046e..d0d6715 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -17,6 +17,20 @@ remain subject to review. The separate collection-only packages contain no Doctor entry. Check actual installed contents and catalog; Git availability, store approval and host adoption are separate checks. +The short `scripts/capture.mjs` entry saves a fresh canonical report, snapshot, +SHA-bound local receipt and Chinese note in a new `.chatcut-doctor/capture-*` +directory. Claude invokes it through native Skill dynamic context before the +model answers, subject to host policy. Default capture sends no requests and +does not run native CLI: Claude's `mcp get` may health-check approved servers. +Only allowlisted current project registration fields are projected in memory; +missing project config does not establish absence in other host scopes. +An explicit `--native-lookup` allows one bounded native query and projects its +result without retaining full configuration/headers. Only the exact no-entry +response supports `not_registered`; denied/timed-out/unrecognized lookups remain +unknown. Saved `.mcp-error.txt` inputs can be formatted without Agent-authored +schemas. `record-recheck.mjs` binds a separately supplied native safe-read result +to the immutable report hash; it does not independently verify the Agent's claim. + The basics scripts are compatibility forwarders; each host package contains one collector implementation under Doctor. The new `scripts/doctor.mjs` entrypoint keeps schema-2 collection unchanged and writes `.diagnosis.json`, diff --git a/tests/doctor-capture.test.mjs b/tests/doctor-capture.test.mjs new file mode 100644 index 0000000..a552a65 --- /dev/null +++ b/tests/doctor-capture.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import { capture, projectNativeLookup } from "../codex/skills/chatcut-mcp-doctor/scripts/capture.mjs"; +import { recordRecheck } from "../codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs"; + +async function temp(t) { + const dir = await mkdtemp(join(tmpdir(), "doctor-capture-")); + t.after(() => rm(dir, { recursive: true, force: true })); return dir; +} +const deps = { collectLogs: async () => ({ entries: [], gaps: [] }), fetchImpl: () => assert.fail("offline must not fetch") }; + +test("fresh offline capture writes and verifies a current report even without any registration or original error", async t => { + const cwd = await temp(t); + const receipt = await capture({ host: "claude", cwd }, { ...deps, nativeRegistration: () => assert.fail("offline must not invoke native get") }); + const report = JSON.parse(await readFile(receipt.report)); + assert.equal(receipt.savedReportVerified, true); + assert.equal(report.originalFailure.status, "unavailable"); + assert.equal(report.originalFailure.occurredAt, null); + assert.equal(receipt.lookupResult, "unavailable"); + assert.ok(receipt.findings.some(x => x.kind === "registration_lookup_unavailable")); + assert.ok(!receipt.findings.some(x => x.kind === "not_registered")); + assert.equal(report.upload.status, "skipped_offline"); + const second = await capture({ host: "claude", cwd }, deps); + assert.notEqual(receipt.report, second.report); + assert.notEqual(receipt.diagnosticId, second.diagnosticId); + assert.ok((await readFile(receipt.chineseNote, "utf8")).includes(receipt.diagnosticId)); +}); + +test("offline project projection excludes headers, environment, unrelated entries and active-session claims", async t => { + const cwd = await temp(t), url = "http://127.0.0.1:1234/api/external-mcp/mcp"; + await writeFile(join(cwd, ".mcp.json"), JSON.stringify({ mcpServers: { chatcut: { type: "http", url, + headers: { "x-private": "PRIVATE_HEADER" }, env: { PRIVATE: "PRIVATE_ENV" } }, other: { url: "PRIVATE_OTHER" } } })); + const receipt = await capture({ host: "claude", cwd }, deps); + const report = JSON.parse(await readFile(receipt.report)); + assert.equal(report.serverUrl, url); + assert.equal(report.hostSnapshot.configuration.enabled, null); + assert.equal(report.hostSnapshot.session, undefined); + const all = JSON.stringify({ report, receipt }) + await readFile(receipt.chineseNote, "utf8"); + for (const secret of ["PRIVATE_HEADER", "PRIVATE_ENV", "PRIVATE_OTHER"]) assert.ok(!all.includes(secret)); +}); + +test("native lookup requires opt-in and only exact absence can become not_found", async t => { + const absent = 'No MCP server named "chatcut". Run `claude mcp add` to add one.'; + for (const result of [ { code: 1, stdout: "unfamiliar failure" }, { code: null, errorCode: "EACCES" }, + { code: null, errorCode: "lookup_timeout" }, { code: 0, stdout: "{}" }, { code: 1, stdout: absent + "\nother failure" } ]) + assert.equal(projectNativeLookup("claude", "chatcut", result).lookupResult, "unavailable"); + const cwd = await temp(t); let calls = 0; + const receipt = await capture({ host: "claude", cwd, nativeLookup: true }, { ...deps, nativeRegistration: async () => { + calls++; return projectNativeLookup("claude", "chatcut", { code: 1, stdout: absent }); + } }); + assert.equal(calls, 1); + assert.equal(receipt.nativeLookupMayConnect, true); + assert.ok(receipt.findings.some(x => x.kind === "not_registered")); + const report = JSON.parse(await readFile(receipt.report)); + assert.equal(report.originalFailure.status, "unavailable"); + assert.equal(report.hostSnapshot.observations[0].message, absent); +}); + +test("saved visible errors are formatted by the program with known operation and unknown original IDs/time", async t => { + const cwd = await temp(t), originalError = join(cwd, "original.mcp-error.txt"); + await writeFile(originalError, "E_VISIBLE opaque failure; Authorization: Bearer SECRET\n"); + const receipt = await capture({ host: "claude", cwd, originalError, operation: "list_projects" }, deps); + const report = JSON.parse(await readFile(receipt.report)); + assert.equal(report.originalFailure.operation, "list_projects"); + assert.equal(report.originalFailure.occurredAt, null); + assert.equal(report.originalFailure.callId, undefined); + assert.ok(report.originalFailure.message.includes("E_VISIBLE")); + assert.ok(!JSON.stringify(report).includes("SECRET")); + assert.ok(receipt.findings.some(x => x.kind === "tool_call_failed")); +}); + +test("safe recheck record binds an immutable original report and never overwrites a prior observation", async t => { + const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps); + const original = await readFile(receipt.report), result = join(cwd, "read.mcp-recheck.txt"); + await writeFile(result, "Controlled unauthenticated service returned []"); + const options = { report: receipt.report, result, operation: "list_projects", outcome: "success", scope: "Same project and isolated profile; explicit new session loaded project .mcp.json" }; + const recheck = await recordRecheck(options); + assert.equal(recheck.sourceReportSha256, receipt.sourceReportSha256); + assert.equal(recheck.observedAt, null); + assert.equal(recheck.accountAuthentication, "unconfirmed"); + assert.deepEqual(await readFile(receipt.report), original); + await assert.rejects(recordRecheck(options), /EEXIST/); + assert.deepEqual(await readFile(receipt.report), original); +}); diff --git a/tests/standalone-doctor.test.mjs b/tests/standalone-doctor.test.mjs index c6b250d..2bd094f 100644 --- a/tests/standalone-doctor.test.mjs +++ b/tests/standalone-doctor.test.mjs @@ -226,7 +226,8 @@ test("SQLite and text runtime records exclude content echoes from permitted MCP test("both packages ship identical standalone executable resources and references", async () => { async function compare(relative = "") { for (const entry of await readdir(join(skill, relative), { withFileTypes: true })) { - if (entry.name === "agents") continue; + // Claude uses native dynamic capture syntax; executable resources stay identical. + if (["agents", "SKILL.md"].includes(entry.name)) continue; const path = join(relative, entry.name); if (entry.isDirectory()) await compare(path); else assert.deepEqual(await readFile(join(skill, path)), await readFile(new URL(`../claude/skills/chatcut-mcp-doctor/${path.replaceAll("\\", "/")}`, import.meta.url))); From 84eddd87cef0e2303a105fc217540c62e074be37 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 23:15:47 +0800 Subject: [PATCH 12/16] Include saved original errors in pre-render Doctor capture --- claude/skills/chatcut-mcp-doctor/SKILL.md | 2 +- .../chatcut-mcp-doctor/scripts/capture.mjs | 20 ++++++++++++--- codex/skills/chatcut-mcp-doctor/SKILL.md | 2 +- .../chatcut-mcp-doctor/scripts/capture.mjs | 20 ++++++++++++--- tests/doctor-capture.test.mjs | 25 +++++++++++++++++++ 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index 27995c6..31a56cd 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -20,7 +20,7 @@ If host policy disables the injected command, run that exact local command with The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. Read these outputs, then explain the observed boundary and remaining gaps in the user's language. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. -If the current invocation already has a visible MCP tool error, save only its exact exposed error text as `original.mcp-error.txt`, then run: +Before the model answers, capture automatically includes an existing project-root `original.mcp-failure.json` (preferred, preserving known metadata) or `original.mcp-error.txt`. These are reserved error-only inputs; it never scans old reports or conversations. If the error has not yet been saved, save only its exact exposed error text as `original.mcp-error.txt`, then run: ```text node "/scripts/capture.mjs" --host --original-error "original.mcp-error.txt" --operation --server-url diff --git a/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs b/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs index 098a3fc..8b8bb26 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs @@ -11,6 +11,17 @@ const execute = promisify(execFile); const save = (path, data) => writeFile(path, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 }); const now = () => new Date().toISOString(); +async function savedOriginal(cwd) { + // Two reserved error-only inputs, never a report, config, glob or transcript. + for (const [key, name] of [["failureRecord", "original.mcp-failure.json"], ["originalError", "original.mcp-error.txt"]]) { + try { + const path = join(cwd, name), info = await lstat(path); + if (info.isFile() && info.size <= 64 * 1024) return { [key]: path }; + } catch (error) { if (error.code !== "ENOENT") return {}; } + } + return {}; +} + async function dedicatedText(path) { if (!/\.mcp-error\.txt$/i.test(basename(path))) throw new Error("Use a dedicated .mcp-error.txt containing only the exposed original error"); const info = await lstat(path); @@ -73,6 +84,7 @@ export async function capture(options, dependencies = {}) { // Validate explicit endpoint before any optional native connection check. const selected = options.serverUrl ? serverUrl(options.serverUrl).href : null; const cwd = resolve(options.cwd ?? process.cwd()); + const originalInput = options.originalError || options.failureRecord ? options : await savedOriginal(cwd); const base = join(cwd, ".chatcut-doctor"); await mkdir(base, { recursive: true, mode: 0o700 }); const dir = await mkdtemp(join(base, "capture-")); @@ -92,11 +104,11 @@ export async function capture(options, dependencies = {}) { if (selected && lookup.url && selected !== lookup.url) snapshot.observations[0].message += " The explicitly selected endpoint differs; it was preserved without replacing the configured endpoint. No connection or authentication inference is made."; const snapshotPath = join(dir, "current.mcp-snapshot.json"); await save(snapshotPath, snapshot); - let failureRecord = options.failureRecord; - if (options.originalError) { + let failureRecord = originalInput.failureRecord; + if (originalInput.originalError) { failureRecord = join(dir, "original.mcp-failure.json"); await save(failureRecord, { schemaVersion: 1, source: "host_tool_result", serverName: name, - occurredAt: null, ...(options.operation && { operation: options.operation }), error: { message: await dedicatedText(options.originalError) } }); + occurredAt: null, ...(options.operation && { operation: options.operation }), error: { message: await dedicatedText(originalInput.originalError) } }); } const { path, report } = await runDoctor({ host: options.host, serverUrl: endpoint, hostSnapshot: snapshotPath, failureRecord, offline: true, output: join(dir, "diagnostic.mcp-report.json") }, dependencies); @@ -110,7 +122,7 @@ export async function capture(options, dependencies = {}) { sourceReportSha256: createHash("sha256").update(bytes).digest("hex"), savedReportVerified: true, nativeLookupRequested: Boolean(options.nativeLookup), diagnosticHttpRequests: 0, nativeLookupMayConnect: Boolean(options.nativeLookup), lookupResult: lookup.lookupResult, - originalInput: options.originalError ? resolve(options.originalError) : options.failureRecord ? resolve(options.failureRecord) : null }; + originalInput: originalInput.originalError ? resolve(originalInput.originalError) : originalInput.failureRecord ? resolve(originalInput.failureRecord) : null }; await save(join(dir, "capture-receipt.json"), receipt); return receipt; } diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index a24b853..8c07755 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -19,7 +19,7 @@ node "/scripts/capture.mjs" --host codex The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. Read these outputs, then explain the observed boundary and remaining gaps in the user's language. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. -If the current invocation already has a visible MCP tool error, save only its exact exposed error text as `original.mcp-error.txt`, then run: +Before the model answers, capture automatically includes an existing project-root `original.mcp-failure.json` (preferred, preserving known metadata) or `original.mcp-error.txt`. These are reserved error-only inputs; it never scans old reports or conversations. If the error has not yet been saved, save only its exact exposed error text as `original.mcp-error.txt`, then run: ```text node "/scripts/capture.mjs" --host --original-error "original.mcp-error.txt" --operation --server-url diff --git a/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs b/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs index 098a3fc..8b8bb26 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs @@ -11,6 +11,17 @@ const execute = promisify(execFile); const save = (path, data) => writeFile(path, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 }); const now = () => new Date().toISOString(); +async function savedOriginal(cwd) { + // Two reserved error-only inputs, never a report, config, glob or transcript. + for (const [key, name] of [["failureRecord", "original.mcp-failure.json"], ["originalError", "original.mcp-error.txt"]]) { + try { + const path = join(cwd, name), info = await lstat(path); + if (info.isFile() && info.size <= 64 * 1024) return { [key]: path }; + } catch (error) { if (error.code !== "ENOENT") return {}; } + } + return {}; +} + async function dedicatedText(path) { if (!/\.mcp-error\.txt$/i.test(basename(path))) throw new Error("Use a dedicated .mcp-error.txt containing only the exposed original error"); const info = await lstat(path); @@ -73,6 +84,7 @@ export async function capture(options, dependencies = {}) { // Validate explicit endpoint before any optional native connection check. const selected = options.serverUrl ? serverUrl(options.serverUrl).href : null; const cwd = resolve(options.cwd ?? process.cwd()); + const originalInput = options.originalError || options.failureRecord ? options : await savedOriginal(cwd); const base = join(cwd, ".chatcut-doctor"); await mkdir(base, { recursive: true, mode: 0o700 }); const dir = await mkdtemp(join(base, "capture-")); @@ -92,11 +104,11 @@ export async function capture(options, dependencies = {}) { if (selected && lookup.url && selected !== lookup.url) snapshot.observations[0].message += " The explicitly selected endpoint differs; it was preserved without replacing the configured endpoint. No connection or authentication inference is made."; const snapshotPath = join(dir, "current.mcp-snapshot.json"); await save(snapshotPath, snapshot); - let failureRecord = options.failureRecord; - if (options.originalError) { + let failureRecord = originalInput.failureRecord; + if (originalInput.originalError) { failureRecord = join(dir, "original.mcp-failure.json"); await save(failureRecord, { schemaVersion: 1, source: "host_tool_result", serverName: name, - occurredAt: null, ...(options.operation && { operation: options.operation }), error: { message: await dedicatedText(options.originalError) } }); + occurredAt: null, ...(options.operation && { operation: options.operation }), error: { message: await dedicatedText(originalInput.originalError) } }); } const { path, report } = await runDoctor({ host: options.host, serverUrl: endpoint, hostSnapshot: snapshotPath, failureRecord, offline: true, output: join(dir, "diagnostic.mcp-report.json") }, dependencies); @@ -110,7 +122,7 @@ export async function capture(options, dependencies = {}) { sourceReportSha256: createHash("sha256").update(bytes).digest("hex"), savedReportVerified: true, nativeLookupRequested: Boolean(options.nativeLookup), diagnosticHttpRequests: 0, nativeLookupMayConnect: Boolean(options.nativeLookup), lookupResult: lookup.lookupResult, - originalInput: options.originalError ? resolve(options.originalError) : options.failureRecord ? resolve(options.failureRecord) : null }; + originalInput: originalInput.originalError ? resolve(originalInput.originalError) : originalInput.failureRecord ? resolve(originalInput.failureRecord) : null }; await save(join(dir, "capture-receipt.json"), receipt); return receipt; } diff --git a/tests/doctor-capture.test.mjs b/tests/doctor-capture.test.mjs index a552a65..a526d3f 100644 --- a/tests/doctor-capture.test.mjs +++ b/tests/doctor-capture.test.mjs @@ -72,6 +72,31 @@ test("saved visible errors are formatted by the program with known operation and assert.ok(receipt.findings.some(x => x.kind === "tool_call_failed")); }); +test("native pre-render capture automatically includes an existing reserved original error record", async t => { + const cwd = await temp(t), path = join(cwd, "original.mcp-failure.json"); + const original = JSON.stringify({ schemaVersion: 1, source: "host_tool_result", serverName: "plugin:chatcut:chatcut", + occurredAt: null, operation: "list_projects", error: { message: "E_SAVED_VISIBLE", cause: { code: "EXPOSED_CAUSE" } } }); + await writeFile(path, original); + const receipt = await capture({ host: "claude", cwd }, deps); + const report = JSON.parse(await readFile(receipt.report)); + assert.equal(receipt.originalInput, path); + assert.equal(report.originalFailure.message, "E_SAVED_VISIBLE"); + assert.equal(report.originalFailure.operation, "list_projects"); + assert.equal(report.originalFailure.error.cause.code, "EXPOSED_CAUSE"); + assert.equal(report.originalFailure.occurredAt, null); + assert.equal(await readFile(path, "utf8"), original); +}); + +test("native pre-render capture includes the reserved saved plain error without guessing an operation", async t => { + const cwd = await temp(t), path = join(cwd, "original.mcp-error.txt"); + await writeFile(path, "E_SAVED_TEXT"); + const receipt = await capture({ host: "claude", cwd }, deps); + const report = JSON.parse(await readFile(receipt.report)); + assert.equal(report.originalFailure.message, "E_SAVED_TEXT"); + assert.equal(report.originalFailure.operation, undefined); + assert.equal(receipt.originalInput, path); +}); + test("safe recheck record binds an immutable original report and never overwrites a prior observation", async t => { const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps); const original = await readFile(receipt.report), result = join(cwd, "read.mcp-recheck.txt"); From ae733c8f7e7a867cc30731da3dcaa073996233c8 Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 23:24:11 +0800 Subject: [PATCH 13/16] Bind Doctor rechecks to the incident report and generated conclusion --- claude/skills/chatcut-mcp-doctor/SKILL.md | 8 ++--- .../scripts/active-case.mjs | 36 +++++++++++++++++++ .../chatcut-mcp-doctor/scripts/capture.mjs | 17 +++++++-- .../scripts/record-recheck.mjs | 9 +++-- codex/skills/chatcut-mcp-doctor/SKILL.md | 8 ++--- .../scripts/active-case.mjs | 36 +++++++++++++++++++ .../chatcut-mcp-doctor/scripts/capture.mjs | 17 +++++++-- .../scripts/record-recheck.mjs | 9 +++-- docs/mcp-doctor.md | 5 +++ tests/doctor-capture.test.mjs | 12 +++++++ 10 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 claude/skills/chatcut-mcp-doctor/scripts/active-case.mjs create mode 100644 codex/skills/chatcut-mcp-doctor/scripts/active-case.mjs diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index 31a56cd..10e8142 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -18,7 +18,7 @@ The native host runs this bounded local capture before rendering the Skill. It s If host policy disables the injected command, run that exact local command with already permitted tools. If denied, retain the denial and report collection incomplete; do not bypass host policy. -The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. Read these outputs, then explain the observed boundary and remaining gaps in the user's language. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. +The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. For Chinese requests, return the program's `userFacingNote` as the diagnosis; it is also saved in the Chinese note file. Add only separately observed repair/recheck/delivery facts. Do not compose a second diagnosis, label error text as an exposed code, exclude possible causes from static config, or invent root-cause hypotheses. For other languages, translate that note without adding inferences. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. Before the model answers, capture automatically includes an existing project-root `original.mcp-failure.json` (preferred, preserving known metadata) or `original.mcp-error.txt`. These are reserved error-only inputs; it never scans old reports or conversations. If the error has not yet been saved, save only its exact exposed error text as `original.mcp-error.txt`, then run: @@ -32,7 +32,7 @@ Omit unknown operation/URL. This program formats the dedicated failure record; n Keep configuration, original failure, current session and later checks distinct. Do not infer account/permission/service health from prior success, static config or an empty controlled project list. Findings are observed boundaries, not root causes. -For current native registration evidence, `capture.mjs --host --native-lookup [--cli ]` runs one bounded native `mcp get` and formats its snapshot/report. Use it only when the user permits a connection check: Claude's command can health-check approved servers. It is **not offline**. It projects allowed fields without retaining headers/full CLI output. Timeout, denied/unavailable CLI and unrecognized output remain unavailable; only the exact completed no-entry response supports not_registered. Select `--server-name plugin:chatcut:chatcut` only when that is the actual selected server; never recreate a plugin server as a manual entry. +For current native registration evidence, `capture.mjs --host --native-lookup [--cli ]` runs one bounded native `mcp get` and formats its snapshot/report. Use it only when the user permits a connection check: Claude's command can health-check approved servers. It is **not offline**. Keep an actually selected endpoint with `--server-url `; never silently substitute production. It projects allowed fields without retaining headers/full CLI output. Timeout, denied/unavailable CLI and unrecognized output remain unavailable; only the exact completed no-entry response supports not_registered. Select `--server-name plugin:chatcut:chatcut` only when that is the actual selected server; never recreate a plugin server as a manual entry. Read [repair and recheck](references/repair-and-recheck.md) before any change. Repair only a demonstrated missing manual registration in the authorized scope; retain prior state, actual command and rollback. No automatic credential/network repair. Local execution denial is evidence, not authorization to change permissions. @@ -41,10 +41,10 @@ Read [repair and recheck](references/repair-and-recheck.md) before any change. R After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save its exposed result separately as `read.mcp-recheck.txt`, then record it without changing the original report: ```text -node "/scripts/record-recheck.mjs" --report "" --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" +node "/scripts/record-recheck.mjs" --active-case --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" ``` -The recording is explicitly Agent supplied and does not independently prove account authentication or original recovery. Finish a usable Chinese explanation for Chinese requests: actual symptom/error, evidence, action, recheck result, diagnostic ID/files and delivery status. Generated Chinese notes have the current facts; add actual repair details in a separate note. Do not ask the user to fill JSON or leave core placeholders. +The program's bounded `active-case.json` selects the original incident report and verifies its SHA; a later entry baseline does not replace a recent same-endpoint case. Use `--active-case`, not the newest report path. A new entry can produce another baseline; it is a separate observation, not the original incident. The recording is explicitly Agent supplied and does not independently prove account authentication or original recovery. Finish a usable Chinese explanation for Chinese requests: actual symptom/error, evidence, action, recheck result, diagnostic ID/files and delivery status. Generated Chinese notes have the current facts; add actual repair details in a separate note. Do not ask the user to fill JSON or leave core placeholders. Report transfer requires authorization. `node "/scripts/doctor.mjs" --upload-report ""` sends the unchanged report once and saves a SHA-bound receipt. Do not recollect to resend, change its endpoint or repeat acknowledged delivery. Ingest acceptance is not final storage or a ticket. No publishing or messaging without authorization. diff --git a/claude/skills/chatcut-mcp-doctor/scripts/active-case.mjs b/claude/skills/chatcut-mcp-doctor/scripts/active-case.mjs new file mode 100644 index 0000000..e226dcf --- /dev/null +++ b/claude/skills/chatcut-mcp-doctor/scripts/active-case.mjs @@ -0,0 +1,36 @@ +import { lstat, readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { join, resolve, sep } from "node:path"; +import { readReport } from "./doctor.mjs"; + +export async function readActiveCase(cwd, host) { + const base = resolve(cwd, ".chatcut-doctor"), path = join(base, "active-case.json"); + const info = await lstat(path); + if (!info.isFile() || info.size > 8192) throw new Error("Active case must be a bounded program-generated index"); + const value = JSON.parse(await readFile(path, "utf8")); + const target = resolve(value.report ?? ""); + if (value.schemaVersion !== 1 || !target.startsWith(base + sep) || + !["codex", "claude"].includes(value.host) || (host && value.host !== host)) + throw new Error("Active case does not identify this workspace/host"); + const { report, sourceReportSha256 } = await readReport(target); + if (report.host !== value.host || report.diagnosticId !== value.diagnosticId || sourceReportSha256 !== value.sourceReportSha256) + throw new Error("Active case report identity/hash mismatch; preserve files for review"); + return { ...value, report: target }; +} + +export async function selectActiveCase(cwd, report, path, sourceReportSha256, explicitEvidence) { + let prior; + try { prior = await readActiveCase(cwd, report.host); } + catch (error) { if (error.code !== "ENOENT") throw error; } + // A follow-up entry's baseline must not replace the incident being rechecked. + // Explicit original evidence or a fresh native missing-entry finding starts + // a case; otherwise keep a recent same-endpoint case, without claiming recovery. + const pending = prior && Date.now() - Date.parse(prior.recordedAt) < 30 * 60 * 1000 && prior.serverUrl === report.serverUrl; + const evidenceFingerprint = report.originalFailure.status === "recorded" + ? createHash("sha256").update(JSON.stringify(report.originalFailure)).digest("hex") : null; + if (pending && (!explicitEvidence || (evidenceFingerprint && evidenceFingerprint === prior.evidenceFingerprint))) return prior; + const selected = { schemaVersion: 1, host: report.host, report: resolve(path), diagnosticId: report.diagnosticId, + sourceReportSha256, serverUrl: report.serverUrl, evidenceFingerprint, recordedAt: new Date().toISOString() }; + await writeFile(join(cwd, ".chatcut-doctor", "active-case.json"), JSON.stringify(selected, null, 2) + "\n", { mode: 0o600 }); + return selected; +} diff --git a/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs b/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs index 8b8bb26..04b632a 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { parseArgs, promisify } from "node:util"; import { redact, runDoctor, serverUrl } from "./mcp-doctor.mjs"; import { writePacket } from "./doctor.mjs"; +import { selectActiveCase } from "./active-case.mjs"; const execute = promisify(execFile); const save = (path, data) => writeFile(path, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 }); @@ -114,14 +115,26 @@ export async function capture(options, dependencies = {}) { hostSnapshot: snapshotPath, failureRecord, offline: true, output: join(dir, "diagnostic.mcp-report.json") }, dependencies); const packet = await writePacket(path, report); const zhPath = path + ".zh-CN.md"; - const note = `# ChatCut MCP 本轮诊断\n\n诊断编号:${report.diagnosticId}\n采集时间:${report.collectedAt}(不是原始错误时间)\n宿主:${report.host}\n\n本轮事实:${lookup.message}\n\n原始错误:${report.originalFailure.status === "recorded" ? report.originalFailure.message : "未提供可见的原始 MCP 错误;未编造。"}\n原始时间:${report.originalFailure.occurredAt ?? "未知"};调用 ID:${report.originalFailure.callId ?? "未知"}。\n\n程序已保存本轮 snapshot、schema-2 正式报告及诊断 sidecar。结论:${packet.findings.map(x => x.kind).join("、")}。根因尚未确认。\n实际处理:${options.nativeLookup ? "已运行一次用户允许的原生注册查询;该命令可能附带健康连接检查。" : "仅投影当前工程允许的配置字段并离线采集;没有调用原生 CLI 或发送诊断请求。"} 未修改注册、登录或权限。\n复验:尚未执行安全的原生工具读取;配置存在不代表当前连接或账号健康。后续复验单独记录,原报告不变。\n发送:未上报、未开工单,最终存储未验证。\n缺口:${report.evidenceSummary?.gaps?.join(";") || "详见正式报告"}。\n\n正式报告:${path}\n`; + const labels = { tool_call_failed: "已保留工具调用失败", startup_or_discovery_failed: "已保留启动或工具发现失败", + registration_lookup_unavailable: "注册状态未确认", not_registered: "原生查询明确报告所选名称未注册", + no_incident_demonstrated: "本轮证据不足以确认原始故障", original_failure_recorded_stage_unconfirmed: "已保留原错,阶段未确认" }; + const registration = lookup.lookupResult === "found" + ? `${options.nativeLookup ? "原生查询返回" : "当前工程静态配置包含"}所选注册;这不能确认当前会话连接、认证或原故障恢复。` + : lookup.lookupResult === "not_found" ? `原生查询明确返回:${lookup.message}` + : "所选注册状态未确认。离线工程配置缺失、查询不可用或无法识别的输出都不能证明宿主未注册;本地、用户和插件作用域及当前会话仍有未覆盖范围。"; + const original = report.originalFailure; + const note = `# ChatCut MCP 本轮诊断\n\n诊断编号:${report.diagnosticId}\n采集时间:${report.collectedAt}(不是原始错误时间)\n宿主:${report.host}\n所选地址:${report.serverUrl ?? "未确认"}\n\n注册观察:${registration}\n\n原始操作:${original.operation ?? "未知"}\n原始错误内容:${original.status === "recorded" ? original.message : "没有可用的原始 MCP 错误;没有编造。"}\n宿主暴露的错误码:${original.error?.code ?? "未提供"}\n原始时间:${original.occurredAt ?? "未知"};调用 ID:${original.callId ?? "未知"}。\n\n结论:${packet.findings.map(x => labels[x.kind] ?? x.kind).join(";")}。根因尚未确认,现有证据不能排除或归因于注册、凭证、网络或服务端。\n实际处理:${options.nativeLookup ? "已运行一次用户允许的原生注册查询;该命令可能附带健康连接检查,不能称为全程离线。" : "仅投影当前工程允许的配置字段并离线采集;没有调用原生 CLI 或发送诊断请求。"} 程序未修改注册、登录或权限。\n复验:尚未执行安全的原生工具读取;后续复验单独记录,原报告不变。\n发送:未上报、未开工单,最终存储未验证。\n缺口:${report.evidenceSummary?.gaps?.join(";") || "详见正式报告"}。\n下一步:${lookup.lookupResult === "not_found" ? "在用户授权的宿主/范围内按修复指南检查缺失注册并保留回滚,再做一次安全原生读取。" : "如用户授权,按实际宿主/范围做一次安全原生读取,单独保留结果;不重复制造失败或猜测根因。"}\n\n正式报告:${path}\n`; await writeFile(zhPath, note, { flag: "wx", mode: 0o600 }); const bytes = await readFile(path), saved = JSON.parse(bytes); if (saved.diagnosticId !== report.diagnosticId || saved.schemaVersion !== 2) throw new Error("Saved report identity check failed"); + const sourceReportSha256 = createHash("sha256").update(bytes).digest("hex"); + const activeReport = await selectActiveCase(cwd, report, path, sourceReportSha256, + report.originalFailure.status === "recorded" || (options.nativeLookup && lookup.lookupResult === "not_found")); const receipt = { schemaVersion: 1, startedAt, completedAt: now(), ...packet, snapshot: snapshotPath, chineseNote: zhPath, - sourceReportSha256: createHash("sha256").update(bytes).digest("hex"), savedReportVerified: true, + sourceReportSha256, savedReportVerified: true, activeReport, nativeLookupRequested: Boolean(options.nativeLookup), diagnosticHttpRequests: 0, nativeLookupMayConnect: Boolean(options.nativeLookup), lookupResult: lookup.lookupResult, + userFacingNote: note, originalInput: originalInput.originalError ? resolve(originalInput.originalError) : originalInput.failureRecord ? resolve(originalInput.failureRecord) : null }; await save(join(dir, "capture-receipt.json"), receipt); return receipt; diff --git a/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs index 1a2f9d3..dee133f 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -5,9 +5,12 @@ import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { readReport } from "./doctor.mjs"; import { redact } from "./mcp-doctor.mjs"; +import { readActiveCase } from "./active-case.mjs"; export async function recordRecheck(options) { - const path = resolve(options.report); + if (Boolean(options.report) === Boolean(options.activeCase)) throw new Error("Choose --active-case for the program-tracked incident, or an explicit --report"); + const active = options.activeCase ? await readActiveCase(options.cwd ?? process.cwd()) : null; + const path = active ? active.report : resolve(options.report); const { report, sourceReportSha256 } = await readReport(path); if (!["success", "failed"].includes(options.outcome) || options.operation !== "list_projects" || typeof options.scope !== "string" || !options.scope.trim() || options.scope.length > 1000) @@ -31,9 +34,9 @@ export async function recordRecheck(options) { } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const { values } = parseArgs({ options: { report: { type: "string" }, result: { type: "string" }, + const { values } = parseArgs({ options: { report: { type: "string" }, "active-case": { type: "boolean", default: false }, result: { type: "string" }, outcome: { type: "string" }, operation: { type: "string" }, scope: { type: "string" } } }); - recordRecheck(values).then(x => console.log(JSON.stringify(x))).catch(error => { + recordRecheck({ ...values, activeCase: values["active-case"] }).then(x => console.log(JSON.stringify(x))).catch(error => { console.error(JSON.stringify({ recheckRecordingFailed: true, message: redact(error.message) })); process.exitCode = 1; }); } diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index 8c07755..c142bb7 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -17,7 +17,7 @@ Run this single command first; resolve the path from this Skill directory: node "/scripts/capture.mjs" --host codex ``` -The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. Read these outputs, then explain the observed boundary and remaining gaps in the user's language. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. +The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. For Chinese requests, return the program's `userFacingNote` as the diagnosis; it is also saved in the Chinese note file. Add only separately observed repair/recheck/delivery facts. Do not compose a second diagnosis, label error text as an exposed code, exclude possible causes from static config, or invent root-cause hypotheses. For other languages, translate that note without adding inferences. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. Before the model answers, capture automatically includes an existing project-root `original.mcp-failure.json` (preferred, preserving known metadata) or `original.mcp-error.txt`. These are reserved error-only inputs; it never scans old reports or conversations. If the error has not yet been saved, save only its exact exposed error text as `original.mcp-error.txt`, then run: @@ -31,7 +31,7 @@ Omit unknown operation/URL. This program formats the dedicated failure record; n Keep configuration, original failure, current session and later checks distinct. Do not infer account/permission/service health from prior success, static config or an empty controlled project list. Findings are observed boundaries, not root causes. -For current native registration evidence, `capture.mjs --host --native-lookup [--cli ]` runs one bounded native `mcp get` and formats its snapshot/report. Use it only when the user permits a connection check: Claude's command can health-check approved servers. It is **not offline**. It projects allowed fields without retaining headers/full CLI output. Timeout, denied/unavailable CLI and unrecognized output remain unavailable; only the exact completed no-entry response supports not_registered. Select `--server-name plugin:chatcut:chatcut` only when that is the actual selected server; never recreate a plugin server as a manual entry. +For current native registration evidence, `capture.mjs --host --native-lookup [--cli ]` runs one bounded native `mcp get` and formats its snapshot/report. Use it only when the user permits a connection check: Claude's command can health-check approved servers. It is **not offline**. Keep an actually selected endpoint with `--server-url `; never silently substitute production. It projects allowed fields without retaining headers/full CLI output. Timeout, denied/unavailable CLI and unrecognized output remain unavailable; only the exact completed no-entry response supports not_registered. Select `--server-name plugin:chatcut:chatcut` only when that is the actual selected server; never recreate a plugin server as a manual entry. Read [repair and recheck](references/repair-and-recheck.md) before any change. Repair only a demonstrated missing manual registration in the authorized scope; retain prior state, actual command and rollback. No automatic credential/network repair. Local execution denial is evidence, not authorization to change permissions. @@ -40,10 +40,10 @@ Read [repair and recheck](references/repair-and-recheck.md) before any change. R After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save its exposed result separately as `read.mcp-recheck.txt`, then record it without changing the original report: ```text -node "/scripts/record-recheck.mjs" --report "" --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" +node "/scripts/record-recheck.mjs" --active-case --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" ``` -The recording is explicitly Agent supplied and does not independently prove account authentication or original recovery. Finish a usable Chinese explanation for Chinese requests: actual symptom/error, evidence, action, recheck result, diagnostic ID/files and delivery status. Generated Chinese notes have the current facts; add actual repair details in a separate note. Do not ask the user to fill JSON or leave core placeholders. +The program's bounded `active-case.json` selects the original incident report and verifies its SHA; a later entry baseline does not replace a recent same-endpoint case. Use `--active-case`, not the newest report path. A new entry can produce another baseline; it is a separate observation, not the original incident. The recording is explicitly Agent supplied and does not independently prove account authentication or original recovery. Finish a usable Chinese explanation for Chinese requests: actual symptom/error, evidence, action, recheck result, diagnostic ID/files and delivery status. Generated Chinese notes have the current facts; add actual repair details in a separate note. Do not ask the user to fill JSON or leave core placeholders. Report transfer requires authorization. `node "/scripts/doctor.mjs" --upload-report ""` sends the unchanged report once and saves a SHA-bound receipt. Do not recollect to resend, change its endpoint or repeat acknowledged delivery. Ingest acceptance is not final storage or a ticket. No publishing or messaging without authorization. diff --git a/codex/skills/chatcut-mcp-doctor/scripts/active-case.mjs b/codex/skills/chatcut-mcp-doctor/scripts/active-case.mjs new file mode 100644 index 0000000..e226dcf --- /dev/null +++ b/codex/skills/chatcut-mcp-doctor/scripts/active-case.mjs @@ -0,0 +1,36 @@ +import { lstat, readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { join, resolve, sep } from "node:path"; +import { readReport } from "./doctor.mjs"; + +export async function readActiveCase(cwd, host) { + const base = resolve(cwd, ".chatcut-doctor"), path = join(base, "active-case.json"); + const info = await lstat(path); + if (!info.isFile() || info.size > 8192) throw new Error("Active case must be a bounded program-generated index"); + const value = JSON.parse(await readFile(path, "utf8")); + const target = resolve(value.report ?? ""); + if (value.schemaVersion !== 1 || !target.startsWith(base + sep) || + !["codex", "claude"].includes(value.host) || (host && value.host !== host)) + throw new Error("Active case does not identify this workspace/host"); + const { report, sourceReportSha256 } = await readReport(target); + if (report.host !== value.host || report.diagnosticId !== value.diagnosticId || sourceReportSha256 !== value.sourceReportSha256) + throw new Error("Active case report identity/hash mismatch; preserve files for review"); + return { ...value, report: target }; +} + +export async function selectActiveCase(cwd, report, path, sourceReportSha256, explicitEvidence) { + let prior; + try { prior = await readActiveCase(cwd, report.host); } + catch (error) { if (error.code !== "ENOENT") throw error; } + // A follow-up entry's baseline must not replace the incident being rechecked. + // Explicit original evidence or a fresh native missing-entry finding starts + // a case; otherwise keep a recent same-endpoint case, without claiming recovery. + const pending = prior && Date.now() - Date.parse(prior.recordedAt) < 30 * 60 * 1000 && prior.serverUrl === report.serverUrl; + const evidenceFingerprint = report.originalFailure.status === "recorded" + ? createHash("sha256").update(JSON.stringify(report.originalFailure)).digest("hex") : null; + if (pending && (!explicitEvidence || (evidenceFingerprint && evidenceFingerprint === prior.evidenceFingerprint))) return prior; + const selected = { schemaVersion: 1, host: report.host, report: resolve(path), diagnosticId: report.diagnosticId, + sourceReportSha256, serverUrl: report.serverUrl, evidenceFingerprint, recordedAt: new Date().toISOString() }; + await writeFile(join(cwd, ".chatcut-doctor", "active-case.json"), JSON.stringify(selected, null, 2) + "\n", { mode: 0o600 }); + return selected; +} diff --git a/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs b/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs index 8b8bb26..04b632a 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { parseArgs, promisify } from "node:util"; import { redact, runDoctor, serverUrl } from "./mcp-doctor.mjs"; import { writePacket } from "./doctor.mjs"; +import { selectActiveCase } from "./active-case.mjs"; const execute = promisify(execFile); const save = (path, data) => writeFile(path, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 }); @@ -114,14 +115,26 @@ export async function capture(options, dependencies = {}) { hostSnapshot: snapshotPath, failureRecord, offline: true, output: join(dir, "diagnostic.mcp-report.json") }, dependencies); const packet = await writePacket(path, report); const zhPath = path + ".zh-CN.md"; - const note = `# ChatCut MCP 本轮诊断\n\n诊断编号:${report.diagnosticId}\n采集时间:${report.collectedAt}(不是原始错误时间)\n宿主:${report.host}\n\n本轮事实:${lookup.message}\n\n原始错误:${report.originalFailure.status === "recorded" ? report.originalFailure.message : "未提供可见的原始 MCP 错误;未编造。"}\n原始时间:${report.originalFailure.occurredAt ?? "未知"};调用 ID:${report.originalFailure.callId ?? "未知"}。\n\n程序已保存本轮 snapshot、schema-2 正式报告及诊断 sidecar。结论:${packet.findings.map(x => x.kind).join("、")}。根因尚未确认。\n实际处理:${options.nativeLookup ? "已运行一次用户允许的原生注册查询;该命令可能附带健康连接检查。" : "仅投影当前工程允许的配置字段并离线采集;没有调用原生 CLI 或发送诊断请求。"} 未修改注册、登录或权限。\n复验:尚未执行安全的原生工具读取;配置存在不代表当前连接或账号健康。后续复验单独记录,原报告不变。\n发送:未上报、未开工单,最终存储未验证。\n缺口:${report.evidenceSummary?.gaps?.join(";") || "详见正式报告"}。\n\n正式报告:${path}\n`; + const labels = { tool_call_failed: "已保留工具调用失败", startup_or_discovery_failed: "已保留启动或工具发现失败", + registration_lookup_unavailable: "注册状态未确认", not_registered: "原生查询明确报告所选名称未注册", + no_incident_demonstrated: "本轮证据不足以确认原始故障", original_failure_recorded_stage_unconfirmed: "已保留原错,阶段未确认" }; + const registration = lookup.lookupResult === "found" + ? `${options.nativeLookup ? "原生查询返回" : "当前工程静态配置包含"}所选注册;这不能确认当前会话连接、认证或原故障恢复。` + : lookup.lookupResult === "not_found" ? `原生查询明确返回:${lookup.message}` + : "所选注册状态未确认。离线工程配置缺失、查询不可用或无法识别的输出都不能证明宿主未注册;本地、用户和插件作用域及当前会话仍有未覆盖范围。"; + const original = report.originalFailure; + const note = `# ChatCut MCP 本轮诊断\n\n诊断编号:${report.diagnosticId}\n采集时间:${report.collectedAt}(不是原始错误时间)\n宿主:${report.host}\n所选地址:${report.serverUrl ?? "未确认"}\n\n注册观察:${registration}\n\n原始操作:${original.operation ?? "未知"}\n原始错误内容:${original.status === "recorded" ? original.message : "没有可用的原始 MCP 错误;没有编造。"}\n宿主暴露的错误码:${original.error?.code ?? "未提供"}\n原始时间:${original.occurredAt ?? "未知"};调用 ID:${original.callId ?? "未知"}。\n\n结论:${packet.findings.map(x => labels[x.kind] ?? x.kind).join(";")}。根因尚未确认,现有证据不能排除或归因于注册、凭证、网络或服务端。\n实际处理:${options.nativeLookup ? "已运行一次用户允许的原生注册查询;该命令可能附带健康连接检查,不能称为全程离线。" : "仅投影当前工程允许的配置字段并离线采集;没有调用原生 CLI 或发送诊断请求。"} 程序未修改注册、登录或权限。\n复验:尚未执行安全的原生工具读取;后续复验单独记录,原报告不变。\n发送:未上报、未开工单,最终存储未验证。\n缺口:${report.evidenceSummary?.gaps?.join(";") || "详见正式报告"}。\n下一步:${lookup.lookupResult === "not_found" ? "在用户授权的宿主/范围内按修复指南检查缺失注册并保留回滚,再做一次安全原生读取。" : "如用户授权,按实际宿主/范围做一次安全原生读取,单独保留结果;不重复制造失败或猜测根因。"}\n\n正式报告:${path}\n`; await writeFile(zhPath, note, { flag: "wx", mode: 0o600 }); const bytes = await readFile(path), saved = JSON.parse(bytes); if (saved.diagnosticId !== report.diagnosticId || saved.schemaVersion !== 2) throw new Error("Saved report identity check failed"); + const sourceReportSha256 = createHash("sha256").update(bytes).digest("hex"); + const activeReport = await selectActiveCase(cwd, report, path, sourceReportSha256, + report.originalFailure.status === "recorded" || (options.nativeLookup && lookup.lookupResult === "not_found")); const receipt = { schemaVersion: 1, startedAt, completedAt: now(), ...packet, snapshot: snapshotPath, chineseNote: zhPath, - sourceReportSha256: createHash("sha256").update(bytes).digest("hex"), savedReportVerified: true, + sourceReportSha256, savedReportVerified: true, activeReport, nativeLookupRequested: Boolean(options.nativeLookup), diagnosticHttpRequests: 0, nativeLookupMayConnect: Boolean(options.nativeLookup), lookupResult: lookup.lookupResult, + userFacingNote: note, originalInput: originalInput.originalError ? resolve(originalInput.originalError) : originalInput.failureRecord ? resolve(originalInput.failureRecord) : null }; await save(join(dir, "capture-receipt.json"), receipt); return receipt; diff --git a/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs index 1a2f9d3..dee133f 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -5,9 +5,12 @@ import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { readReport } from "./doctor.mjs"; import { redact } from "./mcp-doctor.mjs"; +import { readActiveCase } from "./active-case.mjs"; export async function recordRecheck(options) { - const path = resolve(options.report); + if (Boolean(options.report) === Boolean(options.activeCase)) throw new Error("Choose --active-case for the program-tracked incident, or an explicit --report"); + const active = options.activeCase ? await readActiveCase(options.cwd ?? process.cwd()) : null; + const path = active ? active.report : resolve(options.report); const { report, sourceReportSha256 } = await readReport(path); if (!["success", "failed"].includes(options.outcome) || options.operation !== "list_projects" || typeof options.scope !== "string" || !options.scope.trim() || options.scope.length > 1000) @@ -31,9 +34,9 @@ export async function recordRecheck(options) { } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const { values } = parseArgs({ options: { report: { type: "string" }, result: { type: "string" }, + const { values } = parseArgs({ options: { report: { type: "string" }, "active-case": { type: "boolean", default: false }, result: { type: "string" }, outcome: { type: "string" }, operation: { type: "string" }, scope: { type: "string" } } }); - recordRecheck(values).then(x => console.log(JSON.stringify(x))).catch(error => { + recordRecheck({ ...values, activeCase: values["active-case"] }).then(x => console.log(JSON.stringify(x))).catch(error => { console.error(JSON.stringify({ recheckRecordingFailed: true, message: redact(error.message) })); process.exitCode = 1; }); } diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index d0d6715..6a3cc1d 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -30,6 +30,11 @@ response supports `not_registered`; denied/timed-out/unrecognized lookups remain unknown. Saved `.mcp-error.txt` inputs can be formatted without Agent-authored schemas. `record-recheck.mjs` binds a separately supplied native safe-read result to the immutable report hash; it does not independently verify the Agent's claim. +The program-generated `active-case.json` binds follow-up safe-read recording to +the original incident path/hash. A later Skill entry may create a fresh baseline +but cannot replace a recent same-endpoint case; the same saved original is not +treated as a new event. Chinese user-facing diagnoses come from the deterministic +note; the Agent adds only separately observed actions, rechecks and delivery. The basics scripts are compatibility forwarders; each host package contains one collector implementation under Doctor. The new `scripts/doctor.mjs` diff --git a/tests/doctor-capture.test.mjs b/tests/doctor-capture.test.mjs index a526d3f..006cd5a 100644 --- a/tests/doctor-capture.test.mjs +++ b/tests/doctor-capture.test.mjs @@ -110,3 +110,15 @@ test("safe recheck record binds an immutable original report and never overwrite await assert.rejects(recordRecheck(options), /EEXIST/); assert.deepEqual(await readFile(receipt.report), original); }); + +test("follow-up entry baseline cannot replace the active incident report for recheck", async t => { + const cwd = await temp(t), result = join(cwd, "read.mcp-recheck.txt"); + const first = await capture({ host: "claude", cwd, nativeLookup: true }, { ...deps, nativeRegistration: async () => ({ lookupResult: "not_found", message: "Exact native scoped no-entry response" }) }); + const baseline = await capture({ host: "claude", cwd }, deps); + assert.notEqual(baseline.diagnosticId, first.diagnosticId); + assert.equal(baseline.activeReport.diagnosticId, first.diagnosticId); + await writeFile(result, "actual safe native read returned []"); + const recheck = await recordRecheck({ activeCase: true, cwd, result, operation: "list_projects", outcome: "success", scope: "same project; explicit new session" }); + assert.equal(recheck.diagnosticId, first.diagnosticId); + assert.equal(recheck.sourceReportSha256, first.sourceReportSha256); +}); From 87a3d48e7e409b674c0f6bd1e2870f70e22b2d4f Mon Sep 17 00:00:00 2001 From: IanYet Date: Sun, 25 Oct 2026 23:30:31 +0800 Subject: [PATCH 14/16] Reject registration output as a successful MCP recheck --- claude/skills/chatcut-mcp-doctor/SKILL.md | 2 +- .../chatcut-mcp-doctor/scripts/record-recheck.mjs | 7 +++++++ codex/skills/chatcut-mcp-doctor/SKILL.md | 2 +- .../chatcut-mcp-doctor/scripts/record-recheck.mjs | 7 +++++++ tests/doctor-capture.test.mjs | 15 +++++++++++++-- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index 10e8142..b99639b 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -38,7 +38,7 @@ Read [repair and recheck](references/repair-and-recheck.md) before any change. R ## Recheck and deliver -After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save its exposed result separately as `read.mcp-recheck.txt`, then record it without changing the original report: +After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save the actually exposed projects[] JSON (or structuredContent.projects[]) separately as `read.mcp-recheck.txt`. A CLI mcp get/configuration response is never a tool recheck, and cannot be formatted into one. If the native project result is not exposed, retain that gap instead of claiming success. Then then record it without changing the original report: ```text node "/scripts/record-recheck.mjs" --active-case --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" diff --git a/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs index dee133f..c763b60 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -20,6 +20,13 @@ export async function recordRecheck(options) { if (!info.isFile() || info.size > 64 * 1024) throw new Error("Recheck result must be a regular file of at most 64 KiB"); const result = redact(await readFile(options.result, "utf8")); if (!result.trim()) throw new Error("Recheck result is empty"); + if (options.outcome === "success") { + let data; + try { data = JSON.parse(result); } catch { /* CLI configuration text is not a tool result. */ } + if (!data || data.isError === true || data.error || + !Array.isArray(data.projects ?? data.structuredContent?.projects)) + throw new Error("Successful list_projects recording needs the actually exposed projects[] JSON (or structuredContent.projects[]). Native mcp get/configuration text is not a tool result. Preserve it separately and wait for the actual safe native read; do not manufacture JSON or replay a failed tool."); + } const observation = { schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, recordedAt: new Date().toISOString(), observedAt: null, source: "agent_supplied_native_tool_result; not_independently_verified", host: report.host, diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index c142bb7..2680f3d 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -37,7 +37,7 @@ Read [repair and recheck](references/repair-and-recheck.md) before any change. R ## Recheck and deliver -After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save its exposed result separately as `read.mcp-recheck.txt`, then record it without changing the original report: +After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save the actually exposed projects[] JSON (or structuredContent.projects[]) separately as `read.mcp-recheck.txt`. A CLI mcp get/configuration response is never a tool recheck, and cannot be formatted into one. If the native project result is not exposed, retain that gap instead of claiming success. Then then record it without changing the original report: ```text node "/scripts/record-recheck.mjs" --active-case --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" diff --git a/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs index dee133f..c763b60 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -20,6 +20,13 @@ export async function recordRecheck(options) { if (!info.isFile() || info.size > 64 * 1024) throw new Error("Recheck result must be a regular file of at most 64 KiB"); const result = redact(await readFile(options.result, "utf8")); if (!result.trim()) throw new Error("Recheck result is empty"); + if (options.outcome === "success") { + let data; + try { data = JSON.parse(result); } catch { /* CLI configuration text is not a tool result. */ } + if (!data || data.isError === true || data.error || + !Array.isArray(data.projects ?? data.structuredContent?.projects)) + throw new Error("Successful list_projects recording needs the actually exposed projects[] JSON (or structuredContent.projects[]). Native mcp get/configuration text is not a tool result. Preserve it separately and wait for the actual safe native read; do not manufacture JSON or replay a failed tool."); + } const observation = { schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, recordedAt: new Date().toISOString(), observedAt: null, source: "agent_supplied_native_tool_result; not_independently_verified", host: report.host, diff --git a/tests/doctor-capture.test.mjs b/tests/doctor-capture.test.mjs index 006cd5a..48d64b5 100644 --- a/tests/doctor-capture.test.mjs +++ b/tests/doctor-capture.test.mjs @@ -100,7 +100,7 @@ test("native pre-render capture includes the reserved saved plain error without test("safe recheck record binds an immutable original report and never overwrites a prior observation", async t => { const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps); const original = await readFile(receipt.report), result = join(cwd, "read.mcp-recheck.txt"); - await writeFile(result, "Controlled unauthenticated service returned []"); + await writeFile(result, '{"projects":[],"controlled":"owned unauthenticated fixture"}'); const options = { report: receipt.report, result, operation: "list_projects", outcome: "success", scope: "Same project and isolated profile; explicit new session loaded project .mcp.json" }; const recheck = await recordRecheck(options); assert.equal(recheck.sourceReportSha256, receipt.sourceReportSha256); @@ -117,8 +117,19 @@ test("follow-up entry baseline cannot replace the active incident report for rec const baseline = await capture({ host: "claude", cwd }, deps); assert.notEqual(baseline.diagnosticId, first.diagnosticId); assert.equal(baseline.activeReport.diagnosticId, first.diagnosticId); - await writeFile(result, "actual safe native read returned []"); + await writeFile(result, '{"projects":[]}'); const recheck = await recordRecheck({ activeCase: true, cwd, result, operation: "list_projects", outcome: "success", scope: "same project; explicit new session" }); assert.equal(recheck.diagnosticId, first.diagnosticId); assert.equal(recheck.sourceReportSha256, first.sourceReportSha256); }); + +test("registration/status text and configuration JSON cannot be recorded as a successful native project read", async t => { + const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps), result = join(cwd, "read.mcp-recheck.txt"); + const options = { report: receipt.report, result, operation: "list_projects", outcome: "success", scope: "same project" }; + for (const text of ['chatcut:\n Scope: Project config\n Status: Pending approval\n URL: http://127.0.0.1/', '{"name":"chatcut","transport":{"url":"http://127.0.0.1/"}}', '{"isError":true,"projects":[]}']) { + await writeFile(result, text); + await assert.rejects(recordRecheck(options), /Native mcp get\/configuration text is not a tool result/); + } + await writeFile(result, '{"structuredContent":{"projects":[]}}'); + assert.equal((await recordRecheck(options)).outcome, "success"); +}); From e46a04cbcb57b411e8868ecae5a0633c7e036faa Mon Sep 17 00:00:00 2001 From: IanYet Date: Mon, 26 Oct 2026 20:53:29 +0800 Subject: [PATCH 15/16] Simplify hosted incident follow-ups and align collection helpers --- .github/workflows/test.yml | 1 + claude/skills/chatcut-mcp-doctor/SKILL.md | 51 +- .../references/advanced-collection.md | 90 +- .../references/connection-diagnostics.md | 164 +- .../references/discovery.md | 2 +- .../scripts/active-case.mjs | 36 - .../chatcut-mcp-doctor/scripts/capture.mjs | 35 +- .../chatcut-mcp-doctor/scripts/doctor.mjs | 100 +- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 407 +++- .../scripts/mcp-evidence.mjs | 244 ++- .../scripts/mcp-failure.mjs | 100 +- .../scripts/record-recheck.mjs | 93 +- .../references/connection-diagnostics.md | 413 +++- .../scripts/mcp-doctor.mjs | 1740 ++++++++++++++++- .../scripts/mcp-evidence.mjs | 604 +++++- .../scripts/mcp-failure.mjs | 202 +- .../scripts/mcp-wire.mjs | 298 ++- codex/skills/chatcut-mcp-doctor/SKILL.md | 50 +- .../references/advanced-collection.md | 90 +- .../references/connection-diagnostics.md | 164 +- .../references/discovery.md | 2 +- .../scripts/active-case.mjs | 36 - .../chatcut-mcp-doctor/scripts/capture.mjs | 35 +- .../chatcut-mcp-doctor/scripts/doctor.mjs | 100 +- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 407 +++- .../scripts/mcp-evidence.mjs | 244 ++- .../scripts/mcp-failure.mjs | 100 +- .../scripts/record-recheck.mjs | 93 +- .../references/connection-diagnostics.md | 413 +++- .../scripts/mcp-doctor.mjs | 1740 ++++++++++++++++- .../scripts/mcp-evidence.mjs | 604 +++++- .../scripts/mcp-failure.mjs | 202 +- .../scripts/mcp-wire.mjs | 298 ++- docs/mcp-doctor.md | 103 +- scripts/sync-mcp-helpers.mjs | 18 + tests/collection-input-resilience.test.mjs | 393 ++++ tests/doctor-capture.test.mjs | 145 +- tests/doctor-host-acceptance.mjs | 6 +- tests/doctor-upload.test.mjs | 82 + tests/fixtures/owned-claude-mcp-error.log | 2 + tests/host-failure-retention.test.mjs | 191 ++ tests/mcp-doctor.test.mjs | 4 +- tests/native-failure-index.test.mjs | 2 +- tests/standalone-doctor.test.mjs | 33 +- tests/windows-db-fallback.test.mjs | 25 +- 45 files changed, 9116 insertions(+), 1046 deletions(-) delete mode 100644 claude/skills/chatcut-mcp-doctor/scripts/active-case.mjs delete mode 100644 codex/skills/chatcut-mcp-doctor/scripts/active-case.mjs create mode 100644 scripts/sync-mcp-helpers.mjs create mode 100644 tests/collection-input-resilience.test.mjs create mode 100644 tests/doctor-upload.test.mjs create mode 100644 tests/fixtures/owned-claude-mcp-error.log create mode 100644 tests/host-failure-retention.test.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b9af4b..d5a3881 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,3 +9,4 @@ jobs: with: node-version: 24 - run: node --test tests/*.test.mjs + - run: node scripts/sync-mcp-helpers.mjs --check diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index b99639b..676233d 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -1,57 +1,32 @@ --- name: chatcut-mcp-doctor -description: Diagnose hosted ChatCut MCP connection, startup, authentication or tool-call failures locally, save a machine report and usable feedback, and guide scoped repair/recheck. Use for ChatCut Doctor or MCP连接错误; not Desktop ACP or unrelated video editing. -allowed-tools: Bash(node "${CLAUDE_SKILL_DIR}/scripts/capture.mjs" --host claude) +description: Investigate hosted ChatCut MCP connection, authentication and tool errors using the original error, existing evidence and a same-host safe recheck. Use when ChatCut will not connect; not Desktop MCP. --- -# ChatCut MCP Doctor +# ChatCut MCP evidence -Run locally with Node 18+ even when ChatCut MCP is unavailable. Preserve the already visible original error before any retry or change. Never read credentials, auth files or conversation histories. +Help the user understand and resolve the actual failure. This unpublished candidate runs with local file/shell tools even if hosted MCP is unavailable. Preserve the visible original error before a retry or change; do not read credentials or whole conversations. -## Capture first +Use the existing report when following up. Refer to its explicit path and diagnostic ID; do not create another baseline just because the Skill was invoked again. There is no active-case index or time window. A new report is useful only when collecting new evidence for a distinct observation. -A diagnosis is complete only after this invocation's program-generated schema-2 report is saved and its diagnostic ID/path verified. A prose note or an old report is not a substitute. - -The native host runs this bounded local capture before rendering the Skill. It saves a fresh offline report before any model answer: - -!`node "${CLAUDE_SKILL_DIR}/scripts/capture.mjs" --host claude` - -If host policy disables the injected command, run that exact local command with already permitted tools. If denied, retain the denial and report collection incomplete; do not bypass host policy. - -The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. For Chinese requests, return the program's `userFacingNote` as the diagnosis; it is also saved in the Chinese note file. Add only separately observed repair/recheck/delivery facts. Do not compose a second diagnosis, label error text as an exposed code, exclude possible causes from static config, or invent root-cause hypotheses. For other languages, translate that note without adding inferences. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. - -Before the model answers, capture automatically includes an existing project-root `original.mcp-failure.json` (preferred, preserving known metadata) or `original.mcp-error.txt`. These are reserved error-only inputs; it never scans old reports or conversations. If the error has not yet been saved, save only its exact exposed error text as `original.mcp-error.txt`, then run: +When collection is needed, save the exposed error alone and run the local helper, resolving paths from this Skill: ```text -node "/scripts/capture.mjs" --host --original-error "original.mcp-error.txt" --operation --server-url +node "/scripts/capture.mjs" --host --original-error "" ``` -Omit unknown operation/URL. This program formats the dedicated failure record; never handwrite report JSON, schema versions, UUIDs or timestamps. A retained structured `.mcp-failure.json` can instead use `--failure-record` to preserve its code/cause/known IDs. Reuse saved originals; do not recreate a failure. Unknown original time/IDs stay unknown. The snapshot capture time and collecting thread are not original-event evidence. - -## Diagnose and repair +A structured retained original may instead use `--failure-record `. Add the known operation and selected URL if exposed; omit unknowns. Default capture is offline. The receipt gives the saved report and SHA. Optional `--native-lookup` runs a bounded native registration query that may connect; obtain permission for that check. Configuration presence does not establish active-session tools, authentication or recovery. Missing project config is not proof of absence in other scopes. [Collection details](references/connection-diagnostics.md). -Keep configuration, original failure, current session and later checks distinct. Do not infer account/permission/service health from prior success, static config or an empty controlled project list. Findings are observed boundaries, not root causes. +Use the facts to choose the smallest next check. There is no mandatory generated diagnosis or wording. Keep the original failure, later independent probes and actual native tool results distinct. Unknown source bytes, time, IDs and root causes stay unknown. [Existing repair guidance](references/repair-and-recheck.md) covers a demonstrated missing manual Hosted registration; do not duplicate plugin-managed servers, change environments or credentials, or repair Desktop with this Hosted recipe. Preserve the actual action and rollback in a separate note. -For current native registration evidence, `capture.mjs --host --native-lookup [--cli ]` runs one bounded native `mcp get` and formats its snapshot/report. Use it only when the user permits a connection check: Claude's command can health-check approved servers. It is **not offline**. Keep an actually selected endpoint with `--server-url `; never silently substitute production. It projects allowed fields without retaining headers/full CLI output. Timeout, denied/unavailable CLI and unrecognized output remain unavailable; only the exact completed no-entry response supports not_registered. Select `--server-name plugin:chatcut:chatcut` only when that is the actual selected server; never recreate a plugin server as a manual entry. +After a justified authorized change, use the original Codex or Claude host, configuration scope and target service/project for an actual safe tool call. Inspect its current schema. `list_projects` checks a Hosted project listing, not access to an affected project; an empty list does not prove that project's recovery or account authentication. Configuration queries, tool discovery and independent HTTP probes are not tool rechecks. Do not replay mutations or generation calls. -Read [repair and recheck](references/repair-and-recheck.md) before any change. Repair only a demonstrated missing manual registration in the authorized scope; retain prior state, actual command and rollback. No automatic credential/network repair. Local execution denial is evidence, not authorization to change permissions. - -## Recheck and deliver - -After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save the actually exposed projects[] JSON (or structuredContent.projects[]) separately as `read.mcp-recheck.txt`. A CLI mcp get/configuration response is never a tool recheck, and cannot be formatted into one. If the native project result is not exposed, retain that gap instead of claiming success. Then then record it without changing the original report: +Save only the actually exposed tool result in a separate file, then record it against the original report: ```text -node "/scripts/record-recheck.mjs" --active-case --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" +node "/scripts/record-recheck.mjs" --report "" --host --result "" --operation --outcome --server-url --config-scope "" ``` -The program's bounded `active-case.json` selects the original incident report and verifies its SHA; a later entry baseline does not replace a recent same-endpoint case. Use `--active-case`, not the newest report path. A new entry can produce another baseline; it is a separate observation, not the original incident. The recording is explicitly Agent supplied and does not independently prove account authentication or original recovery. Finish a usable Chinese explanation for Chinese requests: actual symptom/error, evidence, action, recheck result, diagnostic ID/files and delivery status. Generated Chinese notes have the current facts; add actual repair details in a separate note. Do not ask the user to fill JSON or leave core placeholders. - -Report transfer requires authorization. `node "/scripts/doctor.mjs" --upload-report ""` sends the unchanged report once and saves a SHA-bound receipt. Do not recollect to resend, change its endpoint or repeat acknowledged delivery. Ingest acceptance is not final storage or a ticket. No publishing or messaging without authorization. - -## Entry and advanced evidence - -Claude plugin public entry: **/chatcut:chatcut-mcp-doctor**. Codex Git plugin: **$chatcut:chatcut-mcp-doctor**; store identity: **$chatcut-desktop:chatcut-mcp-doctor**. Use the exact installed catalog entry. Bare /doctor belongs to the host. +Add known `--project-id`, local `--conversation-id`, `--call-id`, `--observed-at`; omit unknown facts. Never copy an MCP session credential as a conversation ID. Choose the safe operation from the current tool schema; the recorder does not execute tools or impose a fixed tool list. If the host does not expose a structured result, retain the gap rather than manufacturing one. Each invocation saves another observation. It is Agent supplied, not independent proof of the original attempt or recovery. A new host/session is a separate boundary to disclose, not automatic continuation of the old one. No actual result or no saved observation means recheck incomplete. -- [Discovery and fallback](references/discovery.md): entry missing/old plugin or no local execution. -- [Connection diagnostics](references/connection-diagnostics.md): dedicated evidence inputs, bounded native logs and original IDs. -- [Advanced collection](references/advanced-collection.md): optional existing collector modes, precise log/database coverage and feedback reuse. Load only when needed. +Explain the symptom, evidence, actual action, actual recheck and remaining unknowns in the user's language. Deliver the existing local report, repair note and recheck files even when upload is unavailable. Optional `doctor.mjs --report ` derives feedback from the unchanged report; it does not recollect. Authorized transfer uses `doctor.mjs --upload-report ` and a separate SHA-bound receipt. Do not resend acknowledged or uncertain delivery. Acceptance is not complete storage; failed upload retains the reason and local files. No ticket, publishing or messaging without authorization. diff --git a/claude/skills/chatcut-mcp-doctor/references/advanced-collection.md b/claude/skills/chatcut-mcp-doctor/references/advanced-collection.md index 76fa5bc..3d8ee97 100644 --- a/claude/skills/chatcut-mcp-doctor/references/advanced-collection.md +++ b/claude/skills/chatcut-mcp-doctor/references/advanced-collection.md @@ -1,89 +1,13 @@ -# ChatCut MCP Doctor +# Optional collection and delivery -This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. +Use the existing report when following up. Collection is not a prerequisite for reasoning from evidence that is already available. -If the host rejects a local command, preserve that exact rejection and the operation it blocked. One denied compound read does not establish that every file tool or the standalone Node helper is prohibited, or that the workspace is read-only. Use only capabilities already permitted by the current host; do not change permissions, switch shells to bypass a rejection, or call collection complete when no report was written. +For explicit new collection, the canonical helper is `scripts/mcp-doctor.mjs`. Run `--help` for the options in the installed copy. It supports error-only original inputs, bounded native logs, projected host snapshots, offline collection and independent anonymous metadata/wire checks. The latter are later probes, never the original failed request. Keep the selected endpoint; never switch beta/dev to production to obtain success. -## Start here +`capture.mjs --host codex|claude` is an offline convenience that can project the current project registration without retaining credential fields. Original error inputs are explicit, never selected from old files automatically. `--native-lookup` opts into a bounded host CLI query, which may perform health checks. Its receipt identifies the new report; it does not choose which incident should be rechecked. -- In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” -- In Claude Code with the ChatCut plugin, invoke `/chatcut:chatcut-mcp-doctor`. A separately installed project/personal copy uses `/chatcut-mcp-doctor`. -- `/doctor` and `codex doctor` belong to the host; this Skill does not replace them. If the entry is absent or the plugin is old, read [discovery and fallback](references/discovery.md). A browser-only host without local execution cannot run this collector; retain the error and explain that boundary. +`doctor.mjs --report ` optionally derives diagnostic/summary/private draft files without changing the original. Agent repair notes and rechecks are separate files and are not overwritten by rebuilding those derived files. -## Preserve, collect, explain +`doctor.mjs --upload-report ` is the Doctor's saved-report transfer entry. It acquires an exclusive local owner, checks the SHA-bound receipt inside that ownership, and sends no new probes. An interrupted transfer retains the owner/uncertain receipt for review. An acknowledged or uncertain transfer cannot be automatically repeated. A failed transfer keeps its original cause and local evidence. An ACK still requires independent fragment reassembly and SHA verification before claiming complete storage. Repair notes and rechecks are local delivery artifacts, not silently inserted into the original upload. -Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. - -Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. - -For a visible failed tool call, the minimal record is below. Create a file named -`original.mcp-failure.json` (the `.mcp-failure.json` suffix is required). Replace -only the message and the actually known tool name; keep unavailable time null. -Read the reference before adding fields. The failure record uses schema **1**, -not report schema 2; `mcp_tool_call` and `errorMessage` are not accepted fields. - -```json -{"schemaVersion":1,"source":"host_tool_result","serverName":"chatcut","occurredAt":null,"operation":"list_projects","error":{"message":"Exact exposed original error"}} -``` - -Do not lose a known tool name by switching to text-only input after a formatting -mistake. Correct the dedicated record using the already saved original result, -without calling the failed tool again. Keep earlier incomplete reports separate; -reuse the final correct report for feedback and one authorized transfer. - -**CLI registration query → snapshot; failed MCP tool → failure record.** A -`codex/claude mcp get` response belongs in snapshot observations, not in a -failure record with `source: cli_configuration`. Generate a fresh snapshot with -`--create-snapshot current.mcp-snapshot.json`, retain its generated capture time, -then add only the observed registration fact. Explicit native absence permits: - -```json -{"stage":"host_registration","operation":"lookup_registered_server","source":"cli_configuration","observedAt":null,"outcome":"failed","lookupResult":"not_found","message":"Exact native no-entry response"} -``` - -Collect it with `--host-snapshot current.mcp-snapshot.json --server-url --offline --output current.mcp-report.json`. Omit `--failure-record` when no original MCP tool/runtime error is exposed. This keeps the CLI message and `not_registered` finding without inventing an original MCP failure/time. The earlier schema-1 example is for an actual failed MCP tool call only. Keep a known selected URL even offline so a later authorized transfer can reuse the report; never substitute a production URL afterwards. - -If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. - -Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. - -With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. - -If this invocation's native stderr has already been retained by its caller in a known dedicated `.codex-stderr.log`, use `--codex-stderr-file` to read a bounded ChatCut diagnostic tail. The helper retains native `loggedAt` separately, never as original `occurredAt` or a call ID. This is conditional on a real retained error stream; do not manufacture a log, scan for unknown paths or start another host to replace missing evidence. - -Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. - -Only `scripts/doctor.mjs` generates the canonical report, its diagnostic UUID and collection time. Never handwrite a replacement report or invent these values. The agent writes only the dedicated original-error/snapshot inputs and separate explanatory/repair notes. A directory's timestamp is not the failure or repair time; keep unexposed original times null. A successful read of a controlled unauthenticated service proves that read only, not account authentication, permission checks or the contents of a real user's projects. - -```text -node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output -``` - -Use `--host claude` for Claude Code. Omit unavailable optional inputs. If there is no registered URL, omit it; do not silently choose production. `--offline` keeps the registered URL as evidence but sends **no requests**, including upload. Otherwise the collector makes bounded anonymous entry/metadata checks and optional wire checks; an initial anonymous 401 is expected. Use `--upload` for the redacted report when transfer is authorized, after briefly explaining it. An intentionally disabled server is preserved without requests. - -The command writes the existing schema-2 report plus `.diagnosis.json`, `.summary.md` and `.issue-draft.md` sidecars. Read the findings and actual gaps before answering; do not merely echo a category. Findings identify observed boundaries, not root causes. Distinguish original failure, host evidence and later probes. Missing/truncated/denied logs remain gaps. A healthy later probe does not establish recovery. Proxy presence, busy/300-second timeouts and generic transport errors do not justify blaming security software, clearing locks/credentials, restarting or changing timeouts/environments. - -Wire failures/incomplete captures are later **direct-route** checks even if fetch metadata succeeds. Bounded native ERROR records and App `status=failed` notices are visible as unassociated host evidence; they do not fill a missing original error/time/request ID. Ordinary App starting/ready notices do not demonstrate failure. Local summaries show credential-redacted error excerpts; issue drafts keep structured states and evidence pointers without copying native log excerpts. - -## Repair and recheck - -For an observed missing registration or a normal sign-in requirement, read [repair and recheck](references/repair-and-recheck.md). Apply only the minimal change within the user's authorization and actual host/scope. Keep the prior state and rollback. Other findings need evidence-led investigation; this version has no automatic credential/network repair. - -Verify the affected boundary in the **same host and scope** after a change. Registration lookup success proves registration only. Auth success requires the host's authenticated state. Tool recovery requires a safe read-only call such as `list_projects` actually succeeding. If the original operation mutates state, costs credits, or cannot be safely repeated, do not replay it; say that its recovery remains unverified. Record the actual action, time/source and verification result in the summary and draft; never label an unperformed repair as tried. Keep the report's original failure immutable and its collector `recoveryVerified:false`; agent recheck results are separate evidence. - -## Feedback without starting over - -Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. - -Read the generated files and finish a usable explanation in the user's language. For Chinese requests, write a separate Chinese note with the actual symptom, exact error excerpt, evidence/source, action actually taken, safe same-host recheck result and delivery result. Fill core facts from observations; do not leave template placeholders for the user. If a later safe call succeeded, say exactly which operation and host/scope succeeded, while keeping the collector's original report immutable and its historical recovery flag separate. Do not claim an unperformed repair, complete recovery, final storage or an opened ticket. - -A protocol-discovery attempt that was rejected and then successfully negotiated by the same native flow can be normal. Preserve that sequence separately; do not present an intermediate negotiation rejection as the user's terminal failure when actual downstream success is established. Unrelated later success does not erase a recorded original tool failure. - -```text -node "/scripts/doctor.mjs" --report -node "/scripts/doctor.mjs" --upload-report -``` - -`--report` rebuilds sidecars offline without recollecting. It overwrites derived sidecars, so save any agent-written repair notes separately first. Use `--upload-report` only for an authorized first transfer or a single requested retry after failure; it reuses the original diagnostic ID without new probes, keeps the original report unchanged, and writes a separate upload receipt. Do not repeatedly recollect or resend an acknowledged report. `ingest_accepted` is not verified final storage or an opened support ticket. - -Use [ChatCut contact](https://chatcut.io/contact) for private support. The draft names no public issue repository because that destination is undecided. Do not open an issue, publish evidence, or message anyone without authorization. Root-cause rules and prevalence based on natural incidents remain future work; controlled tests are not user incidents. +The collector implementation is authored in Codex basics. Run `node scripts/sync-mcp-helpers.mjs` after changing it; Claude basics and both self-contained Doctor packages are generated copies. `--check` rejects drift in CI. Doctor publication remains separate from collection-only release. diff --git a/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md b/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md index 089a805..58d583c 100644 --- a/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md +++ b/claude/skills/chatcut-mcp-doctor/references/connection-diagnostics.md @@ -92,14 +92,6 @@ Sources: `current_session`, `tool_search`, `host_settings`, Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` (`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, `protocolCode`, `message`, and `originalAttemptMatch` (`confirmed`/`unconfirmed`). -Only `host_registration` / `lookup_registered_server` observations from -`cli_configuration` or `host_settings` may additionally carry `lookupResult`: -`not_found`, `found`, or `unavailable`. `not_found` requires an explicit absence -response from a completed named lookup or a successful complete scoped inventory; -do not derive it from an exit code, permission/parse error, missing CLI, or empty -tool list. Preserve the actual response in `message`. Failed/inconclusive lookups -without this fact retain unknown registration state and do not recommend adding -an entry. This is still agent-supplied evidence, not independent verification. Omit the match flag when unknown. Set `confirmed` only when the captured failed runtime/session observation explicitly identifies the original failed attempt and its actual server request ID; registration, nearby timestamps, successful @@ -110,18 +102,55 @@ 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 +the existing bounded reads and a global 30-record / 128 KiB text-message budget. +When a complete record cannot fit, the report names the omitted record count; +it does not trim an error to make it appear complete. Native failed-item error +projections remain separately bounded and the complete local report is retained +when the upload budget is exceeded. + +`evidenceSummary.failureEvidence` mechanically indexes the original retained +error blocks and failed native items. It records their actual source, log event +time or native lifecycle, and available local call/item identifiers. Read the +referenced original block for the full message and cause. A matching conversation +and call ID is only a local binding to the Agent-supplied original record; nearby +times, one remaining candidate, and the current collection conversation are not +proof that a log is the user's reported failure. No candidate is automatically +selected as `originalFailure`, and its log time never fills `occurredAt`. + +When no original failure record was supplied, the helper can still retain these +host failures but cannot tell whether they are the user's reported attempt. +Codex App startup notices, Claude debug logs, and native local IDs do not provide +a missing server X-Request-ID. The helper cannot capture the host's original TLS +bytes or HTTP response before its parser; later independent probes remain +separate evidence. If the host discarded the original tool error, time, or +response header and no accessible source retained it, those fields remain gaps. + ## Run once for the reported failure ### Preserve the failed call before independent checks -Database reads are supplementary. When the current tool error is visible, save -its dedicated failure record before attempting runtime sources, then run the -offline helper even if SQLite fails. `hostLogCoverage` explicitly distinguishes -the retained error, database errors and other-source coverage. Existing App logs -remain an independent bounded source. An already visible tool error needs no -prior stderr setup; an unexposed startup error still needs an accessible source. -Never copy a live database, use immutable/nolock mode, discard WAL or widen -permissions to make collection pass. If no original error is exposed, say so. +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 @@ -154,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 @@ -164,6 +225,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 @@ -201,10 +280,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 @@ -239,40 +326,6 @@ MCP error stream. Preserve the original stderr/startup error when it is already visible to the current host. The helper cannot recover discarded parent-process stderr or reconstruct an old exec failure from App logs. -When the caller actually retained this invocation's native stderr, explicitly -select its known dedicated `.codex-stderr.log` with `--codex-stderr-file`. -The same native ChatCut selector reads at most128 KiB and30 records; omitted -bytes and inaccessible files remain gaps. `source: codex_stderr_log` records -`loggedAt` as native log-emission time and keeps original attempt association -unconfirmed. It does not assign original `occurredAt`, lifecycle time or IDs. -No capture file is created, discovered or populated by this option. Unknown or -discarded stderr remains unavailable; another host/session is not a substitute. - -For a known thread, the helper can also query the existing Codex -`thread_history_1.sqlite` failed-MCP index in the same supplied SQLite root. -It selects only native IDs, start/completion columns and `error` projections for -that thread, ChatCut server, `mcpToolCall` type, failed status and the bounded -time window. It returns at most ten items and at most128 KiB per error; limits, -missing/unsupported schemas and denied reads remain gaps. It never returns -whole `item_json`, user/assistant messages, tool arguments or successful results, -and never reads a rollout transcript. `created_at_ms` is not substituted for a -lifecycle or failure time. - -`collectionContext` identifies the current collecting thread from the host's -`CODEX_THREAD_ID`; it is not silently copied into `originalFailure`. -`hostLogs.nativeFailedCalls` preserves native `nativeItemId` with its explicit -kind and `startedAtMs`/`completedAtMs`. These fields are native MCP item identity -and lifecycle evidence, not Responses `call_id`, MCP RPC ID, server request ID, -log-emission time or an automatically known original `occurredAt`. Native facts -can be useful even when the original fields remain unknown. Only compare a -known same thread, matching operation and exact error; duplicates or missing -provenance remain candidates. A failed index read does not erase other evidence. -The gap identifies the failed operation (locate/open/schema/query) and retains -the native error. Windows error14 can occur at SELECT even when ordinary file -reads and SQLite open succeed. No database copy, immutable mode or permission -change is attempted. A report that exceeds the existing upload budget remains -fully saved locally; errors are not shortened merely to make upload fit. - If the current host already exposes native `mcpServer/startupStatus/updated` notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, `runtimeStatus` and `toolsError` from that same thread in the error file and @@ -332,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-mcp-doctor/references/discovery.md b/claude/skills/chatcut-mcp-doctor/references/discovery.md index 55594a9..90bbcfb 100644 --- a/claude/skills/chatcut-mcp-doctor/references/discovery.md +++ b/claude/skills/chatcut-mcp-doctor/references/discovery.md @@ -24,7 +24,7 @@ this Skill. The existing collector may still be bundled at not include these summary/draft sidecars. An agent with a trusted local copy of this repository can read this SKILL.md -directly and execute `scripts/doctor.mjs` without installing or connecting MCP. +directly and execute `scripts/capture.mjs` without installing or connecting MCP. For an explicitly requested standalone installation, copy the complete `chatcut-mcp-doctor` directory (including scripts and references) into a host supported project/personal skills directory, then verify discovery in a new diff --git a/claude/skills/chatcut-mcp-doctor/scripts/active-case.mjs b/claude/skills/chatcut-mcp-doctor/scripts/active-case.mjs deleted file mode 100644 index e226dcf..0000000 --- a/claude/skills/chatcut-mcp-doctor/scripts/active-case.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import { lstat, readFile, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import { join, resolve, sep } from "node:path"; -import { readReport } from "./doctor.mjs"; - -export async function readActiveCase(cwd, host) { - const base = resolve(cwd, ".chatcut-doctor"), path = join(base, "active-case.json"); - const info = await lstat(path); - if (!info.isFile() || info.size > 8192) throw new Error("Active case must be a bounded program-generated index"); - const value = JSON.parse(await readFile(path, "utf8")); - const target = resolve(value.report ?? ""); - if (value.schemaVersion !== 1 || !target.startsWith(base + sep) || - !["codex", "claude"].includes(value.host) || (host && value.host !== host)) - throw new Error("Active case does not identify this workspace/host"); - const { report, sourceReportSha256 } = await readReport(target); - if (report.host !== value.host || report.diagnosticId !== value.diagnosticId || sourceReportSha256 !== value.sourceReportSha256) - throw new Error("Active case report identity/hash mismatch; preserve files for review"); - return { ...value, report: target }; -} - -export async function selectActiveCase(cwd, report, path, sourceReportSha256, explicitEvidence) { - let prior; - try { prior = await readActiveCase(cwd, report.host); } - catch (error) { if (error.code !== "ENOENT") throw error; } - // A follow-up entry's baseline must not replace the incident being rechecked. - // Explicit original evidence or a fresh native missing-entry finding starts - // a case; otherwise keep a recent same-endpoint case, without claiming recovery. - const pending = prior && Date.now() - Date.parse(prior.recordedAt) < 30 * 60 * 1000 && prior.serverUrl === report.serverUrl; - const evidenceFingerprint = report.originalFailure.status === "recorded" - ? createHash("sha256").update(JSON.stringify(report.originalFailure)).digest("hex") : null; - if (pending && (!explicitEvidence || (evidenceFingerprint && evidenceFingerprint === prior.evidenceFingerprint))) return prior; - const selected = { schemaVersion: 1, host: report.host, report: resolve(path), diagnosticId: report.diagnosticId, - sourceReportSha256, serverUrl: report.serverUrl, evidenceFingerprint, recordedAt: new Date().toISOString() }; - await writeFile(join(cwd, ".chatcut-doctor", "active-case.json"), JSON.stringify(selected, null, 2) + "\n", { mode: 0o600 }); - return selected; -} diff --git a/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs b/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs index 04b632a..32ecdfe 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/capture.mjs @@ -5,24 +5,11 @@ import { basename, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs, promisify } from "node:util"; import { redact, runDoctor, serverUrl } from "./mcp-doctor.mjs"; -import { writePacket } from "./doctor.mjs"; -import { selectActiveCase } from "./active-case.mjs"; const execute = promisify(execFile); const save = (path, data) => writeFile(path, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 }); const now = () => new Date().toISOString(); -async function savedOriginal(cwd) { - // Two reserved error-only inputs, never a report, config, glob or transcript. - for (const [key, name] of [["failureRecord", "original.mcp-failure.json"], ["originalError", "original.mcp-error.txt"]]) { - try { - const path = join(cwd, name), info = await lstat(path); - if (info.isFile() && info.size <= 64 * 1024) return { [key]: path }; - } catch (error) { if (error.code !== "ENOENT") return {}; } - } - return {}; -} - async function dedicatedText(path) { if (!/\.mcp-error\.txt$/i.test(basename(path))) throw new Error("Use a dedicated .mcp-error.txt containing only the exposed original error"); const info = await lstat(path); @@ -85,7 +72,7 @@ export async function capture(options, dependencies = {}) { // Validate explicit endpoint before any optional native connection check. const selected = options.serverUrl ? serverUrl(options.serverUrl).href : null; const cwd = resolve(options.cwd ?? process.cwd()); - const originalInput = options.originalError || options.failureRecord ? options : await savedOriginal(cwd); + const originalInput = options; const base = join(cwd, ".chatcut-doctor"); await mkdir(base, { recursive: true, mode: 0o700 }); const dir = await mkdtemp(join(base, "capture-")); @@ -113,28 +100,14 @@ export async function capture(options, dependencies = {}) { } const { path, report } = await runDoctor({ host: options.host, serverUrl: endpoint, hostSnapshot: snapshotPath, failureRecord, offline: true, output: join(dir, "diagnostic.mcp-report.json") }, dependencies); - const packet = await writePacket(path, report); - const zhPath = path + ".zh-CN.md"; - const labels = { tool_call_failed: "已保留工具调用失败", startup_or_discovery_failed: "已保留启动或工具发现失败", - registration_lookup_unavailable: "注册状态未确认", not_registered: "原生查询明确报告所选名称未注册", - no_incident_demonstrated: "本轮证据不足以确认原始故障", original_failure_recorded_stage_unconfirmed: "已保留原错,阶段未确认" }; - const registration = lookup.lookupResult === "found" - ? `${options.nativeLookup ? "原生查询返回" : "当前工程静态配置包含"}所选注册;这不能确认当前会话连接、认证或原故障恢复。` - : lookup.lookupResult === "not_found" ? `原生查询明确返回:${lookup.message}` - : "所选注册状态未确认。离线工程配置缺失、查询不可用或无法识别的输出都不能证明宿主未注册;本地、用户和插件作用域及当前会话仍有未覆盖范围。"; - const original = report.originalFailure; - const note = `# ChatCut MCP 本轮诊断\n\n诊断编号:${report.diagnosticId}\n采集时间:${report.collectedAt}(不是原始错误时间)\n宿主:${report.host}\n所选地址:${report.serverUrl ?? "未确认"}\n\n注册观察:${registration}\n\n原始操作:${original.operation ?? "未知"}\n原始错误内容:${original.status === "recorded" ? original.message : "没有可用的原始 MCP 错误;没有编造。"}\n宿主暴露的错误码:${original.error?.code ?? "未提供"}\n原始时间:${original.occurredAt ?? "未知"};调用 ID:${original.callId ?? "未知"}。\n\n结论:${packet.findings.map(x => labels[x.kind] ?? x.kind).join(";")}。根因尚未确认,现有证据不能排除或归因于注册、凭证、网络或服务端。\n实际处理:${options.nativeLookup ? "已运行一次用户允许的原生注册查询;该命令可能附带健康连接检查,不能称为全程离线。" : "仅投影当前工程允许的配置字段并离线采集;没有调用原生 CLI 或发送诊断请求。"} 程序未修改注册、登录或权限。\n复验:尚未执行安全的原生工具读取;后续复验单独记录,原报告不变。\n发送:未上报、未开工单,最终存储未验证。\n缺口:${report.evidenceSummary?.gaps?.join(";") || "详见正式报告"}。\n下一步:${lookup.lookupResult === "not_found" ? "在用户授权的宿主/范围内按修复指南检查缺失注册并保留回滚,再做一次安全原生读取。" : "如用户授权,按实际宿主/范围做一次安全原生读取,单独保留结果;不重复制造失败或猜测根因。"}\n\n正式报告:${path}\n`; - await writeFile(zhPath, note, { flag: "wx", mode: 0o600 }); + const packet = { diagnosticId: report.diagnosticId, report: path, upload: report.upload, recoveryVerified: false }; const bytes = await readFile(path), saved = JSON.parse(bytes); if (saved.diagnosticId !== report.diagnosticId || saved.schemaVersion !== 2) throw new Error("Saved report identity check failed"); const sourceReportSha256 = createHash("sha256").update(bytes).digest("hex"); - const activeReport = await selectActiveCase(cwd, report, path, sourceReportSha256, - report.originalFailure.status === "recorded" || (options.nativeLookup && lookup.lookupResult === "not_found")); - const receipt = { schemaVersion: 1, startedAt, completedAt: now(), ...packet, snapshot: snapshotPath, chineseNote: zhPath, - sourceReportSha256, savedReportVerified: true, activeReport, + const receipt = { schemaVersion: 1, startedAt, completedAt: now(), ...packet, snapshot: snapshotPath, + sourceReportSha256, savedReportVerified: true, nativeLookupRequested: Boolean(options.nativeLookup), diagnosticHttpRequests: 0, nativeLookupMayConnect: Boolean(options.nativeLookup), lookupResult: lookup.lookupResult, - userFacingNote: note, originalInput: originalInput.originalError ? resolve(originalInput.originalError) : originalInput.failureRecord ? resolve(originalInput.failureRecord) : null }; await save(join(dir, "capture-receipt.json"), receipt); return receipt; diff --git a/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs index 3001875..8f24173 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -1,42 +1,37 @@ -import { readFile, stat, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; +import { lstat, open, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; import { basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { runCli, submitReport, errorDetails } from "./mcp-doctor.mjs"; +import { submitReport, errorDetails } from "./mcp-doctor.mjs"; import { diagnose, renderPacket } from "./diagnosis.mjs"; export async function writePacket(path, report) { const diagnosis = diagnose(report); const { summary, draft } = renderPacket(report, diagnosis); const files = { diagnosis: `${path}.diagnosis.json`, summary: `${path}.summary.md`, issueDraft: `${path}.issue-draft.md` }; - // These are derived sidecars. The source report and diagnostic ID stay unchanged. await writeFile(files.diagnosis, JSON.stringify(diagnosis, null, 2) + "\n", { mode: 0o600 }); await writeFile(files.summary, summary, { mode: 0o600 }); await writeFile(files.issueDraft, draft, { mode: 0o600 }); return { diagnosticId: report.diagnosticId, report: path, ...files, findings: diagnosis.findings, upload: report.upload, recoveryVerified: false }; } -function dedicatedReportPath(path) { - return /(?:\.mcp-report|chatcut-mcp-doctor-[a-f0-9-]+)\.json$/i.test(basename(path)) || - /^mcp-report(?:-\d+)?\.json$/i.test(basename(path)); -} - export async function readReport(path) { - // Do not accept config/auth/session files even as a mistakenly supplied input. - if (!dedicatedReportPath(path)) - throw new Error("Use the dedicated .mcp-report.json or default chatcut-mcp-doctor-.json report, never config/auth/session files"); - if ((await stat(path)).size > 2 * 1024 * 1024) throw new Error("Report exceeds 2 MiB; retained locally without reading"); + if (/^(?:config|auth|credentials|session)(?:\.|$)/i.test(basename(path))) + throw new Error("Use a diagnostic report, never configuration, credentials or session files"); + const info = await lstat(path); + if (!info.isFile() || info.size > 2 * 1024 * 1024) throw new Error("Report must be a regular file of at most 2 MiB"); const bytes = await readFile(path); const report = JSON.parse(bytes); if (report.schemaVersion !== 2 || report.scope !== "cloud" || !["codex", "claude"].includes(report.host) || !/^[a-f0-9-]{36}$/i.test(report.diagnosticId ?? "") || !report.originalFailure || !report.hostLogs) - throw new Error("Not a schema-2 ChatCut MCP report"); + throw new Error("Not a schema-2 Hosted ChatCut MCP report"); return { report, sourceReportSha256: createHash("sha256").update(bytes).digest("hex") }; } async function readReceipt(path, report, sourceReportSha256) { try { - if ((await stat(path)).size > 32 * 1024) throw new Error("Upload receipt exceeds 32 KiB"); + const info = await lstat(path); + if (!info.isFile() || info.size > 32 * 1024) throw new Error("Upload receipt must be a regular file of at most 32 KiB"); const stored = JSON.parse(await readFile(path, "utf8")); if (stored.schemaVersion !== 1 || stored.diagnosticId !== report.diagnosticId || stored.sourceReportSha256 !== sourceReportSha256 || !stored.receipt || @@ -46,40 +41,59 @@ async function readReceipt(path, report, sourceReportSha256) { } catch (error) { if (error.code !== "ENOENT") throw error; } } -export async function main(args = process.argv.slice(2)) { - let result; - if (["--report", "--upload-report"].includes(args[0])) { - if (args.length !== 2) throw new Error("Use --report or --upload-report alone"); - const path = resolve(args[1]); +export async function uploadReport(path, dependencies = {}) { + path = resolve(path); + const lockPath = `${path}.upload.lock`; + let lock; + try { lock = await open(lockPath, "wx", 0o600); } + catch (error) { + if (error.code === "EEXIST") throw new Error("Upload already owned or interrupted; inspect the existing receipt and lock before an authorized retry"); + throw error; + } + let release = true; + try { const { report, sourceReportSha256 } = await readReport(path); const receiptPath = `${path}.upload.json`; const prior = await readReceipt(receiptPath, report, sourceReportSha256); - if (args[0] === "--upload-report") { - if (report.upload?.status === "ingest_accepted") - throw new Error("Ingest already acknowledged this report; do not send it again"); - if (prior?.status === "ingest_accepted") throw new Error("Ingest already acknowledged this report; do not send it again"); - report.upload = await submitReport(report); - await writeFile(receiptPath, JSON.stringify({ schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, receipt: report.upload }, null, 2) + "\n", { mode: 0o600 }); - } else { - if (prior) report.upload = prior; - } - result = { path, report }; - } else { - const outputs = args.flatMap((arg, index) => arg === "--output" - ? [args[index + 1] ?? ""] : arg.startsWith("--output=") ? [arg.slice(9)] : []); - if (outputs.some(path => !dedicatedReportPath(path))) - throw new Error("Use --output or mcp-report.json so the saved report can be reused with --report and --upload-report"); - if (args.includes("--help")) { - console.log("ChatCut MCP Doctor: collector options below also produce a diagnosis, summary and private issue draft. --offline sends no requests. --report rebuilds feedback offline. --upload-report sends that report once without new probes; use only for an authorized retry. No automatic repair or ticket submission."); - } - result = await runCli(args); + if (report.upload?.status === "ingest_accepted" || prior?.status === "ingest_accepted") + throw new Error("Ingest already acknowledged this report; do not send it again"); + const uncertain = receipt => receipt?.deliveryUncertain || ["pending", "sending"].includes(receipt?.status) || + (receipt?.status === "failed" && receipt.deliveryUncertain !== false); + if (uncertain(report.upload) || uncertain(prior)) + throw new Error("Previous delivery is uncertain; verify stored fragments before considering another send"); + const persist = async receipt => { + const pending = `${receiptPath}.writing-${randomUUID()}`; + try { + await writeFile(pending, JSON.stringify({ schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, receipt }, null, 2) + "\n", { flag: "wx", mode: 0o600 }); + await rename(pending, receiptPath); + } catch (error) { await unlink(pending).catch(() => {}); throw error; } + }; + // A crash after POST must leave an uncertain delivery, never a fresh retry. + await persist({ status: "sending", storageVerified: false, deliveryUncertain: true }); + release = false; + const receipt = await submitReport(report, dependencies.fetchImpl); + await persist(receipt); + release = true; + return { diagnosticId: report.diagnosticId, report: path, sourceReportSha256, receiptPath, receipt }; + } finally { + await lock.close(); + if (release) await unlink(lockPath); } - if (result) console.log(JSON.stringify(await writePacket(result.path, result.report))); +} + +export async function main(args = process.argv.slice(2)) { + if (args.length !== 2 || !["--report", "--upload-report"].includes(args[0])) + throw new Error("Use --report for optional feedback or --upload-report for authorized delivery. Collect only when needed with capture.mjs or mcp-doctor.mjs."); + const path = resolve(args[1]); + if (args[0] === "--upload-report") { console.log(JSON.stringify(await uploadReport(path))); return; } + const { report, sourceReportSha256 } = await readReport(path); + const prior = await readReceipt(`${path}.upload.json`, report, sourceReportSha256); + if (prior) report.upload = prior; + console.log(JSON.stringify(await writePacket(path, report))); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - main().catch((error) => { - console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); - process.exitCode = 1; + main().catch(error => { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); process.exitCode = 1; }); } diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 6ac313b..6c1e7b9 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -1,12 +1,28 @@ 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"; import { parseArgs } from "node:util"; import { connect as connectTls } from "node:tls"; import { isIP } from "node:net"; -import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; +import { + evidenceSummary, + inspectMetadata, + isHostFailure, + logTime, + snapshotFromRecord, + validateSnapshot, +} from "./mcp-evidence.mjs"; import { probeMetadataWire } from "./mcp-wire.mjs"; import { failureFromRecord } from "./mcp-failure.mjs"; @@ -27,19 +43,26 @@ 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)) { - const sanitized = redact(decoded, jsonDepth + 1); - if (sanitized !== decoded) return JSON.stringify(sanitized); + if (jsonDepth < 16) + value = value.replace(/"(?:\\.|[^"\\\r\n])*"/g, (token) => { + try { + const decoded = JSON.parse(token); + 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); + } + } catch { + /* Non-JSON diagnostic text is retained for the normal rules. */ } - } catch { /* Non-JSON diagnostic text is retained for the normal rules. */ } - return token; - }); + return token; + }); return value .replace(/\b(Mcp-Session-Id[ \t]*:[ \t]*)\S+/gi, "$1[REDACTED]") .replace( @@ -50,7 +73,10 @@ export function redact(text, jsonDepth = 0) { /\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( + /((?:\\*["'])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]", @@ -80,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]" @@ -278,7 +304,7 @@ export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { params: { protocolVersion: "2025-11-25", capabilities: {}, - clientInfo: { name: "chatcut-mcp-doctor", version: "1.3.0" }, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.6" }, }, }), }); @@ -419,7 +445,12 @@ 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; + 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, "", @@ -427,7 +458,9 @@ function hostedTextRecord(line, nativeLevel) { const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); if (module) { if ( - !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::|codex_core::mcp_tool_call$)/.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); @@ -447,9 +480,12 @@ function hostedTextRecord(line, nativeLevel) { if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; 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" && + 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); + /^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)) { @@ -458,7 +494,12 @@ function hostedTextRecord(line, nativeLevel) { 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 ( + /["'](?: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); @@ -552,7 +593,11 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { 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) + if ( + window.conversationId && + context.conversationId && + context.conversationId !== window.conversationId + ) continue; if ( occurredAt && @@ -575,12 +620,45 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; if (unknownTimes) gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); - return entries.slice(-30); + return retainFailureLogs(entries, gaps, source); } finally { await handle.close(); } } +function retainFailureLogs(entries, gaps, source = "host_logs") { + const failures = entries.filter(isHostFailure); + const others = entries.filter((entry) => !isHostFailure(entry)); + const selected = new Set(); + let bytes = 0; + const newest = (values) => + values + .map((entry, index) => ({ + entry, + index, + time: Date.parse(entry.loggedAt ?? entry.occurredAt) || -Infinity, + })) + .sort((a, b) => b.time - a.time || b.index - a.index) + .map((value) => value.entry); + for (const entry of [...newest(failures), ...newest(others)]) { + const size = Buffer.byteLength(entry.message ?? ""); + if (selected.size === 30 || bytes + size > READ_LIMIT) continue; + selected.add(entry); + bytes += size; + } + if (selected.size < entries.length) + gaps.push({ + source, + reason: "failure_prioritized_log_budget", + maximumRecords: 30, + maximumMessageBytes: READ_LIMIT, + retainedMessageBytes: bytes, + omitted: entries.length - selected.size, + omittedFailureRecords: failures.filter((entry) => !selected.has(entry)).length, + }); + return entries.filter((entry) => selected.has(entry)); +} + async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { if ( readOffset !== undefined && @@ -671,9 +749,11 @@ async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = omitted: entries.length - 30, }); return { - entries: entries - .sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")) - .slice(-30), + entries: retainFailureLogs( + entries.sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")), + gaps, + "codex_app_log", + ), scan: { directoryEntriesScanned: scanned, matchingFiles: files.length, @@ -743,14 +823,28 @@ async function nativeFailedMcpItems(sqliteRoot, conversationId, window, gaps) { 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))) { + 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 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, @@ -762,34 +856,71 @@ async function nativeFailedMcpItems(sqliteRoot, conversationId, window, gaps) { 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); + 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 }); + 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) }; } + 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, + 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", + error, + ...(row.error_bytes > READ_LIMIT && { errorCaptureLimited: true }), + originalAttemptMatch: "unconfirmed", }; }); } catch (error) { - gaps.push({ source, reason: "unavailable", operation, error: errorDetails(error), + gaps.push({ + source, + reason: "unavailable", + operation, + error: errorDetails(error), ...(platform() === "win32" && error?.errcode === 14 - ? { accessEvidence: await inspectSqliteAccess(databasePath) } : {}), + ? { accessEvidence: await inspectSqliteAccess(databasePath) } + : {}), }); return []; } finally { - try { db?.close(); } - catch (error) { gaps.push({ source, reason: "close_failed", operation: "close_database", error: errorDetails(error) }); } + try { + db?.close(); + } catch (error) { + gaps.push({ + source, + reason: "close_failed", + operation: "close_database", + error: errorDetails(error), + }); + } } } @@ -823,12 +954,14 @@ export async function collectHostLogs(host, options = {}) { .prepare("PRAGMA table_info(logs)") .all() .some((column) => column.name === "thread_id"); - const threadFilter = hasThreadId && options.conversationId - ? " AND (thread_id IS NULL OR thread_id='' OR thread_id=?)" : ""; + 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`, - ); + `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"; @@ -843,8 +976,10 @@ export async function collectHostLogs(host, options = {}) { context.conversationId !== options.conversationId ) continue; - if (!hostedTextRecord(`${row.target}: ${original}`, row.level) || - !hostedTextRecord(`MCP server "chatcut" ${message}`)) + if ( + !hostedTextRecord(`${row.target}: ${original}`, row.level) || + !hostedTextRecord(`MCP server "chatcut" ${message}`) + ) continue; if ( !["ERROR", "WARN"].includes(row.level) && @@ -881,10 +1016,23 @@ export async function collectHostLogs(host, options = {}) { : {}), }); } finally { - try { db?.close(); } - catch (error) { gaps.push({ source: "codex_runtime_sqlite", reason: "close_failed", operation: "close_database", error: errorDetails(error) }); } + 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); + nativeFailedCalls = await nativeFailedMcpItems( + sqliteRoot, + options.conversationId, + { since, until }, + gaps, + ); try { entries.push( ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { @@ -899,15 +1047,27 @@ export async function collectHostLogs(host, options = {}) { 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"); + 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, + since, + until, + conversationId: options.conversationId, }); - entries.push(...captured.map(({ occurredAt, ...entry }) => ({ - ...entry, loggedAt: occurredAt, timestampKind: "native_log_time_not_original_failure_time", - }))); + 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) }); + gaps.push({ + source: "codex_stderr_log", + reason: "unavailable", + error: errorDetails(error), + }); } } const appData = options.localAppData ?? process.env.LOCALAPPDATA; @@ -976,7 +1136,7 @@ export async function collectHostLogs(host, options = {}) { if (!entries.length && !nativeFailedCalls?.length) gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); return clean({ - entries, + entries: retainFailureLogs(entries, gaps), gaps, ...(appLog ? { appLog } : {}), ...(nativeFailedCalls ? { nativeFailedCalls } : {}), @@ -988,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"); @@ -1035,22 +1199,24 @@ export async function submitReport(report, fetchImpl = fetch) { report.hostSnapshot?.session?.connectionStatus === "disabled") return { status: "skipped_intentionally_disabled", storageVerified: false }; if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; + let attempted = false; + let rejected = false; try { const endpoint = serverUrl(report.serverUrl); const events = fragmentEvents(report); + attempted = true; const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "content-type": "application/json" }, body: JSON.stringify(events), }); + rejected = !response.ok; const body = await limitedBody(response, 16 * 1024); if (!response.ok || JSON.parse(body).ok !== true) throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); - return { - status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, - reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256, - }; + return { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, + reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256 }; } catch (error) { - return { status: "failed", storageVerified: false, error: errorDetails(error) }; + return { status: "failed", storageVerified: false, deliveryUncertain: attempted && !rejected, error: errorDetails(error) }; } } @@ -1072,12 +1238,14 @@ export async function runDoctor(options, dependencies = {}) { } 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 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.3.0", + collectorVersion: "1.2.6", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", @@ -1139,7 +1307,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) { @@ -1177,8 +1366,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"); @@ -1202,11 +1401,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) { @@ -1238,6 +1455,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, @@ -1258,6 +1494,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 || @@ -1269,7 +1508,7 @@ export async function runDoctor(options, dependencies = {}) { ? "host_reports_intentionally_disabled; no_requests_sent" : options.offline ? "offline_requested; no_requests_sent" - : "registered_endpoint_unavailable; no_requests_sent", + : "registered_endpoint_unavailable; no_requests_sent", }); } else { report.entryProbe = await guardProbe(endpoint.href, () => @@ -1372,22 +1611,21 @@ export async function runDoctor(options, dependencies = {}) { } } report.evidenceSummary = evidenceSummary(report); + report.collectionStatus = "complete"; + report.collectionStage = "complete"; report.upload = { status: disabled ? "skipped_intentionally_disabled" : options.offline ? "skipped_offline" - : !endpoint - ? "skipped_endpoint_unavailable" - : options.upload - ? "pending" - : "not_requested", + : !endpoint + ? "skipped_endpoint_unavailable" + : options.upload + ? "pending" + : "not_requested", storageVerified: false, }; - const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); - const save = async (flag) => - writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); - await save("wx"); + await save("w"); if (options.upload && !options.offline && !disabled && endpoint) { report.upload = await submitReport(report, fetchImpl); await save("w"); @@ -1395,10 +1633,9 @@ export async function runDoctor(options, dependencies = {}) { return { path, report: clean(report) }; } -export async function runCli(args = process.argv.slice(2)) { +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const { values } = parseArgs({ - args, options: { host: { type: "string" }, "server-url": { type: "string" }, @@ -1486,16 +1723,14 @@ export async function runCli(args = process.argv.slice(2)) { path, upload: report.upload, evidenceGaps: report.evidenceSummary.gaps, + hostFailureEvidence: report.evidenceSummary.failureEvidence, collectionGaps: report.gaps, recoveryVerified: false, }), ); - return { path, report }; } } catch (error) { console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); process.exitCode = 1; } } - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await runCli(); diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs index 5a35595..9af54e7 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -138,12 +138,17 @@ export function validateSnapshot(input, endpoint) { "lookupResult", ]); source(item.source); - if (item.lookupResult !== undefined && - (item.stage !== "host_registration" || item.operation !== "lookup_registered_server" || - !["cli_configuration", "host_settings"].includes(item.source) || - !["not_found", "found", "unavailable"].includes(item.lookupResult) || - (item.lookupResult === "not_found" && item.outcome === "unknown"))) - throw new Error("lookupResult is a host registration lookup result: not_found only for an explicit absence response; found or unavailable otherwise"); + if ( + 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) || @@ -179,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; @@ -197,11 +287,93 @@ export function stageHint(message) { return "unclassified"; } +export function isHostFailure(entry) { + if ( + ![ + "codex_runtime_sqlite", + "codex_tui_log", + "codex_stderr_log", + "codex_app_log", + "claude_debug_log", + ].includes(entry.source) + ) + return false; + if (entry.source === "codex_app_log") + return [...String(entry.message).matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed"; + const level = + entry.level ?? + String(entry.message) + .match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i) + ?.slice(1) + .find(Boolean); + return ( + ["WARN", "ERROR"].includes(String(level).toUpperCase()) || + /\b(?:connection failed|discovery failed|auth error|error during auth|MCP tool call error|startup failed|HTTP Connection failed)\b|(?:MCP server|Server)\s+["']?(?:plugin:chatcut:)?chatcut["']?:\s*Error\b/i.test( + entry.message, + ) + ); +} + +export function hostFailureEvidence(report) { + const original = report.originalFailure; + const logs = (report.hostLogs.entries ?? []).flatMap((entry, index) => { + if (!isHostFailure(entry)) return []; + const sameCall = + original.status === "recorded" && + original.conversationId && + original.callId && + entry.conversationId === original.conversationId && + entry.callId === original.callId; + return [ + { + evidence: `hostLogs.entries[${index}]`, + errorText: `hostLogs.entries[${index}].message`, + source: entry.source, + ...(entry.sourceFile && { sourceFile: entry.sourceFile }), + ...(entry.rowId !== undefined && { rowId: entry.rowId }), + loggedAt: entry.loggedAt ?? entry.occurredAt ?? null, + timeSource: "host_log_event_time; not_original_failure_occurredAt", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), + extractionRule: + "admitted_native_error_warning_or_explicit_failure_event; original_block_retained", + localCallBinding: sameCall + ? "same_conversation_and_call_id; original_record_agent_supplied" + : "unconfirmed; may_not_be_the_reported_failure", + originalAttemptMatch: "unconfirmed", + }, + ]; + }); + const native = (report.hostLogs.nativeFailedCalls ?? []).flatMap((entry, index) => + entry.status === "failed" && entry.error + ? [ + { + evidence: `hostLogs.nativeFailedCalls[${index}]`, + errorText: `hostLogs.nativeFailedCalls[${index}].error`, + source: entry.source, + conversationId: entry.conversationId, + nativeItemId: entry.nativeItemId, + idKind: entry.idKind, + startedAtMs: entry.startedAtMs, + completedAtMs: entry.completedAtMs, + timeSource: entry.timeSource, + extractionRule: "scoped_native_failed_mcp_item_error_projection", + localCallBinding: "native_item_only; not_bound_to_original_call_or_server_request", + originalAttemptMatch: "unconfirmed", + }, + ] + : [], + ); + return [...logs, ...native]; +} + export function evidenceSummary(report) { const snapshot = report.hostSnapshot; 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"); @@ -210,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" && @@ -220,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"); @@ -249,25 +432,41 @@ 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, + 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, + failureEvidence: hostFailureEvidence(report), 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, + 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", + 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", @@ -283,11 +482,16 @@ export function evidenceSummary(report) { : null, hostLogCoverage: { originalErrorRetained: report.originalFailure.status === "recorded", - originalErrorRetainedWithoutDatabase: report.originalFailure.status === "recorded" && databaseReadFailures.length > 0, + originalErrorRetainedWithoutDatabase: + report.originalFailure.status === "recorded" && databaseReadFailures.length > 0, databaseReadFailures, - runtimeCollectionFailed: (report.hostLogs.gaps ?? []).some(gap => gap.source === "host_logs" && gap.reason === "collector_failed"), + 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, + retainedStderrRecords: report.hostLogs.entries.filter( + (entry) => entry.source === "codex_stderr_log", + ).length, priorStderrCaptureRequired: false, records: report.hostLogs.entries.length, appStartupRecords: appNotices.length, diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs index cb1c8f2..c472e8d 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs +++ b/claude/skills/chatcut-mcp-doctor/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/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs index c763b60..fb6a2a6 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -1,49 +1,76 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { lstat, readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { readReport } from "./doctor.mjs"; -import { redact } from "./mcp-doctor.mjs"; -import { readActiveCase } from "./active-case.mjs"; +import { redact, serverUrl } from "./mcp-doctor.mjs"; + +async function evidenceFile(path) { + if (typeof path !== "string" || /(?:^|[.])(config|auth|credentials|session)(?:[.]|$)/i.test(basename(path))) + throw new Error("Supply only the exposed tool result, never configuration/auth/session files"); + const info = await lstat(path); + if (!info.isFile() || info.size > 128 * 1024) throw new Error("Evidence must be a regular file of at most 128 KiB"); + const value = await readFile(path, "utf8"); + if (!value.trim()) throw new Error("Evidence is empty"); + return value; +} + +const fact = value => typeof value === "string" && value.trim() && value.length <= 1000 ? redact(value) : null; export async function recordRecheck(options) { - if (Boolean(options.report) === Boolean(options.activeCase)) throw new Error("Choose --active-case for the program-tracked incident, or an explicit --report"); - const active = options.activeCase ? await readActiveCase(options.cwd ?? process.cwd()) : null; - const path = active ? active.report : resolve(options.report); + if (!options.report || options.activeCase) throw new Error("Explicit --report is required; no active-case or time-based report selection"); + const path = resolve(options.report); const { report, sourceReportSha256 } = await readReport(path); - if (!["success", "failed"].includes(options.outcome) || options.operation !== "list_projects" || - typeof options.scope !== "string" || !options.scope.trim() || options.scope.length > 1000) - throw new Error("Record the actual safe list_projects result, outcome and same-host/scope boundary"); - if (!options.result.endsWith(".mcp-recheck.txt")) throw new Error("Use the dedicated .mcp-recheck.txt exposed result only"); - const info = await lstat(options.result); - if (!info.isFile() || info.size > 64 * 1024) throw new Error("Recheck result must be a regular file of at most 64 KiB"); - const result = redact(await readFile(options.result, "utf8")); - if (!result.trim()) throw new Error("Recheck result is empty"); + if (!["codex", "claude"].includes(options.host) || !["success", "failed"].includes(options.outcome) || + !/^[A-Za-z0-9_.:/-]{1,256}$/.test(options.operation ?? "")) + throw new Error("Record the actual Hosted native tool operation, host and outcome"); + if (options.source && options.source !== "native_tool_result") + throw new Error("Configuration, tool discovery and independent HTTP probes are not native tool results"); + const endpoint = options.serverUrl ? serverUrl(options.serverUrl).href : null; + if (report.serverUrl && endpoint && endpoint !== report.serverUrl) + throw new Error("Different target service; preserve it separately instead of recording an incident recheck"); + const result = redact(await evidenceFile(options.result)); if (options.outcome === "success") { let data; - try { data = JSON.parse(result); } catch { /* CLI configuration text is not a tool result. */ } - if (!data || data.isError === true || data.error || - !Array.isArray(data.projects ?? data.structuredContent?.projects)) - throw new Error("Successful list_projects recording needs the actually exposed projects[] JSON (or structuredContent.projects[]). Native mcp get/configuration text is not a tool result. Preserve it separately and wait for the actual safe native read; do not manufacture JSON or replay a failed tool."); + try { data = JSON.parse(result); } catch { /* Text alone does not establish a native success. */ } + if (!data || typeof data !== "object" || data.isError === true || data.error || + data.mcpServers || data.transport || data.tools || data.configuration || + (typeof data.status === "number" && (data.body !== undefined || data.headers !== undefined))) + throw new Error("Need the actually exposed native tool result; configuration, tool lists and independent probes are not a successful recheck"); + if (options.operation === "list_projects" && !Array.isArray(data.projects ?? data.structuredContent?.projects)) + throw new Error("The exposed list_projects result must contain projects[]; a configuration response is not a project listing"); } - const observation = { schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, - recordedAt: new Date().toISOString(), observedAt: null, - source: "agent_supplied_native_tool_result; not_independently_verified", host: report.host, - scope: redact(options.scope), operation: options.operation, outcome: options.outcome, message: result, - originalReportUnchanged: true, originalAttemptAssociation: "unconfirmed", accountAuthentication: "unconfirmed" }; - const output = path + ".recheck.json"; + if (options.observedAt !== undefined && + (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.observedAt) || !Number.isFinite(Date.parse(options.observedAt)))) + throw new Error("Observed time must come from the tool event; omit it when unknown"); + const observation = { + schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, + recordedAt: new Date().toISOString(), observedAt: options.observedAt ?? null, + source: "agent_supplied_native_tool_result; not_independently_verified", + host: options.host, configurationScope: fact(options.configScope), serverUrl: endpoint, + projectId: fact(options.projectId), conversationId: fact(options.conversationId), callId: fact(options.callId), + operation: options.operation, outcome: options.outcome, message: result, + originalReportUnchanged: true, originalAttemptAssociation: "unconfirmed", + accountAuthentication: "unconfirmed", recoveryVerified: false, + }; + if (createHash("sha256").update(await readFile(path)).digest("hex") !== sourceReportSha256) + throw new Error("Original report changed during recheck recording"); + const output = `${path}.recheck-${randomUUID()}.json`; await writeFile(output, JSON.stringify(observation, null, 2) + "\n", { flag: "wx", mode: 0o600 }); - if (createHash("sha256").update(await readFile(path)).digest("hex") !== sourceReportSha256) throw new Error("Original report changed during recheck recording"); - const note = `${path}.recheck.zh-CN.md`; - await writeFile(note, `# 本轮安全复验\n\n诊断编号:${report.diagnosticId}\n宿主/范围:${report.host};${observation.scope}\n实际操作:原生 list_projects;结果:${options.outcome}。\n已暴露结果:${result}\n\n记录时间:${observation.recordedAt};原生事件时间未提供,保持未知。上述事实由 Agent 从实际工具结果单独记录;配置/账号认证及原故障恢复未据此确认。原始报告及其 SHA-256 保持不变。没有上报或开工单。\n`, { flag: "wx", mode: 0o600 }); - return { ...observation, path: output, chineseNote: note }; + return { ...observation, path: output }; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const { values } = parseArgs({ options: { report: { type: "string" }, "active-case": { type: "boolean", default: false }, result: { type: "string" }, - outcome: { type: "string" }, operation: { type: "string" }, scope: { type: "string" } } }); - recordRecheck({ ...values, activeCase: values["active-case"] }).then(x => console.log(JSON.stringify(x))).catch(error => { + try { + const { values } = parseArgs({ options: Object.fromEntries([ + "report", "result", "outcome", "operation", "host", "server-url", "config-scope", "project-id", + "conversation-id", "call-id", "observed-at", "source", + ].map(name => [name, { type: "string" }])) }); + console.log(JSON.stringify(await recordRecheck({ ...values, serverUrl: values["server-url"], + configScope: values["config-scope"], projectId: values["project-id"], conversationId: values["conversation-id"], + callId: values["call-id"], observedAt: values["observed-at"] }))); + } catch (error) { console.error(JSON.stringify({ recheckRecordingFailed: true, message: redact(error.message) })); process.exitCode = 1; - }); + } } 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 7ca1dc0..58d583c 100644 --- a/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md +++ b/claude/skills/chatcut-plugin-basics-claude/references/connection-diagnostics.md @@ -1,3 +1,412 @@ -# Hosted MCP Doctor +# Hosted MCP connection diagnostics -For a hosted ChatCut connection or tool failure, load the independent [ChatCut MCP Doctor Skill](../../chatcut-mcp-doctor/SKILL.md). It runs locally even when the ChatCut MCP is unavailable. Preserve the original error before retries. The former scripts/mcp-doctor.mjs entry remains compatible. +Use this branch when a registered hosted ChatCut connection fails, including +OAuth failing before a browser opens, or authentication completing without usable +tools, and failures of already available hosted tools. Preserve evidence before reconnecting or restarting. This version gathers +evidence; it does not repair configuration, credentials, network settings, or the +original connection. + +Briefly tell the user that you will collect ChatCut connection diagnostics and +send the redacted report to ChatCut. Run under the host's existing execution and +network permissions; do not add a separate manual step when those permit it. +Honor a refusal or a denied read/network operation. Do not change permission +settings to make collection work. + +Invoke each helper as a standalone `node` command with its quoted path. Do not +combine it with `cd`, shell variables, pipelines, or other commands; command +allowlists may permit the helper while rejecting the combined shell operation. +Use the host's file-writing tool for the dedicated snapshot and error file. + +## Capture the current host, including unfamiliar failures + +The Agent creates a dedicated `.mcp-snapshot.json` from facts already available +through the current host. The user does not fill a form. Do not copy an entire +configuration, credential file, environment, or conversation. Preserve unfamiliar +errors as well as known OAuth errors; classify uncertain stages as unknown. +Static CLI configuration describes registration, not tools loaded into the +current chat. Browser sign-in does not establish MCP authentication. + +First generate a new snapshot template with the bundled helper, then add only +observed groups from the schema below. This sets the snapshot schema and capture +time mechanically; it does not invent the original failure time or host state: + +```text +node /scripts/mcp-doctor.mjs --create-snapshot +``` + +Validate the completed file locally before collecting or uploading: + +```text +node /scripts/mcp-doctor.mjs --validate-snapshot --server-url +``` + +Snapshot `schemaVersion` is **1**, independently of the outer report version. +Keep unavailable groups omitted. Each observation's `observedAt` may be `null` +when its actual time is unknown. If validation fails, correct only the reported +schema error using already available facts; do not guess times or relax the +credential/config file restriction. If facts remain unavailable, collect the +other evidence and preserve the specific gap. + +Snapshot schema 1 accepts this shape; omit unavailable groups, use null for an +unknown tool count/enabled flag, and label each group's actual source: + +```json +{ + "schemaVersion": 1, + "observedAt": "2026-10-22T12:00:00Z", + "hostVersion": "actual-running-host-version", + "pluginVersion": "actual-loaded-plugin-version-if-known", + "configuration": { + "source": "host_settings", + "serverName": "chatcut", + "url": "https://api.chatcut.io/api/external-mcp/mcp", + "enabled": true + }, + "session": { + "source": "current_session", + "conversationId": "host-chat-id-if-available", + "connectionStatus": "failed", + "authStatus": "unknown", + "visibleToolCount": 0, + "visibleTools": [] + }, + "observations": [ + { + "stage": "initialize", + "operation": "initialize", + "source": "runtime_log", + "observedAt": "2026-10-22T11:59:00Z", + "outcome": "failed", + "requestId": "server-request-id-only-if-recorded", + "message": "Exact original error, including an unfamiliar error code" + } + ] +} +``` + +Sources: `current_session`, `tool_search`, `host_settings`, +`cli_configuration`, `runtime_log`. A session group cannot claim +`cli_configuration` as its source. Connection status is `connected`, `failed`, +`needs_authentication`, `disabled`, or `unknown`; auth status is +`authenticated`, `needs_authentication`, or `unknown`. +Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` +(`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, +`protocolCode`, `message`, and `originalAttemptMatch` (`confirmed`/`unconfirmed`). +Omit the match flag when unknown. Set `confirmed` only when the captured failed +runtime/session observation explicitly identifies the original failed attempt +and its actual server request ID; registration, nearby timestamps, successful +operations, and later probe IDs are not confirmation. An original error must +also have been recorded for that observation to close the original-request gap. +This remains Agent-supplied confirmation, not independent helper verification. +New stage names are preserved. Do not put an +MCP session credential in `conversationId` or guess a server request ID. +Only include ChatCut tool names; keep tool arguments and results out. + +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 +the existing bounded reads and a global 30-record / 128 KiB text-message budget. +When a complete record cannot fit, the report names the omitted record count; +it does not trim an error to make it appear complete. Native failed-item error +projections remain separately bounded and the complete local report is retained +when the upload budget is exceeded. + +`evidenceSummary.failureEvidence` mechanically indexes the original retained +error blocks and failed native items. It records their actual source, log event +time or native lifecycle, and available local call/item identifiers. Read the +referenced original block for the full message and cause. A matching conversation +and call ID is only a local binding to the Agent-supplied original record; nearby +times, one remaining candidate, and the current collection conversation are not +proof that a log is the user's reported failure. No candidate is automatically +selected as `originalFailure`, and its log time never fills `occurredAt`. + +When no original failure record was supplied, the helper can still retain these +host failures but cannot tell whether they are the user's reported attempt. +Codex App startup notices, Claude debug logs, and native local IDs do not provide +a missing server X-Request-ID. The helper cannot capture the host's original TLS +bytes or HTTP response before its parser; later independent probes remain +separate evidence. If the host discarded the original tool error, time, or +response header and no accessible source retained it, those fields remain gaps. + +## Run once for the reported failure + +### 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. +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. + +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 +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. + +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 + silently probing production. If no registered URL is available, omit + `--server-url` to collect a local report without probing or uploading. + Never read credential stores or paste complete + configuration, environment, or conversation dumps into the report. + An intentionally disabled hosted server is not a failed connection; preserve + that choice instead of starting diagnostics or re-enabling it. +2. Save only the original connection error and its available cause chain into a + new temporary text file. Use the exact original text, not a generated root + cause. If unavailable, omit `--original-error`. Obtain the actual bundled + host version if accessible without starting a session. Do not treat update + metadata as the running version. +3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: + + ```text + node /scripts/mcp-doctor.mjs --host codex --server-url --host-snapshot --original-error --host-version --upload + ``` + + For Claude Code, use `--host claude`. Quote paths as shell arguments; do not + interpolate error text into shell code. Omit unknown optional fields. Supply + `--occurred-at` only when the failure's actual ISO timestamp and timezone are + known. A missing timestamp remains unknown; the collection time is separate. + If transfer is denied, omit `--upload` and retain the local JSON report. + +Use an available Node.js 18+ runtime. Do not install a runtime or ChatCut Desktop +just for diagnostics. Codex SQLite extraction additionally needs `node:sqlite` +(available on recent Node 22+/24 runtimes). Without it, other collection still +runs and the report marks the missing SQLite evidence. A denied file read is a +coverage gap, not proof that the server has no errors. + +The helper reads only bounded ChatCut MCP runtime log records or debug log tails. +Codex SQLite locations follow `CODEX_SQLITE_HOME` when present. If the current +host already supplies its actual `sqlite_home` or `log_dir`, pass that directory +with `--codex-sqlite-home` or `--codex-log-dir`; do not read whole config files to +find it. These options select existing log locations and do not enable logging. +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 +server, uses the current conversation ID when available, and keeps at most 30 +records from three 128 KiB tails. Missing/rotated/denied logs remain explicit +gaps. The known Windows MXC `LOCALAPPDATA` sandbox redirect falls back to the +real user's `AppData/Local` without changing permissions. An already supplied alternative App log root can be selected with +`--codex-app-log-dir`; do not scan browser profiles or session transcripts. + +The report names selected/omitted native files and byte ranges that were not read. +For an existing reported gap, use `--codex-app-log-file ` +to select at most three known native files. With exactly one selected file, +`--codex-app-log-offset ` reads at most 128 KiB from +that segment; unread ranges remain explicit. Preserve the original failure's +known time/window. Do not repeatedly upload replacement reports or scan entire +log history to fill an unknown failure time. App startup notices cover startup; +they do not cover every later tool-call failure. SQLite/native log extraction +retains available local conversation/call IDs without promoting them to original +server request IDs or independently confirmed attempt associations. +For a separately chosen future reproduction, Codex's explicit `log_dir` setting +can enable its native plaintext log. Do not change global settings or relaunch +the user's host automatically. Existing logs cannot be reconstructed retroactively. +It never reads `auth.json`, OAuth credential stores, session transcripts, or +other project files. It makes one unauthenticated initialize probe and at most +five public metadata GETs, all within the selected origin and with a 3.5-second +timeout each. An anonymous 401 challenge is expected. It validates resource / +issuer consistency and this product's OAuth-code/PKCE metadata. It never logs in, +refreshes credentials, or lists tools using extracted credentials. + +Codex exec prints diagnostics inline; `--json` stdout alone is not a complete +MCP error stream. Preserve the original stderr/startup error when it is already +visible to the current host. The helper cannot recover discarded parent-process +stderr or reconstruct an old exec failure from App logs. + +If the current host already exposes native `mcpServer/startupStatus/updated` +notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, +`runtimeStatus` and `toolsError` from that same thread in the error file and +snapshot observations (`source: current_session`). Record the native method and +actual thread ID. Authentication inventory alone is not proof of initialization +or usable tools. The status query may perform new auth-discovery requests; keep +those separate from retained runtime errors. Do not start another app-server, +start/reload a thread, invoke OAuth, or launch a daemon to obtain the original +state. These interfaces are not guaranteed to be exposed to a third-party Skill. +`codex doctor --json` is a general installation/config/auth health check, not a +targeted export of the failed MCP attempt. Do not ingest its entire report. +`feedback/upload` submits to OpenAI; it is not ChatCut's log export endpoint. + +The CLI also makes at most two sequential anonymous metadata GETs to compare +the relevant path-scoped and root endpoints at the HTTP/1.1 framing level. +`wireProbes` records selected response headers, request ID, first framing byte, +chunk validity, bounded byte counts/hashes and peer/TLS evidence from that same +connection. It retains no metadata body, cookies or credentials. Each probe +stops at 128 KiB / 3.5 seconds. These are direct connections, explicitly outside +the host/fetch proxy route; normal certificate verification remains enabled. +Complete fixed-length/chunked messages finish at their verified boundary without +waiting for peer close. Invalid HTTP status lines and unsupported informational +responses never count as complete final responses. `completionReason` and +`peerEndObserved` distinguish message completion from connection termination. + +When an HTTPS request/body fails, one additional direct TLS connection may +record the peer certificate issuer and protocol, with the same timeout and +normal certificate verification. This is a separately labelled route, not the +original host's TLS/proxy path. Proxy/CA environment presence is boolean only. +A healthy later check cannot establish recovery or identify antivirus/VPN as +the cause. A disabled connection sends neither probes nor an upload. + +## Interpret the report + +- `originalFailure` is the original error supplied by the Agent. `hostLogs` + are matching records in a bounded time window, with unconfirmed attempt + association. `probes` are new requests made later. Keep these separate. +- `hostSnapshot` is Agent-supplied evidence with per-group sources, not an + independently verified host API result. `evidenceSummary` keeps original + stage observations separate from independent checks. Unknown stages and + unfamiliar errors remain available; missing stages are `not_observed`. +- Timestamped text records outside the requested window are excluded. Records + without parseable timestamps are labelled time-unknown, never window matches. +- Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. + A healthy later probe never proves the original host connection recovered. +- Preserve original error messages and codes. Redact actual credentials, not + entire error categories. No separate metadata response body is retained; + original parser errors may quote response text. +- Missing debug logs cannot be reconstructed. Claude's detailed debug logs may + require `claude --debug=mcp` during a future, separately chosen reproduction; + this helper does not enable logging or relaunch Claude. +- A `diagnosticId` identifies the diagnostic report and its new probes. It is + not the original failed request's ID. Server response request IDs are useful + only when actually present and matched; time/path matching is weaker evidence. +- Match a wire response's `x-request-id` with the server's metadata response + record. The application record observes bytes before Node's HTTP framing; + a successful finish does not prove valid framing after Node/ALB/local HTTPS + inspection, nor receipt by Codex. Different requests may have different paths. + +The 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. +Output larger than the upload budget stays local with an explicit upload error. + +Return the diagnostic ID, local report location, one new observed fact, and the +next action justified by it. If no new actionable fact was obtained, state the +specific missing evidence. Do not assign blame to antivirus, VPN, firewall, host, +or service merely from a generic failure message. Do not claim a user failure +percentage from diagnostic samples. + +This branch runs when the Agent loads it and has access to local execution. It +is not an installation hook or an all-user failure monitor. It cannot inspect a +web host's remote connection process or internal logs. With Claude Code tool +search disabled, connection failures may not be passed to the Agent at all. diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs index 0e45dfe..6c1e7b9 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -1,6 +1,1736 @@ -// Compatibility entry; the standalone Doctor owns the collector. -export * from "../../chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; -import { resolve } from "node:path"; +import { randomUUID, createHash } from "node:crypto"; +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"; -import { runCli } from "../../chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await runCli(); +import { parseArgs } from "node:util"; +import { connect as connectTls } from "node:tls"; +import { isIP } from "node:net"; +import { + evidenceSummary, + inspectMetadata, + isHostFailure, + logTime, + snapshotFromRecord, + validateSnapshot, +} from "./mcp-evidence.mjs"; +import { probeMetadataWire } from "./mcp-wire.mjs"; +import { failureFromRecord } from "./mcp-failure.mjs"; + +const READ_LIMIT = 128 * 1024; +const UPLOAD_LIMIT = 256 * 1024; +const TIMEOUT_MS = 3500; +const CHATCUT_MESSAGE = + /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|\bplugin(?:_name)?[=: "\t]+chatcut(?:@chatcut-inc)?(?:["\s}:]|$)|https:\/\/(?:api\.chatcut\.(?:io|dev)|api-dev\.chatcut\.org)\//i; +const TEXT_ECHO = + /^(?:user (?:prompt|message)|assistant message|tool (?:call|result))\s*:|^(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[]/i; + +function redactFieldValue(_match, prefix, value) { + if (!value) return prefix; + const quote = value.match(/^\\*["']/)?.[0]; + return prefix + (quote ? quote + "[REDACTED]" + quote : "[REDACTED]"); +} + +export function redact(text, jsonDepth = 0) { + let value = String(text); + // A host may quote/stringify an entire JSON response inside its error. Decode + // only JSON string tokens containing 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|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); + } + } catch { + /* Non-JSON diagnostic text is retained for the normal rules. */ + } + return token; + }); + return value + .replace(/\b(Mcp-Session-Id[ \t]*:[ \t]*)\S+/gi, "$1[REDACTED]") + .replace( + /(["']mcp[-_]session[-_]id["']\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*')/gi, + redactFieldValue, + ) + .replace( + /\b((?:mcp[-_]session[-_]id)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^\s"'<>]+)/gi, + redactFieldValue, + ) + .replace( + /((?:\\*["'])mcp[-_]session[-_]id(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, + "$1[REDACTED]", + ) + .replace( + /\bBearer\s+(?!(?:token|required)\b|<|(?:realm|scope|error(?:_description|_uri)?|resource_metadata)\s*=)[A-Za-z0-9._~+/=-]+/gi, + "Bearer [REDACTED]", + ) + .replace(/\b(Authorization\s*[:=]\s*Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[REDACTED]") + .replace( + /\b((?:set-)?cookie\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|\[REDACTED\]|(?:(?![\r\n]|(?]+/gi, + "$1[REDACTED]", + ) + .replace( + /\b((?:access_?token|refresh_?token|id_?token|session_?token|client_?secret|api[_-]?key|token)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|[A-Za-z0-9._~+/=-]+)/gi, + redactFieldValue, + ) + .replace( + /((?:\\*["'])(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, + "$1[REDACTED]", + ) + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"); +} + +function clean(value) { + return JSON.parse( + JSON.stringify(value, (key, item) => + /^(?:mcp[-_]session[-_]id|(?:proxy[-_])?authorization|(?:x[-_])?(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|token)|cookie|set-cookie)$/i.test( + key, + ) + ? "[REDACTED]" + : typeof item === "string" + ? redact(item) + : item, + ), + ); +} + +export function errorDetails(error, seen = new Set()) { + if (!error || typeof error !== "object") return { message: redact(error) }; + if (seen.has(error) || seen.size >= 8) return { unavailable: "error_chain_depth_or_cycle" }; + seen.add(error); + const result = {}; + for (const key of ["name", "message", "code", "errno", "errcode", "errstr", "syscall", "stack"]) { + if (typeof error[key] === "string" || typeof error[key] === "number") result[key] = error[key]; + } + if (error.cause) result.cause = errorDetails(error.cause, seen); + if (Array.isArray(error.errors)) { + result.errors = error.errors.slice(0, 8).map((item) => errorDetails(item, seen)); + if (error.errors.length > 8) result.omittedErrors = error.errors.length - 8; + } + return clean(result); +} + +export function serverUrl(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + const hosted = ["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes( + url.hostname, + ); + if ( + (!local && (!hosted || url.protocol !== "https:" || (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/api\/external-mcp\/(?:mcp|claude|v2\/mcp)\/?$/.test(url.pathname) + ) { + throw new Error( + "Unsupported registered MCP URL; preserve its configuration and report unsupported_endpoint", + ); + } + return url; +} + +async function limitedBody(response, limit = READ_LIMIT) { + const reader = response.body?.getReader(); + if (!reader) return ""; + const parts = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return Buffer.concat(parts).toString("utf8"); + bytes += value.length; + if (bytes > limit) { + const error = new Error(`Response exceeds ${limit} bytes; body not retained`); + error.code = "DIAGNOSTIC_BODY_LIMIT"; + throw error; + } + parts.push(Buffer.from(value)); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export async function probeMetadata(url, diagnosticId, fetchImpl = fetch, expected = {}) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url, + startedAt, + implementation: `node-fetch/${process.version}`, + stage: "request", + status: null, + bodyComplete: false, + jsonValid: null, + originalAttemptMatch: "unconfirmed", + }; + try { + const response = await fetchImpl(url, { + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { accept: "application/json", "x-chatcut-diagnostic-id": diagnosticId }, + }); + result.status = response.status; + result.responseHeaders = {}; + for (const key of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + "www-authenticate", + "retry-after", + "location", + ]) { + const value = response.headers.get(key); + if (value !== null) result.responseHeaders[key] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + result.stage = "json_parse"; + try { + const parsed = JSON.parse(body); + result.jsonValid = true; + result.metadataShape = { + object: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed), + issuerPresent: typeof parsed?.issuer === "string", + resourcePresent: typeof parsed?.resource === "string", + }; + result.metadataValidation = inspectMetadata(parsed, url, expected); + } catch (error) { + result.jsonValid = false; + throw error; + } + result.stage = "complete"; + const required = new URL(url).pathname.includes("oauth-protected-resource") + ? "resourcePresent" + : "issuerPresent"; + result.outcome = !response.ok + ? "http_rejected" + : result.metadataShape.object && result.metadataShape[required] + ? result.metadataValidation.valid + ? "metadata_read" + : "invalid_metadata" + : "invalid_metadata_shape"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +async function guardProbe(url, operation) { + const startedAt = new Date().toISOString(); + try { + return await operation(); + } catch (error) { + return { + evidenceKind: "later_probe", + url, + startedAt, + outcome: "failed", + stage: "probe_collector", + status: null, + originalAttemptMatch: "unconfirmed", + error: errorDetails(error), + gap: "partial_probe_evidence_unavailable; original_failure_retained", + }; + } +} + +export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: endpoint.href, + method: "POST", + operation: "initialize", + implementation: "node-fetch/" + process.version, + startedAt: new Date().toISOString(), + stage: "request", + status: null, + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + }; + try { + const response = await fetchImpl(endpoint.href, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "x-chatcut-diagnostic-id": diagnosticId, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: diagnosticId, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.6" }, + }, + }), + }); + result.status = response.status; + result.responseHeaders = {}; + for (const name of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "www-authenticate", + "retry-after", + "location", + "x-request-id", + "x-amzn-trace-id", + "server", + "via", + ]) { + const value = response.headers.get(name); + if (value !== null) result.responseHeaders[name] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + let parsed; + try { + parsed = JSON.parse(body); + } catch (error) { + result.parseError = errorDetails(error); + if (!response.ok) result.responseError = { message: redact(body) }; + } + if (parsed?.error) result.protocolError = clean(parsed.error); + if (response.status === 401) { + const header = response.headers.get("www-authenticate") ?? ""; + const metadataHint = header.match(/resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i); + result.resourceMetadataUrl = metadataHint?.[1] ?? metadataHint?.[2] ?? null; + result.outcome = /\bBearer\b/i.test(header) + ? "authentication_challenge" + : "missing_authentication_challenge"; + } else if (!response.ok) result.outcome = "http_rejected"; + else if (parsed?.error) result.outcome = "protocol_error"; + else if (parsed?.jsonrpc === "2.0" && typeof parsed.result?.protocolVersion === "string") { + result.outcome = "anonymous_initialize_returned"; + result.serverInfo = parsed.result.serverInfo; + result.protocolVersion = parsed.result.protocolVersion; + } else result.outcome = "invalid_initialize_response"; + result.stage = "complete"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +export function inspectTransport(endpoint) { + if (endpoint.protocol !== "https:") + return Promise.resolve({ status: "not_applicable", reason: "not_tls" }); + return new Promise((resolveProbe) => { + const startedAt = new Date().toISOString(); + let settled = false; + const host = endpoint.hostname.replace(/^\[|\]$/g, ""); + const socket = connectTls({ + host, + port: Number(endpoint.port || 443), + rejectUnauthorized: true, + servername: isIP(host) ? undefined : host, + }); + const finish = (details) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolveProbe( + clean({ + evidenceKind: "later_probe", + implementation: "node:tls", + startedAt, + route: "direct_tls; does_not_reproduce_host_or_fetch_proxy_route", + originalAttemptMatch: "unconfirmed", + ...details, + }), + ); + }; + const timer = setTimeout( + () => + finish({ + status: "failed", + error: { + name: "TimeoutError", + message: "Direct TLS context timed out", + code: "ETIMEDOUT", + }, + }), + TIMEOUT_MS, + ); + socket.once("secureConnect", () => { + const certificate = socket.getPeerCertificate(); + finish({ + status: "observed", + tlsProtocol: socket.getProtocol(), + authorized: socket.authorized, + certificate: { + issuer: certificate.issuer, + validFrom: certificate.valid_from, + validTo: certificate.valid_to, + fingerprint256: certificate.fingerprint256, + }, + }); + }); + socket.once("error", (error) => finish({ status: "failed", error: errorDetails(error) })); + }); +} + +function logMessage(body) { + const marker = body.lastIndexOf("}:"); + return (marker < 0 ? body : body.slice(marker + 2)).trim(); +} + +function runtimeContext(body) { + const boundary = body.lastIndexOf("}:"); + if (boundary < 0) return {}; + const context = body.slice(0, boundary); + const value = (field) => + [ + ...context.matchAll( + new RegExp(`(?:^|[\\s{])${field}="?([A-Za-z0-9_.-]{1,256})(?:"|[\\s}:]|$)`, "g"), + ), + ].at(-1)?.[1]; + const conversationId = value("thread_id") ?? value("thread\\.id") ?? value("conversation\\.id"); + const callId = value("call_id"); + return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; +} + +function hostedTextRecord(line, 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, + "", + ); + const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); + if (module) { + if ( + !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::|codex_core::mcp_tool_call$)/.test( + module[1], + ) + ) + return false; + body = body.slice(module[0].length); + } + if ( + /^(?:user(?: prompt| message)?|assistant(?: message)?|system|developer|transcript)\s*:|^[{[]/i.test( + body, + ) + ) + return false; + const scope = body.match( + /^(?:MCP server|Server)\s+(?:"([^"]+)"|'([^']+)'|((?:plugin:[\w.-]+:)?[\w.-]+))(?::\s*|\s+|$)/i, + ); + const context = body.split(/\berror\s*=/i)[0]; + const server = + context.match(/\bserver_name=["']?([\w:.-]+)/)?.[1] ?? scope?.[1] ?? scope?.[2] ?? scope?.[3]; + if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; + 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 ( + /^(?:Sending|Sent|Received|Receiving)\s+(?:MCP\s+|JSON-RPC\s+)?(?:request|response|message)\b/i.test( + message, + ) && + /"method"\s*:\s*"tools\/call"|"result"\s*:\s*\{\s*"content"\s*:/i.test(message) + ) + return false; + if (/^[{[]/.test(message)) { + try { + const value = JSON.parse(message); + const records = Array.isArray(value) ? value : [value]; + if ( + records.some( + (record) => + record && + (record.method === "tools/call" || + [ + "function_call", + "function_call_output", + "tool_call", + "tool_result", + "user", + "assistant", + "user_message", + "assistant_message", + ].includes(record.type) || + ["user", "assistant", "system", "developer"].includes(record.role) || + Array.isArray(record.result?.content)), + ) + ) + return false; + } catch { + // Unparseable diagnostic text is not proof of a tool or conversation envelope. + } + } + return true; +} + +async function textLog(path, source, gaps, window, select = hostedTextRecord) { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + const start = window.readOffset ?? Math.max(0, info.size - READ_LIMIT); + if (!Number.isSafeInteger(start) || start < 0 || start > info.size) + throw new Error("Log segment offset must point inside the selected existing file"); + const buffer = Buffer.alloc(Math.min(info.size - start, READ_LIMIT)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); + if (start) { + const previous = Buffer.alloc(1); + await handle.read(previous, 0, 1, start - 1); + if (previous[0] !== 10) lines.shift(); + gaps.push({ + source, + reason: "only_log_tail_read", + sourceFile: basename(path), + bytesBeforeWindow: start, + omittedBytes: start, + }); + } + if (start + bytesRead < info.size) { + if (!buffer.subarray(0, bytesRead).toString("utf8").endsWith("\n")) lines.pop(); + gaps.push({ + source, + reason: "only_log_segment_read", + sourceFile: basename(path), + offset: start, + bytesRead, + remainingBytes: info.size - start - bytesRead, + }); + } + const entries = []; + for (let i = 0; i < lines.length; i++) { + if (!select(lines[i])) continue; + const block = [lines[i]]; + while ( + source !== "codex_app_log" && + i + 1 < lines.length && + /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1]) && + !/^\s*(?:\d{4}-\d{2}-\d{2}T|MCP server\s|Server\s|\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\])/i.test( + lines[i + 1], + ) && + !TEXT_ECHO.test(lines[i + 1].trimStart()) + ) + block.push(lines[++i]); + 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 || + Date.parse(occurredAt) > window.until * 1000) + ) + continue; + entries.push({ + source, + sourceFile: basename(path), + occurredAt, + timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", + message: redact(block.join("\n")), + ...context, + originalAttemptMatch: "unconfirmed", + }); + } + if (entries.length > 30) + gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); + const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; + if (unknownTimes) + gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); + return retainFailureLogs(entries, gaps, source); + } finally { + await handle.close(); + } +} + +function retainFailureLogs(entries, gaps, source = "host_logs") { + const failures = entries.filter(isHostFailure); + const others = entries.filter((entry) => !isHostFailure(entry)); + const selected = new Set(); + let bytes = 0; + const newest = (values) => + values + .map((entry, index) => ({ + entry, + index, + time: Date.parse(entry.loggedAt ?? entry.occurredAt) || -Infinity, + })) + .sort((a, b) => b.time - a.time || b.index - a.index) + .map((value) => value.entry); + for (const entry of [...newest(failures), ...newest(others)]) { + const size = Buffer.byteLength(entry.message ?? ""); + if (selected.size === 30 || bytes + size > READ_LIMIT) continue; + selected.add(entry); + bytes += size; + } + if (selected.size < entries.length) + gaps.push({ + source, + reason: "failure_prioritized_log_budget", + maximumRecords: 30, + maximumMessageBytes: READ_LIMIT, + retainedMessageBytes: bytes, + omitted: entries.length - selected.size, + omittedFailureRecords: failures.filter((entry) => !selected.has(entry)).length, + }); + return entries.filter((entry) => selected.has(entry)); +} + +async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { + if ( + readOffset !== undefined && + (selectedFiles.length !== 1 || !Number.isSafeInteger(readOffset) || readOffset < 0) + ) + throw new Error( + "A segment offset requires exactly one selected native App log file and a nonnegative integer", + ); + const files = []; + const dates = new Set(); + for (const time of [window.since * 1000, window.until * 1000]) { + const date = new Date(time); + dates.add(date.toISOString().slice(0, 10).replaceAll("-", "/")); + dates.add( + [date.getFullYear(), date.getMonth() + 1, date.getDate()] + .map((value) => String(value).padStart(2, "0")) + .join("/"), + ); + } + let scanned = 0; + const deadline = Date.now() + 1500; + for (const date of dates) { + try { + const dir = join(root, date); + for await (const item of await opendir(dir)) { + if (++scanned > 200 || Date.now() > deadline) { + gaps.push({ source: "codex_app_log", reason: "directory_scan_limit" }); + break; + } + if (!item.isFile() || !/^codex-desktop-.*\.log$/.test(item.name)) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= window.since * 1000) files.push({ path, modified: info.mtimeMs }); + } + } catch (error) { + gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); + } + if (scanned > 200 || Date.now() > deadline) break; + } + files.sort((a, b) => b.modified - a.modified); + if (selectedFiles.length) { + if (selectedFiles.length > 3) + throw new Error("Select at most three existing Codex App log files"); + files.length = 0; + for (const path of [...new Set(selectedFiles)]) { + if (!/^codex-desktop-.*\.log$/.test(basename(path))) + throw new Error("Select only existing native codex-desktop-*.log files"); + const info = await stat(path); + if (!info.isFile()) throw new Error("Codex App log selection must be a file"); + files.push({ path, modified: info.mtimeMs }); + } + } + const entries = []; + let filesRead = 0; + for (const file of files.slice(0, 3)) { + try { + // App logs mix unrelated UI activity with runtime notices, so accept only this native event. + entries.push( + ...(await textLog(file.path, "codex_app_log", gaps, { ...window, readOffset }, (line) => { + if ( + !/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test( + line, + ) + ) + return false; + const server = [...line.matchAll(/(?:^|\s)server=([^\s]+)/g)].at(-1)?.[1]; + if (!["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; + const thread = [...line.matchAll(/(?:^|\s)threadId=([^\s]+)/g)].at(-1)?.[1]; + return !conversationId || !thread || thread === "null" || thread === conversationId; + })), + ); + filesRead++; + } catch (error) { + gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (files.length > 3) + gaps.push({ + source: "codex_app_log", + reason: "log_file_limit", + omitted: files.length - 3, + omittedFiles: files.slice(3, 13).map((file) => basename(file.path)), + }); + if (entries.length > 30) + gaps.push({ + source: "codex_app_log", + reason: "log_record_limit", + omitted: entries.length - 30, + }); + return { + entries: retainFailureLogs( + entries.sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")), + gaps, + "codex_app_log", + ), + scan: { + directoryEntriesScanned: scanned, + matchingFiles: files.length, + filesRead, + maximumTailBytesPerFile: READ_LIMIT, + currentConversationFilterApplied: Boolean(conversationId), + ...(conversationId && { conversationId }), + selectedFileMode: selectedFiles.length > 0, + ...(readOffset !== undefined && { selectedByteOffset: readOffset }), + filesSelected: files.slice(0, 3).map((file) => basename(file.path)), + observationScope: + "hosted_mcp_startup_notices_only; tool_request_failures_need_original_failure_record_or_matching_runtime_logs", + }, + }; +} + +async function inspectSqliteAccess(path) { + const files = {}; + for (const suffix of ["", "-wal", "-shm"]) { + const name = suffix || "database"; + let handle; + try { + const info = await stat(path + suffix); + handle = await open(path + suffix, "r"); + const header = Buffer.alloc(suffix ? 1 : 20); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + files[name] = { exists: true, readable: true, bytes: info.size }; + if ( + !suffix && + bytesRead === 20 && + header.subarray(0, 16).toString() === "SQLite format 3\0" + ) { + files[name].journalMode = header[18] === 2 ? "wal" : "rollback"; + } + } catch (error) { + files[name] = { readable: false, error: errorDetails(error) }; + } finally { + try { + await handle?.close(); + } catch (error) { + files[name].closeError = errorDetails(error); + } + } + } + return { + files, + interpretation: + "File reads and SQLite WAL/shared-memory access are different operations; readable files do not prove SQLite access. No copy, checkpoint, immutable mode or permission change attempted.", + }; +} + +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); + const until = Math.min(now, center + 15 * 60_000) / 1000; + if (host === "codex") { + const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); + const sqliteRoot = options.codexSqliteHome ?? process.env.CODEX_SQLITE_HOME ?? root; + const logRoot = options.codexLogDir ?? join(root, "log"); + const databasePath = join(sqliteRoot, "logs_2.sqlite"); + let db; + 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 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 ?? ""; + const context = runtimeContext(original); + if (row.thread_id) context.conversationId = row.thread_id; + if ( + options.conversationId && + context.conversationId && + context.conversationId !== options.conversationId + ) + continue; + if ( + !hostedTextRecord(`${row.target}: ${original}`, row.level) || + !hostedTextRecord(`MCP server "chatcut" ${message}`) + ) + continue; + if ( + !["ERROR", "WARN"].includes(row.level) && + !/omitting MCP server|cached MCP catalog/.test(message) + ) + continue; + entries.push({ + source: "codex_runtime_sqlite", + rowId: row.id, + occurredAt: new Date(row.ts * 1000).toISOString(), + target: row.target, + level: row.level, + message: redact(message), + ...context, + originalAttemptMatch: "unconfirmed", + }); + if (entries.length === 30) break; + } + if (rows.length === 200 || entries.length === 30) + gaps.push({ source: "codex_runtime_sqlite", reason: "log_record_limit" }); + } catch (error) { + gaps.push({ + source: "codex_runtime_sqlite", + reason: "unavailable", + operation, + error: errorDetails(error), + locationSource: options.codexSqliteHome + ? "explicit_host_location" + : process.env.CODEX_SQLITE_HOME + ? "CODEX_SQLITE_HOME" + : "CODEX_HOME_default; sqlite_home_config_override_not_inspected", + ...(platform() === "win32" && error?.errcode === 14 + ? { accessEvidence: await inspectSqliteAccess(databasePath) } + : {}), + }); + } finally { + 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 && + /[\\/]Packages[\\/]sandbox\.[^\\/]+[\\/]AC(?:[\\/]|$)/i.test(appData ?? ""); + const appLogRoot = + options.codexAppLogDir ?? + ((options.platform ?? platform()) === "win32" + ? join( + // MXC redirects LOCALAPPDATA; the running App logs stay in the user profile. + (!sandboxAppData && appData) || join(userHome, "AppData", "Local"), + "Codex", + "Logs", + ) + : null); + if (appLogRoot) { + const result = await codexAppLogs( + appLogRoot, + gaps, + { since, until }, + options.conversationId, + options.codexAppLogFiles, + options.codexAppLogOffset, + ); + entries.push(...result.entries); + appLog = { + ...result.scan, + locationSource: options.codexAppLogDir ? "explicit_host_location" : "windows_default", + sandboxLocalAppDataRedirected: Boolean(sandboxAppData), + }; + if (!result.entries.length) + gaps.push({ source: "codex_app_log", reason: "no_matching_records_in_bounded_window" }); + } + } else { + const root = + options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(userHome, ".claude"); + try { + const dir = join(root, "debug"); + const names = await readdir(dir, { withFileTypes: true }); + const files = []; + const deadline = Date.now() + 1500; + for (const item of names) { + if (Date.now() > deadline) { + gaps.push({ source: "claude_debug_log", reason: "directory_scan_deadline" }); + break; + } + if (!item.isFile() || !item.name.endsWith(".txt")) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= since * 1000) files.push({ path, modified: info.mtimeMs }); + } + files.sort((a, b) => b.modified - a.modified); + for (const item of files.slice(0, 3)) + entries.push(...(await textLog(item.path, "claude_debug_log", gaps, { since, until }))); + if (files.length > 3) + gaps.push({ + source: "claude_debug_log", + reason: "log_file_limit", + omitted: files.length - 3, + }); + } catch (error) { + gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (!entries.length && !nativeFailedCalls?.length) + gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); + return clean({ + entries: retainFailureLogs(entries, gaps), + gaps, + ...(appLog ? { appLog } : {}), + ...(nativeFailedCalls ? { nativeFailedCalls } : {}), + window: { + since: new Date(since * 1000).toISOString(), + until: new Date(until * 1000).toISOString(), + }, + }); +} + +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"); + const chunks = []; + let chunk = ""; + let bytes = 0; + for (const char of json) { + const size = Buffer.byteLength(char); + if (bytes + size > 4096) { + chunks.push(chunk); + chunk = ""; + bytes = 0; + } + chunk += char; + bytes += size; + } + if (chunk) chunks.push(chunk); + const sha256 = createHash("sha256").update(json).digest("hex"); + // Canonical fields and a map avoid growing the backend dataset's flat schema. + return chunks.map((fragment, fragmentIndex) => ({ + name: "mcp.doctor.report_fragment", + properties: { + clientEventName: null, + eventName: "mcp.doctor.report_fragment", + attributes: { + mcpDoctor: { + diagnosticId: report.diagnosticId, + schemaVersion: report.schemaVersion, + collectedAt: report.collectedAt, + host: report.host, + scope: "cloud", + fragmentIndex, + fragmentCount: chunks.length, + reportSha256: sha256, + fragment, + }, + }, + }, + })); +} + +export async function submitReport(report, fetchImpl = fetch) { + if (report.upload?.status === "skipped_intentionally_disabled" || + report.hostSnapshot?.configuration?.enabled === false || + report.hostSnapshot?.session?.connectionStatus === "disabled") + return { status: "skipped_intentionally_disabled", storageVerified: false }; + if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; + let attempted = false; + let rejected = false; + try { + const endpoint = serverUrl(report.serverUrl); + const events = fragmentEvents(report); + attempted = true; + const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { + method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { "content-type": "application/json" }, body: JSON.stringify(events), + }); + rejected = !response.ok; + const body = await limitedBody(response, 16 * 1024); + if (!response.ok || JSON.parse(body).ok !== true) + throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); + return { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, + reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256 }; + } catch (error) { + return { status: "failed", storageVerified: false, deliveryUncertain: attempted && !rejected, error: errorDetails(error) }; + } +} + +export async function runDoctor(options, dependencies = {}) { + if (!["codex", "claude"].includes(options.host)) + throw new Error("--host must be codex or claude"); + if (options.originalError && options.failureRecord) + throw new Error("Choose one original failure input: --failure-record or --original-error"); + let endpoint = null; + let endpointError; + try { endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; } + catch (error) { endpointError = errorDetails(error); } + if ( + options.occurredAt && + (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || + !Number.isFinite(Date.parse(options.occurredAt))) + ) { + throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); + } + const fetchImpl = dependencies.fetchImpl ?? fetch; + const 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.6", + 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(), + nodeVersion: process.version, + proxyEnvironmentPresent: [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ].some((key) => Boolean(process.env[key])), + probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", + noProxyEnvironmentPresent: Boolean(process.env.NO_PROXY || process.env.no_proxy), + nodeUseEnvProxyRequested: process.env.NODE_USE_ENV_PROXY === "1", + extraCaConfigured: Boolean(process.env.NODE_EXTRA_CA_CERTS), + tlsVerificationDisabledInEnvironment: process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0", + }, + serverUrl: endpoint?.href ?? null, + originalFailure: { + evidenceKind: "original_failure", + occurredAt: options.occurredAt ?? null, + timeSource: options.occurredAt + ? "command_line; agent_supplied_not_independently_verified" + : "unavailable", + source: "agent_supplied_error_file", + status: "unavailable", + }, + probes: [], + wireProbes: [], + gaps: [], + outcome: "diagnosis_only; original_connection_recovery_unverified", + coverage: "triggered_diagnostics_only; not_all_users_or_all_failures", + correlation: + "diagnosticId identifies this report and later probes, not the original host request", + }; + let disabledBySnapshot = false; + if (endpointError) report.gaps.push({ source: "registration", reason: "unsupported_endpoint; no_requests_sent", error: endpointError }); + if (options.hostSnapshot) { + try { + if (!basename(String(options.hostSnapshot)).endsWith(".mcp-snapshot.json")) + throw new Error( + "Host snapshot must be a dedicated .mcp-snapshot.json file, never a credential/config file", + ); + const info = await stat(options.hostSnapshot); + if (info.size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + const snapshot = JSON.parse(await readFile(options.hostSnapshot, "utf8")); + disabledBySnapshot = + snapshot?.configuration?.enabled === false || + snapshot?.session?.connectionStatus === "disabled"; + 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) { + report.hostVersion = report.hostSnapshot.hostVersion; + report.hostVersionSource = "agent_supplied_host_snapshot"; + } + } catch (error) { + report.gaps.push({ + source: "host_snapshot", + reason: "unavailable", + error: errorDetails(error), + action: + "Create a new dedicated snapshot with --create-snapshot ; fill only observed facts, then --validate-snapshot before collection. Do not rename or read a credential/config file.", + }); + } + } + try { + const manifest = + options.host === "codex" + ? "../../../.codex-plugin/plugin.json" + : "../../../.claude-plugin/plugin.json"; + report.pluginVersion = JSON.parse( + await readFile(new URL(manifest, import.meta.url), "utf8"), + ).version; + report.pluginVersionSource = + "collector_package_manifest; installed_host_plugin_not_independently_verified"; + } catch (error) { + report.gaps.push({ source: "plugin_version", error: errorDetails(error) }); + } + if (options.originalError) { + try { + if (!/\.(?:txt|log)$/i.test(basename(String(options.originalError)))) { + throw new Error( + "Original error must be a dedicated .txt or .log file; credential/config/session files are not inputs", + ); + } + const info = await stat(options.originalError); + if (info.size > 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"); + report.originalFailure.message = redact(message); + report.originalFailure.status = "recorded"; + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "unavailable", + error: errorDetails(error), + }); + } + } else if (!options.failureRecord) + report.gaps.push({ + source: "original_failure", + reason: "not_provided; do_not_invent_original_error", + }); + if (options.failureRecord) { + try { + if (!basename(String(options.failureRecord)).endsWith(".mcp-failure.json")) + throw new Error( + "Use a dedicated .mcp-failure.json file, never a config or session transcript", + ); + 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) { + 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), + }); + } + } + 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, + codexSqliteHome: options.codexSqliteHome, + codexLogDir: options.codexLogDir, + codexAppLogDir: options.codexAppLogDir, + codexAppLogFiles: options.codexAppLogFiles, + codexAppLogOffset: options.codexAppLogOffset, + codexStderrFile: options.codexStderrFile, + conversationId: + report.originalFailure.conversationId ?? + report.hostSnapshot?.session?.conversationId ?? + collectionConversationId, + }); + } catch (error) { + report.hostLogs = { + entries: [], + 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 || + report.hostSnapshot?.session?.connectionStatus === "disabled"; + if (options.offline || disabled || !endpoint) { + report.gaps.push({ + source: "probes", + reason: disabled + ? "host_reports_intentionally_disabled; no_requests_sent" + : options.offline + ? "offline_requested; no_requests_sent" + : "registered_endpoint_unavailable; no_requests_sent", + }); + } else { + report.entryProbe = await guardProbe(endpoint.href, () => + probeEntry(endpoint, diagnosticId, fetchImpl), + ); + const resource = endpoint.pathname.replace(/\/$/, ""); + const queue = [ + new URL("/.well-known/oauth-protected-resource" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server", endpoint.origin).href, + ]; + const enqueue = (value, label) => { + try { + const url = new URL(value); + if ( + url.origin !== endpoint.origin || + url.username || + url.password || + url.search || + url.hash || + !url.pathname.startsWith("/.well-known/") + ) + throw new Error( + "Unsupported metadata target; no cross-origin or credential-bearing probes", + ); + if (!queue.includes(url.href)) queue.push(url.href); + } catch (error) { + report.gaps.push({ source: label, error: errorDetails(error) }); + } + }; + if (report.entryProbe.resourceMetadataUrl) + enqueue(report.entryProbe.resourceMetadataUrl, "challenge_metadata_target"); + let issuer; + for (let index = 0; index < queue.length && index < 5; index++) { + const url = queue[index]; + const result = await guardProbe(url, () => + probeMetadata(url, diagnosticId, fetchImpl, { + resource: endpoint.href, + issuer: url.includes("oauth-authorization-server") ? issuer : undefined, + }), + ); + report.probes.push(result); + if (url === queue[0] && result.outcome !== "metadata_read") + enqueue( + new URL("/.well-known/oauth-protected-resource", endpoint.origin).href, + "root_resource_metadata_target", + ); + const fields = result.metadataValidation?.fields; + if (url.includes("oauth-protected-resource") && result.metadataValidation?.valid) { + issuer = fields.authorization_servers[0]; + const auth = new URL(issuer); + enqueue( + new URL( + "/.well-known/oauth-authorization-server" + auth.pathname.replace(/\/$/, ""), + auth.origin, + ).href, + "issuer_metadata_target", + ); + } + } + if (queue.length > 5) + report.gaps.push({ + source: "metadata_probes", + reason: "probe_limit", + omitted: queue.length - 5, + }); + if (options.wireEvidence) { + const failed = report.probes.find((probe) => probe.outcome !== "metadata_read"); + const selected = new URL(failed?.url ?? queue[0]); + const match = selected.pathname.match( + /^\/\.well-known\/(oauth-protected-resource|oauth-authorization-server|openid-configuration)/, + ); + if (match) { + const root = new URL("/.well-known/" + match[1], endpoint.origin); + const scoped = new URL(root.pathname + resource, endpoint.origin); + for (const url of [scoped.href, root.href]) { + try { + report.wireProbes.push( + await (dependencies.probeWire ?? probeMetadataWire)(url, diagnosticId, errorDetails), + ); + } catch (error) { + report.gaps.push({ source: "wire_probe", url, error: errorDetails(error) }); + } + } + } + } + if ( + endpoint.protocol === "https:" && + [report.entryProbe, ...report.probes].some( + (probe) => probe.outcome === "failed" && probe.stage !== "probe_collector", + ) + ) { + try { + report.transportContext = await (dependencies.inspectTransport ?? inspectTransport)( + endpoint, + ); + } catch (error) { + report.transportContext = { status: "failed", error: errorDetails(error) }; + } + } + } + report.evidenceSummary = evidenceSummary(report); + report.collectionStatus = "complete"; + report.collectionStage = "complete"; + report.upload = { + status: disabled + ? "skipped_intentionally_disabled" + : options.offline + ? "skipped_offline" + : !endpoint + ? "skipped_endpoint_unavailable" + : options.upload + ? "pending" + : "not_requested", + storageVerified: false, + }; + await save("w"); + if (options.upload && !options.offline && !disabled && endpoint) { + report.upload = await submitReport(report, fetchImpl); + await save("w"); + } + return { path, report: clean(report) }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const { values } = parseArgs({ + options: { + host: { type: "string" }, + "server-url": { type: "string" }, + "host-version": { type: "string" }, + "host-snapshot": { type: "string" }, + "original-error": { type: "string" }, + "failure-record": { type: "string" }, + "occurred-at": { type: "string" }, + "codex-sqlite-home": { type: "string" }, + "codex-log-dir": { type: "string" }, + "codex-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" }, + help: { type: "boolean", default: false }, + }, + }); + if (values["create-snapshot"]) { + const path = resolve(values["create-snapshot"]); + if (!basename(path).endsWith(".mcp-snapshot.json")) + throw new Error("Use a new dedicated .mcp-snapshot.json filename"); + await writeFile( + path, + JSON.stringify( + { schemaVersion: 1, observedAt: new Date().toISOString(), observations: [] }, + null, + 2, + ) + "\n", + { flag: "wx", mode: 0o600 }, + ); + console.log( + JSON.stringify({ + path, + snapshotCreated: true, + action: + "Add only observed configuration/session facts using connection-diagnostics.md; omit unknown groups. observedAt is snapshot capture time, not original failure time.", + }), + ); + } else if (values["validate-snapshot"]) { + const path = values["validate-snapshot"]; + if (!basename(path).endsWith(".mcp-snapshot.json")) + throw new Error("Use a dedicated .mcp-snapshot.json file"); + if ((await stat(path)).size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + validateSnapshot( + JSON.parse(await readFile(path, "utf8")), + values["server-url"] ? serverUrl(values["server-url"]) : null, + ); + console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); + } else if (values.help) { + console.log( + "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-stderr-file ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--offline] [--upload]", + ); + } else { + const { path, report } = await runDoctor({ + host: values.host, + serverUrl: values["server-url"], + hostVersion: values["host-version"], + hostSnapshot: values["host-snapshot"], + originalError: values["original-error"], + failureRecord: values["failure-record"], + occurredAt: values["occurred-at"], + codexSqliteHome: values["codex-sqlite-home"], + codexLogDir: values["codex-log-dir"], + codexStderrFile: values["codex-stderr-file"], + codexAppLogDir: values["codex-app-log-dir"], + codexAppLogFiles: values["codex-app-log-file"], + codexAppLogOffset: + values["codex-app-log-offset"] === undefined + ? undefined + : Number(values["codex-app-log-offset"]), + output: values.output, + upload: values.upload, + offline: values.offline, + wireEvidence: values["wire-evidence"], + }); + console.log( + JSON.stringify({ + diagnosticId: report.diagnosticId, + path, + upload: report.upload, + evidenceGaps: report.evidenceSummary.gaps, + hostFailureEvidence: report.evidenceSummary.failureEvidence, + collectionGaps: report.gaps, + recoveryVerified: false, + }), + ); + } + } catch (error) { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + } +} diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs index 54ff8d2..9af54e7 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-evidence.mjs @@ -1,2 +1,602 @@ -// Compatibility entry; the standalone Doctor owns the collector. -export * from "../../chatcut-mcp-doctor/scripts/mcp-evidence.mjs"; +const STAGES = [ + "plugin_loading", + "discovery", + "client_registration", + "authorization", + "token_exchange", + "token_refresh", + "initialize", + "tools_list", + "session_tools", +]; +const SOURCES = [ + "current_session", + "tool_search", + "host_settings", + "cli_configuration", + "runtime_log", +]; +const TIMESTAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/; + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function keys(value, allowed) { + if (!object(value) || Object.keys(value).some((key) => !allowed.includes(key))) + throw new Error("Snapshot contains unsupported fields; use only the dedicated evidence schema"); +} + +function source(value) { + if (!SOURCES.includes(value)) + throw new Error("Snapshot evidence needs an explicit supported source"); +} + +function instant(value) { + return ( + typeof value === "string" && + new RegExp("^" + TIMESTAMP.source + "$").test(value) && + Number.isFinite(Date.parse(value)) + ); +} + +export function validateSnapshot(input, endpoint) { + keys(input, [ + "schemaVersion", + "observedAt", + "hostVersion", + "pluginVersion", + "configuration", + "session", + "observations", + ]); + if (input.schemaVersion !== 1) + throw new Error( + "Snapshot schemaVersion must be 1 (the outer Doctor report uses a separate schemaVersion); generate a template with --create-snapshot", + ); + if (!instant(input.observedAt)) + throw new Error( + "Snapshot observedAt must be its capture time as a complete ISO timestamp with timezone, e.g. 2026-10-23T01:00:00Z; original failure time may remain unknown", + ); + for (const key of ["hostVersion", "pluginVersion"]) + if (input[key] !== undefined && (typeof input[key] !== "string" || input[key].length > 256)) + throw new Error("Invalid snapshot version"); + if (input.configuration) { + const config = input.configuration; + keys(config, ["source", "serverName", "url", "enabled"]); + source(config.source); + if (!["chatcut", "plugin:chatcut:chatcut"].includes(config.serverName)) + throw new Error("Snapshot must describe the hosted ChatCut server"); + if (!publicUrl(config.url, true)) + throw new Error("Snapshot URL must be a public URL without embedded credentials"); + if (endpoint && new URL(config.url).href !== endpoint.href) + throw new Error( + "Snapshot URL differs from the explicitly selected server; preserve both configurations", + ); + if (config.enabled !== null && typeof config.enabled !== "boolean") + throw new Error("Snapshot enabled state must be boolean or null"); + } + if (input.session) { + const session = input.session; + keys(session, [ + "source", + "conversationId", + "connectionStatus", + "authStatus", + "visibleToolCount", + "visibleTools", + ]); + source(session.source); + if (session.source === "cli_configuration") + throw new Error("Static CLI configuration cannot describe current session tool availability"); + if ( + !["connected", "failed", "needs_authentication", "disabled", "unknown"].includes( + session.connectionStatus, + ) + ) + throw new Error("Unsupported connection status"); + if (!["authenticated", "needs_authentication", "unknown"].includes(session.authStatus)) + throw new Error("Unsupported authentication status"); + if ( + session.visibleToolCount !== null && + (!Number.isSafeInteger(session.visibleToolCount) || session.visibleToolCount < 0) + ) + throw new Error("Visible tool count must be a nonnegative integer or null"); + if (session.conversationId !== undefined && typeof session.conversationId !== "string") + throw new Error("conversationId must be a host conversation identifier"); + if ( + session.visibleTools !== undefined && + (!Array.isArray(session.visibleTools) || + session.visibleTools.length > 256 || + session.visibleTools.some( + (name) => typeof name !== "string" || !/^[A-Za-z0-9_:.-]{1,256}$/.test(name), + )) + ) + throw new Error("visibleTools must contain only ChatCut tool names, at most 256"); + if ( + session.visibleTools && + session.visibleToolCount !== null && + session.visibleTools.length > session.visibleToolCount + ) + throw new Error("Tool name count exceeds the observed tool count"); + } + if (input.observations !== undefined) { + if (!Array.isArray(input.observations) || input.observations.length > 32) + throw new Error("Snapshot accepts at most 32 stage observations"); + for (const item of input.observations) { + keys(item, [ + "stage", + "operation", + "source", + "observedAt", + "outcome", + "requestId", + "httpStatus", + "protocolCode", + "message", + "originalAttemptMatch", + "lookupResult", + ]); + source(item.source); + if ( + item.lookupResult !== undefined && + (item.stage !== "host_registration" || + item.operation !== "lookup_registered_server" || + !["cli_configuration", "host_settings"].includes(item.source) || + !["not_found", "found", "unavailable"].includes(item.lookupResult) || + (item.lookupResult === "not_found" && item.outcome === "unknown")) + ) + throw new Error( + "lookupResult 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) || + (item.observedAt !== null && !instant(item.observedAt)) + ) + throw new Error( + "Stage observation needs stage, outcome, and observedAt (ISO timestamp with timezone, or null when the original time is unknown)", + ); + for (const field of ["operation", "requestId", "message"]) + if (item[field] !== undefined && typeof item[field] !== "string") + throw new Error("Observation text fields must be strings"); + for (const field of ["httpStatus", "protocolCode"]) + if (item[field] !== undefined && !Number.isSafeInteger(item[field])) + throw new Error("Observation status/code must be an integer"); + if ( + item.originalAttemptMatch !== undefined && + !["confirmed", "unconfirmed"].includes(item.originalAttemptMatch) + ) + throw new Error( + "Original attempt match must be confirmed or unconfirmed; omit when unknown", + ); + if ( + item.originalAttemptMatch === "confirmed" && + (!item.requestId || + item.outcome !== "failed" || + !["current_session", "runtime_log"].includes(item.source)) + ) + throw new Error( + "Confirmed original request association requires its failed runtime/session observation and actual request ID", + ); + } + } + return input; +} + +export function 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; +} + +export function stageHint(message) { + if (/tools\/list|tool catalog|tool list/i.test(message)) return "tools_list"; + if (/initializ|handshake/i.test(message)) return "initialize"; + if (/refresh.{0,20}token|token.{0,20}refresh/i.test(message)) return "token_refresh"; + if (/token.{0,20}exchange|exchange.{0,20}token|\/mcp\/token/i.test(message)) + return "token_exchange"; + if (/registr|\/mcp\/register/i.test(message)) return "client_registration"; + if (/callback|authoriz|oauth/i.test(message) && !/metadata|discovery/i.test(message)) + return "authorization"; + if (/metadata|discovery/i.test(message)) return "discovery"; + return "unclassified"; +} + +export function isHostFailure(entry) { + if ( + ![ + "codex_runtime_sqlite", + "codex_tui_log", + "codex_stderr_log", + "codex_app_log", + "claude_debug_log", + ].includes(entry.source) + ) + return false; + if (entry.source === "codex_app_log") + return [...String(entry.message).matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed"; + const level = + entry.level ?? + String(entry.message) + .match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i) + ?.slice(1) + .find(Boolean); + return ( + ["WARN", "ERROR"].includes(String(level).toUpperCase()) || + /\b(?:connection failed|discovery failed|auth error|error during auth|MCP tool call error|startup failed|HTTP Connection failed)\b|(?:MCP server|Server)\s+["']?(?:plugin:chatcut:)?chatcut["']?:\s*Error\b/i.test( + entry.message, + ) + ); +} + +export function hostFailureEvidence(report) { + const original = report.originalFailure; + const logs = (report.hostLogs.entries ?? []).flatMap((entry, index) => { + if (!isHostFailure(entry)) return []; + const sameCall = + original.status === "recorded" && + original.conversationId && + original.callId && + entry.conversationId === original.conversationId && + entry.callId === original.callId; + return [ + { + evidence: `hostLogs.entries[${index}]`, + errorText: `hostLogs.entries[${index}].message`, + source: entry.source, + ...(entry.sourceFile && { sourceFile: entry.sourceFile }), + ...(entry.rowId !== undefined && { rowId: entry.rowId }), + loggedAt: entry.loggedAt ?? entry.occurredAt ?? null, + timeSource: "host_log_event_time; not_original_failure_occurredAt", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), + extractionRule: + "admitted_native_error_warning_or_explicit_failure_event; original_block_retained", + localCallBinding: sameCall + ? "same_conversation_and_call_id; original_record_agent_supplied" + : "unconfirmed; may_not_be_the_reported_failure", + originalAttemptMatch: "unconfirmed", + }, + ]; + }); + const native = (report.hostLogs.nativeFailedCalls ?? []).flatMap((entry, index) => + entry.status === "failed" && entry.error + ? [ + { + evidence: `hostLogs.nativeFailedCalls[${index}]`, + errorText: `hostLogs.nativeFailedCalls[${index}].error`, + source: entry.source, + conversationId: entry.conversationId, + nativeItemId: entry.nativeItemId, + idKind: entry.idKind, + startedAtMs: entry.startedAtMs, + completedAtMs: entry.completedAtMs, + timeSource: entry.timeSource, + extractionRule: "scoped_native_failed_mcp_item_error_projection", + localCallBinding: "native_item_only; not_bound_to_original_call_or_server_request", + originalAttemptMatch: "unconfirmed", + }, + ] + : [], + ); + return [...logs, ...native]; +} + +export function evidenceSummary(report) { + const snapshot = report.hostSnapshot; + 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"); + 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" && !report.originalFailure.requestIdConflict + ? observations.flatMap((item, observationIndex) => + item.requestId && + item.originalAttemptMatch === "confirmed" && + item.outcome === "failed" && + ["current_session", "runtime_log"].includes(item.source) + ? [{ requestId: item.requestId, source: item.source, observationIndex }] + : [], + ) + : []; + if ( + report.originalFailure.status === "recorded" && + report.originalFailure.requestId && + !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"); + const failedAppNotices = appNotices.filter( + (entry) => [...entry.message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed", + ); + if (appNotices.length === report.hostLogs.entries.length && !failedAppNotices.length) + gaps.push("matching_host_failure_logs_unavailable"); + const stages = STAGES.map((stage) => ({ + stage, + observations: observations.filter((item) => item.stage === stage), + status: observations.some((item) => item.stage === stage) + ? "observed; see individual attempts" + : "not_observed", + })); + const unknown = observations.filter((item) => !STAGES.includes(item.stage)); + const logHints = report.hostLogs.entries.map((entry, index) => ({ + entryIndex: index, + stage: stageHint(entry.message), + interpretation: "text_hint_only; attempt_match_unconfirmed", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), + })); + 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, + failureEvidence: hostFailureEvidence(report), + 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: 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", + }, + 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: { + 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, + originalToolFailureRecord: + report.originalFailure.source === "agent_supplied_host_tool_result", + qualification: + "startup notices are not tool-request failure evidence; log presence alone does not prove original failure capture", + }, + gaps, + independentChecks: + "entryProbe/probes/wireProbes/transportContext are later requests, never original stage completions; server observation is before Node framing, not proof of downstream wire validity", + recoveryVerified: false, + }; +} + +function publicUrl(value, localAllowed = false) { + try { + const url = new URL(value); + return ( + !url.username && + !url.password && + !url.hash && + (url.protocol === "https:" || + (localAllowed && + url.protocol === "http:" && + ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) + ); + } catch { + return false; + } +} + +export function inspectMetadata(document, url, expected = {}) { + const issues = []; + const result = { valid: false, issues, fields: {} }; + if (!object(document)) { + issues.push({ field: "document", reason: "expected_object" }); + return result; + } + for (const field of [ + "resource", + "authorization_servers", + "issuer", + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "grant_types_supported", + "response_types_supported", + "code_challenge_methods_supported", + "token_endpoint_auth_methods_supported", + ]) + if (document[field] !== undefined) result.fields[field] = document[field]; + const protectedResource = new URL(url).pathname.includes("oauth-protected-resource"); + const requireUrl = (field) => { + if (typeof document[field] !== "string" || !publicUrl(document[field], true)) + issues.push({ field, reason: "missing_or_invalid_public_url" }); + }; + if (protectedResource) { + requireUrl("resource"); + const suffix = new URL(url).pathname.split("/.well-known/oauth-protected-resource")[1]; + const resource = expected.resource ?? (suffix ? new URL(url).origin + suffix : undefined); + if (resource && document.resource !== resource) + issues.push({ + field: "resource", + reason: "resource_mismatch", + expected: resource, + actual: document.resource, + }); + if ( + !Array.isArray(document.authorization_servers) || + !document.authorization_servers.length || + document.authorization_servers.some((item) => !publicUrl(item, true)) + ) + issues.push({ + field: "authorization_servers", + reason: "missing_or_invalid_authorization_servers", + }); + } else { + requireUrl("issuer"); + requireUrl("authorization_endpoint"); + requireUrl("token_endpoint"); + if ( + Array.isArray(document.grant_types_supported) && + !document.grant_types_supported.includes("authorization_code") + ) + issues.push({ field: "grant_types_supported", reason: "authorization_code_not_supported" }); + if ( + !Array.isArray(document.response_types_supported) || + !document.response_types_supported.includes("code") + ) + issues.push({ field: "response_types_supported", reason: "code_response_not_advertised" }); + if (expected.issuer && document.issuer !== expected.issuer) + issues.push({ + field: "issuer", + reason: "issuer_mismatch", + expected: expected.issuer, + actual: document.issuer, + }); + if ( + !Array.isArray(document.code_challenge_methods_supported) || + !document.code_challenge_methods_supported.includes("S256") + ) + issues.push({ field: "code_challenge_methods_supported", reason: "S256_not_advertised" }); + } + result.valid = issues.length === 0; + return result; +} diff --git a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs index 35cf382..c472e8d 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-failure.mjs @@ -1,2 +1,200 @@ -// Compatibility entry; the standalone Doctor owns the collector. -export * from "../../chatcut-mcp-doctor/scripts/mcp-failure.mjs"; +const SOURCES = ["host_tool_result", "host_runtime_event", "captured_stderr"]; +const FIELDS = [ + "schemaVersion", + "source", + "serverName", + "occurredAt", + "conversationId", + "callId", + "operation", + "requestId", + "httpResponse", + "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))); +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 ( + !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 (!validTime(input.occurredAt)) + throw new Error( + "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", + ); + 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`, + ); + 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", + ); + 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; +} + +export function failureFromRecord(input) { + const candidate = + 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({ + 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); + 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", + 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 }), + ...(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/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-wire.mjs b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-wire.mjs index f5d9565..57079a5 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-wire.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-wire.mjs @@ -1,2 +1,296 @@ -// Compatibility entry; the standalone Doctor owns the collector. -export * from "../../chatcut-mcp-doctor/scripts/mcp-wire.mjs"; +import { createHash } from "node:crypto"; +import { connect, isIP } from "node:net"; +import { connect as connectTls } from "node:tls"; + +const LIMIT = 128 * 1024; +const HEADER_LIMIT = 16 * 1024; +const TIMEOUT_MS = 3500; +const HEADERS = new Set([ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "connection", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + "cache-control", + "age", + "x-cache", + "x-amz-cf-id", + "x-amz-cf-pop", +]); + +function target(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ( + (!local && + (!["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes(url.hostname) || + url.protocol !== "https:" || + (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/\.well-known\/(?:oauth-protected-resource|oauth-authorization-server|openid-configuration)(?:\/[a-zA-Z0-9/_-]*)?$/.test( + url.pathname, + ) + ) + throw new Error( + "Wire evidence is restricted to anonymous ChatCut metadata on the selected origin", + ); + return url; +} + +function framing(headers, body, ended, limited) { + const result = { + status: "incomplete", + firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, + bodyRepresentation: "after_transfer_decoding; content_encoding_not_decoded", + }; + const invalid = (reason) => ({ ...result, status: "invalid", reason }); + const incomplete = (reason) => ({ ...result, reason }); + const valid = (decoded) => ({ + ...result, + status: "valid", + bodyBytes: decoded.length, + bodySha256: createHash("sha256").update(decoded).digest("hex"), + }); + const transfer = headers["transfer-encoding"] + ?.toLowerCase() + .split(",") + .map((v) => v.trim()); + if (transfer?.at(-1) === "chunked") { + if (headers["content-length"] !== undefined) + return invalid("both_transfer_encoding_and_content_length"); + let offset = 0; + const parts = []; + while (offset < body.length) { + const end = body.indexOf("\r\n", offset); + if (end < 0) { + if (body.length > offset && !/[0-9a-f]/i.test(String.fromCharCode(body[offset]))) + return invalid("invalid_chunk_size"); + return ended && !limited + ? invalid("unterminated_chunk_size") + : incomplete("chunk_size_not_complete"); + } + const sizeText = body.subarray(offset, end).toString("latin1").split(";")[0]; + if (!/^[0-9a-f]{1,16}$/i.test(sizeText)) return invalid("invalid_chunk_size"); + if (offset === 0) result.firstChunkSizeHex = sizeText; + const size = Number.parseInt(sizeText, 16); + offset = end + 2; + if (!Number.isSafeInteger(size)) return incomplete("chunk_exceeds_capture_limit"); + if (size === 0) { + const trailerEnd = body.indexOf("\r\n\r\n", offset); + const messageEnd = body.subarray(offset, offset + 2).equals(Buffer.from("\r\n")) + ? offset + 2 + : trailerEnd >= 0 + ? trailerEnd + 4 + : null; + if (messageEnd !== null) { + if (messageEnd !== body.length) return invalid("bytes_after_terminal_chunk"); + return valid(Buffer.concat(parts)); + } + return ended && !limited + ? invalid("unterminated_trailers") + : incomplete("trailers_not_complete"); + } + if (offset + size + 2 > body.length) + return ended && !limited ? invalid("truncated_chunk") : incomplete("chunk_not_complete"); + if (!body.subarray(offset + size, offset + size + 2).equals(Buffer.from("\r\n"))) + return invalid("missing_chunk_terminator"); + parts.push(body.subarray(offset, offset + size)); + offset += size + 2; + } + return ended && !limited + ? invalid("missing_terminal_chunk") + : incomplete("terminal_chunk_not_observed"); + } + if (transfer) return incomplete("unsupported_transfer_encoding"); + if (headers["content-length"] !== undefined) { + if (!/^\d+$/.test(headers["content-length"])) return invalid("invalid_content_length"); + const expected = Number(headers["content-length"]); + if (body.length === expected) return valid(body); + if (body.length > expected) return invalid("content_length_mismatch"); + return ended && !limited ? invalid("content_length_mismatch") : incomplete("body_not_complete"); + } + return ended && !limited ? valid(body) : incomplete("close_delimited_body_not_complete"); +} + +function inspectResponse(captured, ended, limited) { + const headerEnd = captured.indexOf("\r\n\r\n"); + const result = { responseHeaders: {} }; + if (headerEnd < 0 || headerEnd > HEADER_LIMIT) { + result.headerGap = + captured.length > HEADER_LIMIT ? "header_limit" : "complete_headers_unavailable"; + return result; + } + const lines = captured.subarray(0, headerEnd).toString("latin1").split("\r\n"); + const status = lines.shift().match(/^HTTP\/(1\.[01]) ([1-5]\d{2}) [\t\x20-\x7e\x80-\xff]*$/); + result.httpVersion = status?.[1] ?? null; + result.status = status ? Number(status[2]) : null; + result.responseState = !status + ? "invalid_status_line" + : result.status < 200 + ? "unsupported_informational_response" + : "final_response"; + for (const line of lines) { + const colon = line.indexOf(":"); + if (colon < 0) continue; + const name = line.slice(0, colon).toLowerCase(); + if (HEADERS.has(name)) { + const field = line.slice(colon + 1).trim(); + result.responseHeaders[name] = + result.responseHeaders[name] === undefined + ? field + : result.responseHeaders[name] + "," + field; + } + } + result.stage = "http_framing"; + const body = captured.subarray(headerEnd + 4); + result.framing = + result.responseState === "final_response" + ? framing(result.responseHeaders, body, ended, limited) + : { + status: "not_evaluated", + reason: result.responseState, + firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, + }; + return result; +} + +export async function probeMetadataWire(value, diagnosticId, describeError) { + const url = target(value); + if (!/^[0-9a-f-]{36}$/i.test(diagnosticId)) throw new Error("Invalid diagnostic ID"); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: url.href, + startedAt: new Date().toISOString(), + implementation: "node:net/tls; HTTP/1.1", + route: "direct_connection; environment_proxy_not_used; host_and_fetch_route_not_reproduced", + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + acceptEncoding: "identity", + byteLimit: LIMIT, + stage: "connect", + }; + return new Promise((resolve) => { + let socket; + let done = false; + let bytes = 0; + let limited = false; + let ended = false; + let headerSeen = false; + const parts = []; + const finish = (error, completionReason) => { + if (done) return; + done = true; + clearTimeout(timer); + const captured = Buffer.concat(parts); + result.capturedBytes = captured.length; + result.captureLimited = limited; + Object.assign(result, inspectResponse(captured, ended, limited)); + result.completionReason = completionReason; + result.peerEndObserved = ended; + result.outcome = error + ? "failed" + : result.responseState === "invalid_status_line" + ? "invalid_response" + : result.responseState === "unsupported_informational_response" + ? "unsupported_response" + : result.framing?.status === "invalid" + ? "invalid_framing" + : !limited && result.framing?.status === "valid" + ? "response_observed" + : "incomplete"; + if (error) result.error = describeError(error); + result.durationMs = Date.now() - started; + socket?.destroy(); + resolve(result); + }; + const timer = setTimeout( + () => + finish( + Object.assign(new Error("Metadata wire probe timed out after 3500ms"), { + code: "DIAGNOSTIC_WIRE_TIMEOUT", + }), + "timeout", + ), + TIMEOUT_MS, + ); + try { + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + const options = { + host: hostname, + port: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + }; + const send = () => { + result.stage = "response"; + result.peer = { address: socket.remoteAddress, port: socket.remotePort }; + if (url.protocol === "https:") { + const certificate = socket.getPeerCertificate(); + result.tls = { + authorized: socket.authorized, + protocol: socket.getProtocol(), + certificate: { issuer: certificate.issuer, fingerprint256: certificate.fingerprint256 }, + }; + } + socket.write( + `GET ${url.pathname} HTTP/1.1\r\nHost: ${url.host}\r\nAccept: application/json\r\nAccept-Encoding: identity\r\nConnection: close\r\nX-ChatCut-Diagnostic-ID: ${diagnosticId}\r\n\r\n`, + ); + }; + socket = + url.protocol === "https:" + ? connectTls( + { + ...options, + servername: isIP(hostname) ? undefined : hostname, + rejectUnauthorized: true, + ALPNProtocols: ["http/1.1"], + }, + send, + ) + : connect(options, send); + socket.on("data", (part) => { + if (done) return; + const remaining = LIMIT - bytes; + parts.push(part.subarray(0, Math.max(0, remaining))); + bytes += Math.min(part.length, remaining); + if (part.length > remaining) { + limited = true; + finish(undefined, "capture_limit"); + return; + } + if (!headerSeen) { + const captured = Buffer.concat(parts); + const headerEnd = captured.indexOf("\r\n\r\n"); + headerSeen = headerEnd >= 0; + if ((!headerSeen && bytes > HEADER_LIMIT) || headerEnd > HEADER_LIMIT) { + limited = true; + finish(undefined, "header_limit"); + return; + } + } + if (headerSeen) { + const observed = inspectResponse(Buffer.concat(parts), false, false); + if (observed.responseState !== "final_response") + finish(undefined, "invalid_or_unsupported_response"); + else if (observed.framing.status === "valid") finish(undefined, "message_boundary"); + else if (observed.framing.status === "invalid") finish(undefined, "invalid_framing"); + } + }); + socket.once("end", () => { + ended = true; + finish(undefined, "peer_end"); + }); + socket.once("error", (error) => finish(error, "transport_error")); + socket.once("close", () => finish(undefined, "socket_close")); + } catch (error) { + finish(error, "collector_error"); + } + }); +} diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index 2680f3d..676233d 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -1,56 +1,32 @@ --- name: chatcut-mcp-doctor -description: Diagnose hosted ChatCut MCP connection, startup, authentication or tool-call failures locally, save a machine report and usable feedback, and guide scoped repair/recheck. Use for ChatCut Doctor or MCP连接错误; not Desktop ACP or unrelated video editing. +description: Investigate hosted ChatCut MCP connection, authentication and tool errors using the original error, existing evidence and a same-host safe recheck. Use when ChatCut will not connect; not Desktop MCP. --- -# ChatCut MCP Doctor +# ChatCut MCP evidence -Run locally with Node 18+ even when ChatCut MCP is unavailable. Preserve the already visible original error before any retry or change. Never read credentials, auth files or conversation histories. +Help the user understand and resolve the actual failure. This unpublished candidate runs with local file/shell tools even if hosted MCP is unavailable. Preserve the visible original error before a retry or change; do not read credentials or whole conversations. -## Capture first +Use the existing report when following up. Refer to its explicit path and diagnostic ID; do not create another baseline just because the Skill was invoked again. There is no active-case index or time window. A new report is useful only when collecting new evidence for a distinct observation. -A diagnosis is complete only after this invocation's program-generated schema-2 report is saved and its diagnostic ID/path verified. A prose note or an old report is not a substitute. - -Run this single command first; resolve the path from this Skill directory: - -```text -node "/scripts/capture.mjs" --host codex -``` - -The capture receipt names the actual saved report, snapshot, diagnosis, Chinese note and SHA-256. For Chinese requests, return the program's `userFacingNote` as the diagnosis; it is also saved in the Chinese note file. Add only separately observed repair/recheck/delivery facts. Do not compose a second diagnosis, label error text as an exposed code, exclude possible causes from static config, or invent root-cause hypotheses. For other languages, translate that note without adding inferences. Default capture is offline: no native CLI, connection probes, login, repair or upload. Claude's current project .mcp.json is projected in memory to allowlisted registration fields; other scopes and active-session health remain unknown. Missing project configuration does not prove missing host registration. - -Before the model answers, capture automatically includes an existing project-root `original.mcp-failure.json` (preferred, preserving known metadata) or `original.mcp-error.txt`. These are reserved error-only inputs; it never scans old reports or conversations. If the error has not yet been saved, save only its exact exposed error text as `original.mcp-error.txt`, then run: +When collection is needed, save the exposed error alone and run the local helper, resolving paths from this Skill: ```text -node "/scripts/capture.mjs" --host --original-error "original.mcp-error.txt" --operation --server-url +node "/scripts/capture.mjs" --host --original-error "" ``` -Omit unknown operation/URL. This program formats the dedicated failure record; never handwrite report JSON, schema versions, UUIDs or timestamps. A retained structured `.mcp-failure.json` can instead use `--failure-record` to preserve its code/cause/known IDs. Reuse saved originals; do not recreate a failure. Unknown original time/IDs stay unknown. The snapshot capture time and collecting thread are not original-event evidence. +A structured retained original may instead use `--failure-record `. Add the known operation and selected URL if exposed; omit unknowns. Default capture is offline. The receipt gives the saved report and SHA. Optional `--native-lookup` runs a bounded native registration query that may connect; obtain permission for that check. Configuration presence does not establish active-session tools, authentication or recovery. Missing project config is not proof of absence in other scopes. [Collection details](references/connection-diagnostics.md). -## Diagnose and repair +Use the facts to choose the smallest next check. There is no mandatory generated diagnosis or wording. Keep the original failure, later independent probes and actual native tool results distinct. Unknown source bytes, time, IDs and root causes stay unknown. [Existing repair guidance](references/repair-and-recheck.md) covers a demonstrated missing manual Hosted registration; do not duplicate plugin-managed servers, change environments or credentials, or repair Desktop with this Hosted recipe. Preserve the actual action and rollback in a separate note. -Keep configuration, original failure, current session and later checks distinct. Do not infer account/permission/service health from prior success, static config or an empty controlled project list. Findings are observed boundaries, not root causes. +After a justified authorized change, use the original Codex or Claude host, configuration scope and target service/project for an actual safe tool call. Inspect its current schema. `list_projects` checks a Hosted project listing, not access to an affected project; an empty list does not prove that project's recovery or account authentication. Configuration queries, tool discovery and independent HTTP probes are not tool rechecks. Do not replay mutations or generation calls. -For current native registration evidence, `capture.mjs --host --native-lookup [--cli ]` runs one bounded native `mcp get` and formats its snapshot/report. Use it only when the user permits a connection check: Claude's command can health-check approved servers. It is **not offline**. Keep an actually selected endpoint with `--server-url `; never silently substitute production. It projects allowed fields without retaining headers/full CLI output. Timeout, denied/unavailable CLI and unrecognized output remain unavailable; only the exact completed no-entry response supports not_registered. Select `--server-name plugin:chatcut:chatcut` only when that is the actual selected server; never recreate a plugin server as a manual entry. - -Read [repair and recheck](references/repair-and-recheck.md) before any change. Repair only a demonstrated missing manual registration in the authorized scope; retain prior state, actual command and rollback. No automatic credential/network repair. Local execution denial is evidence, not authorization to change permissions. - -## Recheck and deliver - -After an authorized change, run one native safe `list_projects` in the same host/scope. If a new session must explicitly load project config, disclose that choice. Do not replay mutation/generation calls or simulate a native read with fetch. Save the actually exposed projects[] JSON (or structuredContent.projects[]) separately as `read.mcp-recheck.txt`. A CLI mcp get/configuration response is never a tool recheck, and cannot be formatted into one. If the native project result is not exposed, retain that gap instead of claiming success. Then then record it without changing the original report: +Save only the actually exposed tool result in a separate file, then record it against the original report: ```text -node "/scripts/record-recheck.mjs" --active-case --result "read.mcp-recheck.txt" --operation list_projects --outcome --scope "" +node "/scripts/record-recheck.mjs" --report "" --host --result "" --operation --outcome --server-url --config-scope "" ``` -The program's bounded `active-case.json` selects the original incident report and verifies its SHA; a later entry baseline does not replace a recent same-endpoint case. Use `--active-case`, not the newest report path. A new entry can produce another baseline; it is a separate observation, not the original incident. The recording is explicitly Agent supplied and does not independently prove account authentication or original recovery. Finish a usable Chinese explanation for Chinese requests: actual symptom/error, evidence, action, recheck result, diagnostic ID/files and delivery status. Generated Chinese notes have the current facts; add actual repair details in a separate note. Do not ask the user to fill JSON or leave core placeholders. - -Report transfer requires authorization. `node "/scripts/doctor.mjs" --upload-report ""` sends the unchanged report once and saves a SHA-bound receipt. Do not recollect to resend, change its endpoint or repeat acknowledged delivery. Ingest acceptance is not final storage or a ticket. No publishing or messaging without authorization. - -## Entry and advanced evidence - -Claude plugin public entry: **/chatcut:chatcut-mcp-doctor**. Codex Git plugin: **$chatcut:chatcut-mcp-doctor**; store identity: **$chatcut-desktop:chatcut-mcp-doctor**. Use the exact installed catalog entry. Bare /doctor belongs to the host. +Add known `--project-id`, local `--conversation-id`, `--call-id`, `--observed-at`; omit unknown facts. Never copy an MCP session credential as a conversation ID. Choose the safe operation from the current tool schema; the recorder does not execute tools or impose a fixed tool list. If the host does not expose a structured result, retain the gap rather than manufacturing one. Each invocation saves another observation. It is Agent supplied, not independent proof of the original attempt or recovery. A new host/session is a separate boundary to disclose, not automatic continuation of the old one. No actual result or no saved observation means recheck incomplete. -- [Discovery and fallback](references/discovery.md): entry missing/old plugin or no local execution. -- [Connection diagnostics](references/connection-diagnostics.md): dedicated evidence inputs, bounded native logs and original IDs. -- [Advanced collection](references/advanced-collection.md): optional existing collector modes, precise log/database coverage and feedback reuse. Load only when needed. +Explain the symptom, evidence, actual action, actual recheck and remaining unknowns in the user's language. Deliver the existing local report, repair note and recheck files even when upload is unavailable. Optional `doctor.mjs --report ` derives feedback from the unchanged report; it does not recollect. Authorized transfer uses `doctor.mjs --upload-report ` and a separate SHA-bound receipt. Do not resend acknowledged or uncertain delivery. Acceptance is not complete storage; failed upload retains the reason and local files. No ticket, publishing or messaging without authorization. diff --git a/codex/skills/chatcut-mcp-doctor/references/advanced-collection.md b/codex/skills/chatcut-mcp-doctor/references/advanced-collection.md index 76fa5bc..3d8ee97 100644 --- a/codex/skills/chatcut-mcp-doctor/references/advanced-collection.md +++ b/codex/skills/chatcut-mcp-doctor/references/advanced-collection.md @@ -1,89 +1,13 @@ -# ChatCut MCP Doctor +# Optional collection and delivery -This Skill runs with local file/shell tools and Node 18+, independently of ChatCut MCP. Do not wait for ChatCut tools, a project, login, or the basics Skill to load. Tell the user that you are collecting connection diagnostics and whether the report will be sent. Honor local-only requests and existing execution/network permissions. +Use the existing report when following up. Collection is not a prerequisite for reasoning from evidence that is already available. -If the host rejects a local command, preserve that exact rejection and the operation it blocked. One denied compound read does not establish that every file tool or the standalone Node helper is prohibited, or that the workspace is read-only. Use only capabilities already permitted by the current host; do not change permissions, switch shells to bypass a rejection, or call collection complete when no report was written. +For explicit new collection, the canonical helper is `scripts/mcp-doctor.mjs`. Run `--help` for the options in the installed copy. It supports error-only original inputs, bounded native logs, projected host snapshots, offline collection and independent anonymous metadata/wire checks. The latter are later probes, never the original failed request. Keep the selected endpoint; never switch beta/dev to production to obtain success. -## Start here +`capture.mjs --host codex|claude` is an offline convenience that can project the current project registration without retaining credential fields. Original error inputs are explicit, never selected from old files automatically. `--native-lookup` opts into a bounded host CLI query, which may perform health checks. Its receipt identifies the new report; it does not choose which incident should be rechecked. -- In Codex, select **ChatCut MCP Doctor** from the skill picker. The Git plugin entry is `$chatcut:chatcut-mcp-doctor`; the existing store identity uses `$chatcut-desktop:chatcut-mcp-doctor`; a standalone project/personal copy is `$chatcut-mcp-doctor`. Use the exact installed catalog entry. Natural language: “Diagnose my ChatCut MCP connection with ChatCut MCP Doctor.” -- In Claude Code with the ChatCut plugin, invoke `/chatcut:chatcut-mcp-doctor`. A separately installed project/personal copy uses `/chatcut-mcp-doctor`. -- `/doctor` and `codex doctor` belong to the host; this Skill does not replace them. If the entry is absent or the plugin is old, read [discovery and fallback](references/discovery.md). A browser-only host without local execution cannot run this collector; retain the error and explain that boundary. +`doctor.mjs --report ` optionally derives diagnostic/summary/private draft files without changing the original. Agent repair notes and rechecks are separate files and are not overwritten by rebuilding those derived files. -## Preserve, collect, explain +`doctor.mjs --upload-report ` is the Doctor's saved-report transfer entry. It acquires an exclusive local owner, checks the SHA-bound receipt inside that ownership, and sends no new probes. An interrupted transfer retains the owner/uncertain receipt for review. An acknowledged or uncertain transfer cannot be automatically repeated. A failed transfer keeps its original cause and local evidence. An ACK still requires independent fragment reassembly and SHA verification before claiming complete storage. Repair notes and rechecks are local delivery artifacts, not silently inserted into the original upload. -Preserve the already visible original error before retries, reconnects or changes. Read [connection diagnostics](references/connection-diagnostics.md) for the dedicated failure/snapshot schema, bounded logs and collector options. Unknown times, IDs and stages stay unknown. Do not read credentials, auth.json, full configuration files or conversation transcripts. - -Invalid optional time/ID hints must not discard an otherwise valid original error. The helper omits those values and records `metadataGaps`; explain the precise gap instead of guessing replacements. Keep the dedicated input file for review. - -For a visible failed tool call, the minimal record is below. Create a file named -`original.mcp-failure.json` (the `.mcp-failure.json` suffix is required). Replace -only the message and the actually known tool name; keep unavailable time null. -Read the reference before adding fields. The failure record uses schema **1**, -not report schema 2; `mcp_tool_call` and `errorMessage` are not accepted fields. - -```json -{"schemaVersion":1,"source":"host_tool_result","serverName":"chatcut","occurredAt":null,"operation":"list_projects","error":{"message":"Exact exposed original error"}} -``` - -Do not lose a known tool name by switching to text-only input after a formatting -mistake. Correct the dedicated record using the already saved original result, -without calling the failed tool again. Keep earlier incomplete reports separate; -reuse the final correct report for feedback and one authorized transfer. - -**CLI registration query → snapshot; failed MCP tool → failure record.** A -`codex/claude mcp get` response belongs in snapshot observations, not in a -failure record with `source: cli_configuration`. Generate a fresh snapshot with -`--create-snapshot current.mcp-snapshot.json`, retain its generated capture time, -then add only the observed registration fact. Explicit native absence permits: - -```json -{"stage":"host_registration","operation":"lookup_registered_server","source":"cli_configuration","observedAt":null,"outcome":"failed","lookupResult":"not_found","message":"Exact native no-entry response"} -``` - -Collect it with `--host-snapshot current.mcp-snapshot.json --server-url --offline --output current.mcp-report.json`. Omit `--failure-record` when no original MCP tool/runtime error is exposed. This keeps the CLI message and `not_registered` finding without inventing an original MCP failure/time. The earlier schema-1 example is for an actual failed MCP tool call only. Keep a known selected URL even offline so a later authorized transfer can reuse the report; never substitute a production URL afterwards. - -If a Windows log database is unavailable, save the just-visible failed tool's error first in a dedicated failure record, including unfamiliar messages/cause and only the stage/time/IDs actually exposed. Continue offline collection: database access is not a prerequisite for preserving that error, and a database failure does not invalidate it. Keep existing bounded App evidence separate. Do not require the user to configure stderr or export/copy a database first, or try permission/WAL/locking workarounds. Check `evidenceSummary.hostLogCoverage` for retained original error, database read failures and remaining sources. A startup error hidden from the Agent with no accessible App log or retained stream remains a coverage gap. - -Save the actual error message and exposed error fields, rather than reconstructing a supposedly raw tool-result envelope. A JSON-RPC error does not establish a `result.isError` flag. The helper records `collectionContext` from the current Codex `CODEX_THREAD_ID`; this is the collecting thread, not automatic proof of the original failed thread. When the failure just happened in this same chat, its known thread ID may be copied into the Agent-supplied failure record with that source explained. Never use `CODEX_TOOL_CALL_ID` from the diagnostic command as the failed MCP call's ID. - -With a known original/session thread and supported local Codex metadata, `hostLogs.nativeFailedCalls` contains at most ten failed ChatCut MCP items projected from that thread's bounded native index. This reads only native IDs, lifecycle times and error fields; do not manually read whole history items, conversations, arguments or successful results. Keep `nativeItemId` and its kind separate from Responses call IDs and server IDs. Keep native `startedAtMs`/`completedAtMs`, log time and original `occurredAt` separate. The collector never backfills original fields or confirms association automatically; compare known thread, operation and exact error, preserving duplicate/unknown matches as candidates. - -If this invocation's native stderr has already been retained by its caller in a known dedicated `.codex-stderr.log`, use `--codex-stderr-file` to read a bounded ChatCut diagnostic tail. The helper retains native `loggedAt` separately, never as original `occurredAt` or a call ID. This is conditional on a real retained error stream; do not manufacture a log, scan for unknown paths or start another host to replace missing evidence. - -Resolve paths from **this Skill's directory**, then invoke standalone quoted Node commands. The agent creates the files; the user should not have to fill JSON. Capture observed host registration/session facts; static CLI configuration cannot establish current tools or authentication. For a registration lookup, record `stage: host_registration`, `operation: lookup_registered_server`, `source: cli_configuration` (or `host_settings`), its actual `outcome`, exact response/error and actual observation time or null. Add `lookupResult: not_found` **only** when a completed host query explicitly reports no named entry in the selected scope, or a successful complete scoped inventory establishes absence. A nonzero exit, EACCES, parse error, missing CLI, or empty tool list cannot establish absence; omit `lookupResult` or use `unavailable` and preserve that error. `found` records an explicitly present entry. These remain agent-supplied facts; the helper does not independently verify a missing registration. A failed original tools/call may use `stage: tools_call` with its actual source. - -Only `scripts/doctor.mjs` generates the canonical report, its diagnostic UUID and collection time. Never handwrite a replacement report or invent these values. The agent writes only the dedicated original-error/snapshot inputs and separate explanatory/repair notes. A directory's timestamp is not the failure or repair time; keep unexposed original times null. A successful read of a controlled unauthenticated service proves that read only, not account authentication, permission checks or the contents of a real user's projects. - -```text -node "/scripts/doctor.mjs" --host codex --server-url --host-snapshot --failure-record --output -``` - -Use `--host claude` for Claude Code. Omit unavailable optional inputs. If there is no registered URL, omit it; do not silently choose production. `--offline` keeps the registered URL as evidence but sends **no requests**, including upload. Otherwise the collector makes bounded anonymous entry/metadata checks and optional wire checks; an initial anonymous 401 is expected. Use `--upload` for the redacted report when transfer is authorized, after briefly explaining it. An intentionally disabled server is preserved without requests. - -The command writes the existing schema-2 report plus `.diagnosis.json`, `.summary.md` and `.issue-draft.md` sidecars. Read the findings and actual gaps before answering; do not merely echo a category. Findings identify observed boundaries, not root causes. Distinguish original failure, host evidence and later probes. Missing/truncated/denied logs remain gaps. A healthy later probe does not establish recovery. Proxy presence, busy/300-second timeouts and generic transport errors do not justify blaming security software, clearing locks/credentials, restarting or changing timeouts/environments. - -Wire failures/incomplete captures are later **direct-route** checks even if fetch metadata succeeds. Bounded native ERROR records and App `status=failed` notices are visible as unassociated host evidence; they do not fill a missing original error/time/request ID. Ordinary App starting/ready notices do not demonstrate failure. Local summaries show credential-redacted error excerpts; issue drafts keep structured states and evidence pointers without copying native log excerpts. - -## Repair and recheck - -For an observed missing registration or a normal sign-in requirement, read [repair and recheck](references/repair-and-recheck.md). Apply only the minimal change within the user's authorization and actual host/scope. Keep the prior state and rollback. Other findings need evidence-led investigation; this version has no automatic credential/network repair. - -Verify the affected boundary in the **same host and scope** after a change. Registration lookup success proves registration only. Auth success requires the host's authenticated state. Tool recovery requires a safe read-only call such as `list_projects` actually succeeding. If the original operation mutates state, costs credits, or cannot be safely repeated, do not replay it; say that its recovery remains unverified. Record the actual action, time/source and verification result in the summary and draft; never label an unperformed repair as tried. Keep the report's original failure immutable and its collector `recoveryVerified:false`; agent recheck results are separate evidence. - -## Feedback without starting over - -Return the observed finding, justified next action, diagnostic ID, report/summary locations and remaining gap in the user's language. An unresolved case should include the ready-to-review issue draft. The local JSON retains full credential-redacted evidence; the draft excludes log dumps and local IDs, but still needs review for personal/project data before sharing. - -Read the generated files and finish a usable explanation in the user's language. For Chinese requests, write a separate Chinese note with the actual symptom, exact error excerpt, evidence/source, action actually taken, safe same-host recheck result and delivery result. Fill core facts from observations; do not leave template placeholders for the user. If a later safe call succeeded, say exactly which operation and host/scope succeeded, while keeping the collector's original report immutable and its historical recovery flag separate. Do not claim an unperformed repair, complete recovery, final storage or an opened ticket. - -A protocol-discovery attempt that was rejected and then successfully negotiated by the same native flow can be normal. Preserve that sequence separately; do not present an intermediate negotiation rejection as the user's terminal failure when actual downstream success is established. Unrelated later success does not erase a recorded original tool failure. - -```text -node "/scripts/doctor.mjs" --report -node "/scripts/doctor.mjs" --upload-report -``` - -`--report` rebuilds sidecars offline without recollecting. It overwrites derived sidecars, so save any agent-written repair notes separately first. Use `--upload-report` only for an authorized first transfer or a single requested retry after failure; it reuses the original diagnostic ID without new probes, keeps the original report unchanged, and writes a separate upload receipt. Do not repeatedly recollect or resend an acknowledged report. `ingest_accepted` is not verified final storage or an opened support ticket. - -Use [ChatCut contact](https://chatcut.io/contact) for private support. The draft names no public issue repository because that destination is undecided. Do not open an issue, publish evidence, or message anyone without authorization. Root-cause rules and prevalence based on natural incidents remain future work; controlled tests are not user incidents. +The collector implementation is authored in Codex basics. Run `node scripts/sync-mcp-helpers.mjs` after changing it; Claude basics and both self-contained Doctor packages are generated copies. `--check` rejects drift in CI. Doctor publication remains separate from collection-only release. diff --git a/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md b/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md index 089a805..58d583c 100644 --- a/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md +++ b/codex/skills/chatcut-mcp-doctor/references/connection-diagnostics.md @@ -92,14 +92,6 @@ Sources: `current_session`, `tool_search`, `host_settings`, Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` (`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, `protocolCode`, `message`, and `originalAttemptMatch` (`confirmed`/`unconfirmed`). -Only `host_registration` / `lookup_registered_server` observations from -`cli_configuration` or `host_settings` may additionally carry `lookupResult`: -`not_found`, `found`, or `unavailable`. `not_found` requires an explicit absence -response from a completed named lookup or a successful complete scoped inventory; -do not derive it from an exit code, permission/parse error, missing CLI, or empty -tool list. Preserve the actual response in `message`. Failed/inconclusive lookups -without this fact retain unknown registration state and do not recommend adding -an entry. This is still agent-supplied evidence, not independent verification. Omit the match flag when unknown. Set `confirmed` only when the captured failed runtime/session observation explicitly identifies the original failed attempt and its actual server request ID; registration, nearby timestamps, successful @@ -110,18 +102,55 @@ 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 +the existing bounded reads and a global 30-record / 128 KiB text-message budget. +When a complete record cannot fit, the report names the omitted record count; +it does not trim an error to make it appear complete. Native failed-item error +projections remain separately bounded and the complete local report is retained +when the upload budget is exceeded. + +`evidenceSummary.failureEvidence` mechanically indexes the original retained +error blocks and failed native items. It records their actual source, log event +time or native lifecycle, and available local call/item identifiers. Read the +referenced original block for the full message and cause. A matching conversation +and call ID is only a local binding to the Agent-supplied original record; nearby +times, one remaining candidate, and the current collection conversation are not +proof that a log is the user's reported failure. No candidate is automatically +selected as `originalFailure`, and its log time never fills `occurredAt`. + +When no original failure record was supplied, the helper can still retain these +host failures but cannot tell whether they are the user's reported attempt. +Codex App startup notices, Claude debug logs, and native local IDs do not provide +a missing server X-Request-ID. The helper cannot capture the host's original TLS +bytes or HTTP response before its parser; later independent probes remain +separate evidence. If the host discarded the original tool error, time, or +response header and no accessible source retained it, those fields remain gaps. + ## Run once for the reported failure ### Preserve the failed call before independent checks -Database reads are supplementary. When the current tool error is visible, save -its dedicated failure record before attempting runtime sources, then run the -offline helper even if SQLite fails. `hostLogCoverage` explicitly distinguishes -the retained error, database errors and other-source coverage. Existing App logs -remain an independent bounded source. An already visible tool error needs no -prior stderr setup; an unexposed startup error still needs an accessible source. -Never copy a live database, use immutable/nolock mode, discard WAL or widen -permissions to make collection pass. If no original error is exposed, say so. +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 @@ -154,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 @@ -164,6 +225,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 @@ -201,10 +280,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 @@ -239,40 +326,6 @@ MCP error stream. Preserve the original stderr/startup error when it is already visible to the current host. The helper cannot recover discarded parent-process stderr or reconstruct an old exec failure from App logs. -When the caller actually retained this invocation's native stderr, explicitly -select its known dedicated `.codex-stderr.log` with `--codex-stderr-file`. -The same native ChatCut selector reads at most128 KiB and30 records; omitted -bytes and inaccessible files remain gaps. `source: codex_stderr_log` records -`loggedAt` as native log-emission time and keeps original attempt association -unconfirmed. It does not assign original `occurredAt`, lifecycle time or IDs. -No capture file is created, discovered or populated by this option. Unknown or -discarded stderr remains unavailable; another host/session is not a substitute. - -For a known thread, the helper can also query the existing Codex -`thread_history_1.sqlite` failed-MCP index in the same supplied SQLite root. -It selects only native IDs, start/completion columns and `error` projections for -that thread, ChatCut server, `mcpToolCall` type, failed status and the bounded -time window. It returns at most ten items and at most128 KiB per error; limits, -missing/unsupported schemas and denied reads remain gaps. It never returns -whole `item_json`, user/assistant messages, tool arguments or successful results, -and never reads a rollout transcript. `created_at_ms` is not substituted for a -lifecycle or failure time. - -`collectionContext` identifies the current collecting thread from the host's -`CODEX_THREAD_ID`; it is not silently copied into `originalFailure`. -`hostLogs.nativeFailedCalls` preserves native `nativeItemId` with its explicit -kind and `startedAtMs`/`completedAtMs`. These fields are native MCP item identity -and lifecycle evidence, not Responses `call_id`, MCP RPC ID, server request ID, -log-emission time or an automatically known original `occurredAt`. Native facts -can be useful even when the original fields remain unknown. Only compare a -known same thread, matching operation and exact error; duplicates or missing -provenance remain candidates. A failed index read does not erase other evidence. -The gap identifies the failed operation (locate/open/schema/query) and retains -the native error. Windows error14 can occur at SELECT even when ordinary file -reads and SQLite open succeed. No database copy, immutable mode or permission -change is attempted. A report that exceeds the existing upload budget remains -fully saved locally; errors are not shortened merely to make upload fit. - If the current host already exposes native `mcpServer/startupStatus/updated` notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, `runtimeStatus` and `toolsError` from that same thread in the error file and @@ -332,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-mcp-doctor/references/discovery.md b/codex/skills/chatcut-mcp-doctor/references/discovery.md index 55594a9..90bbcfb 100644 --- a/codex/skills/chatcut-mcp-doctor/references/discovery.md +++ b/codex/skills/chatcut-mcp-doctor/references/discovery.md @@ -24,7 +24,7 @@ this Skill. The existing collector may still be bundled at not include these summary/draft sidecars. An agent with a trusted local copy of this repository can read this SKILL.md -directly and execute `scripts/doctor.mjs` without installing or connecting MCP. +directly and execute `scripts/capture.mjs` without installing or connecting MCP. For an explicitly requested standalone installation, copy the complete `chatcut-mcp-doctor` directory (including scripts and references) into a host supported project/personal skills directory, then verify discovery in a new diff --git a/codex/skills/chatcut-mcp-doctor/scripts/active-case.mjs b/codex/skills/chatcut-mcp-doctor/scripts/active-case.mjs deleted file mode 100644 index e226dcf..0000000 --- a/codex/skills/chatcut-mcp-doctor/scripts/active-case.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import { lstat, readFile, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import { join, resolve, sep } from "node:path"; -import { readReport } from "./doctor.mjs"; - -export async function readActiveCase(cwd, host) { - const base = resolve(cwd, ".chatcut-doctor"), path = join(base, "active-case.json"); - const info = await lstat(path); - if (!info.isFile() || info.size > 8192) throw new Error("Active case must be a bounded program-generated index"); - const value = JSON.parse(await readFile(path, "utf8")); - const target = resolve(value.report ?? ""); - if (value.schemaVersion !== 1 || !target.startsWith(base + sep) || - !["codex", "claude"].includes(value.host) || (host && value.host !== host)) - throw new Error("Active case does not identify this workspace/host"); - const { report, sourceReportSha256 } = await readReport(target); - if (report.host !== value.host || report.diagnosticId !== value.diagnosticId || sourceReportSha256 !== value.sourceReportSha256) - throw new Error("Active case report identity/hash mismatch; preserve files for review"); - return { ...value, report: target }; -} - -export async function selectActiveCase(cwd, report, path, sourceReportSha256, explicitEvidence) { - let prior; - try { prior = await readActiveCase(cwd, report.host); } - catch (error) { if (error.code !== "ENOENT") throw error; } - // A follow-up entry's baseline must not replace the incident being rechecked. - // Explicit original evidence or a fresh native missing-entry finding starts - // a case; otherwise keep a recent same-endpoint case, without claiming recovery. - const pending = prior && Date.now() - Date.parse(prior.recordedAt) < 30 * 60 * 1000 && prior.serverUrl === report.serverUrl; - const evidenceFingerprint = report.originalFailure.status === "recorded" - ? createHash("sha256").update(JSON.stringify(report.originalFailure)).digest("hex") : null; - if (pending && (!explicitEvidence || (evidenceFingerprint && evidenceFingerprint === prior.evidenceFingerprint))) return prior; - const selected = { schemaVersion: 1, host: report.host, report: resolve(path), diagnosticId: report.diagnosticId, - sourceReportSha256, serverUrl: report.serverUrl, evidenceFingerprint, recordedAt: new Date().toISOString() }; - await writeFile(join(cwd, ".chatcut-doctor", "active-case.json"), JSON.stringify(selected, null, 2) + "\n", { mode: 0o600 }); - return selected; -} diff --git a/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs b/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs index 04b632a..32ecdfe 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/capture.mjs @@ -5,24 +5,11 @@ import { basename, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs, promisify } from "node:util"; import { redact, runDoctor, serverUrl } from "./mcp-doctor.mjs"; -import { writePacket } from "./doctor.mjs"; -import { selectActiveCase } from "./active-case.mjs"; const execute = promisify(execFile); const save = (path, data) => writeFile(path, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 }); const now = () => new Date().toISOString(); -async function savedOriginal(cwd) { - // Two reserved error-only inputs, never a report, config, glob or transcript. - for (const [key, name] of [["failureRecord", "original.mcp-failure.json"], ["originalError", "original.mcp-error.txt"]]) { - try { - const path = join(cwd, name), info = await lstat(path); - if (info.isFile() && info.size <= 64 * 1024) return { [key]: path }; - } catch (error) { if (error.code !== "ENOENT") return {}; } - } - return {}; -} - async function dedicatedText(path) { if (!/\.mcp-error\.txt$/i.test(basename(path))) throw new Error("Use a dedicated .mcp-error.txt containing only the exposed original error"); const info = await lstat(path); @@ -85,7 +72,7 @@ export async function capture(options, dependencies = {}) { // Validate explicit endpoint before any optional native connection check. const selected = options.serverUrl ? serverUrl(options.serverUrl).href : null; const cwd = resolve(options.cwd ?? process.cwd()); - const originalInput = options.originalError || options.failureRecord ? options : await savedOriginal(cwd); + const originalInput = options; const base = join(cwd, ".chatcut-doctor"); await mkdir(base, { recursive: true, mode: 0o700 }); const dir = await mkdtemp(join(base, "capture-")); @@ -113,28 +100,14 @@ export async function capture(options, dependencies = {}) { } const { path, report } = await runDoctor({ host: options.host, serverUrl: endpoint, hostSnapshot: snapshotPath, failureRecord, offline: true, output: join(dir, "diagnostic.mcp-report.json") }, dependencies); - const packet = await writePacket(path, report); - const zhPath = path + ".zh-CN.md"; - const labels = { tool_call_failed: "已保留工具调用失败", startup_or_discovery_failed: "已保留启动或工具发现失败", - registration_lookup_unavailable: "注册状态未确认", not_registered: "原生查询明确报告所选名称未注册", - no_incident_demonstrated: "本轮证据不足以确认原始故障", original_failure_recorded_stage_unconfirmed: "已保留原错,阶段未确认" }; - const registration = lookup.lookupResult === "found" - ? `${options.nativeLookup ? "原生查询返回" : "当前工程静态配置包含"}所选注册;这不能确认当前会话连接、认证或原故障恢复。` - : lookup.lookupResult === "not_found" ? `原生查询明确返回:${lookup.message}` - : "所选注册状态未确认。离线工程配置缺失、查询不可用或无法识别的输出都不能证明宿主未注册;本地、用户和插件作用域及当前会话仍有未覆盖范围。"; - const original = report.originalFailure; - const note = `# ChatCut MCP 本轮诊断\n\n诊断编号:${report.diagnosticId}\n采集时间:${report.collectedAt}(不是原始错误时间)\n宿主:${report.host}\n所选地址:${report.serverUrl ?? "未确认"}\n\n注册观察:${registration}\n\n原始操作:${original.operation ?? "未知"}\n原始错误内容:${original.status === "recorded" ? original.message : "没有可用的原始 MCP 错误;没有编造。"}\n宿主暴露的错误码:${original.error?.code ?? "未提供"}\n原始时间:${original.occurredAt ?? "未知"};调用 ID:${original.callId ?? "未知"}。\n\n结论:${packet.findings.map(x => labels[x.kind] ?? x.kind).join(";")}。根因尚未确认,现有证据不能排除或归因于注册、凭证、网络或服务端。\n实际处理:${options.nativeLookup ? "已运行一次用户允许的原生注册查询;该命令可能附带健康连接检查,不能称为全程离线。" : "仅投影当前工程允许的配置字段并离线采集;没有调用原生 CLI 或发送诊断请求。"} 程序未修改注册、登录或权限。\n复验:尚未执行安全的原生工具读取;后续复验单独记录,原报告不变。\n发送:未上报、未开工单,最终存储未验证。\n缺口:${report.evidenceSummary?.gaps?.join(";") || "详见正式报告"}。\n下一步:${lookup.lookupResult === "not_found" ? "在用户授权的宿主/范围内按修复指南检查缺失注册并保留回滚,再做一次安全原生读取。" : "如用户授权,按实际宿主/范围做一次安全原生读取,单独保留结果;不重复制造失败或猜测根因。"}\n\n正式报告:${path}\n`; - await writeFile(zhPath, note, { flag: "wx", mode: 0o600 }); + const packet = { diagnosticId: report.diagnosticId, report: path, upload: report.upload, recoveryVerified: false }; const bytes = await readFile(path), saved = JSON.parse(bytes); if (saved.diagnosticId !== report.diagnosticId || saved.schemaVersion !== 2) throw new Error("Saved report identity check failed"); const sourceReportSha256 = createHash("sha256").update(bytes).digest("hex"); - const activeReport = await selectActiveCase(cwd, report, path, sourceReportSha256, - report.originalFailure.status === "recorded" || (options.nativeLookup && lookup.lookupResult === "not_found")); - const receipt = { schemaVersion: 1, startedAt, completedAt: now(), ...packet, snapshot: snapshotPath, chineseNote: zhPath, - sourceReportSha256, savedReportVerified: true, activeReport, + const receipt = { schemaVersion: 1, startedAt, completedAt: now(), ...packet, snapshot: snapshotPath, + sourceReportSha256, savedReportVerified: true, nativeLookupRequested: Boolean(options.nativeLookup), diagnosticHttpRequests: 0, nativeLookupMayConnect: Boolean(options.nativeLookup), lookupResult: lookup.lookupResult, - userFacingNote: note, originalInput: originalInput.originalError ? resolve(originalInput.originalError) : originalInput.failureRecord ? resolve(originalInput.failureRecord) : null }; await save(join(dir, "capture-receipt.json"), receipt); return receipt; diff --git a/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs index 3001875..8f24173 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs @@ -1,42 +1,37 @@ -import { readFile, stat, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; +import { lstat, open, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; import { basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { runCli, submitReport, errorDetails } from "./mcp-doctor.mjs"; +import { submitReport, errorDetails } from "./mcp-doctor.mjs"; import { diagnose, renderPacket } from "./diagnosis.mjs"; export async function writePacket(path, report) { const diagnosis = diagnose(report); const { summary, draft } = renderPacket(report, diagnosis); const files = { diagnosis: `${path}.diagnosis.json`, summary: `${path}.summary.md`, issueDraft: `${path}.issue-draft.md` }; - // These are derived sidecars. The source report and diagnostic ID stay unchanged. await writeFile(files.diagnosis, JSON.stringify(diagnosis, null, 2) + "\n", { mode: 0o600 }); await writeFile(files.summary, summary, { mode: 0o600 }); await writeFile(files.issueDraft, draft, { mode: 0o600 }); return { diagnosticId: report.diagnosticId, report: path, ...files, findings: diagnosis.findings, upload: report.upload, recoveryVerified: false }; } -function dedicatedReportPath(path) { - return /(?:\.mcp-report|chatcut-mcp-doctor-[a-f0-9-]+)\.json$/i.test(basename(path)) || - /^mcp-report(?:-\d+)?\.json$/i.test(basename(path)); -} - export async function readReport(path) { - // Do not accept config/auth/session files even as a mistakenly supplied input. - if (!dedicatedReportPath(path)) - throw new Error("Use the dedicated .mcp-report.json or default chatcut-mcp-doctor-.json report, never config/auth/session files"); - if ((await stat(path)).size > 2 * 1024 * 1024) throw new Error("Report exceeds 2 MiB; retained locally without reading"); + if (/^(?:config|auth|credentials|session)(?:\.|$)/i.test(basename(path))) + throw new Error("Use a diagnostic report, never configuration, credentials or session files"); + const info = await lstat(path); + if (!info.isFile() || info.size > 2 * 1024 * 1024) throw new Error("Report must be a regular file of at most 2 MiB"); const bytes = await readFile(path); const report = JSON.parse(bytes); if (report.schemaVersion !== 2 || report.scope !== "cloud" || !["codex", "claude"].includes(report.host) || !/^[a-f0-9-]{36}$/i.test(report.diagnosticId ?? "") || !report.originalFailure || !report.hostLogs) - throw new Error("Not a schema-2 ChatCut MCP report"); + throw new Error("Not a schema-2 Hosted ChatCut MCP report"); return { report, sourceReportSha256: createHash("sha256").update(bytes).digest("hex") }; } async function readReceipt(path, report, sourceReportSha256) { try { - if ((await stat(path)).size > 32 * 1024) throw new Error("Upload receipt exceeds 32 KiB"); + const info = await lstat(path); + if (!info.isFile() || info.size > 32 * 1024) throw new Error("Upload receipt must be a regular file of at most 32 KiB"); const stored = JSON.parse(await readFile(path, "utf8")); if (stored.schemaVersion !== 1 || stored.diagnosticId !== report.diagnosticId || stored.sourceReportSha256 !== sourceReportSha256 || !stored.receipt || @@ -46,40 +41,59 @@ async function readReceipt(path, report, sourceReportSha256) { } catch (error) { if (error.code !== "ENOENT") throw error; } } -export async function main(args = process.argv.slice(2)) { - let result; - if (["--report", "--upload-report"].includes(args[0])) { - if (args.length !== 2) throw new Error("Use --report or --upload-report alone"); - const path = resolve(args[1]); +export async function uploadReport(path, dependencies = {}) { + path = resolve(path); + const lockPath = `${path}.upload.lock`; + let lock; + try { lock = await open(lockPath, "wx", 0o600); } + catch (error) { + if (error.code === "EEXIST") throw new Error("Upload already owned or interrupted; inspect the existing receipt and lock before an authorized retry"); + throw error; + } + let release = true; + try { const { report, sourceReportSha256 } = await readReport(path); const receiptPath = `${path}.upload.json`; const prior = await readReceipt(receiptPath, report, sourceReportSha256); - if (args[0] === "--upload-report") { - if (report.upload?.status === "ingest_accepted") - throw new Error("Ingest already acknowledged this report; do not send it again"); - if (prior?.status === "ingest_accepted") throw new Error("Ingest already acknowledged this report; do not send it again"); - report.upload = await submitReport(report); - await writeFile(receiptPath, JSON.stringify({ schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, receipt: report.upload }, null, 2) + "\n", { mode: 0o600 }); - } else { - if (prior) report.upload = prior; - } - result = { path, report }; - } else { - const outputs = args.flatMap((arg, index) => arg === "--output" - ? [args[index + 1] ?? ""] : arg.startsWith("--output=") ? [arg.slice(9)] : []); - if (outputs.some(path => !dedicatedReportPath(path))) - throw new Error("Use --output or mcp-report.json so the saved report can be reused with --report and --upload-report"); - if (args.includes("--help")) { - console.log("ChatCut MCP Doctor: collector options below also produce a diagnosis, summary and private issue draft. --offline sends no requests. --report rebuilds feedback offline. --upload-report sends that report once without new probes; use only for an authorized retry. No automatic repair or ticket submission."); - } - result = await runCli(args); + if (report.upload?.status === "ingest_accepted" || prior?.status === "ingest_accepted") + throw new Error("Ingest already acknowledged this report; do not send it again"); + const uncertain = receipt => receipt?.deliveryUncertain || ["pending", "sending"].includes(receipt?.status) || + (receipt?.status === "failed" && receipt.deliveryUncertain !== false); + if (uncertain(report.upload) || uncertain(prior)) + throw new Error("Previous delivery is uncertain; verify stored fragments before considering another send"); + const persist = async receipt => { + const pending = `${receiptPath}.writing-${randomUUID()}`; + try { + await writeFile(pending, JSON.stringify({ schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, receipt }, null, 2) + "\n", { flag: "wx", mode: 0o600 }); + await rename(pending, receiptPath); + } catch (error) { await unlink(pending).catch(() => {}); throw error; } + }; + // A crash after POST must leave an uncertain delivery, never a fresh retry. + await persist({ status: "sending", storageVerified: false, deliveryUncertain: true }); + release = false; + const receipt = await submitReport(report, dependencies.fetchImpl); + await persist(receipt); + release = true; + return { diagnosticId: report.diagnosticId, report: path, sourceReportSha256, receiptPath, receipt }; + } finally { + await lock.close(); + if (release) await unlink(lockPath); } - if (result) console.log(JSON.stringify(await writePacket(result.path, result.report))); +} + +export async function main(args = process.argv.slice(2)) { + if (args.length !== 2 || !["--report", "--upload-report"].includes(args[0])) + throw new Error("Use --report for optional feedback or --upload-report for authorized delivery. Collect only when needed with capture.mjs or mcp-doctor.mjs."); + const path = resolve(args[1]); + if (args[0] === "--upload-report") { console.log(JSON.stringify(await uploadReport(path))); return; } + const { report, sourceReportSha256 } = await readReport(path); + const prior = await readReceipt(`${path}.upload.json`, report, sourceReportSha256); + if (prior) report.upload = prior; + console.log(JSON.stringify(await writePacket(path, report))); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - main().catch((error) => { - console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); - process.exitCode = 1; + main().catch(error => { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); process.exitCode = 1; }); } diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 6ac313b..6c1e7b9 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -1,12 +1,28 @@ 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"; import { parseArgs } from "node:util"; import { connect as connectTls } from "node:tls"; import { isIP } from "node:net"; -import { evidenceSummary, inspectMetadata, logTime, validateSnapshot } from "./mcp-evidence.mjs"; +import { + evidenceSummary, + inspectMetadata, + isHostFailure, + logTime, + snapshotFromRecord, + validateSnapshot, +} from "./mcp-evidence.mjs"; import { probeMetadataWire } from "./mcp-wire.mjs"; import { failureFromRecord } from "./mcp-failure.mjs"; @@ -27,19 +43,26 @@ 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)) { - const sanitized = redact(decoded, jsonDepth + 1); - if (sanitized !== decoded) return JSON.stringify(sanitized); + if (jsonDepth < 16) + value = value.replace(/"(?:\\.|[^"\\\r\n])*"/g, (token) => { + try { + const decoded = JSON.parse(token); + 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); + } + } catch { + /* Non-JSON diagnostic text is retained for the normal rules. */ } - } catch { /* Non-JSON diagnostic text is retained for the normal rules. */ } - return token; - }); + return token; + }); return value .replace(/\b(Mcp-Session-Id[ \t]*:[ \t]*)\S+/gi, "$1[REDACTED]") .replace( @@ -50,7 +73,10 @@ export function redact(text, jsonDepth = 0) { /\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( + /((?:\\*["'])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]", @@ -80,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]" @@ -278,7 +304,7 @@ export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { params: { protocolVersion: "2025-11-25", capabilities: {}, - clientInfo: { name: "chatcut-mcp-doctor", version: "1.3.0" }, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.6" }, }, }), }); @@ -419,7 +445,12 @@ 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; + 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, "", @@ -427,7 +458,9 @@ function hostedTextRecord(line, nativeLevel) { const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); if (module) { if ( - !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::|codex_core::mcp_tool_call$)/.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); @@ -447,9 +480,12 @@ function hostedTextRecord(line, nativeLevel) { if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; 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" && + 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); + /^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)) { @@ -458,7 +494,12 @@ function hostedTextRecord(line, nativeLevel) { 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 ( + /["'](?: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); @@ -552,7 +593,11 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { 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) + if ( + window.conversationId && + context.conversationId && + context.conversationId !== window.conversationId + ) continue; if ( occurredAt && @@ -575,12 +620,45 @@ async function textLog(path, source, gaps, window, select = hostedTextRecord) { const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; if (unknownTimes) gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); - return entries.slice(-30); + return retainFailureLogs(entries, gaps, source); } finally { await handle.close(); } } +function retainFailureLogs(entries, gaps, source = "host_logs") { + const failures = entries.filter(isHostFailure); + const others = entries.filter((entry) => !isHostFailure(entry)); + const selected = new Set(); + let bytes = 0; + const newest = (values) => + values + .map((entry, index) => ({ + entry, + index, + time: Date.parse(entry.loggedAt ?? entry.occurredAt) || -Infinity, + })) + .sort((a, b) => b.time - a.time || b.index - a.index) + .map((value) => value.entry); + for (const entry of [...newest(failures), ...newest(others)]) { + const size = Buffer.byteLength(entry.message ?? ""); + if (selected.size === 30 || bytes + size > READ_LIMIT) continue; + selected.add(entry); + bytes += size; + } + if (selected.size < entries.length) + gaps.push({ + source, + reason: "failure_prioritized_log_budget", + maximumRecords: 30, + maximumMessageBytes: READ_LIMIT, + retainedMessageBytes: bytes, + omitted: entries.length - selected.size, + omittedFailureRecords: failures.filter((entry) => !selected.has(entry)).length, + }); + return entries.filter((entry) => selected.has(entry)); +} + async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { if ( readOffset !== undefined && @@ -671,9 +749,11 @@ async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = omitted: entries.length - 30, }); return { - entries: entries - .sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")) - .slice(-30), + entries: retainFailureLogs( + entries.sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")), + gaps, + "codex_app_log", + ), scan: { directoryEntriesScanned: scanned, matchingFiles: files.length, @@ -743,14 +823,28 @@ async function nativeFailedMcpItems(sqliteRoot, conversationId, window, gaps) { 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))) { + 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 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, @@ -762,34 +856,71 @@ async function nativeFailedMcpItems(sqliteRoot, conversationId, window, gaps) { 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); + 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 }); + 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) }; } + 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, + 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", + error, + ...(row.error_bytes > READ_LIMIT && { errorCaptureLimited: true }), + originalAttemptMatch: "unconfirmed", }; }); } catch (error) { - gaps.push({ source, reason: "unavailable", operation, error: errorDetails(error), + gaps.push({ + source, + reason: "unavailable", + operation, + error: errorDetails(error), ...(platform() === "win32" && error?.errcode === 14 - ? { accessEvidence: await inspectSqliteAccess(databasePath) } : {}), + ? { accessEvidence: await inspectSqliteAccess(databasePath) } + : {}), }); return []; } finally { - try { db?.close(); } - catch (error) { gaps.push({ source, reason: "close_failed", operation: "close_database", error: errorDetails(error) }); } + try { + db?.close(); + } catch (error) { + gaps.push({ + source, + reason: "close_failed", + operation: "close_database", + error: errorDetails(error), + }); + } } } @@ -823,12 +954,14 @@ export async function collectHostLogs(host, options = {}) { .prepare("PRAGMA table_info(logs)") .all() .some((column) => column.name === "thread_id"); - const threadFilter = hasThreadId && options.conversationId - ? " AND (thread_id IS NULL OR thread_id='' OR thread_id=?)" : ""; + 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`, - ); + `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"; @@ -843,8 +976,10 @@ export async function collectHostLogs(host, options = {}) { context.conversationId !== options.conversationId ) continue; - if (!hostedTextRecord(`${row.target}: ${original}`, row.level) || - !hostedTextRecord(`MCP server "chatcut" ${message}`)) + if ( + !hostedTextRecord(`${row.target}: ${original}`, row.level) || + !hostedTextRecord(`MCP server "chatcut" ${message}`) + ) continue; if ( !["ERROR", "WARN"].includes(row.level) && @@ -881,10 +1016,23 @@ export async function collectHostLogs(host, options = {}) { : {}), }); } finally { - try { db?.close(); } - catch (error) { gaps.push({ source: "codex_runtime_sqlite", reason: "close_failed", operation: "close_database", error: errorDetails(error) }); } + 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); + nativeFailedCalls = await nativeFailedMcpItems( + sqliteRoot, + options.conversationId, + { since, until }, + gaps, + ); try { entries.push( ...(await textLog(join(logRoot, "codex-tui.log"), "codex_tui_log", gaps, { @@ -899,15 +1047,27 @@ export async function collectHostLogs(host, options = {}) { 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"); + 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, + since, + until, + conversationId: options.conversationId, }); - entries.push(...captured.map(({ occurredAt, ...entry }) => ({ - ...entry, loggedAt: occurredAt, timestampKind: "native_log_time_not_original_failure_time", - }))); + 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) }); + gaps.push({ + source: "codex_stderr_log", + reason: "unavailable", + error: errorDetails(error), + }); } } const appData = options.localAppData ?? process.env.LOCALAPPDATA; @@ -976,7 +1136,7 @@ export async function collectHostLogs(host, options = {}) { if (!entries.length && !nativeFailedCalls?.length) gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); return clean({ - entries, + entries: retainFailureLogs(entries, gaps), gaps, ...(appLog ? { appLog } : {}), ...(nativeFailedCalls ? { nativeFailedCalls } : {}), @@ -988,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"); @@ -1035,22 +1199,24 @@ export async function submitReport(report, fetchImpl = fetch) { report.hostSnapshot?.session?.connectionStatus === "disabled") return { status: "skipped_intentionally_disabled", storageVerified: false }; if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; + let attempted = false; + let rejected = false; try { const endpoint = serverUrl(report.serverUrl); const events = fragmentEvents(report); + attempted = true; const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "content-type": "application/json" }, body: JSON.stringify(events), }); + rejected = !response.ok; const body = await limitedBody(response, 16 * 1024); if (!response.ok || JSON.parse(body).ok !== true) throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); - return { - status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, - reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256, - }; + return { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, + reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256 }; } catch (error) { - return { status: "failed", storageVerified: false, error: errorDetails(error) }; + return { status: "failed", storageVerified: false, deliveryUncertain: attempted && !rejected, error: errorDetails(error) }; } } @@ -1072,12 +1238,14 @@ export async function runDoctor(options, dependencies = {}) { } 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 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.3.0", + collectorVersion: "1.2.6", diagnosticId, collectedAt: new Date().toISOString(), scope: "cloud", @@ -1139,7 +1307,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) { @@ -1177,8 +1366,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"); @@ -1202,11 +1401,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) { @@ -1238,6 +1455,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, @@ -1258,6 +1494,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 || @@ -1269,7 +1508,7 @@ export async function runDoctor(options, dependencies = {}) { ? "host_reports_intentionally_disabled; no_requests_sent" : options.offline ? "offline_requested; no_requests_sent" - : "registered_endpoint_unavailable; no_requests_sent", + : "registered_endpoint_unavailable; no_requests_sent", }); } else { report.entryProbe = await guardProbe(endpoint.href, () => @@ -1372,22 +1611,21 @@ export async function runDoctor(options, dependencies = {}) { } } report.evidenceSummary = evidenceSummary(report); + report.collectionStatus = "complete"; + report.collectionStage = "complete"; report.upload = { status: disabled ? "skipped_intentionally_disabled" : options.offline ? "skipped_offline" - : !endpoint - ? "skipped_endpoint_unavailable" - : options.upload - ? "pending" - : "not_requested", + : !endpoint + ? "skipped_endpoint_unavailable" + : options.upload + ? "pending" + : "not_requested", storageVerified: false, }; - const path = resolve(options.output ?? join(tmpdir(), `chatcut-mcp-doctor-${diagnosticId}.json`)); - const save = async (flag) => - writeFile(path, JSON.stringify(clean(report), null, 2) + "\n", { flag, mode: 0o600 }); - await save("wx"); + await save("w"); if (options.upload && !options.offline && !disabled && endpoint) { report.upload = await submitReport(report, fetchImpl); await save("w"); @@ -1395,10 +1633,9 @@ export async function runDoctor(options, dependencies = {}) { return { path, report: clean(report) }; } -export async function runCli(args = process.argv.slice(2)) { +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const { values } = parseArgs({ - args, options: { host: { type: "string" }, "server-url": { type: "string" }, @@ -1486,16 +1723,14 @@ export async function runCli(args = process.argv.slice(2)) { path, upload: report.upload, evidenceGaps: report.evidenceSummary.gaps, + hostFailureEvidence: report.evidenceSummary.failureEvidence, collectionGaps: report.gaps, recoveryVerified: false, }), ); - return { path, report }; } } catch (error) { console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); process.exitCode = 1; } } - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await runCli(); diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs index 5a35595..9af54e7 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-evidence.mjs @@ -138,12 +138,17 @@ export function validateSnapshot(input, endpoint) { "lookupResult", ]); source(item.source); - if (item.lookupResult !== undefined && - (item.stage !== "host_registration" || item.operation !== "lookup_registered_server" || - !["cli_configuration", "host_settings"].includes(item.source) || - !["not_found", "found", "unavailable"].includes(item.lookupResult) || - (item.lookupResult === "not_found" && item.outcome === "unknown"))) - throw new Error("lookupResult is a host registration lookup result: not_found only for an explicit absence response; found or unavailable otherwise"); + if ( + 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) || @@ -179,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; @@ -197,11 +287,93 @@ export function stageHint(message) { return "unclassified"; } +export function isHostFailure(entry) { + if ( + ![ + "codex_runtime_sqlite", + "codex_tui_log", + "codex_stderr_log", + "codex_app_log", + "claude_debug_log", + ].includes(entry.source) + ) + return false; + if (entry.source === "codex_app_log") + return [...String(entry.message).matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed"; + const level = + entry.level ?? + String(entry.message) + .match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i) + ?.slice(1) + .find(Boolean); + return ( + ["WARN", "ERROR"].includes(String(level).toUpperCase()) || + /\b(?:connection failed|discovery failed|auth error|error during auth|MCP tool call error|startup failed|HTTP Connection failed)\b|(?:MCP server|Server)\s+["']?(?:plugin:chatcut:)?chatcut["']?:\s*Error\b/i.test( + entry.message, + ) + ); +} + +export function hostFailureEvidence(report) { + const original = report.originalFailure; + const logs = (report.hostLogs.entries ?? []).flatMap((entry, index) => { + if (!isHostFailure(entry)) return []; + const sameCall = + original.status === "recorded" && + original.conversationId && + original.callId && + entry.conversationId === original.conversationId && + entry.callId === original.callId; + return [ + { + evidence: `hostLogs.entries[${index}]`, + errorText: `hostLogs.entries[${index}].message`, + source: entry.source, + ...(entry.sourceFile && { sourceFile: entry.sourceFile }), + ...(entry.rowId !== undefined && { rowId: entry.rowId }), + loggedAt: entry.loggedAt ?? entry.occurredAt ?? null, + timeSource: "host_log_event_time; not_original_failure_occurredAt", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), + extractionRule: + "admitted_native_error_warning_or_explicit_failure_event; original_block_retained", + localCallBinding: sameCall + ? "same_conversation_and_call_id; original_record_agent_supplied" + : "unconfirmed; may_not_be_the_reported_failure", + originalAttemptMatch: "unconfirmed", + }, + ]; + }); + const native = (report.hostLogs.nativeFailedCalls ?? []).flatMap((entry, index) => + entry.status === "failed" && entry.error + ? [ + { + evidence: `hostLogs.nativeFailedCalls[${index}]`, + errorText: `hostLogs.nativeFailedCalls[${index}].error`, + source: entry.source, + conversationId: entry.conversationId, + nativeItemId: entry.nativeItemId, + idKind: entry.idKind, + startedAtMs: entry.startedAtMs, + completedAtMs: entry.completedAtMs, + timeSource: entry.timeSource, + extractionRule: "scoped_native_failed_mcp_item_error_projection", + localCallBinding: "native_item_only; not_bound_to_original_call_or_server_request", + originalAttemptMatch: "unconfirmed", + }, + ] + : [], + ); + return [...logs, ...native]; +} + export function evidenceSummary(report) { const snapshot = report.hostSnapshot; 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"); @@ -210,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" && @@ -220,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"); @@ -249,25 +432,41 @@ 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, + 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, + failureEvidence: hostFailureEvidence(report), 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, + 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", + 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", @@ -283,11 +482,16 @@ export function evidenceSummary(report) { : null, hostLogCoverage: { originalErrorRetained: report.originalFailure.status === "recorded", - originalErrorRetainedWithoutDatabase: report.originalFailure.status === "recorded" && databaseReadFailures.length > 0, + originalErrorRetainedWithoutDatabase: + report.originalFailure.status === "recorded" && databaseReadFailures.length > 0, databaseReadFailures, - runtimeCollectionFailed: (report.hostLogs.gaps ?? []).some(gap => gap.source === "host_logs" && gap.reason === "collector_failed"), + 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, + retainedStderrRecords: report.hostLogs.entries.filter( + (entry) => entry.source === "codex_stderr_log", + ).length, priorStderrCaptureRequired: false, records: report.hostLogs.entries.length, appStartupRecords: appNotices.length, diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs index cb1c8f2..c472e8d 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-failure.mjs +++ b/codex/skills/chatcut-mcp-doctor/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-mcp-doctor/scripts/record-recheck.mjs b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs index c763b60..fb6a2a6 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -1,49 +1,76 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { lstat, readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { readReport } from "./doctor.mjs"; -import { redact } from "./mcp-doctor.mjs"; -import { readActiveCase } from "./active-case.mjs"; +import { redact, serverUrl } from "./mcp-doctor.mjs"; + +async function evidenceFile(path) { + if (typeof path !== "string" || /(?:^|[.])(config|auth|credentials|session)(?:[.]|$)/i.test(basename(path))) + throw new Error("Supply only the exposed tool result, never configuration/auth/session files"); + const info = await lstat(path); + if (!info.isFile() || info.size > 128 * 1024) throw new Error("Evidence must be a regular file of at most 128 KiB"); + const value = await readFile(path, "utf8"); + if (!value.trim()) throw new Error("Evidence is empty"); + return value; +} + +const fact = value => typeof value === "string" && value.trim() && value.length <= 1000 ? redact(value) : null; export async function recordRecheck(options) { - if (Boolean(options.report) === Boolean(options.activeCase)) throw new Error("Choose --active-case for the program-tracked incident, or an explicit --report"); - const active = options.activeCase ? await readActiveCase(options.cwd ?? process.cwd()) : null; - const path = active ? active.report : resolve(options.report); + if (!options.report || options.activeCase) throw new Error("Explicit --report is required; no active-case or time-based report selection"); + const path = resolve(options.report); const { report, sourceReportSha256 } = await readReport(path); - if (!["success", "failed"].includes(options.outcome) || options.operation !== "list_projects" || - typeof options.scope !== "string" || !options.scope.trim() || options.scope.length > 1000) - throw new Error("Record the actual safe list_projects result, outcome and same-host/scope boundary"); - if (!options.result.endsWith(".mcp-recheck.txt")) throw new Error("Use the dedicated .mcp-recheck.txt exposed result only"); - const info = await lstat(options.result); - if (!info.isFile() || info.size > 64 * 1024) throw new Error("Recheck result must be a regular file of at most 64 KiB"); - const result = redact(await readFile(options.result, "utf8")); - if (!result.trim()) throw new Error("Recheck result is empty"); + if (!["codex", "claude"].includes(options.host) || !["success", "failed"].includes(options.outcome) || + !/^[A-Za-z0-9_.:/-]{1,256}$/.test(options.operation ?? "")) + throw new Error("Record the actual Hosted native tool operation, host and outcome"); + if (options.source && options.source !== "native_tool_result") + throw new Error("Configuration, tool discovery and independent HTTP probes are not native tool results"); + const endpoint = options.serverUrl ? serverUrl(options.serverUrl).href : null; + if (report.serverUrl && endpoint && endpoint !== report.serverUrl) + throw new Error("Different target service; preserve it separately instead of recording an incident recheck"); + const result = redact(await evidenceFile(options.result)); if (options.outcome === "success") { let data; - try { data = JSON.parse(result); } catch { /* CLI configuration text is not a tool result. */ } - if (!data || data.isError === true || data.error || - !Array.isArray(data.projects ?? data.structuredContent?.projects)) - throw new Error("Successful list_projects recording needs the actually exposed projects[] JSON (or structuredContent.projects[]). Native mcp get/configuration text is not a tool result. Preserve it separately and wait for the actual safe native read; do not manufacture JSON or replay a failed tool."); + try { data = JSON.parse(result); } catch { /* Text alone does not establish a native success. */ } + if (!data || typeof data !== "object" || data.isError === true || data.error || + data.mcpServers || data.transport || data.tools || data.configuration || + (typeof data.status === "number" && (data.body !== undefined || data.headers !== undefined))) + throw new Error("Need the actually exposed native tool result; configuration, tool lists and independent probes are not a successful recheck"); + if (options.operation === "list_projects" && !Array.isArray(data.projects ?? data.structuredContent?.projects)) + throw new Error("The exposed list_projects result must contain projects[]; a configuration response is not a project listing"); } - const observation = { schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, - recordedAt: new Date().toISOString(), observedAt: null, - source: "agent_supplied_native_tool_result; not_independently_verified", host: report.host, - scope: redact(options.scope), operation: options.operation, outcome: options.outcome, message: result, - originalReportUnchanged: true, originalAttemptAssociation: "unconfirmed", accountAuthentication: "unconfirmed" }; - const output = path + ".recheck.json"; + if (options.observedAt !== undefined && + (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.observedAt) || !Number.isFinite(Date.parse(options.observedAt)))) + throw new Error("Observed time must come from the tool event; omit it when unknown"); + const observation = { + schemaVersion: 1, diagnosticId: report.diagnosticId, sourceReportSha256, + recordedAt: new Date().toISOString(), observedAt: options.observedAt ?? null, + source: "agent_supplied_native_tool_result; not_independently_verified", + host: options.host, configurationScope: fact(options.configScope), serverUrl: endpoint, + projectId: fact(options.projectId), conversationId: fact(options.conversationId), callId: fact(options.callId), + operation: options.operation, outcome: options.outcome, message: result, + originalReportUnchanged: true, originalAttemptAssociation: "unconfirmed", + accountAuthentication: "unconfirmed", recoveryVerified: false, + }; + if (createHash("sha256").update(await readFile(path)).digest("hex") !== sourceReportSha256) + throw new Error("Original report changed during recheck recording"); + const output = `${path}.recheck-${randomUUID()}.json`; await writeFile(output, JSON.stringify(observation, null, 2) + "\n", { flag: "wx", mode: 0o600 }); - if (createHash("sha256").update(await readFile(path)).digest("hex") !== sourceReportSha256) throw new Error("Original report changed during recheck recording"); - const note = `${path}.recheck.zh-CN.md`; - await writeFile(note, `# 本轮安全复验\n\n诊断编号:${report.diagnosticId}\n宿主/范围:${report.host};${observation.scope}\n实际操作:原生 list_projects;结果:${options.outcome}。\n已暴露结果:${result}\n\n记录时间:${observation.recordedAt};原生事件时间未提供,保持未知。上述事实由 Agent 从实际工具结果单独记录;配置/账号认证及原故障恢复未据此确认。原始报告及其 SHA-256 保持不变。没有上报或开工单。\n`, { flag: "wx", mode: 0o600 }); - return { ...observation, path: output, chineseNote: note }; + return { ...observation, path: output }; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const { values } = parseArgs({ options: { report: { type: "string" }, "active-case": { type: "boolean", default: false }, result: { type: "string" }, - outcome: { type: "string" }, operation: { type: "string" }, scope: { type: "string" } } }); - recordRecheck({ ...values, activeCase: values["active-case"] }).then(x => console.log(JSON.stringify(x))).catch(error => { + try { + const { values } = parseArgs({ options: Object.fromEntries([ + "report", "result", "outcome", "operation", "host", "server-url", "config-scope", "project-id", + "conversation-id", "call-id", "observed-at", "source", + ].map(name => [name, { type: "string" }])) }); + console.log(JSON.stringify(await recordRecheck({ ...values, serverUrl: values["server-url"], + configScope: values["config-scope"], projectId: values["project-id"], conversationId: values["conversation-id"], + callId: values["call-id"], observedAt: values["observed-at"] }))); + } catch (error) { console.error(JSON.stringify({ recheckRecordingFailed: true, message: redact(error.message) })); process.exitCode = 1; - }); + } } diff --git a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md index 7ca1dc0..58d583c 100644 --- a/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md +++ b/codex/skills/chatcut-plugin-basics/references/connection-diagnostics.md @@ -1,3 +1,412 @@ -# Hosted MCP Doctor +# Hosted MCP connection diagnostics -For a hosted ChatCut connection or tool failure, load the independent [ChatCut MCP Doctor Skill](../../chatcut-mcp-doctor/SKILL.md). It runs locally even when the ChatCut MCP is unavailable. Preserve the original error before retries. The former scripts/mcp-doctor.mjs entry remains compatible. +Use this branch when a registered hosted ChatCut connection fails, including +OAuth failing before a browser opens, or authentication completing without usable +tools, and failures of already available hosted tools. Preserve evidence before reconnecting or restarting. This version gathers +evidence; it does not repair configuration, credentials, network settings, or the +original connection. + +Briefly tell the user that you will collect ChatCut connection diagnostics and +send the redacted report to ChatCut. Run under the host's existing execution and +network permissions; do not add a separate manual step when those permit it. +Honor a refusal or a denied read/network operation. Do not change permission +settings to make collection work. + +Invoke each helper as a standalone `node` command with its quoted path. Do not +combine it with `cd`, shell variables, pipelines, or other commands; command +allowlists may permit the helper while rejecting the combined shell operation. +Use the host's file-writing tool for the dedicated snapshot and error file. + +## Capture the current host, including unfamiliar failures + +The Agent creates a dedicated `.mcp-snapshot.json` from facts already available +through the current host. The user does not fill a form. Do not copy an entire +configuration, credential file, environment, or conversation. Preserve unfamiliar +errors as well as known OAuth errors; classify uncertain stages as unknown. +Static CLI configuration describes registration, not tools loaded into the +current chat. Browser sign-in does not establish MCP authentication. + +First generate a new snapshot template with the bundled helper, then add only +observed groups from the schema below. This sets the snapshot schema and capture +time mechanically; it does not invent the original failure time or host state: + +```text +node /scripts/mcp-doctor.mjs --create-snapshot +``` + +Validate the completed file locally before collecting or uploading: + +```text +node /scripts/mcp-doctor.mjs --validate-snapshot --server-url +``` + +Snapshot `schemaVersion` is **1**, independently of the outer report version. +Keep unavailable groups omitted. Each observation's `observedAt` may be `null` +when its actual time is unknown. If validation fails, correct only the reported +schema error using already available facts; do not guess times or relax the +credential/config file restriction. If facts remain unavailable, collect the +other evidence and preserve the specific gap. + +Snapshot schema 1 accepts this shape; omit unavailable groups, use null for an +unknown tool count/enabled flag, and label each group's actual source: + +```json +{ + "schemaVersion": 1, + "observedAt": "2026-10-22T12:00:00Z", + "hostVersion": "actual-running-host-version", + "pluginVersion": "actual-loaded-plugin-version-if-known", + "configuration": { + "source": "host_settings", + "serverName": "chatcut", + "url": "https://api.chatcut.io/api/external-mcp/mcp", + "enabled": true + }, + "session": { + "source": "current_session", + "conversationId": "host-chat-id-if-available", + "connectionStatus": "failed", + "authStatus": "unknown", + "visibleToolCount": 0, + "visibleTools": [] + }, + "observations": [ + { + "stage": "initialize", + "operation": "initialize", + "source": "runtime_log", + "observedAt": "2026-10-22T11:59:00Z", + "outcome": "failed", + "requestId": "server-request-id-only-if-recorded", + "message": "Exact original error, including an unfamiliar error code" + } + ] +} +``` + +Sources: `current_session`, `tool_search`, `host_settings`, +`cli_configuration`, `runtime_log`. A session group cannot claim +`cli_configuration` as its source. Connection status is `connected`, `failed`, +`needs_authentication`, `disabled`, or `unknown`; auth status is +`authenticated`, `needs_authentication`, or `unknown`. +Observations accept `stage`, `operation`, `source`, `observedAt`, `outcome` +(`success`/`failed`/`unknown`), optional `requestId`, `httpStatus`, +`protocolCode`, `message`, and `originalAttemptMatch` (`confirmed`/`unconfirmed`). +Omit the match flag when unknown. Set `confirmed` only when the captured failed +runtime/session observation explicitly identifies the original failed attempt +and its actual server request ID; registration, nearby timestamps, successful +operations, and later probe IDs are not confirmation. An original error must +also have been recorded for that observation to close the original-request gap. +This remains Agent-supplied confirmation, not independent helper verification. +New stage names are preserved. Do not put an +MCP session credential in `conversationId` or guess a server request ID. +Only include ChatCut tool names; keep tool arguments and results out. + +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 +the existing bounded reads and a global 30-record / 128 KiB text-message budget. +When a complete record cannot fit, the report names the omitted record count; +it does not trim an error to make it appear complete. Native failed-item error +projections remain separately bounded and the complete local report is retained +when the upload budget is exceeded. + +`evidenceSummary.failureEvidence` mechanically indexes the original retained +error blocks and failed native items. It records their actual source, log event +time or native lifecycle, and available local call/item identifiers. Read the +referenced original block for the full message and cause. A matching conversation +and call ID is only a local binding to the Agent-supplied original record; nearby +times, one remaining candidate, and the current collection conversation are not +proof that a log is the user's reported failure. No candidate is automatically +selected as `originalFailure`, and its log time never fills `occurredAt`. + +When no original failure record was supplied, the helper can still retain these +host failures but cannot tell whether they are the user's reported attempt. +Codex App startup notices, Claude debug logs, and native local IDs do not provide +a missing server X-Request-ID. The helper cannot capture the host's original TLS +bytes or HTTP response before its parser; later independent probes remain +separate evidence. If the host discarded the original tool error, time, or +response header and no accessible source retained it, those fields remain gaps. + +## Run once for the reported failure + +### 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. +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. + +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 +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. + +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 + silently probing production. If no registered URL is available, omit + `--server-url` to collect a local report without probing or uploading. + Never read credential stores or paste complete + configuration, environment, or conversation dumps into the report. + An intentionally disabled hosted server is not a failed connection; preserve + that choice instead of starting diagnostics or re-enabling it. +2. Save only the original connection error and its available cause chain into a + new temporary text file. Use the exact original text, not a generated root + cause. If unavailable, omit `--original-error`. Obtain the actual bundled + host version if accessible without starting a session. Do not treat update + metadata as the running version. +3. Run the bundled `scripts/mcp-doctor.mjs` relative to this Skill's directory: + + ```text + node /scripts/mcp-doctor.mjs --host codex --server-url --host-snapshot --original-error --host-version --upload + ``` + + For Claude Code, use `--host claude`. Quote paths as shell arguments; do not + interpolate error text into shell code. Omit unknown optional fields. Supply + `--occurred-at` only when the failure's actual ISO timestamp and timezone are + known. A missing timestamp remains unknown; the collection time is separate. + If transfer is denied, omit `--upload` and retain the local JSON report. + +Use an available Node.js 18+ runtime. Do not install a runtime or ChatCut Desktop +just for diagnostics. Codex SQLite extraction additionally needs `node:sqlite` +(available on recent Node 22+/24 runtimes). Without it, other collection still +runs and the report marks the missing SQLite evidence. A denied file read is a +coverage gap, not proof that the server has no errors. + +The helper reads only bounded ChatCut MCP runtime log records or debug log tails. +Codex SQLite locations follow `CODEX_SQLITE_HOME` when present. If the current +host already supplies its actual `sqlite_home` or `log_dir`, pass that directory +with `--codex-sqlite-home` or `--codex-log-dir`; do not read whole config files to +find it. These options select existing log locations and do not enable logging. +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 +server, uses the current conversation ID when available, and keeps at most 30 +records from three 128 KiB tails. Missing/rotated/denied logs remain explicit +gaps. The known Windows MXC `LOCALAPPDATA` sandbox redirect falls back to the +real user's `AppData/Local` without changing permissions. An already supplied alternative App log root can be selected with +`--codex-app-log-dir`; do not scan browser profiles or session transcripts. + +The report names selected/omitted native files and byte ranges that were not read. +For an existing reported gap, use `--codex-app-log-file ` +to select at most three known native files. With exactly one selected file, +`--codex-app-log-offset ` reads at most 128 KiB from +that segment; unread ranges remain explicit. Preserve the original failure's +known time/window. Do not repeatedly upload replacement reports or scan entire +log history to fill an unknown failure time. App startup notices cover startup; +they do not cover every later tool-call failure. SQLite/native log extraction +retains available local conversation/call IDs without promoting them to original +server request IDs or independently confirmed attempt associations. +For a separately chosen future reproduction, Codex's explicit `log_dir` setting +can enable its native plaintext log. Do not change global settings or relaunch +the user's host automatically. Existing logs cannot be reconstructed retroactively. +It never reads `auth.json`, OAuth credential stores, session transcripts, or +other project files. It makes one unauthenticated initialize probe and at most +five public metadata GETs, all within the selected origin and with a 3.5-second +timeout each. An anonymous 401 challenge is expected. It validates resource / +issuer consistency and this product's OAuth-code/PKCE metadata. It never logs in, +refreshes credentials, or lists tools using extracted credentials. + +Codex exec prints diagnostics inline; `--json` stdout alone is not a complete +MCP error stream. Preserve the original stderr/startup error when it is already +visible to the current host. The helper cannot recover discarded parent-process +stderr or reconstruct an old exec failure from App logs. + +If the current host already exposes native `mcpServer/startupStatus/updated` +notifications or `mcpServerStatus/list`, preserve `error`, `failureReason`, +`runtimeStatus` and `toolsError` from that same thread in the error file and +snapshot observations (`source: current_session`). Record the native method and +actual thread ID. Authentication inventory alone is not proof of initialization +or usable tools. The status query may perform new auth-discovery requests; keep +those separate from retained runtime errors. Do not start another app-server, +start/reload a thread, invoke OAuth, or launch a daemon to obtain the original +state. These interfaces are not guaranteed to be exposed to a third-party Skill. +`codex doctor --json` is a general installation/config/auth health check, not a +targeted export of the failed MCP attempt. Do not ingest its entire report. +`feedback/upload` submits to OpenAI; it is not ChatCut's log export endpoint. + +The CLI also makes at most two sequential anonymous metadata GETs to compare +the relevant path-scoped and root endpoints at the HTTP/1.1 framing level. +`wireProbes` records selected response headers, request ID, first framing byte, +chunk validity, bounded byte counts/hashes and peer/TLS evidence from that same +connection. It retains no metadata body, cookies or credentials. Each probe +stops at 128 KiB / 3.5 seconds. These are direct connections, explicitly outside +the host/fetch proxy route; normal certificate verification remains enabled. +Complete fixed-length/chunked messages finish at their verified boundary without +waiting for peer close. Invalid HTTP status lines and unsupported informational +responses never count as complete final responses. `completionReason` and +`peerEndObserved` distinguish message completion from connection termination. + +When an HTTPS request/body fails, one additional direct TLS connection may +record the peer certificate issuer and protocol, with the same timeout and +normal certificate verification. This is a separately labelled route, not the +original host's TLS/proxy path. Proxy/CA environment presence is boolean only. +A healthy later check cannot establish recovery or identify antivirus/VPN as +the cause. A disabled connection sends neither probes nor an upload. + +## Interpret the report + +- `originalFailure` is the original error supplied by the Agent. `hostLogs` + are matching records in a bounded time window, with unconfirmed attempt + association. `probes` are new requests made later. Keep these separate. +- `hostSnapshot` is Agent-supplied evidence with per-group sources, not an + independently verified host API result. `evidenceSummary` keeps original + stage observations separate from independent checks. Unknown stages and + unfamiliar errors remain available; missing stages are `not_observed`. +- Timestamped text records outside the requested window are excluded. Records + without parseable timestamps are labelled time-unknown, never window matches. +- Status 200 with `bodyComplete:false` or `jsonValid:false` is a failed probe. + A healthy later probe never proves the original host connection recovered. +- Preserve original error messages and codes. Redact actual credentials, not + entire error categories. No separate metadata response body is retained; + original parser errors may quote response text. +- Missing debug logs cannot be reconstructed. Claude's detailed debug logs may + require `claude --debug=mcp` during a future, separately chosen reproduction; + this helper does not enable logging or relaunch Claude. +- A `diagnosticId` identifies the diagnostic report and its new probes. It is + not the original failed request's ID. Server response request IDs are useful + only when actually present and matched; time/path matching is weaker evidence. +- Match a wire response's `x-request-id` with the server's metadata response + record. The application record observes bytes before Node's HTTP framing; + a successful finish does not prove valid framing after Node/ALB/local HTTPS + inspection, nor receipt by Codex. Different requests may have different paths. + +The 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. +Output larger than the upload budget stays local with an explicit upload error. + +Return the diagnostic ID, local report location, one new observed fact, and the +next action justified by it. If no new actionable fact was obtained, state the +specific missing evidence. Do not assign blame to antivirus, VPN, firewall, host, +or service merely from a generic failure message. Do not claim a user failure +percentage from diagnostic samples. + +This branch runs when the Agent loads it and has access to local execution. It +is not an installation hook or an all-user failure monitor. It cannot inspect a +web host's remote connection process or internal logs. With Claude Code tool +search disabled, connection failures may not be passed to the Agent at all. diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs index 0e45dfe..6c1e7b9 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -1,6 +1,1736 @@ -// Compatibility entry; the standalone Doctor owns the collector. -export * from "../../chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; -import { resolve } from "node:path"; +import { randomUUID, createHash } from "node:crypto"; +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"; -import { runCli } from "../../chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await runCli(); +import { parseArgs } from "node:util"; +import { connect as connectTls } from "node:tls"; +import { isIP } from "node:net"; +import { + evidenceSummary, + inspectMetadata, + isHostFailure, + logTime, + snapshotFromRecord, + validateSnapshot, +} from "./mcp-evidence.mjs"; +import { probeMetadataWire } from "./mcp-wire.mjs"; +import { failureFromRecord } from "./mcp-failure.mjs"; + +const READ_LIMIT = 128 * 1024; +const UPLOAD_LIMIT = 256 * 1024; +const TIMEOUT_MS = 3500; +const CHATCUT_MESSAGE = + /server_name="?chatcut(?:["\s}:]|$)|(?:MCP server|Server)\s*["': ]+(?:plugin:chatcut:)?chatcut(?:["': ]|$)|\bplugin(?:_name)?[=: "\t]+chatcut(?:@chatcut-inc)?(?:["\s}:]|$)|https:\/\/(?:api\.chatcut\.(?:io|dev)|api-dev\.chatcut\.org)\//i; +const TEXT_ECHO = + /^(?:user (?:prompt|message)|assistant message|tool (?:call|result))\s*:|^(?:function_call(?:_output)?|tool_call(?:_output)?)\s*:?\s*[{[]/i; + +function redactFieldValue(_match, prefix, value) { + if (!value) return prefix; + const quote = value.match(/^\\*["']/)?.[0]; + return prefix + (quote ? quote + "[REDACTED]" + quote : "[REDACTED]"); +} + +export function redact(text, jsonDepth = 0) { + let value = String(text); + // A host may quote/stringify an entire JSON response inside its error. Decode + // only JSON string tokens containing 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|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); + } + } catch { + /* Non-JSON diagnostic text is retained for the normal rules. */ + } + return token; + }); + return value + .replace(/\b(Mcp-Session-Id[ \t]*:[ \t]*)\S+/gi, "$1[REDACTED]") + .replace( + /(["']mcp[-_]session[-_]id["']\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*')/gi, + redactFieldValue, + ) + .replace( + /\b((?:mcp[-_]session[-_]id)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^\s"'<>]+)/gi, + redactFieldValue, + ) + .replace( + /((?:\\*["'])mcp[-_]session[-_]id(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, + "$1[REDACTED]", + ) + .replace( + /\bBearer\s+(?!(?:token|required)\b|<|(?:realm|scope|error(?:_description|_uri)?|resource_metadata)\s*=)[A-Za-z0-9._~+/=-]+/gi, + "Bearer [REDACTED]", + ) + .replace(/\b(Authorization\s*[:=]\s*Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[REDACTED]") + .replace( + /\b((?:set-)?cookie\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|\[REDACTED\]|(?:(?![\r\n]|(?]+/gi, + "$1[REDACTED]", + ) + .replace( + /\b((?:access_?token|refresh_?token|id_?token|session_?token|client_?secret|api[_-]?key|token)\s*[:=]\s*)("(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|\\+"[^"\\\r\n]*\\+"|\\+'[^'\\\r\n]*\\+'|[A-Za-z0-9._~+/=-]+)/gi, + redactFieldValue, + ) + .replace( + /((?:\\*["'])(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|authorization|token|cookie|set-cookie)(?:\\*["'])\s*[:=]\s*(?:\\*["']))[^"'\\\r\n]*/gi, + "$1[REDACTED]", + ) + .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"); +} + +function clean(value) { + return JSON.parse( + JSON.stringify(value, (key, item) => + /^(?:mcp[-_]session[-_]id|(?:proxy[-_])?authorization|(?:x[-_])?(?:access_?token|refresh_?token|id_?token|client_?secret|session_?token|api[_-]?key|token)|cookie|set-cookie)$/i.test( + key, + ) + ? "[REDACTED]" + : typeof item === "string" + ? redact(item) + : item, + ), + ); +} + +export function errorDetails(error, seen = new Set()) { + if (!error || typeof error !== "object") return { message: redact(error) }; + if (seen.has(error) || seen.size >= 8) return { unavailable: "error_chain_depth_or_cycle" }; + seen.add(error); + const result = {}; + for (const key of ["name", "message", "code", "errno", "errcode", "errstr", "syscall", "stack"]) { + if (typeof error[key] === "string" || typeof error[key] === "number") result[key] = error[key]; + } + if (error.cause) result.cause = errorDetails(error.cause, seen); + if (Array.isArray(error.errors)) { + result.errors = error.errors.slice(0, 8).map((item) => errorDetails(item, seen)); + if (error.errors.length > 8) result.omittedErrors = error.errors.length - 8; + } + return clean(result); +} + +export function serverUrl(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + const hosted = ["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes( + url.hostname, + ); + if ( + (!local && (!hosted || url.protocol !== "https:" || (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/api\/external-mcp\/(?:mcp|claude|v2\/mcp)\/?$/.test(url.pathname) + ) { + throw new Error( + "Unsupported registered MCP URL; preserve its configuration and report unsupported_endpoint", + ); + } + return url; +} + +async function limitedBody(response, limit = READ_LIMIT) { + const reader = response.body?.getReader(); + if (!reader) return ""; + const parts = []; + let bytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return Buffer.concat(parts).toString("utf8"); + bytes += value.length; + if (bytes > limit) { + const error = new Error(`Response exceeds ${limit} bytes; body not retained`); + error.code = "DIAGNOSTIC_BODY_LIMIT"; + throw error; + } + parts.push(Buffer.from(value)); + } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export async function probeMetadata(url, diagnosticId, fetchImpl = fetch, expected = {}) { + const startedAt = new Date().toISOString(); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url, + startedAt, + implementation: `node-fetch/${process.version}`, + stage: "request", + status: null, + bodyComplete: false, + jsonValid: null, + originalAttemptMatch: "unconfirmed", + }; + try { + const response = await fetchImpl(url, { + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { accept: "application/json", "x-chatcut-diagnostic-id": diagnosticId }, + }); + result.status = response.status; + result.responseHeaders = {}; + for (const key of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + "www-authenticate", + "retry-after", + "location", + ]) { + const value = response.headers.get(key); + if (value !== null) result.responseHeaders[key] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + result.stage = "json_parse"; + try { + const parsed = JSON.parse(body); + result.jsonValid = true; + result.metadataShape = { + object: parsed !== null && typeof parsed === "object" && !Array.isArray(parsed), + issuerPresent: typeof parsed?.issuer === "string", + resourcePresent: typeof parsed?.resource === "string", + }; + result.metadataValidation = inspectMetadata(parsed, url, expected); + } catch (error) { + result.jsonValid = false; + throw error; + } + result.stage = "complete"; + const required = new URL(url).pathname.includes("oauth-protected-resource") + ? "resourcePresent" + : "issuerPresent"; + result.outcome = !response.ok + ? "http_rejected" + : result.metadataShape.object && result.metadataShape[required] + ? result.metadataValidation.valid + ? "metadata_read" + : "invalid_metadata" + : "invalid_metadata_shape"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +async function guardProbe(url, operation) { + const startedAt = new Date().toISOString(); + try { + return await operation(); + } catch (error) { + return { + evidenceKind: "later_probe", + url, + startedAt, + outcome: "failed", + stage: "probe_collector", + status: null, + originalAttemptMatch: "unconfirmed", + error: errorDetails(error), + gap: "partial_probe_evidence_unavailable; original_failure_retained", + }; + } +} + +export async function probeEntry(endpoint, diagnosticId, fetchImpl = fetch) { + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: endpoint.href, + method: "POST", + operation: "initialize", + implementation: "node-fetch/" + process.version, + startedAt: new Date().toISOString(), + stage: "request", + status: null, + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + }; + try { + const response = await fetchImpl(endpoint.href, { + method: "POST", + redirect: "manual", + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "x-chatcut-diagnostic-id": diagnosticId, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: diagnosticId, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "chatcut-mcp-doctor", version: "1.2.6" }, + }, + }), + }); + result.status = response.status; + result.responseHeaders = {}; + for (const name of [ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "www-authenticate", + "retry-after", + "location", + "x-request-id", + "x-amzn-trace-id", + "server", + "via", + ]) { + const value = response.headers.get(name); + if (value !== null) result.responseHeaders[name] = value; + } + result.stage = "response_body"; + const body = await limitedBody(response); + result.bodyComplete = true; + result.bodyBytes = Buffer.byteLength(body); + result.bodySha256 = createHash("sha256").update(body).digest("hex"); + let parsed; + try { + parsed = JSON.parse(body); + } catch (error) { + result.parseError = errorDetails(error); + if (!response.ok) result.responseError = { message: redact(body) }; + } + if (parsed?.error) result.protocolError = clean(parsed.error); + if (response.status === 401) { + const header = response.headers.get("www-authenticate") ?? ""; + const metadataHint = header.match(/resource_metadata\s*=\s*(?:"([^"]+)"|([^,\s]+))/i); + result.resourceMetadataUrl = metadataHint?.[1] ?? metadataHint?.[2] ?? null; + result.outcome = /\bBearer\b/i.test(header) + ? "authentication_challenge" + : "missing_authentication_challenge"; + } else if (!response.ok) result.outcome = "http_rejected"; + else if (parsed?.error) result.outcome = "protocol_error"; + else if (parsed?.jsonrpc === "2.0" && typeof parsed.result?.protocolVersion === "string") { + result.outcome = "anonymous_initialize_returned"; + result.serverInfo = parsed.result.serverInfo; + result.protocolVersion = parsed.result.protocolVersion; + } else result.outcome = "invalid_initialize_response"; + result.stage = "complete"; + } catch (error) { + result.outcome = "failed"; + result.error = errorDetails(error); + } + result.durationMs = Date.now() - started; + return clean(result); +} + +export function inspectTransport(endpoint) { + if (endpoint.protocol !== "https:") + return Promise.resolve({ status: "not_applicable", reason: "not_tls" }); + return new Promise((resolveProbe) => { + const startedAt = new Date().toISOString(); + let settled = false; + const host = endpoint.hostname.replace(/^\[|\]$/g, ""); + const socket = connectTls({ + host, + port: Number(endpoint.port || 443), + rejectUnauthorized: true, + servername: isIP(host) ? undefined : host, + }); + const finish = (details) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolveProbe( + clean({ + evidenceKind: "later_probe", + implementation: "node:tls", + startedAt, + route: "direct_tls; does_not_reproduce_host_or_fetch_proxy_route", + originalAttemptMatch: "unconfirmed", + ...details, + }), + ); + }; + const timer = setTimeout( + () => + finish({ + status: "failed", + error: { + name: "TimeoutError", + message: "Direct TLS context timed out", + code: "ETIMEDOUT", + }, + }), + TIMEOUT_MS, + ); + socket.once("secureConnect", () => { + const certificate = socket.getPeerCertificate(); + finish({ + status: "observed", + tlsProtocol: socket.getProtocol(), + authorized: socket.authorized, + certificate: { + issuer: certificate.issuer, + validFrom: certificate.valid_from, + validTo: certificate.valid_to, + fingerprint256: certificate.fingerprint256, + }, + }); + }); + socket.once("error", (error) => finish({ status: "failed", error: errorDetails(error) })); + }); +} + +function logMessage(body) { + const marker = body.lastIndexOf("}:"); + return (marker < 0 ? body : body.slice(marker + 2)).trim(); +} + +function runtimeContext(body) { + const boundary = body.lastIndexOf("}:"); + if (boundary < 0) return {}; + const context = body.slice(0, boundary); + const value = (field) => + [ + ...context.matchAll( + new RegExp(`(?:^|[\\s{])${field}="?([A-Za-z0-9_.-]{1,256})(?:"|[\\s}:]|$)`, "g"), + ), + ].at(-1)?.[1]; + const conversationId = value("thread_id") ?? value("thread\\.id") ?? value("conversation\\.id"); + const callId = value("call_id"); + return { ...(conversationId && { conversationId }), ...(callId && { callId }) }; +} + +function hostedTextRecord(line, 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, + "", + ); + const module = body.match(/^([A-Za-z_]\w*(?:::\w+)+):\s*/); + if (module) { + if ( + !/^(?:codex_(?:mcp|rmcp_client|plugins|core(?:::|_)plugins)(?:::|$)|rmcp::|codex_core::mcp_tool_call$)/.test( + module[1], + ) + ) + return false; + body = body.slice(module[0].length); + } + if ( + /^(?:user(?: prompt| message)?|assistant(?: message)?|system|developer|transcript)\s*:|^[{[]/i.test( + body, + ) + ) + return false; + const scope = body.match( + /^(?:MCP server|Server)\s+(?:"([^"]+)"|'([^']+)'|((?:plugin:[\w.-]+:)?[\w.-]+))(?::\s*|\s+|$)/i, + ); + const context = body.split(/\berror\s*=/i)[0]; + const server = + context.match(/\bserver_name=["']?([\w:.-]+)/)?.[1] ?? scope?.[1] ?? scope?.[2] ?? scope?.[3]; + if (server && !["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; + 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 ( + /^(?:Sending|Sent|Received|Receiving)\s+(?:MCP\s+|JSON-RPC\s+)?(?:request|response|message)\b/i.test( + message, + ) && + /"method"\s*:\s*"tools\/call"|"result"\s*:\s*\{\s*"content"\s*:/i.test(message) + ) + return false; + if (/^[{[]/.test(message)) { + try { + const value = JSON.parse(message); + const records = Array.isArray(value) ? value : [value]; + if ( + records.some( + (record) => + record && + (record.method === "tools/call" || + [ + "function_call", + "function_call_output", + "tool_call", + "tool_result", + "user", + "assistant", + "user_message", + "assistant_message", + ].includes(record.type) || + ["user", "assistant", "system", "developer"].includes(record.role) || + Array.isArray(record.result?.content)), + ) + ) + return false; + } catch { + // Unparseable diagnostic text is not proof of a tool or conversation envelope. + } + } + return true; +} + +async function textLog(path, source, gaps, window, select = hostedTextRecord) { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + const start = window.readOffset ?? Math.max(0, info.size - READ_LIMIT); + if (!Number.isSafeInteger(start) || start < 0 || start > info.size) + throw new Error("Log segment offset must point inside the selected existing file"); + const buffer = Buffer.alloc(Math.min(info.size - start, READ_LIMIT)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/); + if (start) { + const previous = Buffer.alloc(1); + await handle.read(previous, 0, 1, start - 1); + if (previous[0] !== 10) lines.shift(); + gaps.push({ + source, + reason: "only_log_tail_read", + sourceFile: basename(path), + bytesBeforeWindow: start, + omittedBytes: start, + }); + } + if (start + bytesRead < info.size) { + if (!buffer.subarray(0, bytesRead).toString("utf8").endsWith("\n")) lines.pop(); + gaps.push({ + source, + reason: "only_log_segment_read", + sourceFile: basename(path), + offset: start, + bytesRead, + remainingBytes: info.size - start - bytesRead, + }); + } + const entries = []; + for (let i = 0; i < lines.length; i++) { + if (!select(lines[i])) continue; + const block = [lines[i]]; + while ( + source !== "codex_app_log" && + i + 1 < lines.length && + /^(?:\s+|Caused by:|Error:)/.test(lines[i + 1]) && + !/^\s*(?:\d{4}-\d{2}-\d{2}T|MCP server\s|Server\s|\[(?:TRACE|DEBUG|INFO|WARN|ERROR)\])/i.test( + lines[i + 1], + ) && + !TEXT_ECHO.test(lines[i + 1].trimStart()) + ) + block.push(lines[++i]); + 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 || + Date.parse(occurredAt) > window.until * 1000) + ) + continue; + entries.push({ + source, + sourceFile: basename(path), + occurredAt, + timeStatus: occurredAt ? "within_requested_window" : "unknown; not_confirmed_within_window", + message: redact(block.join("\n")), + ...context, + originalAttemptMatch: "unconfirmed", + }); + } + if (entries.length > 30) + gaps.push({ source, reason: "log_record_limit", omitted: entries.length - 30 }); + const unknownTimes = entries.filter((entry) => !entry.occurredAt).length; + if (unknownTimes) + gaps.push({ source, reason: "records_with_unknown_time", count: unknownTimes }); + return retainFailureLogs(entries, gaps, source); + } finally { + await handle.close(); + } +} + +function retainFailureLogs(entries, gaps, source = "host_logs") { + const failures = entries.filter(isHostFailure); + const others = entries.filter((entry) => !isHostFailure(entry)); + const selected = new Set(); + let bytes = 0; + const newest = (values) => + values + .map((entry, index) => ({ + entry, + index, + time: Date.parse(entry.loggedAt ?? entry.occurredAt) || -Infinity, + })) + .sort((a, b) => b.time - a.time || b.index - a.index) + .map((value) => value.entry); + for (const entry of [...newest(failures), ...newest(others)]) { + const size = Buffer.byteLength(entry.message ?? ""); + if (selected.size === 30 || bytes + size > READ_LIMIT) continue; + selected.add(entry); + bytes += size; + } + if (selected.size < entries.length) + gaps.push({ + source, + reason: "failure_prioritized_log_budget", + maximumRecords: 30, + maximumMessageBytes: READ_LIMIT, + retainedMessageBytes: bytes, + omitted: entries.length - selected.size, + omittedFailureRecords: failures.filter((entry) => !selected.has(entry)).length, + }); + return entries.filter((entry) => selected.has(entry)); +} + +async function codexAppLogs(root, gaps, window, conversationId, selectedFiles = [], readOffset) { + if ( + readOffset !== undefined && + (selectedFiles.length !== 1 || !Number.isSafeInteger(readOffset) || readOffset < 0) + ) + throw new Error( + "A segment offset requires exactly one selected native App log file and a nonnegative integer", + ); + const files = []; + const dates = new Set(); + for (const time of [window.since * 1000, window.until * 1000]) { + const date = new Date(time); + dates.add(date.toISOString().slice(0, 10).replaceAll("-", "/")); + dates.add( + [date.getFullYear(), date.getMonth() + 1, date.getDate()] + .map((value) => String(value).padStart(2, "0")) + .join("/"), + ); + } + let scanned = 0; + const deadline = Date.now() + 1500; + for (const date of dates) { + try { + const dir = join(root, date); + for await (const item of await opendir(dir)) { + if (++scanned > 200 || Date.now() > deadline) { + gaps.push({ source: "codex_app_log", reason: "directory_scan_limit" }); + break; + } + if (!item.isFile() || !/^codex-desktop-.*\.log$/.test(item.name)) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= window.since * 1000) files.push({ path, modified: info.mtimeMs }); + } + } catch (error) { + gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); + } + if (scanned > 200 || Date.now() > deadline) break; + } + files.sort((a, b) => b.modified - a.modified); + if (selectedFiles.length) { + if (selectedFiles.length > 3) + throw new Error("Select at most three existing Codex App log files"); + files.length = 0; + for (const path of [...new Set(selectedFiles)]) { + if (!/^codex-desktop-.*\.log$/.test(basename(path))) + throw new Error("Select only existing native codex-desktop-*.log files"); + const info = await stat(path); + if (!info.isFile()) throw new Error("Codex App log selection must be a file"); + files.push({ path, modified: info.mtimeMs }); + } + } + const entries = []; + let filesRead = 0; + for (const file of files.slice(0, 3)) { + try { + // App logs mix unrelated UI activity with runtime notices, so accept only this native event. + entries.push( + ...(await textLog(file.path, "codex_app_log", gaps, { ...window, readOffset }, (line) => { + if ( + !/^\d{4}-\d{2}-\d{2}T\S+\s+\w+\s+\[AppServerConnection\] mcp_server_startup_status_updated\s/.test( + line, + ) + ) + return false; + const server = [...line.matchAll(/(?:^|\s)server=([^\s]+)/g)].at(-1)?.[1]; + if (!["chatcut", "plugin:chatcut:chatcut"].includes(server)) return false; + const thread = [...line.matchAll(/(?:^|\s)threadId=([^\s]+)/g)].at(-1)?.[1]; + return !conversationId || !thread || thread === "null" || thread === conversationId; + })), + ); + filesRead++; + } catch (error) { + gaps.push({ source: "codex_app_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (files.length > 3) + gaps.push({ + source: "codex_app_log", + reason: "log_file_limit", + omitted: files.length - 3, + omittedFiles: files.slice(3, 13).map((file) => basename(file.path)), + }); + if (entries.length > 30) + gaps.push({ + source: "codex_app_log", + reason: "log_record_limit", + omitted: entries.length - 30, + }); + return { + entries: retainFailureLogs( + entries.sort((a, b) => (a.occurredAt ?? "").localeCompare(b.occurredAt ?? "")), + gaps, + "codex_app_log", + ), + scan: { + directoryEntriesScanned: scanned, + matchingFiles: files.length, + filesRead, + maximumTailBytesPerFile: READ_LIMIT, + currentConversationFilterApplied: Boolean(conversationId), + ...(conversationId && { conversationId }), + selectedFileMode: selectedFiles.length > 0, + ...(readOffset !== undefined && { selectedByteOffset: readOffset }), + filesSelected: files.slice(0, 3).map((file) => basename(file.path)), + observationScope: + "hosted_mcp_startup_notices_only; tool_request_failures_need_original_failure_record_or_matching_runtime_logs", + }, + }; +} + +async function inspectSqliteAccess(path) { + const files = {}; + for (const suffix of ["", "-wal", "-shm"]) { + const name = suffix || "database"; + let handle; + try { + const info = await stat(path + suffix); + handle = await open(path + suffix, "r"); + const header = Buffer.alloc(suffix ? 1 : 20); + const { bytesRead } = await handle.read(header, 0, header.length, 0); + files[name] = { exists: true, readable: true, bytes: info.size }; + if ( + !suffix && + bytesRead === 20 && + header.subarray(0, 16).toString() === "SQLite format 3\0" + ) { + files[name].journalMode = header[18] === 2 ? "wal" : "rollback"; + } + } catch (error) { + files[name] = { readable: false, error: errorDetails(error) }; + } finally { + try { + await handle?.close(); + } catch (error) { + files[name].closeError = errorDetails(error); + } + } + } + return { + files, + interpretation: + "File reads and SQLite WAL/shared-memory access are different operations; readable files do not prove SQLite access. No copy, checkpoint, immutable mode or permission change attempted.", + }; +} + +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); + const until = Math.min(now, center + 15 * 60_000) / 1000; + if (host === "codex") { + const root = options.codexHome ?? process.env.CODEX_HOME ?? join(userHome, ".codex"); + const sqliteRoot = options.codexSqliteHome ?? process.env.CODEX_SQLITE_HOME ?? root; + const logRoot = options.codexLogDir ?? join(root, "log"); + const databasePath = join(sqliteRoot, "logs_2.sqlite"); + let db; + 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 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 ?? ""; + const context = runtimeContext(original); + if (row.thread_id) context.conversationId = row.thread_id; + if ( + options.conversationId && + context.conversationId && + context.conversationId !== options.conversationId + ) + continue; + if ( + !hostedTextRecord(`${row.target}: ${original}`, row.level) || + !hostedTextRecord(`MCP server "chatcut" ${message}`) + ) + continue; + if ( + !["ERROR", "WARN"].includes(row.level) && + !/omitting MCP server|cached MCP catalog/.test(message) + ) + continue; + entries.push({ + source: "codex_runtime_sqlite", + rowId: row.id, + occurredAt: new Date(row.ts * 1000).toISOString(), + target: row.target, + level: row.level, + message: redact(message), + ...context, + originalAttemptMatch: "unconfirmed", + }); + if (entries.length === 30) break; + } + if (rows.length === 200 || entries.length === 30) + gaps.push({ source: "codex_runtime_sqlite", reason: "log_record_limit" }); + } catch (error) { + gaps.push({ + source: "codex_runtime_sqlite", + reason: "unavailable", + operation, + error: errorDetails(error), + locationSource: options.codexSqliteHome + ? "explicit_host_location" + : process.env.CODEX_SQLITE_HOME + ? "CODEX_SQLITE_HOME" + : "CODEX_HOME_default; sqlite_home_config_override_not_inspected", + ...(platform() === "win32" && error?.errcode === 14 + ? { accessEvidence: await inspectSqliteAccess(databasePath) } + : {}), + }); + } finally { + 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 && + /[\\/]Packages[\\/]sandbox\.[^\\/]+[\\/]AC(?:[\\/]|$)/i.test(appData ?? ""); + const appLogRoot = + options.codexAppLogDir ?? + ((options.platform ?? platform()) === "win32" + ? join( + // MXC redirects LOCALAPPDATA; the running App logs stay in the user profile. + (!sandboxAppData && appData) || join(userHome, "AppData", "Local"), + "Codex", + "Logs", + ) + : null); + if (appLogRoot) { + const result = await codexAppLogs( + appLogRoot, + gaps, + { since, until }, + options.conversationId, + options.codexAppLogFiles, + options.codexAppLogOffset, + ); + entries.push(...result.entries); + appLog = { + ...result.scan, + locationSource: options.codexAppLogDir ? "explicit_host_location" : "windows_default", + sandboxLocalAppDataRedirected: Boolean(sandboxAppData), + }; + if (!result.entries.length) + gaps.push({ source: "codex_app_log", reason: "no_matching_records_in_bounded_window" }); + } + } else { + const root = + options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(userHome, ".claude"); + try { + const dir = join(root, "debug"); + const names = await readdir(dir, { withFileTypes: true }); + const files = []; + const deadline = Date.now() + 1500; + for (const item of names) { + if (Date.now() > deadline) { + gaps.push({ source: "claude_debug_log", reason: "directory_scan_deadline" }); + break; + } + if (!item.isFile() || !item.name.endsWith(".txt")) continue; + const path = join(dir, item.name); + const info = await stat(path); + if (info.mtimeMs >= since * 1000) files.push({ path, modified: info.mtimeMs }); + } + files.sort((a, b) => b.modified - a.modified); + for (const item of files.slice(0, 3)) + entries.push(...(await textLog(item.path, "claude_debug_log", gaps, { since, until }))); + if (files.length > 3) + gaps.push({ + source: "claude_debug_log", + reason: "log_file_limit", + omitted: files.length - 3, + }); + } catch (error) { + gaps.push({ source: "claude_debug_log", reason: "unavailable", error: errorDetails(error) }); + } + } + if (!entries.length && !nativeFailedCalls?.length) + gaps.push({ source: "host_logs", reason: "no_matching_records_in_bounded_window" }); + return clean({ + entries: retainFailureLogs(entries, gaps), + gaps, + ...(appLog ? { appLog } : {}), + ...(nativeFailedCalls ? { nativeFailedCalls } : {}), + window: { + since: new Date(since * 1000).toISOString(), + until: new Date(until * 1000).toISOString(), + }, + }); +} + +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"); + const chunks = []; + let chunk = ""; + let bytes = 0; + for (const char of json) { + const size = Buffer.byteLength(char); + if (bytes + size > 4096) { + chunks.push(chunk); + chunk = ""; + bytes = 0; + } + chunk += char; + bytes += size; + } + if (chunk) chunks.push(chunk); + const sha256 = createHash("sha256").update(json).digest("hex"); + // Canonical fields and a map avoid growing the backend dataset's flat schema. + return chunks.map((fragment, fragmentIndex) => ({ + name: "mcp.doctor.report_fragment", + properties: { + clientEventName: null, + eventName: "mcp.doctor.report_fragment", + attributes: { + mcpDoctor: { + diagnosticId: report.diagnosticId, + schemaVersion: report.schemaVersion, + collectedAt: report.collectedAt, + host: report.host, + scope: "cloud", + fragmentIndex, + fragmentCount: chunks.length, + reportSha256: sha256, + fragment, + }, + }, + }, + })); +} + +export async function submitReport(report, fetchImpl = fetch) { + if (report.upload?.status === "skipped_intentionally_disabled" || + report.hostSnapshot?.configuration?.enabled === false || + report.hostSnapshot?.session?.connectionStatus === "disabled") + return { status: "skipped_intentionally_disabled", storageVerified: false }; + if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; + let attempted = false; + let rejected = false; + try { + const endpoint = serverUrl(report.serverUrl); + const events = fragmentEvents(report); + attempted = true; + const response = await fetchImpl(new URL("/telemetry/events", endpoint.origin).href, { + method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { "content-type": "application/json" }, body: JSON.stringify(events), + }); + rejected = !response.ok; + const body = await limitedBody(response, 16 * 1024); + if (!response.ok || JSON.parse(body).ok !== true) + throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); + return { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, + reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256 }; + } catch (error) { + return { status: "failed", storageVerified: false, deliveryUncertain: attempted && !rejected, error: errorDetails(error) }; + } +} + +export async function runDoctor(options, dependencies = {}) { + if (!["codex", "claude"].includes(options.host)) + throw new Error("--host must be codex or claude"); + if (options.originalError && options.failureRecord) + throw new Error("Choose one original failure input: --failure-record or --original-error"); + let endpoint = null; + let endpointError; + try { endpoint = options.serverUrl ? serverUrl(options.serverUrl) : null; } + catch (error) { endpointError = errorDetails(error); } + if ( + options.occurredAt && + (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.occurredAt) || + !Number.isFinite(Date.parse(options.occurredAt))) + ) { + throw new Error("--occurred-at must be an ISO timestamp with timezone; omit it when unknown"); + } + const fetchImpl = dependencies.fetchImpl ?? fetch; + const 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.6", + 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(), + nodeVersion: process.version, + proxyEnvironmentPresent: [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ].some((key) => Boolean(process.env[key])), + probeProxyPolicy: "node_fetch_default; host_transport_not_reproduced", + noProxyEnvironmentPresent: Boolean(process.env.NO_PROXY || process.env.no_proxy), + nodeUseEnvProxyRequested: process.env.NODE_USE_ENV_PROXY === "1", + extraCaConfigured: Boolean(process.env.NODE_EXTRA_CA_CERTS), + tlsVerificationDisabledInEnvironment: process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0", + }, + serverUrl: endpoint?.href ?? null, + originalFailure: { + evidenceKind: "original_failure", + occurredAt: options.occurredAt ?? null, + timeSource: options.occurredAt + ? "command_line; agent_supplied_not_independently_verified" + : "unavailable", + source: "agent_supplied_error_file", + status: "unavailable", + }, + probes: [], + wireProbes: [], + gaps: [], + outcome: "diagnosis_only; original_connection_recovery_unverified", + coverage: "triggered_diagnostics_only; not_all_users_or_all_failures", + correlation: + "diagnosticId identifies this report and later probes, not the original host request", + }; + let disabledBySnapshot = false; + if (endpointError) report.gaps.push({ source: "registration", reason: "unsupported_endpoint; no_requests_sent", error: endpointError }); + if (options.hostSnapshot) { + try { + if (!basename(String(options.hostSnapshot)).endsWith(".mcp-snapshot.json")) + throw new Error( + "Host snapshot must be a dedicated .mcp-snapshot.json file, never a credential/config file", + ); + const info = await stat(options.hostSnapshot); + if (info.size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + const snapshot = JSON.parse(await readFile(options.hostSnapshot, "utf8")); + disabledBySnapshot = + snapshot?.configuration?.enabled === false || + snapshot?.session?.connectionStatus === "disabled"; + 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) { + report.hostVersion = report.hostSnapshot.hostVersion; + report.hostVersionSource = "agent_supplied_host_snapshot"; + } + } catch (error) { + report.gaps.push({ + source: "host_snapshot", + reason: "unavailable", + error: errorDetails(error), + action: + "Create a new dedicated snapshot with --create-snapshot ; fill only observed facts, then --validate-snapshot before collection. Do not rename or read a credential/config file.", + }); + } + } + try { + const manifest = + options.host === "codex" + ? "../../../.codex-plugin/plugin.json" + : "../../../.claude-plugin/plugin.json"; + report.pluginVersion = JSON.parse( + await readFile(new URL(manifest, import.meta.url), "utf8"), + ).version; + report.pluginVersionSource = + "collector_package_manifest; installed_host_plugin_not_independently_verified"; + } catch (error) { + report.gaps.push({ source: "plugin_version", error: errorDetails(error) }); + } + if (options.originalError) { + try { + if (!/\.(?:txt|log)$/i.test(basename(String(options.originalError)))) { + throw new Error( + "Original error must be a dedicated .txt or .log file; credential/config/session files are not inputs", + ); + } + const info = await stat(options.originalError); + if (info.size > 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"); + report.originalFailure.message = redact(message); + report.originalFailure.status = "recorded"; + } catch (error) { + report.gaps.push({ + source: "original_failure", + reason: "unavailable", + error: errorDetails(error), + }); + } + } else if (!options.failureRecord) + report.gaps.push({ + source: "original_failure", + reason: "not_provided; do_not_invent_original_error", + }); + if (options.failureRecord) { + try { + if (!basename(String(options.failureRecord)).endsWith(".mcp-failure.json")) + throw new Error( + "Use a dedicated .mcp-failure.json file, never a config or session transcript", + ); + 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) { + 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), + }); + } + } + 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, + codexSqliteHome: options.codexSqliteHome, + codexLogDir: options.codexLogDir, + codexAppLogDir: options.codexAppLogDir, + codexAppLogFiles: options.codexAppLogFiles, + codexAppLogOffset: options.codexAppLogOffset, + codexStderrFile: options.codexStderrFile, + conversationId: + report.originalFailure.conversationId ?? + report.hostSnapshot?.session?.conversationId ?? + collectionConversationId, + }); + } catch (error) { + report.hostLogs = { + entries: [], + 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 || + report.hostSnapshot?.session?.connectionStatus === "disabled"; + if (options.offline || disabled || !endpoint) { + report.gaps.push({ + source: "probes", + reason: disabled + ? "host_reports_intentionally_disabled; no_requests_sent" + : options.offline + ? "offline_requested; no_requests_sent" + : "registered_endpoint_unavailable; no_requests_sent", + }); + } else { + report.entryProbe = await guardProbe(endpoint.href, () => + probeEntry(endpoint, diagnosticId, fetchImpl), + ); + const resource = endpoint.pathname.replace(/\/$/, ""); + const queue = [ + new URL("/.well-known/oauth-protected-resource" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server" + resource, endpoint.origin).href, + new URL("/.well-known/oauth-authorization-server", endpoint.origin).href, + ]; + const enqueue = (value, label) => { + try { + const url = new URL(value); + if ( + url.origin !== endpoint.origin || + url.username || + url.password || + url.search || + url.hash || + !url.pathname.startsWith("/.well-known/") + ) + throw new Error( + "Unsupported metadata target; no cross-origin or credential-bearing probes", + ); + if (!queue.includes(url.href)) queue.push(url.href); + } catch (error) { + report.gaps.push({ source: label, error: errorDetails(error) }); + } + }; + if (report.entryProbe.resourceMetadataUrl) + enqueue(report.entryProbe.resourceMetadataUrl, "challenge_metadata_target"); + let issuer; + for (let index = 0; index < queue.length && index < 5; index++) { + const url = queue[index]; + const result = await guardProbe(url, () => + probeMetadata(url, diagnosticId, fetchImpl, { + resource: endpoint.href, + issuer: url.includes("oauth-authorization-server") ? issuer : undefined, + }), + ); + report.probes.push(result); + if (url === queue[0] && result.outcome !== "metadata_read") + enqueue( + new URL("/.well-known/oauth-protected-resource", endpoint.origin).href, + "root_resource_metadata_target", + ); + const fields = result.metadataValidation?.fields; + if (url.includes("oauth-protected-resource") && result.metadataValidation?.valid) { + issuer = fields.authorization_servers[0]; + const auth = new URL(issuer); + enqueue( + new URL( + "/.well-known/oauth-authorization-server" + auth.pathname.replace(/\/$/, ""), + auth.origin, + ).href, + "issuer_metadata_target", + ); + } + } + if (queue.length > 5) + report.gaps.push({ + source: "metadata_probes", + reason: "probe_limit", + omitted: queue.length - 5, + }); + if (options.wireEvidence) { + const failed = report.probes.find((probe) => probe.outcome !== "metadata_read"); + const selected = new URL(failed?.url ?? queue[0]); + const match = selected.pathname.match( + /^\/\.well-known\/(oauth-protected-resource|oauth-authorization-server|openid-configuration)/, + ); + if (match) { + const root = new URL("/.well-known/" + match[1], endpoint.origin); + const scoped = new URL(root.pathname + resource, endpoint.origin); + for (const url of [scoped.href, root.href]) { + try { + report.wireProbes.push( + await (dependencies.probeWire ?? probeMetadataWire)(url, diagnosticId, errorDetails), + ); + } catch (error) { + report.gaps.push({ source: "wire_probe", url, error: errorDetails(error) }); + } + } + } + } + if ( + endpoint.protocol === "https:" && + [report.entryProbe, ...report.probes].some( + (probe) => probe.outcome === "failed" && probe.stage !== "probe_collector", + ) + ) { + try { + report.transportContext = await (dependencies.inspectTransport ?? inspectTransport)( + endpoint, + ); + } catch (error) { + report.transportContext = { status: "failed", error: errorDetails(error) }; + } + } + } + report.evidenceSummary = evidenceSummary(report); + report.collectionStatus = "complete"; + report.collectionStage = "complete"; + report.upload = { + status: disabled + ? "skipped_intentionally_disabled" + : options.offline + ? "skipped_offline" + : !endpoint + ? "skipped_endpoint_unavailable" + : options.upload + ? "pending" + : "not_requested", + storageVerified: false, + }; + await save("w"); + if (options.upload && !options.offline && !disabled && endpoint) { + report.upload = await submitReport(report, fetchImpl); + await save("w"); + } + return { path, report: clean(report) }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const { values } = parseArgs({ + options: { + host: { type: "string" }, + "server-url": { type: "string" }, + "host-version": { type: "string" }, + "host-snapshot": { type: "string" }, + "original-error": { type: "string" }, + "failure-record": { type: "string" }, + "occurred-at": { type: "string" }, + "codex-sqlite-home": { type: "string" }, + "codex-log-dir": { type: "string" }, + "codex-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" }, + help: { type: "boolean", default: false }, + }, + }); + if (values["create-snapshot"]) { + const path = resolve(values["create-snapshot"]); + if (!basename(path).endsWith(".mcp-snapshot.json")) + throw new Error("Use a new dedicated .mcp-snapshot.json filename"); + await writeFile( + path, + JSON.stringify( + { schemaVersion: 1, observedAt: new Date().toISOString(), observations: [] }, + null, + 2, + ) + "\n", + { flag: "wx", mode: 0o600 }, + ); + console.log( + JSON.stringify({ + path, + snapshotCreated: true, + action: + "Add only observed configuration/session facts using connection-diagnostics.md; omit unknown groups. observedAt is snapshot capture time, not original failure time.", + }), + ); + } else if (values["validate-snapshot"]) { + const path = values["validate-snapshot"]; + if (!basename(path).endsWith(".mcp-snapshot.json")) + throw new Error("Use a dedicated .mcp-snapshot.json file"); + if ((await stat(path)).size > READ_LIMIT) throw new Error("Host snapshot exceeds 128 KiB"); + validateSnapshot( + JSON.parse(await readFile(path, "utf8")), + values["server-url"] ? serverUrl(values["server-url"]) : null, + ); + console.log(JSON.stringify({ snapshotValid: true, networkRequests: 0 })); + } else if (values.help) { + console.log( + "node mcp-doctor.mjs --create-snapshot | --validate-snapshot | --host codex|claude --server-url [--host-snapshot ] [--host-version ] [--failure-record | --original-error ] [--occurred-at ] [--codex-sqlite-home ] [--codex-log-dir ] [--codex-stderr-file ] [--codex-app-log-dir ] [--codex-app-log-file ] [--wire-evidence] [--output ] [--offline] [--upload]", + ); + } else { + const { path, report } = await runDoctor({ + host: values.host, + serverUrl: values["server-url"], + hostVersion: values["host-version"], + hostSnapshot: values["host-snapshot"], + originalError: values["original-error"], + failureRecord: values["failure-record"], + occurredAt: values["occurred-at"], + codexSqliteHome: values["codex-sqlite-home"], + codexLogDir: values["codex-log-dir"], + codexStderrFile: values["codex-stderr-file"], + codexAppLogDir: values["codex-app-log-dir"], + codexAppLogFiles: values["codex-app-log-file"], + codexAppLogOffset: + values["codex-app-log-offset"] === undefined + ? undefined + : Number(values["codex-app-log-offset"]), + output: values.output, + upload: values.upload, + offline: values.offline, + wireEvidence: values["wire-evidence"], + }); + console.log( + JSON.stringify({ + diagnosticId: report.diagnosticId, + path, + upload: report.upload, + evidenceGaps: report.evidenceSummary.gaps, + hostFailureEvidence: report.evidenceSummary.failureEvidence, + collectionGaps: report.gaps, + recoveryVerified: false, + }), + ); + } + } catch (error) { + console.error(JSON.stringify({ doctorFailed: true, error: errorDetails(error) })); + process.exitCode = 1; + } +} diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs index 54ff8d2..9af54e7 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs @@ -1,2 +1,602 @@ -// Compatibility entry; the standalone Doctor owns the collector. -export * from "../../chatcut-mcp-doctor/scripts/mcp-evidence.mjs"; +const STAGES = [ + "plugin_loading", + "discovery", + "client_registration", + "authorization", + "token_exchange", + "token_refresh", + "initialize", + "tools_list", + "session_tools", +]; +const SOURCES = [ + "current_session", + "tool_search", + "host_settings", + "cli_configuration", + "runtime_log", +]; +const TIMESTAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/; + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function keys(value, allowed) { + if (!object(value) || Object.keys(value).some((key) => !allowed.includes(key))) + throw new Error("Snapshot contains unsupported fields; use only the dedicated evidence schema"); +} + +function source(value) { + if (!SOURCES.includes(value)) + throw new Error("Snapshot evidence needs an explicit supported source"); +} + +function instant(value) { + return ( + typeof value === "string" && + new RegExp("^" + TIMESTAMP.source + "$").test(value) && + Number.isFinite(Date.parse(value)) + ); +} + +export function validateSnapshot(input, endpoint) { + keys(input, [ + "schemaVersion", + "observedAt", + "hostVersion", + "pluginVersion", + "configuration", + "session", + "observations", + ]); + if (input.schemaVersion !== 1) + throw new Error( + "Snapshot schemaVersion must be 1 (the outer Doctor report uses a separate schemaVersion); generate a template with --create-snapshot", + ); + if (!instant(input.observedAt)) + throw new Error( + "Snapshot observedAt must be its capture time as a complete ISO timestamp with timezone, e.g. 2026-10-23T01:00:00Z; original failure time may remain unknown", + ); + for (const key of ["hostVersion", "pluginVersion"]) + if (input[key] !== undefined && (typeof input[key] !== "string" || input[key].length > 256)) + throw new Error("Invalid snapshot version"); + if (input.configuration) { + const config = input.configuration; + keys(config, ["source", "serverName", "url", "enabled"]); + source(config.source); + if (!["chatcut", "plugin:chatcut:chatcut"].includes(config.serverName)) + throw new Error("Snapshot must describe the hosted ChatCut server"); + if (!publicUrl(config.url, true)) + throw new Error("Snapshot URL must be a public URL without embedded credentials"); + if (endpoint && new URL(config.url).href !== endpoint.href) + throw new Error( + "Snapshot URL differs from the explicitly selected server; preserve both configurations", + ); + if (config.enabled !== null && typeof config.enabled !== "boolean") + throw new Error("Snapshot enabled state must be boolean or null"); + } + if (input.session) { + const session = input.session; + keys(session, [ + "source", + "conversationId", + "connectionStatus", + "authStatus", + "visibleToolCount", + "visibleTools", + ]); + source(session.source); + if (session.source === "cli_configuration") + throw new Error("Static CLI configuration cannot describe current session tool availability"); + if ( + !["connected", "failed", "needs_authentication", "disabled", "unknown"].includes( + session.connectionStatus, + ) + ) + throw new Error("Unsupported connection status"); + if (!["authenticated", "needs_authentication", "unknown"].includes(session.authStatus)) + throw new Error("Unsupported authentication status"); + if ( + session.visibleToolCount !== null && + (!Number.isSafeInteger(session.visibleToolCount) || session.visibleToolCount < 0) + ) + throw new Error("Visible tool count must be a nonnegative integer or null"); + if (session.conversationId !== undefined && typeof session.conversationId !== "string") + throw new Error("conversationId must be a host conversation identifier"); + if ( + session.visibleTools !== undefined && + (!Array.isArray(session.visibleTools) || + session.visibleTools.length > 256 || + session.visibleTools.some( + (name) => typeof name !== "string" || !/^[A-Za-z0-9_:.-]{1,256}$/.test(name), + )) + ) + throw new Error("visibleTools must contain only ChatCut tool names, at most 256"); + if ( + session.visibleTools && + session.visibleToolCount !== null && + session.visibleTools.length > session.visibleToolCount + ) + throw new Error("Tool name count exceeds the observed tool count"); + } + if (input.observations !== undefined) { + if (!Array.isArray(input.observations) || input.observations.length > 32) + throw new Error("Snapshot accepts at most 32 stage observations"); + for (const item of input.observations) { + keys(item, [ + "stage", + "operation", + "source", + "observedAt", + "outcome", + "requestId", + "httpStatus", + "protocolCode", + "message", + "originalAttemptMatch", + "lookupResult", + ]); + source(item.source); + if ( + item.lookupResult !== undefined && + (item.stage !== "host_registration" || + item.operation !== "lookup_registered_server" || + !["cli_configuration", "host_settings"].includes(item.source) || + !["not_found", "found", "unavailable"].includes(item.lookupResult) || + (item.lookupResult === "not_found" && item.outcome === "unknown")) + ) + throw new Error( + "lookupResult 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) || + (item.observedAt !== null && !instant(item.observedAt)) + ) + throw new Error( + "Stage observation needs stage, outcome, and observedAt (ISO timestamp with timezone, or null when the original time is unknown)", + ); + for (const field of ["operation", "requestId", "message"]) + if (item[field] !== undefined && typeof item[field] !== "string") + throw new Error("Observation text fields must be strings"); + for (const field of ["httpStatus", "protocolCode"]) + if (item[field] !== undefined && !Number.isSafeInteger(item[field])) + throw new Error("Observation status/code must be an integer"); + if ( + item.originalAttemptMatch !== undefined && + !["confirmed", "unconfirmed"].includes(item.originalAttemptMatch) + ) + throw new Error( + "Original attempt match must be confirmed or unconfirmed; omit when unknown", + ); + if ( + item.originalAttemptMatch === "confirmed" && + (!item.requestId || + item.outcome !== "failed" || + !["current_session", "runtime_log"].includes(item.source)) + ) + throw new Error( + "Confirmed original request association requires its failed runtime/session observation and actual request ID", + ); + } + } + return input; +} + +export function 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; +} + +export function stageHint(message) { + if (/tools\/list|tool catalog|tool list/i.test(message)) return "tools_list"; + if (/initializ|handshake/i.test(message)) return "initialize"; + if (/refresh.{0,20}token|token.{0,20}refresh/i.test(message)) return "token_refresh"; + if (/token.{0,20}exchange|exchange.{0,20}token|\/mcp\/token/i.test(message)) + return "token_exchange"; + if (/registr|\/mcp\/register/i.test(message)) return "client_registration"; + if (/callback|authoriz|oauth/i.test(message) && !/metadata|discovery/i.test(message)) + return "authorization"; + if (/metadata|discovery/i.test(message)) return "discovery"; + return "unclassified"; +} + +export function isHostFailure(entry) { + if ( + ![ + "codex_runtime_sqlite", + "codex_tui_log", + "codex_stderr_log", + "codex_app_log", + "claude_debug_log", + ].includes(entry.source) + ) + return false; + if (entry.source === "codex_app_log") + return [...String(entry.message).matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed"; + const level = + entry.level ?? + String(entry.message) + .match(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:\[(WARN|ERROR)\]|(WARN|ERROR))\s+/i) + ?.slice(1) + .find(Boolean); + return ( + ["WARN", "ERROR"].includes(String(level).toUpperCase()) || + /\b(?:connection failed|discovery failed|auth error|error during auth|MCP tool call error|startup failed|HTTP Connection failed)\b|(?:MCP server|Server)\s+["']?(?:plugin:chatcut:)?chatcut["']?:\s*Error\b/i.test( + entry.message, + ) + ); +} + +export function hostFailureEvidence(report) { + const original = report.originalFailure; + const logs = (report.hostLogs.entries ?? []).flatMap((entry, index) => { + if (!isHostFailure(entry)) return []; + const sameCall = + original.status === "recorded" && + original.conversationId && + original.callId && + entry.conversationId === original.conversationId && + entry.callId === original.callId; + return [ + { + evidence: `hostLogs.entries[${index}]`, + errorText: `hostLogs.entries[${index}].message`, + source: entry.source, + ...(entry.sourceFile && { sourceFile: entry.sourceFile }), + ...(entry.rowId !== undefined && { rowId: entry.rowId }), + loggedAt: entry.loggedAt ?? entry.occurredAt ?? null, + timeSource: "host_log_event_time; not_original_failure_occurredAt", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), + extractionRule: + "admitted_native_error_warning_or_explicit_failure_event; original_block_retained", + localCallBinding: sameCall + ? "same_conversation_and_call_id; original_record_agent_supplied" + : "unconfirmed; may_not_be_the_reported_failure", + originalAttemptMatch: "unconfirmed", + }, + ]; + }); + const native = (report.hostLogs.nativeFailedCalls ?? []).flatMap((entry, index) => + entry.status === "failed" && entry.error + ? [ + { + evidence: `hostLogs.nativeFailedCalls[${index}]`, + errorText: `hostLogs.nativeFailedCalls[${index}].error`, + source: entry.source, + conversationId: entry.conversationId, + nativeItemId: entry.nativeItemId, + idKind: entry.idKind, + startedAtMs: entry.startedAtMs, + completedAtMs: entry.completedAtMs, + timeSource: entry.timeSource, + extractionRule: "scoped_native_failed_mcp_item_error_projection", + localCallBinding: "native_item_only; not_bound_to_original_call_or_server_request", + originalAttemptMatch: "unconfirmed", + }, + ] + : [], + ); + return [...logs, ...native]; +} + +export function evidenceSummary(report) { + const snapshot = report.hostSnapshot; + 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"); + 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" && !report.originalFailure.requestIdConflict + ? observations.flatMap((item, observationIndex) => + item.requestId && + item.originalAttemptMatch === "confirmed" && + item.outcome === "failed" && + ["current_session", "runtime_log"].includes(item.source) + ? [{ requestId: item.requestId, source: item.source, observationIndex }] + : [], + ) + : []; + if ( + report.originalFailure.status === "recorded" && + report.originalFailure.requestId && + !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"); + const failedAppNotices = appNotices.filter( + (entry) => [...entry.message.matchAll(/(?:^|\s)status=([^\s]+)/g)].at(-1)?.[1] === "failed", + ); + if (appNotices.length === report.hostLogs.entries.length && !failedAppNotices.length) + gaps.push("matching_host_failure_logs_unavailable"); + const stages = STAGES.map((stage) => ({ + stage, + observations: observations.filter((item) => item.stage === stage), + status: observations.some((item) => item.stage === stage) + ? "observed; see individual attempts" + : "not_observed", + })); + const unknown = observations.filter((item) => !STAGES.includes(item.stage)); + const logHints = report.hostLogs.entries.map((entry, index) => ({ + entryIndex: index, + stage: stageHint(entry.message), + interpretation: "text_hint_only; attempt_match_unconfirmed", + ...(entry.conversationId && { conversationId: entry.conversationId }), + ...(entry.callId && { callId: entry.callId }), + })); + 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, + failureEvidence: hostFailureEvidence(report), + 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: 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", + }, + 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: { + 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, + originalToolFailureRecord: + report.originalFailure.source === "agent_supplied_host_tool_result", + qualification: + "startup notices are not tool-request failure evidence; log presence alone does not prove original failure capture", + }, + gaps, + independentChecks: + "entryProbe/probes/wireProbes/transportContext are later requests, never original stage completions; server observation is before Node framing, not proof of downstream wire validity", + recoveryVerified: false, + }; +} + +function publicUrl(value, localAllowed = false) { + try { + const url = new URL(value); + return ( + !url.username && + !url.password && + !url.hash && + (url.protocol === "https:" || + (localAllowed && + url.protocol === "http:" && + ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))) + ); + } catch { + return false; + } +} + +export function inspectMetadata(document, url, expected = {}) { + const issues = []; + const result = { valid: false, issues, fields: {} }; + if (!object(document)) { + issues.push({ field: "document", reason: "expected_object" }); + return result; + } + for (const field of [ + "resource", + "authorization_servers", + "issuer", + "authorization_endpoint", + "token_endpoint", + "registration_endpoint", + "grant_types_supported", + "response_types_supported", + "code_challenge_methods_supported", + "token_endpoint_auth_methods_supported", + ]) + if (document[field] !== undefined) result.fields[field] = document[field]; + const protectedResource = new URL(url).pathname.includes("oauth-protected-resource"); + const requireUrl = (field) => { + if (typeof document[field] !== "string" || !publicUrl(document[field], true)) + issues.push({ field, reason: "missing_or_invalid_public_url" }); + }; + if (protectedResource) { + requireUrl("resource"); + const suffix = new URL(url).pathname.split("/.well-known/oauth-protected-resource")[1]; + const resource = expected.resource ?? (suffix ? new URL(url).origin + suffix : undefined); + if (resource && document.resource !== resource) + issues.push({ + field: "resource", + reason: "resource_mismatch", + expected: resource, + actual: document.resource, + }); + if ( + !Array.isArray(document.authorization_servers) || + !document.authorization_servers.length || + document.authorization_servers.some((item) => !publicUrl(item, true)) + ) + issues.push({ + field: "authorization_servers", + reason: "missing_or_invalid_authorization_servers", + }); + } else { + requireUrl("issuer"); + requireUrl("authorization_endpoint"); + requireUrl("token_endpoint"); + if ( + Array.isArray(document.grant_types_supported) && + !document.grant_types_supported.includes("authorization_code") + ) + issues.push({ field: "grant_types_supported", reason: "authorization_code_not_supported" }); + if ( + !Array.isArray(document.response_types_supported) || + !document.response_types_supported.includes("code") + ) + issues.push({ field: "response_types_supported", reason: "code_response_not_advertised" }); + if (expected.issuer && document.issuer !== expected.issuer) + issues.push({ + field: "issuer", + reason: "issuer_mismatch", + expected: expected.issuer, + actual: document.issuer, + }); + if ( + !Array.isArray(document.code_challenge_methods_supported) || + !document.code_challenge_methods_supported.includes("S256") + ) + issues.push({ field: "code_challenge_methods_supported", reason: "S256_not_advertised" }); + } + result.valid = issues.length === 0; + return result; +} diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs index 35cf382..c472e8d 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-failure.mjs @@ -1,2 +1,200 @@ -// Compatibility entry; the standalone Doctor owns the collector. -export * from "../../chatcut-mcp-doctor/scripts/mcp-failure.mjs"; +const SOURCES = ["host_tool_result", "host_runtime_event", "captured_stderr"]; +const FIELDS = [ + "schemaVersion", + "source", + "serverName", + "occurredAt", + "conversationId", + "callId", + "operation", + "requestId", + "httpResponse", + "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))); +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 ( + !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 (!validTime(input.occurredAt)) + throw new Error( + "Failure occurredAt must be its observed ISO timestamp with timezone, or null when unavailable", + ); + 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`, + ); + 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", + ); + 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; +} + +export function failureFromRecord(input) { + const candidate = + 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({ + 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); + 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", + 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 }), + ...(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/scripts/mcp-wire.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-wire.mjs index f5d9565..57079a5 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-wire.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-wire.mjs @@ -1,2 +1,296 @@ -// Compatibility entry; the standalone Doctor owns the collector. -export * from "../../chatcut-mcp-doctor/scripts/mcp-wire.mjs"; +import { createHash } from "node:crypto"; +import { connect, isIP } from "node:net"; +import { connect as connectTls } from "node:tls"; + +const LIMIT = 128 * 1024; +const HEADER_LIMIT = 16 * 1024; +const TIMEOUT_MS = 3500; +const HEADERS = new Set([ + "content-type", + "content-length", + "content-encoding", + "transfer-encoding", + "connection", + "server", + "via", + "x-request-id", + "x-amzn-trace-id", + "cache-control", + "age", + "x-cache", + "x-amz-cf-id", + "x-amz-cf-pop", +]); + +function target(value) { + const url = new URL(value); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ( + (!local && + (!["api.chatcut.io", "api.chatcut.dev", "api-dev.chatcut.org"].includes(url.hostname) || + url.protocol !== "https:" || + (url.port && url.port !== "443"))) || + (local && !["http:", "https:"].includes(url.protocol)) || + url.username || + url.password || + url.search || + url.hash || + !/^\/\.well-known\/(?:oauth-protected-resource|oauth-authorization-server|openid-configuration)(?:\/[a-zA-Z0-9/_-]*)?$/.test( + url.pathname, + ) + ) + throw new Error( + "Wire evidence is restricted to anonymous ChatCut metadata on the selected origin", + ); + return url; +} + +function framing(headers, body, ended, limited) { + const result = { + status: "incomplete", + firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, + bodyRepresentation: "after_transfer_decoding; content_encoding_not_decoded", + }; + const invalid = (reason) => ({ ...result, status: "invalid", reason }); + const incomplete = (reason) => ({ ...result, reason }); + const valid = (decoded) => ({ + ...result, + status: "valid", + bodyBytes: decoded.length, + bodySha256: createHash("sha256").update(decoded).digest("hex"), + }); + const transfer = headers["transfer-encoding"] + ?.toLowerCase() + .split(",") + .map((v) => v.trim()); + if (transfer?.at(-1) === "chunked") { + if (headers["content-length"] !== undefined) + return invalid("both_transfer_encoding_and_content_length"); + let offset = 0; + const parts = []; + while (offset < body.length) { + const end = body.indexOf("\r\n", offset); + if (end < 0) { + if (body.length > offset && !/[0-9a-f]/i.test(String.fromCharCode(body[offset]))) + return invalid("invalid_chunk_size"); + return ended && !limited + ? invalid("unterminated_chunk_size") + : incomplete("chunk_size_not_complete"); + } + const sizeText = body.subarray(offset, end).toString("latin1").split(";")[0]; + if (!/^[0-9a-f]{1,16}$/i.test(sizeText)) return invalid("invalid_chunk_size"); + if (offset === 0) result.firstChunkSizeHex = sizeText; + const size = Number.parseInt(sizeText, 16); + offset = end + 2; + if (!Number.isSafeInteger(size)) return incomplete("chunk_exceeds_capture_limit"); + if (size === 0) { + const trailerEnd = body.indexOf("\r\n\r\n", offset); + const messageEnd = body.subarray(offset, offset + 2).equals(Buffer.from("\r\n")) + ? offset + 2 + : trailerEnd >= 0 + ? trailerEnd + 4 + : null; + if (messageEnd !== null) { + if (messageEnd !== body.length) return invalid("bytes_after_terminal_chunk"); + return valid(Buffer.concat(parts)); + } + return ended && !limited + ? invalid("unterminated_trailers") + : incomplete("trailers_not_complete"); + } + if (offset + size + 2 > body.length) + return ended && !limited ? invalid("truncated_chunk") : incomplete("chunk_not_complete"); + if (!body.subarray(offset + size, offset + size + 2).equals(Buffer.from("\r\n"))) + return invalid("missing_chunk_terminator"); + parts.push(body.subarray(offset, offset + size)); + offset += size + 2; + } + return ended && !limited + ? invalid("missing_terminal_chunk") + : incomplete("terminal_chunk_not_observed"); + } + if (transfer) return incomplete("unsupported_transfer_encoding"); + if (headers["content-length"] !== undefined) { + if (!/^\d+$/.test(headers["content-length"])) return invalid("invalid_content_length"); + const expected = Number(headers["content-length"]); + if (body.length === expected) return valid(body); + if (body.length > expected) return invalid("content_length_mismatch"); + return ended && !limited ? invalid("content_length_mismatch") : incomplete("body_not_complete"); + } + return ended && !limited ? valid(body) : incomplete("close_delimited_body_not_complete"); +} + +function inspectResponse(captured, ended, limited) { + const headerEnd = captured.indexOf("\r\n\r\n"); + const result = { responseHeaders: {} }; + if (headerEnd < 0 || headerEnd > HEADER_LIMIT) { + result.headerGap = + captured.length > HEADER_LIMIT ? "header_limit" : "complete_headers_unavailable"; + return result; + } + const lines = captured.subarray(0, headerEnd).toString("latin1").split("\r\n"); + const status = lines.shift().match(/^HTTP\/(1\.[01]) ([1-5]\d{2}) [\t\x20-\x7e\x80-\xff]*$/); + result.httpVersion = status?.[1] ?? null; + result.status = status ? Number(status[2]) : null; + result.responseState = !status + ? "invalid_status_line" + : result.status < 200 + ? "unsupported_informational_response" + : "final_response"; + for (const line of lines) { + const colon = line.indexOf(":"); + if (colon < 0) continue; + const name = line.slice(0, colon).toLowerCase(); + if (HEADERS.has(name)) { + const field = line.slice(colon + 1).trim(); + result.responseHeaders[name] = + result.responseHeaders[name] === undefined + ? field + : result.responseHeaders[name] + "," + field; + } + } + result.stage = "http_framing"; + const body = captured.subarray(headerEnd + 4); + result.framing = + result.responseState === "final_response" + ? framing(result.responseHeaders, body, ended, limited) + : { + status: "not_evaluated", + reason: result.responseState, + firstBodyByteHex: body.length ? body[0].toString(16).padStart(2, "0") : null, + }; + return result; +} + +export async function probeMetadataWire(value, diagnosticId, describeError) { + const url = target(value); + if (!/^[0-9a-f-]{36}$/i.test(diagnosticId)) throw new Error("Invalid diagnostic ID"); + const started = Date.now(); + const result = { + evidenceKind: "later_probe", + url: url.href, + startedAt: new Date().toISOString(), + implementation: "node:net/tls; HTTP/1.1", + route: "direct_connection; environment_proxy_not_used; host_and_fetch_route_not_reproduced", + originalAttemptMatch: "unconfirmed", + credentialsSent: false, + acceptEncoding: "identity", + byteLimit: LIMIT, + stage: "connect", + }; + return new Promise((resolve) => { + let socket; + let done = false; + let bytes = 0; + let limited = false; + let ended = false; + let headerSeen = false; + const parts = []; + const finish = (error, completionReason) => { + if (done) return; + done = true; + clearTimeout(timer); + const captured = Buffer.concat(parts); + result.capturedBytes = captured.length; + result.captureLimited = limited; + Object.assign(result, inspectResponse(captured, ended, limited)); + result.completionReason = completionReason; + result.peerEndObserved = ended; + result.outcome = error + ? "failed" + : result.responseState === "invalid_status_line" + ? "invalid_response" + : result.responseState === "unsupported_informational_response" + ? "unsupported_response" + : result.framing?.status === "invalid" + ? "invalid_framing" + : !limited && result.framing?.status === "valid" + ? "response_observed" + : "incomplete"; + if (error) result.error = describeError(error); + result.durationMs = Date.now() - started; + socket?.destroy(); + resolve(result); + }; + const timer = setTimeout( + () => + finish( + Object.assign(new Error("Metadata wire probe timed out after 3500ms"), { + code: "DIAGNOSTIC_WIRE_TIMEOUT", + }), + "timeout", + ), + TIMEOUT_MS, + ); + try { + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + const options = { + host: hostname, + port: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + }; + const send = () => { + result.stage = "response"; + result.peer = { address: socket.remoteAddress, port: socket.remotePort }; + if (url.protocol === "https:") { + const certificate = socket.getPeerCertificate(); + result.tls = { + authorized: socket.authorized, + protocol: socket.getProtocol(), + certificate: { issuer: certificate.issuer, fingerprint256: certificate.fingerprint256 }, + }; + } + socket.write( + `GET ${url.pathname} HTTP/1.1\r\nHost: ${url.host}\r\nAccept: application/json\r\nAccept-Encoding: identity\r\nConnection: close\r\nX-ChatCut-Diagnostic-ID: ${diagnosticId}\r\n\r\n`, + ); + }; + socket = + url.protocol === "https:" + ? connectTls( + { + ...options, + servername: isIP(hostname) ? undefined : hostname, + rejectUnauthorized: true, + ALPNProtocols: ["http/1.1"], + }, + send, + ) + : connect(options, send); + socket.on("data", (part) => { + if (done) return; + const remaining = LIMIT - bytes; + parts.push(part.subarray(0, Math.max(0, remaining))); + bytes += Math.min(part.length, remaining); + if (part.length > remaining) { + limited = true; + finish(undefined, "capture_limit"); + return; + } + if (!headerSeen) { + const captured = Buffer.concat(parts); + const headerEnd = captured.indexOf("\r\n\r\n"); + headerSeen = headerEnd >= 0; + if ((!headerSeen && bytes > HEADER_LIMIT) || headerEnd > HEADER_LIMIT) { + limited = true; + finish(undefined, "header_limit"); + return; + } + } + if (headerSeen) { + const observed = inspectResponse(Buffer.concat(parts), false, false); + if (observed.responseState !== "final_response") + finish(undefined, "invalid_or_unsupported_response"); + else if (observed.framing.status === "valid") finish(undefined, "message_boundary"); + else if (observed.framing.status === "invalid") finish(undefined, "invalid_framing"); + } + }); + socket.once("end", () => { + ended = true; + finish(undefined, "peer_end"); + }); + socket.once("error", (error) => finish(error, "transport_error")); + socket.once("close", () => finish(undefined, "socket_close")); + } catch (error) { + finish(error, "collector_error"); + } + }); +} diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md index 6a3cc1d..186ed66 100644 --- a/docs/mcp-doctor.md +++ b/docs/mcp-doctor.md @@ -1,77 +1,42 @@ # Hosted MCP evidence report v2 -## Independent Doctor Skill - -`codex/skills/chatcut-mcp-doctor` and `claude/skills/chatcut-mcp-doctor` now -package the collector independently of MCP, with diagnosis and feedback sidecars. -Use Codex's skill picker / `$chatcut:chatcut-mcp-doctor` (standalone copies use -`$chatcut-mcp-doctor`; the existing store identity uses -`$chatcut-desktop:chatcut-mcp-doctor`), or Claude Code's -`/chatcut:chatcut-mcp-doctor`. Neither -overrides the host's `/doctor`. Its description also enables agent selection for -natural-language connection-failure requests. See its [entrypoint](../codex/skills/chatcut-mcp-doctor/SKILL.md) -and [fallback guide](../codex/skills/chatcut-mcp-doctor/references/discovery.md). -This is an unpublished Doctor candidate with collector 1.3.0. The candidate -manifest versions do not establish a released minimum version; final versions -remain subject to review. The separate collection-only packages contain no -Doctor entry. Check actual installed contents and catalog; Git availability, -store approval and host adoption are separate checks. - -The short `scripts/capture.mjs` entry saves a fresh canonical report, snapshot, -SHA-bound local receipt and Chinese note in a new `.chatcut-doctor/capture-*` -directory. Claude invokes it through native Skill dynamic context before the -model answers, subject to host policy. Default capture sends no requests and -does not run native CLI: Claude's `mcp get` may health-check approved servers. -Only allowlisted current project registration fields are projected in memory; -missing project config does not establish absence in other host scopes. -An explicit `--native-lookup` allows one bounded native query and projects its -result without retaining full configuration/headers. Only the exact no-entry -response supports `not_registered`; denied/timed-out/unrecognized lookups remain -unknown. Saved `.mcp-error.txt` inputs can be formatted without Agent-authored -schemas. `record-recheck.mjs` binds a separately supplied native safe-read result -to the immutable report hash; it does not independently verify the Agent's claim. -The program-generated `active-case.json` binds follow-up safe-read recording to -the original incident path/hash. A later Skill entry may create a fresh baseline -but cannot replace a recent same-endpoint case; the same saved original is not -treated as a new event. Chinese user-facing diagnoses come from the deterministic -note; the Agent adds only separately observed actions, rechecks and delivery. - -The basics scripts are compatibility forwarders; each host package contains -one collector implementation under Doctor. The new `scripts/doctor.mjs` -entrypoint keeps schema-2 collection unchanged and writes `.diagnosis.json`, -`.summary.md`, `.issue-draft.md` beside the local report. `--offline` forbids -all diagnostic HTTP requests even with a registered URL and `--upload`. -`--report ` rebuilds sidecars without probes; -`--upload-report ` sends the same report/ID and retains -a separate receipt. An acknowledged report is not automatically resent. Derived -sidecars can be regenerated; keep actual agent repair notes separately. -Supplementary upload receipts bind the immutable report bytes' SHA-256 and its -diagnostic ID; a foreign receipt cannot establish delivery. Disabled snapshots -remain disabled through later submission even if snapshot validation failed. -Credential redaction includes opaque MCP session headers, and permitted native -SQLite modules still filter tool-call/content echoes before retaining records. - -Findings are evidence pointers to observed host boundaries and later checks, -not root-cause assertions. The helper performs no host repair. The repair guide -limits configuration changes to a genuinely absent manual registration in the -selected host/scope, with rollback and lookup verification. Sign-in remains a -host-guided action, and tool recovery requires an actual safe host call. -The issue draft is private review material; a public issue destination is still -undecided, and ingest acknowledgement does not create a ticket. - -The Codex and Claude Code basics skills route actual connection failures to a -bundled, dependency-free Node helper. This does not change the MCP URL, transport, -authentication, plugin installation, or Desktop integration. It is triggered by -the Agent during diagnosis and is not an automatic monitor of all connections. - -The two plugin packages contain identical copies of the helper and its reference -because either package must remain self-contained. Update both; tests check -parity. Node 18+ runs probes and text-log extraction. Optional Codex SQLite reads -need a runtime with `node:sqlite`; missing support is an explicit evidence gap. +## Unpublished Doctor candidate + +Use the existing Hosted evidence collector and actual host tools. The Doctor +Skill is a thin explanation and local evidence entry, not an automatic repair +service. Current collector code is aligned with 1.2.6; older candidate 1.3.0 +reports keep their original identity and bytes. Manifest versions and Skill +folders do not establish publication, installation, natural triggering or user +recovery. Doctor remains on hold. + +Follow-ups explicitly reference their original report. No active-case index, +time window, automatic original-file selection, mandatory new report or Chinese +answer generator selects the incident. Agent reasoning is based on the actual +error and canonical project/configuration state. The optional capture entry is +offline unless the user explicitly permits a native lookup. + +Recheck observations are separate files referencing the original UUID and SHA. +Multiple failed and successful observations can coexist. They preserve actual +host, service, configuration scope and known project/session/call facts; unknown +facts stay null. A safe native tool result is required. A project listing is not +proof of access to an affected project. Agent-supplied observations are not +independent verification or an automatic recovery conclusion. + +Saved-report upload has one Doctor entry, an exclusive local owner and SHA-bound +receipt. Interrupted/ambiguous delivery needs review rather than an automatic +retry; an ACK is still not complete storage. All local files remain deliverable +without upload. Nothing in this candidate makes a production request during +normal Skill loading. + +Collection source: Codex basics scripts. Generated self-contained copies serve +Claude basics and both Doctor Skills. Run `node scripts/sync-mcp-helpers.mjs` +after changing those four helpers; CI runs `--check`. See the +[Skill](../codex/skills/chatcut-mcp-doctor/SKILL.md) and +[optional collection](../codex/skills/chatcut-mcp-doctor/references/advanced-collection.md). ## Evidence and delivery contract -Collector 1.3.0 packages the independent Doctor workflow, offline mode and +Historical candidate collector 1.3.0 packaged the independent Doctor workflow, offline mode and immutable-report feedback reuse. It adds explicit registration lookup results, surfaces later wire failures and unassociated native errors, and strengthens session-credential/content-echo filtering. These behavior changes have a new diff --git a/scripts/sync-mcp-helpers.mjs b/scripts/sync-mcp-helpers.mjs new file mode 100644 index 0000000..9730f9d --- /dev/null +++ b/scripts/sync-mcp-helpers.mjs @@ -0,0 +1,18 @@ +import { readFile, writeFile } from "node:fs/promises"; + +// Packages remain self-contained; only this source is edited for collection changes. +const source = new URL("../codex/skills/chatcut-plugin-basics/", import.meta.url); +const targets = [ + "../claude/skills/chatcut-plugin-basics-claude/", + "../codex/skills/chatcut-mcp-doctor/", + "../claude/skills/chatcut-mcp-doctor/", +]; +for (const file of ["mcp-doctor.mjs", "mcp-evidence.mjs", "mcp-failure.mjs", "mcp-wire.mjs"]) { + const bytes = await readFile(new URL(`scripts/${file}`, source)); + for (const target of targets) { + const path = new URL(`${target}scripts/${file}`, import.meta.url); + if (process.argv.includes("--check")) { + if (!bytes.equals(await readFile(path))) throw new Error(`Generated MCP helper differs: ${path.pathname}; run node scripts/sync-mcp-helpers.mjs`); + } else await writeFile(path, bytes); + } +} 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, + ), + ); + }); +} diff --git a/tests/doctor-capture.test.mjs b/tests/doctor-capture.test.mjs index 48d64b5..c79f361 100644 --- a/tests/doctor-capture.test.mjs +++ b/tests/doctor-capture.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; @@ -12,24 +12,22 @@ async function temp(t) { } const deps = { collectLogs: async () => ({ entries: [], gaps: [] }), fetchImpl: () => assert.fail("offline must not fetch") }; -test("fresh offline capture writes and verifies a current report even without any registration or original error", async t => { +test("explicit offline collection does not select an old original file or create an active-case", async t => { const cwd = await temp(t); - const receipt = await capture({ host: "claude", cwd }, { ...deps, nativeRegistration: () => assert.fail("offline must not invoke native get") }); + await writeFile(join(cwd, "original.mcp-error.txt"), "STALE_OR_UNRELATED_ERROR"); + const receipt = await capture({ host: "claude", cwd }, deps); const report = JSON.parse(await readFile(receipt.report)); assert.equal(receipt.savedReportVerified, true); + assert.equal(receipt.originalInput, null); assert.equal(report.originalFailure.status, "unavailable"); assert.equal(report.originalFailure.occurredAt, null); - assert.equal(receipt.lookupResult, "unavailable"); - assert.ok(receipt.findings.some(x => x.kind === "registration_lookup_unavailable")); - assert.ok(!receipt.findings.some(x => x.kind === "not_registered")); assert.equal(report.upload.status, "skipped_offline"); - const second = await capture({ host: "claude", cwd }, deps); - assert.notEqual(receipt.report, second.report); - assert.notEqual(receipt.diagnosticId, second.diagnosticId); - assert.ok((await readFile(receipt.chineseNote, "utf8")).includes(receipt.diagnosticId)); + assert.equal(receipt.lookupResult, "unavailable"); + assert.deepEqual((await readdir(join(cwd, ".chatcut-doctor"))).length, 1); + assert.ok(!JSON.stringify(report).includes("STALE_OR_UNRELATED_ERROR")); }); -test("offline project projection excludes headers, environment, unrelated entries and active-session claims", async t => { +test("offline project projection excludes credentials and active-session claims", async t => { const cwd = await temp(t), url = "http://127.0.0.1:1234/api/external-mcp/mcp"; await writeFile(join(cwd, ".mcp.json"), JSON.stringify({ mcpServers: { chatcut: { type: "http", url, headers: { "x-private": "PRIVATE_HEADER" }, env: { PRIVATE: "PRIVATE_ENV" } }, other: { url: "PRIVATE_OTHER" } } })); @@ -38,8 +36,7 @@ test("offline project projection excludes headers, environment, unrelated entrie assert.equal(report.serverUrl, url); assert.equal(report.hostSnapshot.configuration.enabled, null); assert.equal(report.hostSnapshot.session, undefined); - const all = JSON.stringify({ report, receipt }) + await readFile(receipt.chineseNote, "utf8"); - for (const secret of ["PRIVATE_HEADER", "PRIVATE_ENV", "PRIVATE_OTHER"]) assert.ok(!all.includes(secret)); + for (const secret of ["PRIVATE_HEADER", "PRIVATE_ENV", "PRIVATE_OTHER"]) assert.ok(!JSON.stringify({ report, receipt }).includes(secret)); }); test("native lookup requires opt-in and only exact absence can become not_found", async t => { @@ -53,83 +50,85 @@ test("native lookup requires opt-in and only exact absence can become not_found" } }); assert.equal(calls, 1); assert.equal(receipt.nativeLookupMayConnect, true); - assert.ok(receipt.findings.some(x => x.kind === "not_registered")); const report = JSON.parse(await readFile(receipt.report)); assert.equal(report.originalFailure.status, "unavailable"); - assert.equal(report.hostSnapshot.observations[0].message, absent); + assert.equal(report.hostSnapshot.observations[0].lookupResult, "not_found"); }); -test("saved visible errors are formatted by the program with known operation and unknown original IDs/time", async t => { - const cwd = await temp(t), originalError = join(cwd, "original.mcp-error.txt"); - await writeFile(originalError, "E_VISIBLE opaque failure; Authorization: Bearer SECRET\n"); - const receipt = await capture({ host: "claude", cwd, originalError, operation: "list_projects" }, deps); +test("explicit original preserves message/cause/HTTP despite invalid optional metadata", async t => { + const cwd = await temp(t), failureRecord = join(cwd, "visible.mcp-failure.json"); + const input = JSON.stringify({ schemaVersion: 1, source: "host_tool_result", serverName: "chatcut", + occurredAt: "unknown", callId: { invalid: true }, operation: "list_projects", + httpResponse: { source: "host_original_error_response", status: 200, headers: { "Content-Length": "12" }, body: "EXPOSED_BYTES", bodyComplete: false }, + error: { message: "E_VISIBLE opaque failure", cause: { code: "EXPOSED_CAUSE" } } }); + await writeFile(failureRecord, input); + const receipt = await capture({ host: "claude", cwd, failureRecord }, deps); const report = JSON.parse(await readFile(receipt.report)); - assert.equal(report.originalFailure.operation, "list_projects"); + assert.equal(report.collectorVersion, "1.2.6"); + assert.equal(report.originalFailure.message, "E_VISIBLE opaque failure"); + assert.equal(report.originalFailure.error.cause.code, "EXPOSED_CAUSE"); + assert.equal(report.originalFailure.httpResponse.body, "EXPOSED_BYTES"); assert.equal(report.originalFailure.occurredAt, null); assert.equal(report.originalFailure.callId, undefined); - assert.ok(report.originalFailure.message.includes("E_VISIBLE")); - assert.ok(!JSON.stringify(report).includes("SECRET")); - assert.ok(receipt.findings.some(x => x.kind === "tool_call_failed")); + assert.equal(await readFile(failureRecord, "utf8"), input); }); -test("native pre-render capture automatically includes an existing reserved original error record", async t => { - const cwd = await temp(t), path = join(cwd, "original.mcp-failure.json"); - const original = JSON.stringify({ schemaVersion: 1, source: "host_tool_result", serverName: "plugin:chatcut:chatcut", - occurredAt: null, operation: "list_projects", error: { message: "E_SAVED_VISIBLE", cause: { code: "EXPOSED_CAUSE" } } }); - await writeFile(path, original); - const receipt = await capture({ host: "claude", cwd }, deps); - const report = JSON.parse(await readFile(receipt.report)); - assert.equal(receipt.originalInput, path); - assert.equal(report.originalFailure.message, "E_SAVED_VISIBLE"); - assert.equal(report.originalFailure.operation, "list_projects"); - assert.equal(report.originalFailure.error.cause.code, "EXPOSED_CAUSE"); - assert.equal(report.originalFailure.occurredAt, null); - assert.equal(await readFile(path, "utf8"), original); +test("same original supports failure then success and concurrent observations without replacing bytes", async t => { + const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps); + const original = await readFile(receipt.report), result = join(cwd, "actual-tool-result.txt"); + const options = { report: receipt.report, host: "claude", result, operation: "list_projects", configScope: "project /fixture/.mcp.json" }; + await writeFile(result, "E_NATIVE_FAILURE"); + const failed = await recordRecheck({ ...options, outcome: "failed" }); + await writeFile(result, '{"projects":[]}'); + const successes = await Promise.all([recordRecheck({ ...options, outcome: "success" }), recordRecheck({ ...options, outcome: "success" })]); + assert.equal(new Set([failed.path, ...successes.map(x => x.path)]).size, 3); + for (const observation of [failed, ...successes]) { + assert.equal(observation.sourceReportSha256, receipt.sourceReportSha256); + assert.equal(observation.observedAt, null); + assert.equal(observation.callId, null); + assert.equal(observation.projectId, null); + assert.equal(observation.accountAuthentication, "unconfirmed"); + assert.equal(observation.recoveryVerified, false); + assert.equal(JSON.parse(await readFile(observation.path)).outcome, observation.outcome); + } + assert.deepEqual(await readFile(receipt.report), original); }); -test("native pre-render capture includes the reserved saved plain error without guessing an operation", async t => { - const cwd = await temp(t), path = join(cwd, "original.mcp-error.txt"); - await writeFile(path, "E_SAVED_TEXT"); - const receipt = await capture({ host: "claude", cwd }, deps); +test("explicit report remains the baseline across another host and any age; no implicit fallback", async t => { + const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps); const report = JSON.parse(await readFile(receipt.report)); - assert.equal(report.originalFailure.message, "E_SAVED_TEXT"); - assert.equal(report.originalFailure.operation, undefined); - assert.equal(receipt.originalInput, path); + report.collectedAt = "2000-01-01T00:00:00.000Z"; + await writeFile(receipt.report, JSON.stringify(report)); + const later = await capture({ host: "codex", cwd }, deps); + const result = join(cwd, "native-result.txt"); await writeFile(result, '{"projects":[]}'); + const observation = await recordRecheck({ report: receipt.report, host: "codex", result, operation: "list_projects", outcome: "success" }); + assert.equal(observation.diagnosticId, receipt.diagnosticId); + assert.notEqual(observation.diagnosticId, later.diagnosticId); + assert.equal(observation.host, "codex"); + assert.equal(observation.originalAttemptAssociation, "unconfirmed"); + await assert.rejects(recordRecheck({ activeCase: true, cwd, result }), /Explicit --report/); }); -test("safe recheck record binds an immutable original report and never overwrites a prior observation", async t => { +test("a different actual Hosted safe read is preserved without a hardcoded operation or invented schema", async t => { const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps); - const original = await readFile(receipt.report), result = join(cwd, "read.mcp-recheck.txt"); - await writeFile(result, '{"projects":[],"controlled":"owned unauthenticated fixture"}'); - const options = { report: receipt.report, result, operation: "list_projects", outcome: "success", scope: "Same project and isolated profile; explicit new session loaded project .mcp.json" }; - const recheck = await recordRecheck(options); - assert.equal(recheck.sourceReportSha256, receipt.sourceReportSha256); - assert.equal(recheck.observedAt, null); - assert.equal(recheck.accountAuthentication, "unconfirmed"); - assert.deepEqual(await readFile(receipt.report), original); - await assert.rejects(recordRecheck(options), /EEXIST/); - assert.deepEqual(await readFile(receipt.report), original); + const result = join(cwd, "native-result.txt"); + await writeFile(result, '{"projectId":"project-1","assets":[]}'); + const observation = await recordRecheck({ report: receipt.report, host: "claude", result, operation: "inspect_project", outcome: "success", projectId: "project-1" }); + assert.equal(observation.operation, "inspect_project"); + assert.equal(observation.projectId, "project-1"); + assert.equal(observation.recoveryVerified, false); }); -test("follow-up entry baseline cannot replace the active incident report for recheck", async t => { - const cwd = await temp(t), result = join(cwd, "read.mcp-recheck.txt"); - const first = await capture({ host: "claude", cwd, nativeLookup: true }, { ...deps, nativeRegistration: async () => ({ lookupResult: "not_found", message: "Exact native scoped no-entry response" }) }); - const baseline = await capture({ host: "claude", cwd }, deps); - assert.notEqual(baseline.diagnosticId, first.diagnosticId); - assert.equal(baseline.activeReport.diagnosticId, first.diagnosticId); - await writeFile(result, '{"projects":[]}'); - const recheck = await recordRecheck({ activeCase: true, cwd, result, operation: "list_projects", outcome: "success", scope: "same project; explicit new session" }); - assert.equal(recheck.diagnosticId, first.diagnosticId); - assert.equal(recheck.sourceReportSha256, first.sourceReportSha256); -}); - -test("registration/status text and configuration JSON cannot be recorded as a successful native project read", async t => { - const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps), result = join(cwd, "read.mcp-recheck.txt"); - const options = { report: receipt.report, result, operation: "list_projects", outcome: "success", scope: "same project" }; - for (const text of ['chatcut:\n Scope: Project config\n Status: Pending approval\n URL: http://127.0.0.1/', '{"name":"chatcut","transport":{"url":"http://127.0.0.1/"}}', '{"isError":true,"projects":[]}']) { +test("configuration, tool lists, HTTP probes, failed results and different endpoints cannot establish a native success", async t => { + const cwd = await temp(t), receipt = await capture({ host: "claude", cwd, serverUrl: "http://127.0.0.1:1234/api/external-mcp/mcp" }, deps); + const result = join(cwd, "native-result.txt"); + const options = { report: receipt.report, host: "claude", result, operation: "list_projects", outcome: "success" }; + for (const text of ['chatcut:\n Status: Connected', '{"name":"chatcut","transport":{"url":"http://127.0.0.1/"}}', '{"isError":true,"projects":[]}', '{"tools":[]}', '{"status":200,"body":"{}"}']) { await writeFile(result, text); - await assert.rejects(recordRecheck(options), /Native mcp get\/configuration text is not a tool result/); + await assert.rejects(recordRecheck(options), /actually exposed native tool result/); } - await writeFile(result, '{"structuredContent":{"projects":[]}}'); - assert.equal((await recordRecheck(options)).outcome, "success"); + await writeFile(result, '{"projects":[]}'); + await assert.rejects(recordRecheck({ ...options, source: "independent_http_probe" }), /not native tool results/); + await assert.rejects(recordRecheck({ ...options, serverUrl: "http://127.0.0.1:5678/api/external-mcp/mcp" }), /Different target service/); + await assert.rejects(recordRecheck({ ...options, observedAt: "yesterday" }), /Observed time/); }); diff --git a/tests/doctor-host-acceptance.mjs b/tests/doctor-host-acceptance.mjs index 6ba9afb..19dc899 100644 --- a/tests/doctor-host-acceptance.mjs +++ b/tests/doctor-host-acceptance.mjs @@ -140,7 +140,7 @@ async function setup(host) { await mkdir(join(dir, "home"), { recursive: true }); const skill = join(dir, host === "codex" ? values["codex-plugin"] ? "marketplace/codex/skills/chatcut-mcp-doctor" : ".agents/skills/chatcut-mcp-doctor" : "plugin/skills/chatcut-mcp-doctor"); await cp(join(repo, `${host}/skills/chatcut-mcp-doctor`), skill, { recursive: true }); - hostCommands.set(host, `node "${join(skill, "scripts/doctor.mjs").replaceAll("\\", "/")}" --host ${host} --server-url ${origin}/api/external-mcp/mcp --offline --output "${join(dir, "native.mcp-report.json").replaceAll("\\", "/")}"`); + hostCommands.set(host, `node "${join(skill, "scripts/mcp-doctor.mjs").replaceAll("\\", "/")}" --host ${host} --server-url ${origin}/api/external-mcp/mcp --offline --output "${join(dir, "native.mcp-report.json").replaceAll("\\", "/")}"`); if (values["tool-failure"]) hostCommands.set(host, hostCommands.get(host) + ` --failure-record "${join(dir, "native.mcp-failure.json").replaceAll("\\", "/")}" --host-snapshot "${join(dir, "native.mcp-snapshot.json").replaceAll("\\", "/")}"`); return { dir, skill, childEnv: env(dir) }; } @@ -169,7 +169,7 @@ async function registration(host, exe, context) { message: result.stderr || result.stdout, }] }, null); await writeFile(path, JSON.stringify(snapshot, null, 2)); - await cli(process.execPath, [join(context.skill, "scripts/doctor.mjs"), "--host", host, "--host-snapshot", path, "--offline", "--output", output], context, `${label}-doctor`); + await cli(process.execPath, [join(context.skill, "scripts/mcp-doctor.mjs"), "--host", host, "--host-snapshot", path, "--offline", "--output", output], context, `${label}-doctor`); const report = JSON.parse(await readFile(output)); const findings = diagnose(report).findings; assert.equal(findings.some((item) => item.kind === "not_registered"), lookupResult === "not_found"); @@ -253,7 +253,7 @@ try { await writeFile(join(context.dir, "skill-catalog.json"), JSON.stringify(catalog, null, 2)); const found = catalog.data.flatMap((item) => item.skills).find((item) => ["chatcut-mcp-doctor", "chatcut:chatcut-mcp-doctor"].includes(item.name)); assert.ok(found, "native skill catalog must discover standalone Doctor"); - hostCommands.set("codex", hostCommands.get("codex").replace(join(context.skill, "scripts/doctor.mjs").replaceAll("\\", "/"), join(found.path, "../scripts/doctor.mjs").replaceAll("\\", "/"))); + hostCommands.set("codex", hostCommands.get("codex").replace(join(context.skill, "scripts/mcp-doctor.mjs").replaceAll("\\", "/"), join(found.path, "../scripts/mcp-doctor.mjs").replaceAll("\\", "/"))); const thread = await request("thread/start", { cwd: context.dir, ephemeral: true, approvalPolicy: "never", sandbox: "danger-full-access" }); await request("turn/start", { threadId: thread.thread.id, input: [{ type: "text", text: "$chatcut-mcp-doctor Diagnose the controlled broken ChatCut MCP locally; no upload." }, { type: "skill", name: found.name, path: found.path }] }); const deadline = Date.now() + 35000; diff --git a/tests/doctor-upload.test.mjs b/tests/doctor-upload.test.mjs new file mode 100644 index 0000000..4b2973c --- /dev/null +++ b/tests/doctor-upload.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import test from "node:test"; +import { runDoctor } from "../codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; +import { uploadReport } from "../codex/skills/chatcut-mcp-doctor/scripts/doctor.mjs"; + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), "doctor-upload-owned-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, "original.json"); + await runDoctor({ host: "codex", serverUrl: "http://127.0.0.1:1234/api/external-mcp/mcp", offline: true, output: path }, { collectLogs: async () => ({ entries: [], gaps: [] }) }); + return { path, bytes: await readFile(path) }; +} + +test("concurrent saved-report transfers have one owner and one POST; original remains identical", async t => { + const { path, bytes } = await fixture(t); + let posts = 0, release, started; + const gate = new Promise(resolve => { release = resolve; }); + const began = new Promise(resolve => { started = resolve; }); + const fetchImpl = async () => { posts++; started(); await gate; return new Response('{"ok":true}'); }; + const first = uploadReport(path, { fetchImpl }); + await began; + await assert.rejects(uploadReport(path, { fetchImpl }), /already owned/); + release(); + const sent = await first; + assert.equal(sent.receipt.status, "ingest_accepted"); + assert.equal(sent.receipt.storageVerified, false); + await assert.rejects(uploadReport(path, { fetchImpl }), /already acknowledged/); + assert.equal(posts, 1); + assert.deepEqual(await readFile(path), bytes); +}); + +test("lost ACK preserves original cause and blocks blind resend", async t => { + const { path, bytes } = await fixture(t); let posts = 0; + const fetchImpl = async () => { posts++; throw Object.assign(new Error("original ACK read failed"), { cause: { code: "E_SOCKET" } }); }; + const sent = await uploadReport(path, { fetchImpl }); + assert.equal(sent.receipt.status, "failed"); + assert.equal(sent.receipt.deliveryUncertain, true); + assert.match(JSON.stringify(sent.receipt.error), /original ACK read failed/); + assert.match(JSON.stringify(sent.receipt.error), /E_SOCKET/); + await assert.rejects(uploadReport(path, { fetchImpl }), /delivery is uncertain/); + assert.equal(posts, 1); + assert.deepEqual(await readFile(path), bytes); +}); + +test("interrupted owner and persisted sending receipt never become automatic retries", async t => { + const { path } = await fixture(t); + await writeFile(path + ".upload.lock", "interrupted fixture owner", { flag: "wx" }); + await assert.rejects(uploadReport(path, { fetchImpl: () => assert.fail("must not send") }), /already owned/); + assert.equal(await readFile(path + ".upload.lock", "utf8"), "interrupted fixture owner"); +}); + +test("a complete failure response permits a separately requested retry, and source hash mismatch refuses it", async t => { + const { path } = await fixture(t); let posts = 0; + const failed = await uploadReport(path, { fetchImpl: async () => { posts++; return new Response("busy", { status: 503 }); } }); + assert.equal(failed.receipt.deliveryUncertain, false); + const changed = JSON.parse(await readFile(path)); changed.collectedAt = "2000-01-01T00:00:00Z"; + await writeFile(path, JSON.stringify(changed)); + await assert.rejects(uploadReport(path, { fetchImpl: () => assert.fail("must not send changed evidence") }), /does not match/); + assert.equal(posts, 1); +}); + +test("incomplete checkpoint is locally deliverable and never posted", async t => { + const { path } = await fixture(t); + const report = JSON.parse(await readFile(path)); report.collectionStatus = "in_progress"; + await writeFile(path, JSON.stringify(report)); const bytes = await readFile(path); + const sent = await uploadReport(path, { fetchImpl: () => assert.fail("must not send incomplete report") }); + assert.equal(sent.receipt.status, "failed"); + assert.equal(sent.receipt.deliveryUncertain, false); + assert.match(sent.receipt.error.message, /Incomplete collection/); + assert.deepEqual(await readFile(path), bytes); +}); + +test("legacy failed delivery without certainty evidence is not treated as permission to resend", async t => { + const { path } = await fixture(t); + const report = JSON.parse(await readFile(path)); + report.upload = { status: "failed", storageVerified: false, error: { message: "legacy connection closed" } }; + await writeFile(path, JSON.stringify(report)); + await assert.rejects(uploadReport(path, { fetchImpl: () => assert.fail("must not resend uncertain legacy evidence") }), /delivery is uncertain/); +}); diff --git a/tests/fixtures/owned-claude-mcp-error.log b/tests/fixtures/owned-claude-mcp-error.log new file mode 100644 index 0000000..a338579 --- /dev/null +++ b/tests/fixtures/owned-claude-mcp-error.log @@ -0,0 +1,2 @@ +2026-10-23T02:17:00.282Z [ERROR] MCP server "chatcut" TypeError: InvalidHTTPResponse fetching "http://127.0.0.1:57985/.well-known/oauth-protected-resource/api/external-mcp/mcp". For more information, pass `verbose: true` in the second argument to fetch() +2026-10-23T02:17:00.292Z [DEBUG] MCP server "chatcut": Connection failed after 59ms (InvalidHTTPResponse): InvalidHTTPResponse fetching "http://127.0.0.1:57985/.well-known/oauth-protected-resource[redacted]". For more information, pass `verbose: true` in the second argument to fetch() diff --git a/tests/host-failure-retention.test.mjs b/tests/host-failure-retention.test.mjs new file mode 100644 index 0000000..db70d79 --- /dev/null +++ b/tests/host-failure-retention.test.mjs @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + collectHostLogs, + runDoctor, + fragmentEvents, +} from "../codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs"; +import { hostFailureEvidence } from "../codex/skills/chatcut-plugin-basics/scripts/mcp-evidence.mjs"; + +async function fixture(t) { + const dir = await mkdtemp(join(tmpdir(), "chatcut-host-failures-")); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} +const now = Date.parse("2026-10-23T02:18:00Z"); +const options = (dir) => ({ + home: dir, + codexHome: dir, + codexSqliteHome: dir, + codexLogDir: dir, + codexAppLogDir: dir, + claudeConfigDir: dir, + now, +}); +const report = (logs) => ({ + originalFailure: { status: "unavailable", occurredAt: null }, + hostLogs: logs, +}); + +test("recorded Claude native failures survive later normal debug notices within the global bound", async (t) => { + const dir = await fixture(t); + await mkdir(join(dir, "debug")); + // Exact redacted native lines from the previously owned controlled report + // 0e9e0e2a-8807-44c6-98e6-27c9e3f57ff9; no new host call is made here. + const native = await readFile( + new URL("./fixtures/owned-claude-mcp-error.log", import.meta.url), + "utf8", + ); + const notices = Array.from( + { length: 45 }, + (_, index) => + `2026-10-23T02:17:${String(10 + index).padStart(2, "0")}.000Z [DEBUG] MCP server "chatcut": Normal native transport notice ${index}`, + ); + await writeFile(join(dir, "debug", "owned.txt"), native + notices.join("\n")); + const logs = await collectHostLogs("claude", options(dir)); + assert.equal(logs.entries.length, 30); + assert.ok(logs.entries.some((entry) => entry.message === native.trimEnd().split("\n")[0])); + assert.ok(logs.entries.some((entry) => entry.message.includes("Connection failed after 59ms"))); + const candidates = hostFailureEvidence(report(logs)); + assert.equal(candidates.length, 2); + assert.equal(candidates[0].loggedAt, "2026-10-23T02:17:00.282Z"); + assert.equal(candidates[0].localCallBinding, "unconfirmed; may_not_be_the_reported_failure"); + assert.ok(logs.gaps.some((gap) => gap.reason === "failure_prioritized_log_budget")); +}); + +test("two nearby same-conversation failures keep separate call bindings and never fill original time", async (t) => { + const dir = await fixture(t); + const line = (call, second) => + `2026-10-23T02:17:0${second}Z ERROR codex_mcp::rmcp_client: request{server_name=chatcut thread_id=thread-original call_id=${call}}: Original failure E_${call}\n Caused by: native cause token=PRIVATE_TOKEN\n`; + await writeFile(join(dir, "codex-tui.log"), line("call-one", 1) + line("call-two", 2)); + const logs = await collectHostLogs("codex", options(dir)); + const failure = join(dir, "original.mcp-failure.json"); + await writeFile( + failure, + JSON.stringify({ + schemaVersion: 1, + source: "host_tool_result", + serverName: "chatcut", + occurredAt: null, + conversationId: "thread-original", + callId: "call-one", + operation: "invalid operation", + error: { message: "E_call-one", cause: { message: "original cause" } }, + }), + ); + const { report: saved } = await runDoctor( + { host: "codex", failureRecord: failure, offline: true, output: join(dir, "report.json") }, + { collectLogs: async () => logs }, + ); + const evidence = saved.evidenceSummary.failureEvidence; + assert.equal(evidence.length, 2); + assert.match( + evidence.find((entry) => entry.callId === "call-one").localCallBinding, + /^same_conversation_and_call_id/, + ); + assert.match( + evidence.find((entry) => entry.callId === "call-two").localCallBinding, + /^unconfirmed/, + ); + assert.equal(saved.originalFailure.occurredAt, null); + assert.equal(saved.originalFailure.error.cause.message, "original cause"); + assert.equal(saved.originalFailure.operation, undefined); + assert.equal(saved.originalFailure.requestId, undefined); + assert.equal(saved.evidenceSummary.originalRequestCorrelation.status, "unconfirmed"); + assert.ok(!JSON.stringify(saved).includes("PRIVATE_TOKEN")); + assert.equal(evidence[0].loggedAt, "2026-10-23T02:17:01.000Z"); + assert.ok(evidence.every((entry) => entry.originalAttemptMatch === "unconfirmed")); + const rebuilt = JSON.parse( + fragmentEvents(saved) + .map((event) => event.properties.attributes.mcpDoctor.fragment) + .join(""), + ); + assert.deepEqual(rebuilt, saved); +}); + +test("normal notices, other servers and content echoes do not become failure evidence", async (t) => { + const dir = await fixture(t); + await writeFile( + join(dir, "codex-tui.log"), + [ + "2026-10-23T02:17:01Z ERROR codex_mcp::rmcp_client: request{server_name=other}: PRIVATE_OTHER", + '2026-10-23T02:17:01Z ERROR codex_mcp::rmcp_client: request{server_name=chatcut}: tool result: {"content":[{"text":"PRIVATE_ECHO"}]}', + "2026-10-23T02:17:01Z ERROR codex_core::tools: user prompt: PRIVATE_CONVERSATION server_name=chatcut", + '2026-10-23T02:17:01Z INFO codex_mcp::rmcp_client: MCP server "chatcut": starting connection', + ].join("\n"), + ); + const logs = await collectHostLogs("codex", options(dir)); + assert.equal(hostFailureEvidence(report(logs)).length, 0); + assert.ok(!JSON.stringify(logs).includes("PRIVATE_")); + const app = report({ + entries: [{ source: "codex_app_log", message: "status=starting error=null" }], + gaps: [], + }); + assert.deepEqual(hostFailureEvidence(app), []); +}); + +test("missing and malformed log times remain unknown while full admitted errors are preserved", async (t) => { + const dir = await fixture(t); + await writeFile( + join(dir, "codex-tui.log"), + [ + 'MCP server "chatcut": Error E_NO_TIME', + '2026-99-99T02:17:01Z ERROR codex_mcp::rmcp_client: MCP server "chatcut": Error E_BAD_TIME', + ].join("\n"), + ); + const logs = await collectHostLogs("codex", options(dir)); + const evidence = hostFailureEvidence(report(logs)); + assert.ok(logs.entries.some((entry) => entry.message.includes("E_NO_TIME"))); + assert.ok(evidence.some((entry) => entry.loggedAt === null)); + assert.ok(evidence.every((entry) => entry.loggedAt === null)); +}); + +test("native failed item references preserve error objects and never masquerade as a server ID", () => { + const sample = report({ + entries: [], + nativeFailedCalls: [ + { + status: "failed", + source: "codex_native_failed_mcp_items", + nativeItemId: "native-item", + conversationId: "thread-original", + idKind: "native_item_only", + startedAtMs: now - 1000, + completedAtMs: now, + error: { message: "E_NATIVE", cause: { code: "ECONNRESET" } }, + }, + ], + }); + const evidence = hostFailureEvidence(sample); + assert.equal(evidence[0].errorText, "hostLogs.nativeFailedCalls[0].error"); + assert.equal(evidence[0].nativeItemId, "native-item"); + assert.equal(evidence[0].requestId, undefined); + assert.equal(sample.originalFailure.status, "unavailable"); +}); + +test("the combined host-log output prioritizes complete failure blocks within 30 records and 128 KiB", async (t) => { + const dir = await fixture(t); + await mkdir(join(dir, "debug")); + for (let index = 0; index < 3; index++) { + const line = `2026-10-23T02:17:0${index}Z [ERROR] MCP server "chatcut": E_FULL_${index} ${"X".repeat(90000)} E_UNTRIMMED_TAIL_${index}\n`; + await writeFile(join(dir, "debug", `error-${index}.txt`), line); + } + const logs = await collectHostLogs("claude", options(dir)); + assert.equal(logs.entries.length, 1); + assert.match(logs.entries[0].message, /E_UNTRIMMED_TAIL_\d$/); + assert.ok( + logs.entries.reduce((bytes, entry) => bytes + Buffer.byteLength(entry.message), 0) <= + 128 * 1024, + ); + assert.ok( + logs.gaps.some( + (gap) => + gap.source === "host_logs" && + gap.reason === "failure_prioritized_log_budget" && + gap.omittedFailureRecords === 2, + ), + ); +}); diff --git a/tests/mcp-doctor.test.mjs b/tests/mcp-doctor.test.mjs index 19c178b..0ac3288 100644 --- a/tests/mcp-doctor.test.mjs +++ b/tests/mcp-doctor.test.mjs @@ -476,7 +476,7 @@ for (const [host, folder, manifest] of [ assert.equal(receipt.upload.status, "ingest_accepted"); assert.equal(receipt.upload.storageVerified, false); const report = JSON.parse(await readFile(output)); - assert.equal(report.collectorVersion, "1.3.0"); + assert.equal(report.collectorVersion, "1.2.6"); assert.equal(initializeClientVersion, report.collectorVersion); assert.equal(uploadedCollectorVersion, report.collectorVersion); assert.equal(report.host, host); @@ -731,7 +731,7 @@ test("App fallback remains bounded and does not promote host notices into the or codexLogDir: join(dir, "empty-tui"), output: join(dir, "report.json"), }); - assert.equal(report.collectorVersion, "1.3.0"); + assert.equal(report.collectorVersion, "1.2.6"); assert.equal( report.hostLogs.entries.filter((entry) => entry.source === "codex_app_log").length, 30, diff --git a/tests/native-failure-index.test.mjs b/tests/native-failure-index.test.mjs index 2e5fbb3..7eddb6d 100644 --- a/tests/native-failure-index.test.mjs +++ b/tests/native-failure-index.test.mjs @@ -147,7 +147,7 @@ test("an explicit retained native stderr tail preserves log time and errors with 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 report = { diagnosticId: "00000000-0000-0000-0000-000000000001", host: "codex", collectorVersion: "1.3.0", + const report = { diagnosticId: "00000000-0000-0000-0000-000000000001", host: "codex", collectorVersion: "1.2.6", originalFailure: { status: "unavailable", occurredAt: null }, hostLogs: logs, gaps: [] }; const diagnosis = diagnose(report); assert.ok(diagnosis.findings.some(f => f.kind === "host_runtime_error_unassociated" && f.observed.level === "WARN" && f.observed.loggedAt === found[0].loggedAt)); diff --git a/tests/standalone-doctor.test.mjs b/tests/standalone-doctor.test.mjs index 2bd094f..414aefa 100644 --- a/tests/standalone-doctor.test.mjs +++ b/tests/standalone-doctor.test.mjs @@ -15,19 +15,14 @@ const exec = promisify(execFile); const skill = fileURLToPath(new URL("../codex/skills/chatcut-mcp-doctor/", import.meta.url)); const noLogs = async () => ({ entries: [], gaps: [{ source: "fixture", reason: "logs_unavailable" }] }); -test("Doctor reuses its dedicated short report names and rejects unusable output names before collection", async t => { +test("existing reports are identified by content, not a required filename", async t => { const dir = await temp(t), helper = join(skill, "scripts/doctor.mjs"); - for (const name of ["mcp-report.json", "mcp-report-2.json"]) { - const output = join(dir, name); - await cli(helper, ["--host", "codex", "--offline", "--output", output], dir); - const before = await readFile(output, "utf8"), id = JSON.parse(before).diagnosticId; - await cli(helper, ["--report", output], dir); - assert.equal(await readFile(output, "utf8"), before); - assert.equal(JSON.parse(await readFile(output + ".diagnosis.json", "utf8")).diagnosticId, id); - } - await assert.rejects(cli(helper, ["--host", "codex", "--offline", "--output", join(dir, "config.json")], dir), /saved report can be reused/); - await assert.rejects(cli(helper, ["--host", "codex", "--offline", `--output=${join(dir, "config.json")}`], dir), /saved report can be reused/); - assert.ok(!(await readdir(dir)).includes("config.json")); + const output = join(dir, "user-selected-evidence.json"); + await runDoctor({ host: "codex", offline: true, output }, { collectLogs: noLogs }); + const before = await readFile(output, "utf8"); + await cli(helper, ["--report", output], dir); + assert.equal(await readFile(output, "utf8"), before); + await assert.rejects(cli(helper, ["--host", "codex", "--upload"], dir), /Collect only when needed/); }); async function temp(t) { const dir = await mkdtemp(join(tmpdir(), "chatcut-standalone-doctor-")); @@ -52,17 +47,17 @@ test("standalone copied skill runs offline without a parent plugin or a live MCP let requests = 0; const origin = await service(t, (_, res) => { requests++; res.end("{}"); }); const output = join(dir, "offline.mcp-report.json"); - await cli(join(dir, "doctor/scripts/doctor.mjs"), ["--host", "codex", "--server-url", `${origin}/api/external-mcp/mcp`, "--offline", "--upload", "--output", output], dir); + await cli(join(dir, "doctor/scripts/mcp-doctor.mjs"), ["--host", "codex", "--server-url", `${origin}/api/external-mcp/mcp`, "--offline", "--upload", "--output", output], dir); const report = JSON.parse(await readFile(output)); assert.equal(requests, 0); - assert.equal(report.collectorVersion, "1.3.0"); + assert.equal(report.collectorVersion, "1.2.6"); assert.equal(report.upload.status, "skipped_offline"); assert.equal(report.probes.length, 0); assert.ok(report.gaps.some((gap) => gap.source === "plugin_version")); - for (const suffix of [".diagnosis.json", ".summary.md", ".issue-draft.md"]) assert.ok((await readFile(output + suffix)).length > 0); const before = await readFile(output, "utf8"); await cli(join(dir, "doctor/scripts/doctor.mjs"), ["--report", output], dir); assert.equal(await readFile(output, "utf8"), before); + for (const suffix of [".diagnosis.json", ".summary.md", ".issue-draft.md"]) assert.ok((await readFile(output + suffix)).length > 0); assert.equal(requests, 0); }); @@ -111,14 +106,14 @@ test("startup, post-connection tool failure, missing registration and truncated } }); -test("unsupported endpoint yields a local report without retargeting or any requests", async (t) => { +test("unsupported endpoint preserves the local report without retargeting or any requests", async t => { const dir = await temp(t); const { report } = await runDoctor({ host: "codex", serverUrl: "https://unrecognized.invalid/api/external-mcp/mcp", upload: true, output: join(dir, "unsupported.mcp-report.json") }, { collectLogs: noLogs, fetchImpl: () => assert.fail("must not send"), }); assert.equal(report.serverUrl, null); assert.equal(report.upload.status, "skipped_endpoint_unavailable"); - assert.ok(report.gaps.some((gap) => gap.reason.startsWith("unsupported_endpoint"))); + assert.ok(report.gaps.some(gap => gap.reason.startsWith("unsupported_endpoint"))); }); test("failed ingest can resend the same saved report once, without probes or report mutations", async (t) => { @@ -226,8 +221,8 @@ test("SQLite and text runtime records exclude content echoes from permitted MCP test("both packages ship identical standalone executable resources and references", async () => { async function compare(relative = "") { for (const entry of await readdir(join(skill, relative), { withFileTypes: true })) { - // Claude uses native dynamic capture syntax; executable resources stay identical. - if (["agents", "SKILL.md"].includes(entry.name)) continue; + // Host catalog metadata differs; packaged evidence capabilities stay identical. + if (["agents"].includes(entry.name)) continue; const path = join(relative, entry.name); if (entry.isDirectory()) await compare(path); else assert.deepEqual(await readFile(join(skill, path)), await readFile(new URL(`../claude/skills/chatcut-mcp-doctor/${path.replaceAll("\\", "/")}`, import.meta.url))); diff --git a/tests/windows-db-fallback.test.mjs b/tests/windows-db-fallback.test.mjs index a8e47f2..9436cf4 100644 --- a/tests/windows-db-fallback.test.mjs +++ b/tests/windows-db-fallback.test.mjs @@ -3,11 +3,26 @@ 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 { runDoctor } from "../codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs"; -import { diagnose, renderPacket } from "../codex/skills/chatcut-mcp-doctor/scripts/diagnosis.mjs"; +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.6"); + 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 })); @@ -46,9 +61,6 @@ test("database errors do not replace the visible original error or independent A assert.equal(coverage.retainedStderrRecords, 0); assert.equal(report.upload.status, "skipped_offline"); assert.deepEqual(JSON.parse(await readFile(path, "utf8")).originalFailure.error, error); - const packet = renderPacket(report, diagnose(report)); - assert.ok(packet.summary.includes("original error retained") && packet.summary.includes("14")); - assert.ok(!packet.draft.includes("PRIVATE_APP_ERROR")); }); test("an unexpected runtime collector exception still produces the saved original error and explicit coverage", async t => { @@ -71,7 +83,4 @@ test("database failure without an exposed original error is not called successfu assert.equal(report.evidenceSummary.hostLogCoverage.originalErrorRetained, false); assert.equal(report.evidenceSummary.hostLogCoverage.originalErrorRetainedWithoutDatabase, false); assert.equal(report.originalFailure.occurredAt, null); - const packet = renderPacket(report, diagnose(report)); - assert.ok(packet.summary.includes("original error unavailable")); - assert.ok(packet.summary.includes("an unexposed startup error still needs an accessible source")); }); From dd194495e6917093cc4b601e5ff1d6dd836fd8c7 Mon Sep 17 00:00:00 2001 From: IanYet Date: Mon, 26 Oct 2026 21:17:53 +0800 Subject: [PATCH 16/16] Keep unacknowledged delivery uncertain and accept native content results --- claude/skills/chatcut-mcp-doctor/SKILL.md | 2 +- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 5 ++- .../scripts/record-recheck.mjs | 2 -- .../scripts/mcp-doctor.mjs | 5 ++- codex/skills/chatcut-mcp-doctor/SKILL.md | 2 +- .../chatcut-mcp-doctor/scripts/mcp-doctor.mjs | 5 ++- .../scripts/record-recheck.mjs | 2 -- .../scripts/mcp-doctor.mjs | 5 ++- tests/doctor-capture.test.mjs | 18 +++++++++++ tests/doctor-upload.test.mjs | 31 +++++++++++++++++-- tests/standalone-doctor.test.mjs | 27 ++++++++-------- 11 files changed, 70 insertions(+), 34 deletions(-) diff --git a/claude/skills/chatcut-mcp-doctor/SKILL.md b/claude/skills/chatcut-mcp-doctor/SKILL.md index 676233d..810e909 100644 --- a/claude/skills/chatcut-mcp-doctor/SKILL.md +++ b/claude/skills/chatcut-mcp-doctor/SKILL.md @@ -27,6 +27,6 @@ Save only the actually exposed tool result in a separate file, then record it ag node "/scripts/record-recheck.mjs" --report "" --host --result "" --operation --outcome --server-url --config-scope "" ``` -Add known `--project-id`, local `--conversation-id`, `--call-id`, `--observed-at`; omit unknown facts. Never copy an MCP session credential as a conversation ID. Choose the safe operation from the current tool schema; the recorder does not execute tools or impose a fixed tool list. If the host does not expose a structured result, retain the gap rather than manufacturing one. Each invocation saves another observation. It is Agent supplied, not independent proof of the original attempt or recovery. A new host/session is a separate boundary to disclose, not automatic continuation of the old one. No actual result or no saved observation means recheck incomplete. +Add known `--project-id`, local `--conversation-id`, `--call-id`, `--observed-at`; omit unknown facts. Never copy an MCP session credential as a conversation ID. Choose the safe operation from the current tool schema; the recorder does not execute tools or impose a fixed tool list. A native MCP content-only result is usable as exposed; do not fabricate projects[] or structuredContent to satisfy a result format. If no actual tool result is exposed, retain that gap. Each invocation saves another observation. It is Agent supplied, not independent proof of the original attempt or recovery. A new host/session is a separate boundary to disclose, not automatic continuation of the old one. No actual result or no saved observation means recheck incomplete. Explain the symptom, evidence, actual action, actual recheck and remaining unknowns in the user's language. Deliver the existing local report, repair note and recheck files even when upload is unavailable. Optional `doctor.mjs --report ` derives feedback from the unchanged report; it does not recollect. Authorized transfer uses `doctor.mjs --upload-report ` and a separate SHA-bound receipt. Do not resend acknowledged or uncertain delivery. Acceptance is not complete storage; failed upload retains the reason and local files. No ticket, publishing or messaging without authorization. diff --git a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 6c1e7b9..57adc29 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -1200,7 +1200,6 @@ export async function submitReport(report, fetchImpl = fetch) { return { status: "skipped_intentionally_disabled", storageVerified: false }; if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; let attempted = false; - let rejected = false; try { const endpoint = serverUrl(report.serverUrl); const events = fragmentEvents(report); @@ -1209,14 +1208,14 @@ export async function submitReport(report, fetchImpl = fetch) { method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "content-type": "application/json" }, body: JSON.stringify(events), }); - rejected = !response.ok; const body = await limitedBody(response, 16 * 1024); if (!response.ok || JSON.parse(body).ok !== true) throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); return { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256 }; } catch (error) { - return { status: "failed", storageVerified: false, deliveryUncertain: attempted && !rejected, error: errorDetails(error) }; + // HTTP status alone cannot prove that no fragment was persisted downstream. + return { status: "failed", storageVerified: false, deliveryUncertain: attempted, error: errorDetails(error) }; } } diff --git a/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs index fb6a2a6..37be051 100644 --- a/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs +++ b/claude/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -38,8 +38,6 @@ export async function recordRecheck(options) { data.mcpServers || data.transport || data.tools || data.configuration || (typeof data.status === "number" && (data.body !== undefined || data.headers !== undefined))) throw new Error("Need the actually exposed native tool result; configuration, tool lists and independent probes are not a successful recheck"); - if (options.operation === "list_projects" && !Array.isArray(data.projects ?? data.structuredContent?.projects)) - throw new Error("The exposed list_projects result must contain projects[]; a configuration response is not a project listing"); } if (options.observedAt !== undefined && (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.observedAt) || !Number.isFinite(Date.parse(options.observedAt)))) 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 6c1e7b9..57adc29 100644 --- a/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs +++ b/claude/skills/chatcut-plugin-basics-claude/scripts/mcp-doctor.mjs @@ -1200,7 +1200,6 @@ export async function submitReport(report, fetchImpl = fetch) { return { status: "skipped_intentionally_disabled", storageVerified: false }; if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; let attempted = false; - let rejected = false; try { const endpoint = serverUrl(report.serverUrl); const events = fragmentEvents(report); @@ -1209,14 +1208,14 @@ export async function submitReport(report, fetchImpl = fetch) { method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "content-type": "application/json" }, body: JSON.stringify(events), }); - rejected = !response.ok; const body = await limitedBody(response, 16 * 1024); if (!response.ok || JSON.parse(body).ok !== true) throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); return { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256 }; } catch (error) { - return { status: "failed", storageVerified: false, deliveryUncertain: attempted && !rejected, error: errorDetails(error) }; + // HTTP status alone cannot prove that no fragment was persisted downstream. + return { status: "failed", storageVerified: false, deliveryUncertain: attempted, error: errorDetails(error) }; } } diff --git a/codex/skills/chatcut-mcp-doctor/SKILL.md b/codex/skills/chatcut-mcp-doctor/SKILL.md index 676233d..810e909 100644 --- a/codex/skills/chatcut-mcp-doctor/SKILL.md +++ b/codex/skills/chatcut-mcp-doctor/SKILL.md @@ -27,6 +27,6 @@ Save only the actually exposed tool result in a separate file, then record it ag node "/scripts/record-recheck.mjs" --report "" --host --result "" --operation --outcome --server-url --config-scope "" ``` -Add known `--project-id`, local `--conversation-id`, `--call-id`, `--observed-at`; omit unknown facts. Never copy an MCP session credential as a conversation ID. Choose the safe operation from the current tool schema; the recorder does not execute tools or impose a fixed tool list. If the host does not expose a structured result, retain the gap rather than manufacturing one. Each invocation saves another observation. It is Agent supplied, not independent proof of the original attempt or recovery. A new host/session is a separate boundary to disclose, not automatic continuation of the old one. No actual result or no saved observation means recheck incomplete. +Add known `--project-id`, local `--conversation-id`, `--call-id`, `--observed-at`; omit unknown facts. Never copy an MCP session credential as a conversation ID. Choose the safe operation from the current tool schema; the recorder does not execute tools or impose a fixed tool list. A native MCP content-only result is usable as exposed; do not fabricate projects[] or structuredContent to satisfy a result format. If no actual tool result is exposed, retain that gap. Each invocation saves another observation. It is Agent supplied, not independent proof of the original attempt or recovery. A new host/session is a separate boundary to disclose, not automatic continuation of the old one. No actual result or no saved observation means recheck incomplete. Explain the symptom, evidence, actual action, actual recheck and remaining unknowns in the user's language. Deliver the existing local report, repair note and recheck files even when upload is unavailable. Optional `doctor.mjs --report ` derives feedback from the unchanged report; it does not recollect. Authorized transfer uses `doctor.mjs --upload-report ` and a separate SHA-bound receipt. Do not resend acknowledged or uncertain delivery. Acceptance is not complete storage; failed upload retains the reason and local files. No ticket, publishing or messaging without authorization. diff --git a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs index 6c1e7b9..57adc29 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/mcp-doctor.mjs @@ -1200,7 +1200,6 @@ export async function submitReport(report, fetchImpl = fetch) { return { status: "skipped_intentionally_disabled", storageVerified: false }; if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; let attempted = false; - let rejected = false; try { const endpoint = serverUrl(report.serverUrl); const events = fragmentEvents(report); @@ -1209,14 +1208,14 @@ export async function submitReport(report, fetchImpl = fetch) { method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "content-type": "application/json" }, body: JSON.stringify(events), }); - rejected = !response.ok; const body = await limitedBody(response, 16 * 1024); if (!response.ok || JSON.parse(body).ok !== true) throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); return { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256 }; } catch (error) { - return { status: "failed", storageVerified: false, deliveryUncertain: attempted && !rejected, error: errorDetails(error) }; + // HTTP status alone cannot prove that no fragment was persisted downstream. + return { status: "failed", storageVerified: false, deliveryUncertain: attempted, error: errorDetails(error) }; } } diff --git a/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs index fb6a2a6..37be051 100644 --- a/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs +++ b/codex/skills/chatcut-mcp-doctor/scripts/record-recheck.mjs @@ -38,8 +38,6 @@ export async function recordRecheck(options) { data.mcpServers || data.transport || data.tools || data.configuration || (typeof data.status === "number" && (data.body !== undefined || data.headers !== undefined))) throw new Error("Need the actually exposed native tool result; configuration, tool lists and independent probes are not a successful recheck"); - if (options.operation === "list_projects" && !Array.isArray(data.projects ?? data.structuredContent?.projects)) - throw new Error("The exposed list_projects result must contain projects[]; a configuration response is not a project listing"); } if (options.observedAt !== undefined && (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(options.observedAt) || !Number.isFinite(Date.parse(options.observedAt)))) diff --git a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs index 6c1e7b9..57adc29 100644 --- a/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs +++ b/codex/skills/chatcut-plugin-basics/scripts/mcp-doctor.mjs @@ -1200,7 +1200,6 @@ export async function submitReport(report, fetchImpl = fetch) { return { status: "skipped_intentionally_disabled", storageVerified: false }; if (!report.serverUrl) return { status: "skipped_endpoint_unavailable", storageVerified: false }; let attempted = false; - let rejected = false; try { const endpoint = serverUrl(report.serverUrl); const events = fragmentEvents(report); @@ -1209,14 +1208,14 @@ export async function submitReport(report, fetchImpl = fetch) { method: "POST", redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS), headers: { "content-type": "application/json" }, body: JSON.stringify(events), }); - rejected = !response.ok; const body = await limitedBody(response, 16 * 1024); if (!response.ok || JSON.parse(body).ok !== true) throw new Error(`Diagnostic ingest rejected: HTTP ${response.status}; ${body}`); return { status: "ingest_accepted", storageVerified: false, fragmentCount: events.length, reportSha256: events[0].properties.attributes.mcpDoctor.reportSha256 }; } catch (error) { - return { status: "failed", storageVerified: false, deliveryUncertain: attempted && !rejected, error: errorDetails(error) }; + // HTTP status alone cannot prove that no fragment was persisted downstream. + return { status: "failed", storageVerified: false, deliveryUncertain: attempted, error: errorDetails(error) }; } } diff --git a/tests/doctor-capture.test.mjs b/tests/doctor-capture.test.mjs index c79f361..22e0485 100644 --- a/tests/doctor-capture.test.mjs +++ b/tests/doctor-capture.test.mjs @@ -109,6 +109,24 @@ test("explicit report remains the baseline across another host and any age; no i await assert.rejects(recordRecheck({ activeCase: true, cwd, result }), /Explicit --report/); }); +test("content-only native MCP results append after failure without manufacturing projects or structuredContent", async t => { + const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps); + const original = await readFile(receipt.report), result = join(cwd, "native-result.json"); + const options = { report: receipt.report, host: "claude", result, operation: "list_projects" }; + await writeFile(result, JSON.stringify({ content: [{ type: "text", text: "original native read failed" }], isError: true })); + const failed = await recordRecheck({ ...options, outcome: "failed" }); + const exposed = { content: [{ type: "text", text: "actual native safe-read result; no structured fields exposed" }], isError: false }; + await writeFile(result, JSON.stringify(exposed)); + const succeeded = await recordRecheck({ ...options, outcome: "success" }); + assert.notEqual(failed.path, succeeded.path); + assert.equal(succeeded.sourceReportSha256, receipt.sourceReportSha256); + assert.equal(failed.sourceReportSha256, succeeded.sourceReportSha256); + assert.deepEqual(JSON.parse(succeeded.message), exposed); + assert.equal(succeeded.recoveryVerified, false); + assert.equal(succeeded.originalAttemptAssociation, "unconfirmed"); + assert.deepEqual(await readFile(receipt.report), original); +}); + test("a different actual Hosted safe read is preserved without a hardcoded operation or invented schema", async t => { const cwd = await temp(t), receipt = await capture({ host: "claude", cwd }, deps); const result = join(cwd, "native-result.txt"); diff --git a/tests/doctor-upload.test.mjs b/tests/doctor-upload.test.mjs index 4b2973c..9422825 100644 --- a/tests/doctor-upload.test.mjs +++ b/tests/doctor-upload.test.mjs @@ -52,10 +52,10 @@ test("interrupted owner and persisted sending receipt never become automatic ret assert.equal(await readFile(path + ".upload.lock", "utf8"), "interrupted fixture owner"); }); -test("a complete failure response permits a separately requested retry, and source hash mismatch refuses it", async t => { +test("source hash mismatch refuses a changed report before any subsequent send", async t => { const { path } = await fixture(t); let posts = 0; const failed = await uploadReport(path, { fetchImpl: async () => { posts++; return new Response("busy", { status: 503 }); } }); - assert.equal(failed.receipt.deliveryUncertain, false); + assert.equal(failed.receipt.deliveryUncertain, true); const changed = JSON.parse(await readFile(path)); changed.collectedAt = "2000-01-01T00:00:00Z"; await writeFile(path, JSON.stringify(changed)); await assert.rejects(uploadReport(path, { fetchImpl: () => assert.fail("must not send changed evidence") }), /does not match/); @@ -70,6 +70,9 @@ test("incomplete checkpoint is locally deliverable and never posted", async t => assert.equal(sent.receipt.status, "failed"); assert.equal(sent.receipt.deliveryUncertain, false); assert.match(sent.receipt.error.message, /Incomplete collection/); + const retried = await uploadReport(path, { fetchImpl: () => assert.fail("no-attempt retry must still not send an incomplete report") }); + assert.equal(retried.receipt.deliveryUncertain, false); + assert.equal(retried.sourceReportSha256, sent.sourceReportSha256); assert.deepEqual(await readFile(path), bytes); }); @@ -80,3 +83,27 @@ test("legacy failed delivery without certainty evidence is not treated as permis await writeFile(path, JSON.stringify(report)); await assert.rejects(uploadReport(path, { fetchImpl: () => assert.fail("must not resend uncertain legacy evidence") }), /delivery is uncertain/); }); + +for (const [label, response] of [ + ["HTTP 500", () => new Response("upstream failed after ingest", { status: 500 })], + ["HTTP 503", () => new Response("upstream busy", { status: 503 })], + ["missing success ACK", () => new Response("{}")], + ["malformed ACK", () => new Response("{\"ok\":")], + ["interrupted ACK body", () => new Response(new ReadableStream({ start(controller) { + controller.enqueue(new TextEncoder().encode('{"ok":')); + controller.error(Object.assign(new Error("original response body interrupted"), { cause: { code: "E_ACK_BODY" } })); + } }))], +]) test(label + " cannot prove no-write and blocks resend while retaining original evidence", async t => { + const { path, bytes } = await fixture(t); let posts = 0; + const fetchImpl = async () => { posts++; return response(); }; + const sent = await uploadReport(path, { fetchImpl }); + assert.equal(sent.receipt.status, "failed"); + assert.equal(sent.receipt.deliveryUncertain, true); + assert.equal(sent.receipt.storageVerified, false); + if (label === "interrupted ACK body") assert.match(JSON.stringify(sent.receipt.error), /E_ACK_BODY/); + const saved = await readFile(path + ".upload.json"); + await assert.rejects(uploadReport(path, { fetchImpl }), /delivery is uncertain/); + assert.equal(posts, 1); + assert.deepEqual(await readFile(path), bytes); + assert.deepEqual(await readFile(path + ".upload.json"), saved); +}); diff --git a/tests/standalone-doctor.test.mjs b/tests/standalone-doctor.test.mjs index 414aefa..0c54fdb 100644 --- a/tests/standalone-doctor.test.mjs +++ b/tests/standalone-doctor.test.mjs @@ -116,32 +116,31 @@ test("unsupported endpoint preserves the local report without retargeting or any assert.ok(report.gaps.some(gap => gap.reason.startsWith("unsupported_endpoint"))); }); -test("failed ingest can resend the same saved report once, without probes or report mutations", async (t) => { +test("an HTTP 503 leaves delivery uncertain and cannot trigger another POST", async (t) => { const dir = await temp(t); - let posts = 0; - let reject = true; - let received; + let posts = 0, reject = true, received; const origin = await service(t, (req, res) => { assert.equal(req.url, "/telemetry/events"); assert.equal(req.headers.authorization, undefined); posts++; let body = ""; - req.on("data", (chunk) => body += chunk); + req.on("data", chunk => body += chunk); req.on("end", () => { received = JSON.parse(body); res.writeHead(reject ? 503 : 200); res.end(reject ? "busy" : '{"ok":true}'); }); }); - const output = join(dir, "retry.mcp-report.json"); - await runDoctor({ host: "codex", serverUrl: `${origin}/api/external-mcp/mcp`, offline: true, output }, { collectLogs: noLogs }); + const output = join(dir, "uncertain.mcp-report.json"); + await runDoctor({ host: "codex", serverUrl: origin + "/api/external-mcp/mcp", offline: true, output }, { collectLogs: noLogs }); const before = await readFile(output, "utf8"); await cli(join(skill, "scripts/doctor.mjs"), ["--upload-report", output], dir); - assert.equal(JSON.parse(await readFile(output + ".upload.json")).receipt.status, "failed"); - reject = false; - await cli(join(skill, "scripts/doctor.mjs"), ["--upload-report", output], dir); - assert.equal(JSON.parse(await readFile(output + ".upload.json")).receipt.storageVerified, false); - assert.equal(JSON.parse(await readFile(output + ".upload.json")).receipt.status, "ingest_accepted"); + const saved = JSON.parse(await readFile(output + ".upload.json")); + assert.equal(saved.receipt.status, "failed"); + assert.equal(saved.receipt.deliveryUncertain, true); + assert.equal(saved.receipt.storageVerified, false); assert.equal(received[0].properties.attributes.mcpDoctor.diagnosticId, JSON.parse(before).diagnosticId); + reject = false; + await assert.rejects(cli(join(skill, "scripts/doctor.mjs"), ["--upload-report", output], dir), /delivery is uncertain/); assert.equal(await readFile(output, "utf8"), before); - await assert.rejects(cli(join(skill, "scripts/doctor.mjs"), ["--upload-report", output], dir), /already acknowledged/); - assert.equal(posts, 2); + assert.deepEqual(JSON.parse(await readFile(output + ".upload.json")), saved); + assert.equal(posts, 1); }); test("private issue draft excludes local IDs, full stacks, paths, credential values and log dumps", async (t) => {