Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/cli/doctor-report.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -33,6 +34,7 @@ export interface DoctorCheck {
target: string;
explanation: string;
remediation: string;
diagnostic?: UpstreamStartupDiagnostic;
}

export interface DoctorReport {
Expand Down Expand Up @@ -107,6 +109,7 @@ const packageLaunchers = new Map<string, string>([
["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(
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -196,7 +218,14 @@ function compareText(left: string, right: string): number {

export function normalizeDoctorReport<T extends DoctorCheck>(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) ||
Expand All @@ -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");
Expand Down
14 changes: 10 additions & 4 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -537,6 +539,7 @@ export async function runDoctor(configPath: string): Promise<DoctorReport> {
};
const probeTarget = async (target: DoctorTarget): Promise<void> => {
const targetText = targetLabel(target);
let executableAvailable = true;
let runtime: Awaited<ReturnType<typeof createRuntime>>;
try {
runtime = await createRuntime(canonicalConfigPath, {
Expand Down Expand Up @@ -574,6 +577,7 @@ export async function runDoctor(configPath: string): Promise<DoctorReport> {
available ? noAction() : "Install or correct the executable before starting the wrapper."
)
);
executableAvailable = available;
} else {
checks.push(
check(
Expand All @@ -598,15 +602,17 @@ export async function runDoctor(configPath: string): Promise<DoctorReport> {
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"),
Expand Down
27 changes: 27 additions & 0 deletions src/cli/error-output.ts
Original file line number Diff line number Diff line change
@@ -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");
}
22 changes: 15 additions & 7 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -350,14 +351,21 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
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") {
Expand Down
10 changes: 8 additions & 2 deletions src/mcp/server/miftah-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/create-miftah-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ async function createConfiguredMiftahRuntime(
}),
runtime.plugins,
runtime.oauth,
runtime.identities
runtime.identities,
runtimeConfigPath
);

return {
Expand Down
9 changes: 8 additions & 1 deletion src/upstream/contained-stdio-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions src/upstream/startup-diagnostic.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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)}`;
}
Loading