diff --git a/src/cli/doctor-report.ts b/src/cli/doctor-report.ts index 8ee72514..80a65854 100644 --- a/src/cli/doctor-report.ts +++ b/src/cli/doctor-report.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { stat } from "node:fs/promises"; import { SecretRedactor } from "../secrets/redact.js"; +import type { UpstreamStartupDiagnostic } from "../upstream/startup-diagnostic.js"; export const DOCTOR_CODES = { CONFIGURATION: "DOCTOR_CONFIGURATION", @@ -33,6 +34,7 @@ export interface DoctorCheck { target: string; explanation: string; remediation: string; + diagnostic?: UpstreamStartupDiagnostic; } export interface DoctorReport { @@ -107,6 +109,7 @@ const packageLaunchers = new Map([ ["yarn", "dlx"], ["npm", "exec"] ]); +const uvxValueOptions = new Set(["--constraint", "--from", "--index", "--override", "--python", "--with", "--with-editable"]); const semverNumericIdentifier = "(?:0|[1-9]\\d*)"; const semverPrereleaseIdentifier = `(?:${semverNumericIdentifier}|\\d*[A-Za-z-][0-9A-Za-z-]*)`; const strictSemver = new RegExp( @@ -161,6 +164,25 @@ function isDigestPinned(image: string): boolean { } function packageArgument(command: string, args: readonly string[]): string | undefined { + if (command === "uvx") { + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (!argument) return undefined; + if (argument === "--from") return args[index + 1]; + if (argument.startsWith("--from=")) return argument.slice("--from=".length) || undefined; + if (uvxValueOptions.has(argument)) { + index += 1; + continue; + } + if (argument.startsWith("--with=") || argument.startsWith("--constraint=") || + argument.startsWith("--override=") || argument.startsWith("--python=") || argument.startsWith("--index=")) { + continue; + } + if (argument === "--") continue; + if (!argument.startsWith("-")) return argument; + } + return undefined; + } const launcherArgument = packageLaunchers.get(command); if (launcherArgument === undefined) return undefined; @@ -196,7 +218,14 @@ function compareText(left: string, right: string): number { export function normalizeDoctorReport(checks: readonly T[]): DoctorReport { const normalizedChecks = checks - .map(({ code, status, target, explanation, remediation }) => ({ code, status, target, explanation, remediation })) + .map(({ code, status, target, explanation, remediation, diagnostic }) => ({ + code, + status, + target, + explanation, + remediation, + ...(diagnostic === undefined ? {} : { diagnostic }) + })) .sort( (left, right) => codePosition(left.code) - codePosition(right.code) || @@ -220,6 +249,12 @@ export function formatDoctorReport(report: DoctorReport): string { (check) => `[${check.status.toUpperCase()}] ${check.code} — ${check.target}\n` + ` ${check.explanation}\n` + + (check.diagnostic === undefined + ? "" + : ` Cause: ${check.diagnostic.cause.replace(/\n/gu, "\n ")}\n` + + (check.diagnostic.exitCode === undefined ? "" : ` Exit code: ${check.diagnostic.exitCode}\n`) + + (check.diagnostic.signal === undefined ? "" : ` Signal: ${check.diagnostic.signal}\n`) + + (check.diagnostic.truncated ? " Cause output was truncated.\n" : "")) + ` Remediation: ${check.remediation}` ); return [`Doctor: ${report.overallStatus}`, `Summary: ${summary}`, ...checks].join("\n"); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index c2147b67..9f7dd3d7 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -23,6 +23,7 @@ import { resolveProcessEnvironment } from "../upstream/upstream-process-manager. import { resolveWindowsStdioCommand } from "../upstream/windows-stdio-command.js"; import { MiftahError } from "../utils/errors.js"; import { SecretRedactor } from "../secrets/redact.js"; +import { startupDiagnosticFromError } from "../upstream/startup-diagnostic.js"; import { createRuntime } from "./create-runtime.js"; import { DOCTOR_CODES, @@ -57,9 +58,10 @@ function check( status: DoctorCheck["status"], target: string, explanation: string, - remediation: string + remediation: string, + diagnostic?: DoctorCheck["diagnostic"] ): DoctorCheck { - return { code, status, target, explanation, remediation }; + return { code, status, target, explanation, remediation, ...(diagnostic === undefined ? {} : { diagnostic }) }; } function skippedDiscoveryCheck(code: DoctorCheck["code"], target: string, capability: string): DoctorCheck { @@ -537,6 +539,7 @@ export async function runDoctor(configPath: string): Promise { }; const probeTarget = async (target: DoctorTarget): Promise => { const targetText = targetLabel(target); + let executableAvailable = true; let runtime: Awaited>; try { runtime = await createRuntime(canonicalConfigPath, { @@ -574,6 +577,7 @@ export async function runDoctor(configPath: string): Promise { available ? noAction() : "Install or correct the executable before starting the wrapper." ) ); + executableAvailable = available; } else { checks.push( check( @@ -598,15 +602,17 @@ export async function runDoctor(configPath: string): Promise { noAction() ) ); - } catch { + } catch (error) { incompleteProfiles.add(target.profile); + const diagnostic = executableAvailable ? startupDiagnosticFromError(error) : undefined; checks.push( check( DOCTOR_CODES.STARTUP, "error", targetText, "Upstream startup or initialization did not complete.", - "Correct upstream availability or configuration before retrying doctor." + diagnostic?.remediation ?? "Correct upstream availability or configuration before retrying doctor.", + diagnostic ), skippedDiscoveryCheck(DOCTOR_CODES.TOOLS_DISCOVERY, targetText, "Tool"), unavailableIdentityCheck(target, targetText, "startup"), diff --git a/src/cli/error-output.ts b/src/cli/error-output.ts new file mode 100644 index 00000000..e05ef5c0 --- /dev/null +++ b/src/cli/error-output.ts @@ -0,0 +1,27 @@ +import { startupDiagnosticFromError, testProfileDiagnosticCommand } from "../upstream/startup-diagnostic.js"; + +export interface UpstreamFailureCommandContext { + readonly configPath: string; + readonly profile: string; +} + +function indent(value: string): string { + return value.replace(/\n/gu, "\n "); +} + +/** Renders actionable details only when they came from Miftah's redacted startup boundary. */ +export function formatUpstreamStartupFailure(error: unknown, context: UpstreamFailureCommandContext): string { + const message = error instanceof Error ? error.message : String(error); + const diagnostic = startupDiagnosticFromError(error); + if (diagnostic === undefined) return message; + + return [ + message, + `Cause: ${indent(diagnostic.cause)}`, + ...(diagnostic.exitCode === undefined ? [] : [`Exit code: ${diagnostic.exitCode}`]), + ...(diagnostic.signal === undefined ? [] : [`Signal: ${diagnostic.signal}`]), + ...(diagnostic.truncated ? ["Cause output was truncated."] : []), + `Remediation: ${diagnostic.remediation}`, + `Retry: ${testProfileDiagnosticCommand(context.configPath, context.profile)}` + ].join("\n"); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index a5d7b294..b6fe09bb 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -34,6 +34,7 @@ import { openSystemBrowser } from "../console/open-browser.js"; import { ConsoleDashboardApplicationService } from "../console/console-dashboard-application-service.js"; import { FileSetupDraftStore } from "../setup/setup-draft.js"; import { environmentReferencesFromConfig } from "../setup/setup-completion.js"; +import { formatUpstreamStartupFailure } from "./error-output.js"; function oauthSelector(args: { readonly connection?: string; readonly profile?: string; readonly upstream?: string }) { return { @@ -350,14 +351,21 @@ async function main(argv = process.argv.slice(2)): Promise { if (command === "list-tools" || command === "test-profile") { const runtime = await createRuntime(args.config); const profile = args.profile ?? runtime.config.defaultProfile; - if (command === "list-tools") { - process.stdout.write(`${JSON.stringify(await runtime.manager.listTools(profile), null, 2)}\n`); - } else { - const session = await runtime.manager.get(profile); - await session.listTools(); - process.stdout.write(`${JSON.stringify({ ok: true, profile }, null, 2)}\n`); + try { + if (command === "list-tools") { + process.stdout.write(`${JSON.stringify(await runtime.manager.listTools(profile), null, 2)}\n`); + } else { + const session = await runtime.manager.get(profile); + await session.listTools(); + process.stdout.write(`${JSON.stringify({ ok: true, profile }, null, 2)}\n`); + } + } catch (error) { + if (command !== "test-profile") throw error; + process.stderr.write(`${formatUpstreamStartupFailure(error, { configPath: args.config, profile })}\n`); + process.exitCode = exitCodeForError(error); + } finally { + await runtime.manager.close(); } - await runtime.manager.close(); return; } if (command === "logs") { diff --git a/src/mcp/server/miftah-server.ts b/src/mcp/server/miftah-server.ts index e5b6f6c8..16a9f2d5 100644 --- a/src/mcp/server/miftah-server.ts +++ b/src/mcp/server/miftah-server.ts @@ -70,6 +70,7 @@ import { MultiUpstreamProcessManager } from "../../upstream/multi-upstream-proce import type { UpstreamRequestOptions, UpstreamSession } from "../../upstream/upstream-session.js"; import { MiftahError } from "../../utils/errors.js"; import { MIFTAH_VERSION } from "../../version.js"; +import { startupFailureProfile, testProfileDiagnosticCommand } from "../../upstream/startup-diagnostic.js"; import { OperationPipeline, evaluatePolicyEnforcement, @@ -322,7 +323,8 @@ export class MiftahServer { private readonly routingContextCollector?: RoutingContextCollector, private readonly plugins?: PluginRegistry, private readonly oauth?: RemoteOAuthRuntime, - identityManager?: IdentityManager + identityManager?: IdentityManager, + private readonly runtimeConfigPath?: string ) { bindProfileTransitionConfirmationVerifier(profiles, (request) => { const binding = this.profileTransitionConfirmations.get(request.proof); @@ -687,7 +689,11 @@ export class MiftahServer { private reportResourceSubscriptionCapabilityFailure(error: unknown): void { const safeError = this.toSafeError(error); - process.emitWarning(safeError.message, { code: "MIFTAH_RESOURCE_SUBSCRIPTION_CAPABILITY_UNAVAILABLE" }); + const profile = startupFailureProfile(safeError); + const retry = this.runtimeConfigPath === undefined || profile === undefined + ? "" + : ` Run: ${testProfileDiagnosticCommand(this.runtimeConfigPath, profile)}`; + process.emitWarning(`${safeError.message}${retry}`, { code: "MIFTAH_RESOURCE_SUBSCRIPTION_CAPABILITY_UNAVAILABLE" }); } private resetMcpRoots(): void { diff --git a/src/runtime/create-miftah-runtime.ts b/src/runtime/create-miftah-runtime.ts index f3639734..47d0611f 100644 --- a/src/runtime/create-miftah-runtime.ts +++ b/src/runtime/create-miftah-runtime.ts @@ -40,7 +40,8 @@ async function createConfiguredMiftahRuntime( }), runtime.plugins, runtime.oauth, - runtime.identities + runtime.identities, + runtimeConfigPath ); return { diff --git a/src/upstream/contained-stdio-transport.ts b/src/upstream/contained-stdio-transport.ts index c0c30444..33cb4469 100644 --- a/src/upstream/contained-stdio-transport.ts +++ b/src/upstream/contained-stdio-transport.ts @@ -60,6 +60,7 @@ export class ContainedStdioClientTransport implements Transport { private explicitlyClosing = false; private closeEmitted = false; private containmentFailure: Error | undefined; + private unexpectedExit: { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null } | undefined; constructor( private readonly server: StdioServerParameters, @@ -95,7 +96,8 @@ export class ContainedStdioClientTransport implements Transport { this.containedPid = child.pid; resolve(); }); - child.once("exit", () => { + child.once("exit", (exitCode, signal) => { + if (!this.explicitlyClosing) this.unexpectedExit = { exitCode, signal }; // Node emits `exit` before `close` when a child leaves descendants // holding inherited stdio. Reap at the exit boundary; waiting for // `close` here would prevent crash recovery forever. @@ -126,6 +128,11 @@ export class ContainedStdioClientTransport implements Transport { return this.child?.pid ?? null; } + /** Returns the natural child exit that ended a live transport, excluding Miftah cleanup signals. */ + get startupExit(): { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null } | undefined { + return this.unexpectedExit; + } + /** * Requests forceful termination without declaring containment complete. * Only a verified close signal permits the manager to recycle capacity. diff --git a/src/upstream/startup-diagnostic.ts b/src/upstream/startup-diagnostic.ts new file mode 100644 index 00000000..2b5c7220 --- /dev/null +++ b/src/upstream/startup-diagnostic.ts @@ -0,0 +1,50 @@ +import { MiftahError, type MiftahErrorCode } from "../utils/errors.js"; + +export type UpstreamStartupDiagnosticKind = "process-exit" | "signal" | "timeout" | "initialization"; + +/** Secret-safe, bounded details explaining why an upstream could not initialize. */ +export interface UpstreamStartupDiagnostic { + readonly errorCode: MiftahErrorCode; + readonly kind: UpstreamStartupDiagnosticKind; + readonly cause: string; + readonly exitCode?: number; + readonly signal?: NodeJS.Signals; + readonly truncated: boolean; + readonly remediation: string; +} + +function isStartupDiagnostic(value: unknown): value is UpstreamStartupDiagnostic { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate.errorCode === "string" && + ["process-exit", "signal", "timeout", "initialization"].includes(String(candidate.kind)) && + typeof candidate.cause === "string" && + typeof candidate.truncated === "boolean" && + typeof candidate.remediation === "string" && + (candidate.exitCode === undefined || typeof candidate.exitCode === "number") && + (candidate.signal === undefined || typeof candidate.signal === "string") + ); +} + +/** Reads only the manager-produced safe startup diagnostic from a domain error. */ +export function startupDiagnosticFromError(error: unknown): UpstreamStartupDiagnostic | undefined { + if (!(error instanceof MiftahError)) return undefined; + const diagnostic = error.details?.startupDiagnostic; + return isStartupDiagnostic(diagnostic) ? diagnostic : undefined; +} + +/** Reads the already-public profile identifier associated with a startup failure. */ +export function startupFailureProfile(error: unknown): string | undefined { + if (!(error instanceof MiftahError)) return undefined; + return typeof error.details?.profile === "string" ? error.details.profile : undefined; +} + +function quotedCliArgument(value: string): string { + return `'${value.replaceAll("'", "'\\\\''")}'`; +} + +/** Renders the exact legacy readiness command used to diagnose an upstream start. */ +export function testProfileDiagnosticCommand(configPath: string, profile: string): string { + return `miftah test-profile --config ${quotedCliArgument(configPath)} --profile ${quotedCliArgument(profile)}`; +} diff --git a/src/upstream/upstream-process-manager.ts b/src/upstream/upstream-process-manager.ts index 08c1af36..745d058c 100644 --- a/src/upstream/upstream-process-manager.ts +++ b/src/upstream/upstream-process-manager.ts @@ -22,6 +22,7 @@ import { ContainedStdioClientTransport, createContainedStdioClientTransport } from "./contained-stdio-transport.js"; +import type { UpstreamStartupDiagnostic, UpstreamStartupDiagnosticKind } from "./startup-diagnostic.js"; const defaultStartupTimeoutMs = 30_000; const defaultShutdownTimeoutMs = 5_000; @@ -31,6 +32,44 @@ const initialRestartDelayMs = 100; const maximumRestartDelayMs = 5_000; const restartJitterFraction = 0.2; const restartStabilityWindowMs = 30_000; +const maxStartupDiagnosticBytes = 8_192; +const startupRemediation = "Correct the upstream command, runtime, or dependency configuration, then retry the upstream profile."; +const escapeCharacter = String.fromCodePoint(27); +const ansiCsiPattern = new RegExp(`${escapeCharacter}\\[[0-?]*[ -/]*[@-~]`, "gu"); + +class BoundedStartupStderr { + private readonly chunks: Buffer[] = []; + private bytes = 0; + truncated = false; + + write(value: string): void { + if (value.length === 0) return; + const encoded = Buffer.from(value, "utf8"); + const remaining = maxStartupDiagnosticBytes - this.bytes; + if (remaining <= 0) { + this.truncated = true; + return; + } + const accepted = encoded.subarray(0, remaining); + this.chunks.push(accepted); + this.bytes += accepted.length; + if (accepted.length < encoded.length) this.truncated = true; + } + + value(): string { + return sanitizeStartupDiagnostic(Buffer.concat(this.chunks).toString("utf8")).trim(); + } +} + +function sanitizeStartupDiagnostic(value: string): string { + return Array.from(value.replace(ansiCsiPattern, ""), (character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 8 || codePoint === 11 || codePoint === 12 || + (codePoint >= 14 && codePoint <= 31) || codePoint === 127 + ? "" + : character; + }).join(""); +} const credentialKeyPattern = /(token|secret|password|api[_-]?key|auth|private|credential|cookie)/i; /** @@ -448,6 +487,7 @@ export class UpstreamProcessManager { let startingAttempt: StartingAttempt | undefined; let transportClosed = createCloseSignal(); let reserved = false; + const startupStderr = new BoundedStartupStderr(); const trackTransportClose = (managedTransport: Transport): TransportCloseSignal => { const closeSignal = createCloseSignal(); @@ -503,7 +543,7 @@ export class UpstreamProcessManager { }); this.assertCurrentStartup(profile, generation); transport = stdioTransport; - this.attachStderr(profile, stdioTransport.stderr, suppressStderr); + this.attachStderr(profile, stdioTransport.stderr, suppressStderr, startupStderr); } else { if (!this.upstream.url) { throw new MiftahError("UPSTREAM_START_FAILED", "UPSTREAM_START_FAILED: remote upstream requires a url"); @@ -651,7 +691,7 @@ export class UpstreamProcessManager { if (this.startingAttempts.get(profile) === startingAttempt) this.startingAttempts.delete(profile); const current = this.isCurrent(profile, generation); const failure = current - ? this.asStartFailure(profile, error) + ? this.asStartFailure(profile, error, stdioTransport, startupStderr) : new MiftahError("UPSTREAM_START_FAILED", `UPSTREAM_START_FAILED: startup for '${profile}' was cancelled`); if (current) { this.setProcessState(profile, "failed", { error: failure.message, resetCapabilities: true, pid: null }); @@ -758,19 +798,27 @@ export class UpstreamProcessManager { } /** Emits process stderr only after applying static and dynamically resolved secret redaction. */ - private attachStderr(profile: string, stderr: Stream | null, suppressOutput = false): void { + private attachStderr( + profile: string, + stderr: Stream | null, + suppressOutput = false, + startupCapture?: BoundedStartupStderr + ): void { if (suppressOutput) { let emitted = false; stderr?.on("data", () => { if (emitted) return; emitted = true; + startupCapture?.write("[REDACTED]\n"); this.options.onStderr?.(profile, "[REDACTED]\n"); }); return; } const streamRedactor = this.redactor.createTextStream(); const emit = (value: string): void => { - if (value.length > 0) this.options.onStderr?.(profile, value); + if (value.length === 0) return; + startupCapture?.write(value); + this.options.onStderr?.(profile, value); }; stderr?.on("data", (chunk: Buffer) => { emit(streamRedactor.write(chunk.toString("utf8"))); @@ -1329,14 +1377,58 @@ export class UpstreamProcessManager { } } - private asStartFailure(profile: string, error: unknown): MiftahError { - if (error instanceof MiftahError) return error; + private asStartFailure( + profile: string, + error: unknown, + stdioTransport?: ContainedStdioClientTransport, + startupStderr?: BoundedStartupStderr + ): MiftahError { + if ( + error instanceof MiftahError && + error.code !== "UPSTREAM_START_FAILED" && + error.code !== "UPSTREAM_INIT_FAILED" + ) { + return error; + } if (this.upstream.transport !== "stdio") { + if (error instanceof MiftahError) return error; const remoteError = asRemoteError(profile, this.upstream.transport, error); if (remoteError) return remoteError; } - return new MiftahError("UPSTREAM_INIT_FAILED", `UPSTREAM_INIT_FAILED: could not initialize profile '${profile}'`, { - cause: this.redactProcessOutput(error instanceof Error ? error.message : String(error)) + const failure = error instanceof MiftahError + ? error + : new MiftahError("UPSTREAM_INIT_FAILED", `UPSTREAM_INIT_FAILED: could not initialize profile '${profile}'`); + const processExit = stdioTransport?.startupExit; + const capturedCause = startupStderr?.value(); + const fallbackCause = typeof failure.details?.cause === "string" + ? failure.details.cause + : error instanceof Error + ? error.message + : String(error); + const cause = capturedCause && capturedCause.length > 0 + ? capturedCause + : sanitizeStartupDiagnostic(this.redactProcessOutput(fallbackCause)).trim(); + const kind: UpstreamStartupDiagnosticKind = processExit?.signal + ? "signal" + : processExit?.exitCode !== undefined && processExit.exitCode !== null + ? "process-exit" + : failure.message.includes("startup timed out") + ? "timeout" + : "initialization"; + const diagnostic: UpstreamStartupDiagnostic = { + errorCode: failure.code, + kind, + cause, + ...(processExit?.exitCode === undefined || processExit.exitCode === null ? {} : { exitCode: processExit.exitCode }), + ...(processExit?.signal === undefined || processExit.signal === null ? {} : { signal: processExit.signal }), + truncated: startupStderr?.truncated ?? false, + remediation: startupRemediation + }; + return new MiftahError(failure.code, failure.message, { + ...failure.details, + cause, + profile, + startupDiagnostic: diagnostic }); } diff --git a/tests/cli-error-output.test.ts b/tests/cli-error-output.test.ts new file mode 100644 index 00000000..ae558922 --- /dev/null +++ b/tests/cli-error-output.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { formatUpstreamStartupFailure } from "../src/cli/error-output.js"; +import { MiftahError } from "../src/utils/errors.js"; + +describe("CLI upstream error output", () => { + it("renders an actionable test-profile failure from safe structured details", () => { + const error = new MiftahError( + "UPSTREAM_INIT_FAILED", + "UPSTREAM_INIT_FAILED: could not initialize profile 'google-personal'", + { + startupDiagnostic: { + errorCode: "UPSTREAM_INIT_FAILED", + kind: "process-exit", + cause: "ModuleNotFoundError: No module named 'mcp.server.fastmcp'", + exitCode: 1, + truncated: false, + remediation: "Correct the upstream command or dependency and retry." + } + } + ); + + const output = formatUpstreamStartupFailure(error, { + configPath: "/Users/example/My Config/miftah.json", + profile: "google-personal" + }); + + expect(output).toContain(error.message); + expect(output).toContain("Cause: ModuleNotFoundError"); + expect(output).toContain("Exit code: 1"); + expect(output).toContain("Remediation: Correct the upstream command or dependency and retry."); + expect(output).toContain( + "miftah test-profile --config '/Users/example/My Config/miftah.json' --profile 'google-personal'" + ); + }); + + it("shell-quotes diagnostic command arguments", () => { + const error = new MiftahError("UPSTREAM_INIT_FAILED", "UPSTREAM_INIT_FAILED", { + startupDiagnostic: { + errorCode: "UPSTREAM_INIT_FAILED", + kind: "initialization", + cause: "safe cause", + truncated: false, + remediation: "Retry." + } + }); + + expect(formatUpstreamStartupFailure(error, { + configPath: "/tmp/$HOME/config.json", + profile: "team's-profile" + })).toContain("--config '/tmp/$HOME/config.json' --profile 'team'\\\\''s-profile'"); + }); + + it("falls back to the safe top-level message for unrelated errors", () => { + const error = new MiftahError("CONFIG_NOT_FOUND", "CONFIG_NOT_FOUND: configuration file was not found"); + + expect(formatUpstreamStartupFailure(error, { configPath: "config.json", profile: "work" })).toBe(error.message); + }); +}); diff --git a/tests/doctor-report.test.ts b/tests/doctor-report.test.ts index 067945ff..977d11ff 100644 --- a/tests/doctor-report.test.ts +++ b/tests/doctor-report.test.ts @@ -185,6 +185,19 @@ describe("doctor report", () => { } }); + it("recognizes uvx packages without treating --with dependencies as the launched package", () => { + const checks = [ + diagnoseCommandPinning("uvx", ["mcp-search-console@0.3.2"]), + diagnoseCommandPinning("uvx", ["--with", "mcp<2", "mcp-search-console@0.3.2"]), + diagnoseCommandPinning("uvx", ["--from", "mcp-search-console@0.3.2", "mcp-search-console"]), + diagnoseCommandPinning("uvx", ["--with=mcp<2", "mcp-search-console"]) + ]; + + expect(checks.map((check) => check.status)).toEqual(["pass", "pass", "pass", "warning"]); + expect(checks.every((check) => check.target === "package dependency")).toBe(true); + expect(formatDoctorReport(normalizeDoctorReport(checks))).not.toContain("mcp-search-console"); + }); + it("warns for malformed semantic versions in package invocations", () => { const malformedVersions = ["1.2.3-.", "1.2.3-alpha..beta", "01.2.3"]; const checks = malformedVersions.map((version) => diagnoseCommandPinning("npx", [`package@${version}`])); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index ef3ab8dc..a2a3a563 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -791,6 +791,7 @@ describe("doctor readiness runner", () => { ...baseConfig( stdioUpstream({ API_TOKEN: "secretref:dotenv://MIFTAH_DOCTOR_SECRET", + TEST_STDERR_MESSAGE: "ModuleNotFoundError: missing safe dependency", TEST_FAIL_INITIALIZE: "true" }) ), @@ -802,7 +803,17 @@ describe("doctor readiness runner", () => { const report = await runDoctor(configPath); expect(report.overallStatus).toBe("failed"); - expect(check(report, DOCTOR_CODES.STARTUP).status).toBe("error"); + expect(check(report, DOCTOR_CODES.STARTUP)).toMatchObject({ + status: "error", + explanation: "Upstream startup or initialization did not complete.", + diagnostic: { + errorCode: "UPSTREAM_INIT_FAILED", + cause: expect.stringContaining("ModuleNotFoundError"), + kind: "initialization", + truncated: false, + remediation: expect.stringContaining("upstream") + } + }); expect(JSON.stringify(report)).not.toContain(secret); }); diff --git a/tests/helpers/upstream-manager-contracts.ts b/tests/helpers/upstream-manager-contracts.ts index c62d5083..a9dcabd1 100644 --- a/tests/helpers/upstream-manager-contracts.ts +++ b/tests/helpers/upstream-manager-contracts.ts @@ -10,6 +10,7 @@ import { MultiUpstreamProcessManager } from "../../src/upstream/multi-upstream-p import { UpstreamProcessManager } from "../../src/upstream/upstream-process-manager.js"; import { SecretRedactor } from "../../src/secrets/redact.js"; import { MiftahError } from "../../src/utils/errors.js"; +import { startupDiagnosticFromError } from "../../src/upstream/startup-diagnostic.js"; const fixture = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures", "fake-upstream.mjs"); const retainedStdioDescendantFixture = join( @@ -397,6 +398,55 @@ function registerBasics(): void { } }); + it("captures a bounded redacted diagnostic when a child exits before initialization", async () => { + const secret = "split-startup-diagnostic-secret"; + const manager = new UpstreamProcessManager( + { + transport: "stdio", + command: process.execPath, + args: [ + "--eval", + [ + "const secret = process.env.API_TOKEN;", + "process.stderr.write('ModuleNotFoundError: missing safe dependency\\n');", + "process.stderr.write(secret.slice(0, 7));", + "setImmediate(() => {", + " process.stderr.write(secret.slice(7) + '\\n' + Array.from({ length: 300 }, () => 'x'.repeat(40)).join('\\n'));", + " process.exit(23);", + "});" + ].join("\n") + ] + }, + { work: { env: { API_TOKEN: secret } } }, + { startupTimeoutMs: 1_000 } + ); + + try { + const failure = await manager.get("work").catch((error: unknown) => error); + expect(failure).toBeInstanceOf(MiftahError); + expect(failure).toMatchObject({ + code: "UPSTREAM_INIT_FAILED", + details: { + startupDiagnostic: { + kind: "process-exit", + exitCode: 23, + cause: expect.stringContaining("ModuleNotFoundError"), + truncated: true, + remediation: expect.stringContaining("upstream") + } + } + }); + const serialized = JSON.stringify(failure); + expect(serialized).toContain("[REDACTED]"); + expect(serialized).not.toContain(secret); + const diagnostic = startupDiagnosticFromError(failure); + if (diagnostic === undefined) throw new Error("Expected a safe startup diagnostic"); + expect(Buffer.byteLength(diagnostic.cause, "utf8")).toBeLessThanOrEqual(8_192); + } finally { + await manager.close(); + } + }); + it("shuts down an idle profile and starts a fresh process on its next use", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-idle-")); const startCountPath = join(directory, "starts"); diff --git a/tests/mcp-wrapper.test.ts b/tests/mcp-wrapper.test.ts index 842c1d64..f982ff3d 100644 --- a/tests/mcp-wrapper.test.ts +++ b/tests/mcp-wrapper.test.ts @@ -3488,6 +3488,56 @@ describe("Miftah MCP wrapper", () => { } }); + it("keeps startup warnings concise and points to the exact profile diagnostic command", async () => { + const configPath = "/Users/example/My Config/miftah.json"; + const config = validateConfig({ + version: "1", + name: "accounts", + defaultProfile: "work", + upstream: { transport: "stdio", command: process.execPath, args: [fixture] }, + profiles: { + work: { + env: { + TEST_FAIL_INITIALIZE: "true", + TEST_STDERR_MESSAGE: "ModuleNotFoundError: hidden from serve warning" + } + } + } + }); + const manager = new UpstreamProcessManager(config.upstream!, config.profiles, { startupTimeoutMs: 5_000 }); + const wrapper = new MiftahServer( + config, + new ProfileManager(config), + manager, + undefined, + undefined, + undefined, + undefined, + configPath + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "resource-subscription-diagnostic-client", version: "1.0.0" }); + const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined); + + try { + await Promise.all([wrapper.connect(serverTransport), client.connect(clientTransport)]); + + expect(emitWarning).toHaveBeenCalledWith( + expect.stringContaining( + "miftah test-profile --config '/Users/example/My Config/miftah.json' --profile 'work'" + ), + { code: "MIFTAH_RESOURCE_SUBSCRIPTION_CAPABILITY_UNAVAILABLE" } + ); + const warning = String(emitWarning.mock.calls[0]?.[0]); + expect(warning).toContain("UPSTREAM_INIT_FAILED"); + expect(warning).not.toContain("ModuleNotFoundError"); + } finally { + emitWarning.mockRestore(); + await client.close(); + await wrapper.close(); + } + }); + it("releases subscription-capability probes before serving the active profile at capacity", async () => { const config = validateConfig({ version: "1", diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index e903f854..c7ef8db8 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -1840,6 +1840,7 @@ describe("packed artifact contract", () => { expect(missingSecret.stdout).toBe(""); expect(missingSecret.stderr).toContain("SECRET_ENV_MISSING"); expect(missingSecret.stderr).not.toContain(`secretref:env://${unavailableSecretName}`); + expect(missingSecret.stderr).not.toContain("Remediation:"); await expect(readFile(missingSecretStartPath, "utf8")).rejects.toThrow(); const failedInitSecret = "packed-cli-init-secret"; @@ -1869,6 +1870,9 @@ describe("packed artifact contract", () => { expect(failedInit.status).toBe(5); expect(failedInit.stdout).toBe(""); expect(failedInit.stderr).toContain("UPSTREAM_INIT_FAILED"); + expect(failedInit.stderr).toContain("Cause:"); + expect(failedInit.stderr).toContain("Remediation:"); + expect(failedInit.stderr).toContain("Retry: miftah test-profile --config"); expect(`${failedInit.stdout}${failedInit.stderr}`).not.toContain(failedInitSecret); expect(await readFile(upstreamShutdownPath, "utf8")).toBe("ended");