From e600a8ef199db6b2faf2ead7b51296d1601e0b2d Mon Sep 17 00:00:00 2001 From: Mohammed Naji Date: Sun, 9 Aug 2026 16:45:02 +0400 Subject: [PATCH 1/8] Make upstream startup failures actionable (#348) --- src/cli/doctor-report.ts | 37 ++++++- src/cli/doctor.ts | 14 ++- src/cli/error-output.ts | 27 +++++ src/cli/main.ts | 22 ++-- src/mcp/server/miftah-server.ts | 10 +- src/runtime/create-miftah-runtime.ts | 3 +- src/upstream/contained-stdio-transport.ts | 9 +- src/upstream/startup-diagnostic.ts | 50 +++++++++ src/upstream/upstream-process-manager.ts | 108 ++++++++++++++++++-- tests/cli-error-output.test.ts | 58 +++++++++++ tests/doctor-report.test.ts | 13 +++ tests/doctor.test.ts | 13 ++- tests/helpers/upstream-manager-contracts.ts | 50 +++++++++ tests/mcp-wrapper.test.ts | 50 +++++++++ tests/package-contract.test.ts | 4 + 15 files changed, 443 insertions(+), 25 deletions(-) create mode 100644 src/cli/error-output.ts create mode 100644 src/upstream/startup-diagnostic.ts create mode 100644 tests/cli-error-output.test.ts 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"); From 62552102cc635c6a718c6ab6aaf2c07abe09b9c8 Mon Sep 17 00:00:00 2001 From: Mohammed Naji Date: Sun, 9 Aug 2026 17:19:54 +0400 Subject: [PATCH 2/8] chore(deps): refresh safe runtime and lint dependencies (#349) --- package-lock.json | 156 +++++++++++++++++++++++----------------------- package.json | 6 +- 2 files changed, 81 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1912ade0..4aa35d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "dotenv": "^16.6.1", + "dotenv": "^17.4.2", "zod": "^3.25.76", "zod-to-json-schema": "3.25.2" }, @@ -23,10 +23,10 @@ "@types/node": "^22.15.30", "@vitest/coverage-v8": "^3.2.7", "esbuild": "0.28.1", - "eslint": "^10.6.0", + "eslint": "^10.8.0", "tsup": "^8.5.0", "typescript": "^5.8.3", - "typescript-eslint": "^8.63.0", + "typescript-eslint": "^8.65.0", "vitest": "^3.2.4" }, "engines": { @@ -607,9 +607,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1517,17 +1517,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", - "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/type-utils": "8.63.0", - "@typescript-eslint/utils": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1540,15 +1540,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.63.0", + "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -1556,16 +1556,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", - "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "engines": { @@ -1581,14 +1581,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -1603,14 +1603,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1621,9 +1621,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -1638,15 +1638,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1663,9 +1663,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -1677,16 +1677,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1705,16 +1705,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1729,13 +1729,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2302,9 +2302,9 @@ } }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2441,9 +2441,9 @@ } }, "node_modules/eslint": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", - "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -2453,7 +2453,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -2477,7 +2477,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4510,16 +4510,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.63.0.tgz", - "integrity": "sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.63.0", - "@typescript-eslint/parser": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0" + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index 4cf54c15..66fb6bcb 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "1.3.0", - "dotenv": "^16.6.1", + "dotenv": "^17.4.2", "zod": "^3.25.76", "zod-to-json-schema": "3.25.2" }, @@ -73,10 +73,10 @@ "@types/node": "^22.15.30", "@vitest/coverage-v8": "^3.2.7", "esbuild": "0.28.1", - "eslint": "^10.6.0", + "eslint": "^10.8.0", "tsup": "^8.5.0", "typescript": "^5.8.3", - "typescript-eslint": "^8.63.0", + "typescript-eslint": "^8.65.0", "vitest": "^3.2.4" }, "overrides": { From 33cd6249425c60da008e0028e276397e3c8dd47b Mon Sep 17 00:00:00 2001 From: Mohammed Naji Date: Sun, 9 Aug 2026 18:00:08 +0400 Subject: [PATCH 3/8] test(windows): stabilize ACL probe budget (#353) --- tests/windows-config-migration-acl.test.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index 5297e697..49445e62 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -13,6 +13,8 @@ import { const requestEnvironmentName = "MIFTAH_TEST_CONFIG_ACL_REQUEST"; const privateDirectoryRequestEnvironmentName = "MIFTAH_TEST_PRIVATE_DIRECTORY_ACL_REQUEST"; +const privateDirectoryProbeTimeoutMs = 15_000; +const privateDirectoryContractTimeoutMs = 30_000; const temporaryDirectories: string[] = []; afterEach(async () => { @@ -405,7 +407,7 @@ async function windowsPrivateDirectoryProbe(directory: string): Promise { // The probe has no verified result after its bounded execution time. } reject(new Error(`Windows private-directory ACL probe timed out: ${safePrivateDirectoryProbeStage([...output, ...errorOutput])}`)); - }, 5_000); + }, privateDirectoryProbeTimeoutMs); child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)); child.stderr?.on("data", (chunk: Buffer) => errorOutput.push(chunk)); child.once("error", () => { @@ -516,7 +518,7 @@ describe("Windows migration ACL contract", () => { await expect(readFile(path, "utf8")).resolves.toBe(content); await expect(verifyWindowsConfigPathSecurity(path, "file")).resolves.toBe(true); }, - 10_000 + privateDirectoryContractTimeoutMs ); it.runIf(process.platform === "win32")( @@ -536,7 +538,7 @@ describe("Windows migration ACL contract", () => { await expect(writeWindowsPrivateConfigFile(path, "{\"version\":\"3\",\"name\":\"different\"}\n")).resolves.toBe("exists"); await expect(readFile(path, "utf8")).resolves.toBe(content); }, - 10_000 + privateDirectoryContractTimeoutMs ); it.runIf(process.platform === "win32")( @@ -549,7 +551,7 @@ describe("Windows migration ACL contract", () => { await expect(createWindowsPrivateDirectoryInPrivateParent(privateParent, join(privateParent, "miftah"))).resolves.toBe(true); }, - 10_000 + privateDirectoryContractTimeoutMs ); it.runIf(process.platform === "win32")( @@ -566,7 +568,7 @@ describe("Windows migration ACL contract", () => { await expect(createWindowsPrivateDirectoryInPrivateParent(privateParent, child)).resolves.toBe(false); }, - 10_000 + privateDirectoryContractTimeoutMs ); it.runIf(process.platform === "win32")( @@ -577,7 +579,7 @@ describe("Windows migration ACL contract", () => { await expect(windowsPrivateDirectoryProbe(join(parentDirectory, ".miftah-migrate-transaction"))).resolves.toBeUndefined(); }, - 10_000 + privateDirectoryContractTimeoutMs ); it.runIf(process.platform === "win32")( @@ -672,7 +674,7 @@ describe("Windows migration ACL contract", () => { expect(await windowsAclSddl(targetPath, "read")).toBe(expectedPersistedInheritedDaclSddl(expectedSddl)); }, - 10_000 + privateDirectoryContractTimeoutMs ); it.runIf(process.platform === "win32")( @@ -694,7 +696,7 @@ describe("Windows migration ACL contract", () => { await expect(windowsCopyFileSecurityProbe(sourcePath, targetPath)).resolves.toBeUndefined(); expect(await windowsAclSddl(targetPath, "read")).toBe(expectedPersistedInheritedDaclSddl(expectedSddl)); }, - 10_000 + privateDirectoryContractTimeoutMs ); it.runIf(process.platform === "win32")( @@ -716,7 +718,7 @@ describe("Windows migration ACL contract", () => { await expect(windowsCopyFileSecurityProbe(sourcePath, targetPath, "verify-access-rules")).resolves.toBeUndefined(); expect(await windowsAclSddl(targetPath, "read")).toBe(expectedPersistedInheritedDaclSddl(expectedSddl)); }, - 10_000 + privateDirectoryContractTimeoutMs ); it.runIf(process.platform === "win32")( @@ -738,7 +740,7 @@ describe("Windows migration ACL contract", () => { await expect(windowsCopyFileSecurityProbe(sourcePath, targetPath, "fresh-security")).resolves.toBeUndefined(); expect(await windowsAclSddl(targetPath, "read")).toBe(expectedPersistedInheritedDaclSddl(expectedSddl)); }, - 10_000 + privateDirectoryContractTimeoutMs ); it.runIf(process.platform === "win32")( From 76803fce8377244a57c50eecb3b119b4f00e61dd Mon Sep 17 00:00:00 2001 From: Mohammed Naji Date: Sun, 9 Aug 2026 18:34:32 +0400 Subject: [PATCH 4/8] chore(release): prepare v0.5.8 (#351) * chore(release): prepare v0.5.8 * test(release): assert dependency scope * docs(release): correct startup diagnostic claims --- CHANGELOG.md | 11 +++++++++++ README.md | 2 +- docs/presets-and-clients.md | 2 +- docs/whats-new-in-0.5.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- tests/release-version.test.ts | 36 ++++++++++++++++++++--------------- 7 files changed, 39 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4018dec9..99239739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +## [0.5.8] - 2026-08-09 + +### Fixed + +- [#347](https://github.com/mohanagy/miftah/issues/347) Made upstream startup failures actionable without exposing configuration or credential material. Runtime warnings remain concise and point to the exact `miftah test-profile` command; `test-profile` and `doctor` now surface a bounded, secret-redacted failure cause and remediation. Diagnostics also recognize pinned `uvx` package invocations while retaining the existing fail-closed startup boundary. + +### Changed + +- [#349](https://github.com/mohanagy/miftah/pull/349) Refreshed the compatible `dotenv`, ESLint, and TypeScript ESLint dependencies after the full supported Node and operating-system matrix passed. The deferred Vitest 4 and TypeScript 7 major upgrades are not part of this release. +- [#350](https://github.com/mohanagy/miftah/issues/350) Prepared the compatible v0.5.8 patch release for actionable upstream-startup diagnostics and the safe dependency refresh. Miftah remains experimental and pre-1.0. Technical delivery and owner dogfooding do not satisfy external evaluator counts; external validation remains incomplete under #25, #88, #202, and #290. + ## [0.5.7] - 2026-08-01 ### Changed diff --git a/README.md b/README.md index 552239a0..e106abda 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Install Miftah, then choose the terminal wizard or the browser Console. Both use ### 1. Install the current release ```bash -npm install -g @lubab/miftah@0.5.7 +npm install -g @lubab/miftah@0.5.8 miftah version ``` diff --git a/docs/presets-and-clients.md b/docs/presets-and-clients.md index 336157a7..d3d4325e 100644 --- a/docs/presets-and-clients.md +++ b/docs/presets-and-clients.md @@ -3,7 +3,7 @@ This is the compatibility source of truth for generated `miftah init` configurations and client snippets. - Catalog version: `3` -- Miftah package version: `0.5.7` +- Miftah package version: `0.5.8` - Last tested / validation boundary: the catalog builds strict Miftah configuration that `validateConfig` accepts. The docs contract test checks generated configuration only; it does **not** construct a runtime, start, authenticate to, or smoke-test external providers. Miftah itself requires Node.js `>=20`. That does not establish an upstream server's Node requirement. diff --git a/docs/whats-new-in-0.5.md b/docs/whats-new-in-0.5.md index 782cf176..23a7a571 100644 --- a/docs/whats-new-in-0.5.md +++ b/docs/whats-new-in-0.5.md @@ -1,9 +1,9 @@ # What is in Miftah 0.5 -Install `@lubab/miftah@0.5.7` when you want Miftah to guide setup instead of assembling a multi-account configuration by hand: +Install `@lubab/miftah@0.5.8` when you want Miftah to guide setup instead of assembling a multi-account configuration by hand: ```bash -npm install -g @lubab/miftah@0.5.7 +npm install -g @lubab/miftah@0.5.8 miftah version ``` diff --git a/package-lock.json b/package-lock.json index 4aa35d4d..c1b1b0e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lubab/miftah", - "version": "0.5.7", + "version": "0.5.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lubab/miftah", - "version": "0.5.7", + "version": "0.5.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 66fb6bcb..dd86a228 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lubab/miftah", - "version": "0.5.7", + "version": "0.5.8", "description": "Wrap any MCP. Use the right account without reconnecting.", "keywords": [ "mcp", diff --git a/tests/release-version.test.ts b/tests/release-version.test.ts index e7325b36..52d7d211 100644 --- a/tests/release-version.test.ts +++ b/tests/release-version.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -const releaseVersion = "0.5.7"; +const releaseVersion = "0.5.8"; function readRepositoryFile(path: string): string { return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); @@ -21,15 +21,15 @@ function releaseNotes(changelog: string, version: string): string { return changelog.slice(match.index, end < 0 ? undefined : end); } -describe("v0.5.7 release artifacts", () => { +describe("v0.5.8 release artifacts", () => { it.each([ { name: "a non-zero-padded date", - changelog: "## [0.5.7] - 2026-8-1\n\n### Changed\n" + changelog: "## [0.5.8] - 2026-8-9\n\n### Changed\n" }, { name: "a heading that does not start its line", - changelog: "Release candidate: ## [0.5.7] - 2026-08-01\n\n### Changed\n" + changelog: "Release candidate: ## [0.5.8] - 2026-08-09\n\n### Changed\n" } ])("rejects $name", ({ changelog }) => { expect(() => releaseNotes(changelog, releaseVersion)).toThrow( @@ -71,22 +71,28 @@ describe("v0.5.7 release artifacts", () => { } }); - it("documents the Console usability patch while retaining the experimental package status", () => { + it("documents actionable startup diagnostics and the safe maintenance refresh", () => { const changelog = readRepositoryFile("CHANGELOG.md"); const notes = releaseNotes(changelog, releaseVersion); expect(notes).toContain("Miftah remains experimental and pre-1.0"); + expect(notes).toContain("### Fixed"); + expect(notes).toContain("[#347](https://github.com/mohanagy/miftah/issues/347)"); + expect(notes).toMatch( + /Runtime warnings remain concise and point to the exact `miftah test-profile` command/iu, + ); + expect(notes).toMatch( + /`test-profile` and `doctor` now surface a bounded, secret-redacted failure cause and remediation/iu, + ); + expect(notes).toContain("uvx"); expect(notes).toContain("### Changed"); - const changedStart = notes.indexOf("### Changed"); - const changedEnd = notes.indexOf("\n### ", changedStart + "### Changed".length); - const changedNotes = notes.slice(changedStart, changedEnd < 0 ? undefined : changedEnd); - for (const issue of [331, 332, 333, 334, 339]) { - expect(changedNotes).toContain(`[#${issue}](https://github.com/mohanagy/miftah/issues/${issue})`); - } - expect(notes).toMatch(/account switching/iu); - expect(notes).toMatch(/task sections/iu); - expect(notes).toMatch(/setup.*user language/iu); - expect(notes).toMatch(/responsive/iu); + expect(notes).toContain("[#349](https://github.com/mohanagy/miftah/pull/349)"); + expect(notes).toContain("[#350](https://github.com/mohanagy/miftah/issues/350)"); + expect(notes).toMatch(/dependency/iu); + expect(notes).toMatch(/\bdotenv\b/iu); + expect(notes).toMatch(/TypeScript ESLint/iu); + expect(notes).toContain("Vitest 4"); + expect(notes).toContain("TypeScript 7"); expect(notes).toContain("external validation remains incomplete under #25, #88, #202, and #290"); const readme = readRepositoryFile("README.md"); From dd3ac48f15eba72228f36fdb50e990fc46576a24 Mon Sep 17 00:00:00 2001 From: Mohammed Naji Date: Sun, 9 Aug 2026 19:10:52 +0400 Subject: [PATCH 5/8] fix(cli): close startup diagnostic redaction gaps (#357) --- src/cli/main.ts | 3 +- tests/helpers/upstream-manager-contracts.ts | 53 ++++++++++++++++++++- tests/package-contract.test.ts | 29 +++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/cli/main.ts b/src/cli/main.ts index b6fe09bb..3bc164d9 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -361,7 +361,8 @@ async function main(argv = process.argv.slice(2)): Promise { } } catch (error) { if (command !== "test-profile") throw error; - process.stderr.write(`${formatUpstreamStartupFailure(error, { configPath: args.config, profile })}\n`); + const output = formatUpstreamStartupFailure(error, { configPath: args.config, profile }); + process.stderr.write(`${runtime.redactor.redactText(output)}\n`); process.exitCode = exitCodeForError(error); } finally { await runtime.manager.close(); diff --git a/tests/helpers/upstream-manager-contracts.ts b/tests/helpers/upstream-manager-contracts.ts index a9dcabd1..79da0080 100644 --- a/tests/helpers/upstream-manager-contracts.ts +++ b/tests/helpers/upstream-manager-contracts.ts @@ -447,6 +447,46 @@ function registerBasics(): void { } }); + it.runIf(process.platform !== "win32")( + "classifies a child signal during initialization in the startup diagnostic", + async () => { + const manager = new UpstreamProcessManager( + { + transport: "stdio", + command: process.execPath, + args: [ + "--eval", + [ + "process.stderr.write('signal startup diagnostic\\n');", + "setTimeout(() => process.kill(process.pid, 'SIGTERM'), 10);" + ].join("\n") + ] + }, + { work: {} }, + { startupTimeoutMs: 1_000 } + ); + + try { + const failure = await manager.get("work").catch((error: unknown) => error); + expect(failure).toMatchObject({ + code: "UPSTREAM_INIT_FAILED", + details: { + startupDiagnostic: { + errorCode: "UPSTREAM_INIT_FAILED", + kind: "signal", + signal: "SIGTERM", + cause: expect.stringContaining("signal startup diagnostic"), + truncated: false, + remediation: expect.stringContaining("upstream") + } + } + }); + } 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"); @@ -792,7 +832,18 @@ function registerRecovery(): void { try { const startup = manager.get("work"); void startup.catch(() => undefined); - await expect(startup).rejects.toMatchObject({ code: "UPSTREAM_START_FAILED" }); + await expect(startup).rejects.toMatchObject({ + code: "UPSTREAM_START_FAILED", + details: { + startupDiagnostic: { + errorCode: "UPSTREAM_START_FAILED", + kind: "timeout", + cause: expect.stringContaining("startup timed out after 200ms"), + truncated: false, + remediation: expect.stringContaining("upstream") + } + } + }); expect(Date.now() - startedAt).toBeLessThan(500); expect(manager.listHealth()).toMatchObject([{ profile: "work", processState: "failed" }]); } finally { diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index c7ef8db8..03decb10 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -1876,6 +1876,35 @@ describe("packed artifact contract", () => { expect(`${failedInit.stdout}${failedInit.stderr}`).not.toContain(failedInitSecret); expect(await readFile(upstreamShutdownPath, "utf8")).toBe("ended"); + const failedToolListSecret = "packed-cli-tool-list-secret"; + const failedToolListConfigPath = await writeCliConfig( + "failed tool list config.json", + cliConfig( + "packed-cli-failed-tool-list", + { + work: { + env: { + API_TOKEN: `secretref:plain://${failedToolListSecret}`, + TEST_FAIL_LIST_TOOLS: "true" + } + } + }, + [fakeStdioUpstreamFixture], + { secrets: { allowPlaintextSecrets: true } } + ) + ); + const failedToolList = await runInstalledBinaryAsync( + binary, + ["test-profile", "--config", failedToolListConfigPath, "--profile", "work"], + cliContractDirectory + ); + expect(failedToolList.status).toBe(1); + expect(failedToolList.stdout).toBe(""); + expect(failedToolList.stderr).toContain("test tool list failure"); + expect(failedToolList.stderr).toContain("[REDACTED]"); + expect(failedToolList.stderr).not.toContain(failedToolListSecret); + expect(failedToolList.stderr).not.toContain(`secretref:plain://${failedToolListSecret}`); + const auditPath = join(cliContractDirectory, "audit output with spaces", "events with spaces.jsonl"); const auditUsername = ["user", "name"].join(""); const auditPassword = ["pass", "word"].join(""); From 56d4b634127a3045e7f81263e1fa606ee8f8b168 Mon Sep 17 00:00:00 2001 From: Mohammed Naji Date: Sun, 9 Aug 2026 19:20:39 +0400 Subject: [PATCH 6/8] test(windows): separate ACL probe startup budget (#358) --- tests/windows-config-migration-acl.test.ts | 217 ++++++++++++++------- 1 file changed, 150 insertions(+), 67 deletions(-) diff --git a/tests/windows-config-migration-acl.test.ts b/tests/windows-config-migration-acl.test.ts index 49445e62..dc1e053c 100644 --- a/tests/windows-config-migration-acl.test.ts +++ b/tests/windows-config-migration-acl.test.ts @@ -13,10 +13,23 @@ import { const requestEnvironmentName = "MIFTAH_TEST_CONFIG_ACL_REQUEST"; const privateDirectoryRequestEnvironmentName = "MIFTAH_TEST_PRIVATE_DIRECTORY_ACL_REQUEST"; -const privateDirectoryProbeTimeoutMs = 15_000; -const privateDirectoryContractTimeoutMs = 30_000; +// Hosted Windows runners can spend substantial time starting PowerShell before the first script instruction runs. +const powerShellBootstrapTimeoutMs = 60_000; +const powerShellProbeExecutionTimeoutMs = 5_000; +const privateDirectoryProbeExecutionTimeoutMs = 15_000; +const hangingAclProbeTimeoutMs = 5_000; +const powerShellContractSlackMs = 30_000; +const aclProbeBootstrapMarker = "MIFTAH_ACL_PROBE_BOOTSTRAP"; +const privateDirectoryProbeBootstrapMarker = "MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_BOOTSTRAP"; +const unsafeAncestorProbeBootstrapMarker = "MIFTAH_ACL_UNSAFE_ANCESTOR_PROBE_BOOTSTRAP"; +const copyFileSecurityProbeBootstrapMarker = "MIFTAH_ACL_COPY_FILE_PROBE_BOOTSTRAP"; const temporaryDirectories: string[] = []; +function powerShellContractTimeout(probeCount: number): number { + const maximumFunctionalProbeTime = powerShellBootstrapTimeoutMs + privateDirectoryProbeExecutionTimeoutMs; + return probeCount * maximumFunctionalProbeTime + powerShellContractSlackMs; +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); @@ -36,6 +49,58 @@ function trustedPowerShellExecutable(): string { return executable; } +function bufferIncludesAsciiMarker(bytes: Buffer, marker: string): boolean { + return bytes.includes(Buffer.from(marker, "utf8")) || bytes.includes(Buffer.from(marker, "utf16le")); +} + +function createPowerShellProbeDeadline( + child: Pick, "kill">, + bootstrapMarker: string | undefined, + executionTimeoutMs: number, + onTimeout: () => void +): { clear: () => void; observe: (chunk: Buffer) => void } { + let phase: "bootstrap" | "execution" = bootstrapMarker === undefined ? "execution" : "bootstrap"; + let markerTail = Buffer.alloc(0); + let settled = false; + let timeout: NodeJS.Timeout | undefined; + const maximumMarkerBytes = bootstrapMarker === undefined ? 0 : Buffer.byteLength(bootstrapMarker, "utf16le"); + + const expire = (): void => { + if (settled) return; + settled = true; + try { + child.kill(); + } catch { + // The probe has no verified result after its bounded phase deadline. + } + onTimeout(); + }; + const arm = (durationMs: number): void => { + if (timeout !== undefined) clearTimeout(timeout); + timeout = setTimeout(expire, durationMs); + }; + + arm(phase === "bootstrap" ? powerShellBootstrapTimeoutMs : executionTimeoutMs); + + return { + clear: () => { + settled = true; + if (timeout !== undefined) clearTimeout(timeout); + }, + observe: (chunk) => { + if (settled || phase !== "bootstrap" || bootstrapMarker === undefined) return; + const candidate = Buffer.concat([markerTail, chunk]); + if (bufferIncludesAsciiMarker(candidate, bootstrapMarker)) { + phase = "execution"; + markerTail = Buffer.alloc(0); + arm(executionTimeoutMs); + return; + } + markerTail = candidate.subarray(-Math.min(candidate.length, maximumMarkerBytes - 1)); + } + }; +} + function aclEnvironment(request: string): NodeJS.ProcessEnv { const environment: NodeJS.ProcessEnv = {}; for (const name of ["SystemRoot", "windir", "ComSpec", "TEMP", "TMP", "PSModulePath", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"]) { @@ -56,7 +121,8 @@ function restrictedAclEnvironment(request: string): NodeJS.ProcessEnv { return environment; } -const aclProbe = String.raw`$ErrorActionPreference = 'Stop' +const aclProbe = String.raw`[Console]::Error.Write('${aclProbeBootstrapMarker}') +$ErrorActionPreference = 'Stop' $requestName = '${requestEnvironmentName}' $sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner -bor [System.Security.AccessControl.AccessControlSections]::Group $stage = 'bootstrap' @@ -106,7 +172,8 @@ try { const encodedAclProbe = Buffer.from(aclProbe, "utf16le").toString("base64"); const encodedHangingAclProbe = Buffer.from("Start-Sleep -Seconds 10", "utf16le").toString("base64"); -const unsafeAncestorProbe = String.raw`$ErrorActionPreference = 'Stop' +const unsafeAncestorProbe = String.raw`[Console]::Out.Write('${unsafeAncestorProbeBootstrapMarker}') +$ErrorActionPreference = 'Stop' $requestName = '${privateDirectoryRequestEnvironmentName}' try { $encoded = [Environment]::GetEnvironmentVariable($requestName, [EnvironmentVariableTarget]::Process) @@ -127,7 +194,7 @@ try { }`; const encodedUnsafeAncestorProbe = Buffer.from(unsafeAncestorProbe, "utf16le").toString("base64"); -const privateDirectoryProbe = String.raw`[Console]::Out.Write('MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_BOOTSTRAP') +const privateDirectoryProbe = String.raw`[Console]::Out.Write('${privateDirectoryProbeBootstrapMarker}') $ErrorActionPreference = 'Stop' $requestName = '${privateDirectoryRequestEnvironmentName}' $directorySections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner @@ -183,7 +250,7 @@ try { const encodedPrivateDirectoryProbe = Buffer.from(privateDirectoryProbe, "utf16le").toString("base64"); -const copyFileSecurityProbe = String.raw`[Console]::Out.Write('MIFTAH_ACL_COPY_FILE_PROBE_BOOTSTRAP') +const copyFileSecurityProbe = String.raw`[Console]::Out.Write('${copyFileSecurityProbeBootstrapMarker}') $ErrorActionPreference = 'Stop' $requestName = '${privateDirectoryRequestEnvironmentName}' $sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner -bor [System.Security.AccessControl.AccessControlSections]::Group @@ -285,6 +352,7 @@ function safeAclProbeStage(output: readonly Buffer[]): string { )?.[0]; if (stage !== undefined) return stage; } + if (bufferIncludesAsciiMarker(bytes, aclProbeBootstrapMarker)) return "MIFTAH_ACL_PROBE_STAGE:bootstrap"; return "MIFTAH_ACL_PROBE_STAGE:unavailable"; } @@ -303,7 +371,7 @@ function safePrivateDirectoryProbeStage(output: readonly Buffer[]): string { if (bytes.toString("utf8").includes("MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_SECTIONS")) { return "MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_STAGE:sections"; } - if (bytes.toString("utf8").includes("MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_BOOTSTRAP")) { + if (bufferIncludesAsciiMarker(bytes, privateDirectoryProbeBootstrapMarker)) { return "MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_STAGE:bootstrap"; } return "MIFTAH_ACL_PRIVATE_DIRECTORY_PROBE_STAGE:unavailable"; @@ -331,7 +399,7 @@ function safeCopyFileSecurityProbeStage(output: readonly Buffer[]): string { if (bytes.toString("utf8").includes("MIFTAH_ACL_COPY_FILE_PROBE_SECTIONS")) { return "MIFTAH_ACL_COPY_FILE_PROBE_STAGE:sections"; } - if (bytes.toString("utf8").includes("MIFTAH_ACL_COPY_FILE_PROBE_BOOTSTRAP")) { + if (bufferIncludesAsciiMarker(bytes, copyFileSecurityProbeBootstrapMarker)) { return "MIFTAH_ACL_COPY_FILE_PROBE_STAGE:bootstrap"; } return "MIFTAH_ACL_COPY_FILE_PROBE_STAGE:unavailable"; @@ -354,6 +422,7 @@ async function windowsAclSddl( ): Promise { const request = Buffer.from(JSON.stringify({ path, operation }), "utf8").toString("base64"); return new Promise((resolve, reject) => { + const isFunctionalProbe = encodedCommand === encodedAclProbe; const child = spawn( trustedPowerShellExecutable(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedCommand], @@ -361,22 +430,28 @@ async function windowsAclSddl( ); const output: Buffer[] = []; const errorOutput: Buffer[] = []; - const timeout = setTimeout(() => { - try { - child.kill(); - } catch { - // The probe has no verified result after its bounded execution time. + const deadline = createPowerShellProbeDeadline( + child, + isFunctionalProbe ? aclProbeBootstrapMarker : undefined, + isFunctionalProbe ? powerShellProbeExecutionTimeoutMs : hangingAclProbeTimeoutMs, + () => { + reject(new Error(`Windows ACL probe timed out: ${safeAclProbeStage([...output, ...errorOutput])}`)); } - reject(new Error(`Windows ACL probe timed out: ${safeAclProbeStage([...output, ...errorOutput])}`)); - }, 5_000); - child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)); - child.stderr?.on("data", (chunk: Buffer) => errorOutput.push(chunk)); + ); + child.stdout?.on("data", (chunk: Buffer) => { + output.push(chunk); + deadline.observe(chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + errorOutput.push(chunk); + deadline.observe(chunk); + }); child.once("error", () => { - clearTimeout(timeout); + deadline.clear(); reject(new Error("Windows ACL probe could not start")); }); child.once("close", (code) => { - clearTimeout(timeout); + deadline.clear(); if (code !== 0) { reject(new Error(`Windows ACL probe failed: ${safeAclProbeStage([...output, ...errorOutput])}`)); return; @@ -400,22 +475,26 @@ async function windowsPrivateDirectoryProbe(directory: string): Promise { ); const output: Buffer[] = []; const errorOutput: Buffer[] = []; - const timeout = setTimeout(() => { - try { - child.kill(); - } catch { - // The probe has no verified result after its bounded execution time. - } - reject(new Error(`Windows private-directory ACL probe timed out: ${safePrivateDirectoryProbeStage([...output, ...errorOutput])}`)); - }, privateDirectoryProbeTimeoutMs); - child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)); - child.stderr?.on("data", (chunk: Buffer) => errorOutput.push(chunk)); + const deadline = createPowerShellProbeDeadline( + child, + privateDirectoryProbeBootstrapMarker, + privateDirectoryProbeExecutionTimeoutMs, + () => reject(new Error(`Windows private-directory ACL probe timed out: ${safePrivateDirectoryProbeStage([...output, ...errorOutput])}`)) + ); + child.stdout?.on("data", (chunk: Buffer) => { + output.push(chunk); + deadline.observe(chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + errorOutput.push(chunk); + deadline.observe(chunk); + }); child.once("error", () => { - clearTimeout(timeout); + deadline.clear(); reject(new Error("Windows private-directory ACL probe could not start")); }); child.once("close", (code) => { - clearTimeout(timeout); + deadline.clear(); if (code === 0) { resolve(); return; @@ -431,22 +510,22 @@ async function grantUntrustedAncestorMutation(directory: string): Promise const child = spawn( trustedPowerShellExecutable(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encodedUnsafeAncestorProbe], - { env: restrictedAclEnvironment(request), shell: false, windowsHide: true, stdio: "ignore" } + { env: restrictedAclEnvironment(request), shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] } ); - const timeout = setTimeout(() => { - try { - child.kill(); - } catch { - // The test probe has no verified result after its bounded execution time. - } - reject(new Error("Windows unsafe-ancestor ACL probe timed out")); - }, 5_000); + const deadline = createPowerShellProbeDeadline( + child, + unsafeAncestorProbeBootstrapMarker, + powerShellProbeExecutionTimeoutMs, + () => reject(new Error("Windows unsafe-ancestor ACL probe timed out")) + ); + child.stdout?.on("data", (chunk: Buffer) => deadline.observe(chunk)); + child.stderr?.on("data", (chunk: Buffer) => deadline.observe(chunk)); child.once("error", () => { - clearTimeout(timeout); + deadline.clear(); reject(new Error("Windows unsafe-ancestor ACL probe could not start")); }); child.once("close", (code) => { - clearTimeout(timeout); + deadline.clear(); if (code === 0) { resolve(); return; @@ -475,22 +554,26 @@ async function windowsCopyFileSecurityProbe( ); const output: Buffer[] = []; const errorOutput: Buffer[] = []; - const timeout = setTimeout(() => { - try { - child.kill(); - } catch { - // The probe has no verified result after its bounded execution time. - } - reject(new Error(`Windows copy-file ACL probe timed out: ${safeCopyFileSecurityProbeStage([...output, ...errorOutput])}`)); - }, 5_000); - child.stdout?.on("data", (chunk: Buffer) => output.push(chunk)); - child.stderr?.on("data", (chunk: Buffer) => errorOutput.push(chunk)); + const deadline = createPowerShellProbeDeadline( + child, + copyFileSecurityProbeBootstrapMarker, + powerShellProbeExecutionTimeoutMs, + () => reject(new Error(`Windows copy-file ACL probe timed out: ${safeCopyFileSecurityProbeStage([...output, ...errorOutput])}`)) + ); + child.stdout?.on("data", (chunk: Buffer) => { + output.push(chunk); + deadline.observe(chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + errorOutput.push(chunk); + deadline.observe(chunk); + }); child.once("error", () => { - clearTimeout(timeout); + deadline.clear(); reject(new Error("Windows copy-file ACL probe could not start")); }); child.once("close", (code) => { - clearTimeout(timeout); + deadline.clear(); if (code === 0) { resolve(); return; @@ -518,7 +601,7 @@ describe("Windows migration ACL contract", () => { await expect(readFile(path, "utf8")).resolves.toBe(content); await expect(verifyWindowsConfigPathSecurity(path, "file")).resolves.toBe(true); }, - privateDirectoryContractTimeoutMs + powerShellContractTimeout(1) ); it.runIf(process.platform === "win32")( @@ -538,7 +621,7 @@ describe("Windows migration ACL contract", () => { await expect(writeWindowsPrivateConfigFile(path, "{\"version\":\"3\",\"name\":\"different\"}\n")).resolves.toBe("exists"); await expect(readFile(path, "utf8")).resolves.toBe(content); }, - privateDirectoryContractTimeoutMs + powerShellContractTimeout(1) ); it.runIf(process.platform === "win32")( @@ -551,7 +634,7 @@ describe("Windows migration ACL contract", () => { await expect(createWindowsPrivateDirectoryInPrivateParent(privateParent, join(privateParent, "miftah"))).resolves.toBe(true); }, - privateDirectoryContractTimeoutMs + powerShellContractTimeout(1) ); it.runIf(process.platform === "win32")( @@ -568,7 +651,7 @@ describe("Windows migration ACL contract", () => { await expect(createWindowsPrivateDirectoryInPrivateParent(privateParent, child)).resolves.toBe(false); }, - privateDirectoryContractTimeoutMs + powerShellContractTimeout(2) ); it.runIf(process.platform === "win32")( @@ -579,7 +662,7 @@ describe("Windows migration ACL contract", () => { await expect(windowsPrivateDirectoryProbe(join(parentDirectory, ".miftah-migrate-transaction"))).resolves.toBeUndefined(); }, - privateDirectoryContractTimeoutMs + powerShellContractTimeout(1) ); it.runIf(process.platform === "win32")( @@ -611,7 +694,7 @@ describe("Windows migration ACL contract", () => { await expect(windowsCopyFileSecurityProbe(sourcePath, targetPath)).resolves.toBeUndefined(); expect(await windowsAclSddl(targetPath, "read")).toBe(expectedSddl); }, - 10_000 + powerShellContractTimeout(3) ); it.runIf(process.platform === "win32")( @@ -626,7 +709,7 @@ describe("Windows migration ACL contract", () => { await expect(verifyWindowsConfigPathSecurity(path, "file")).resolves.toBe(true); }, - 10_000 + powerShellContractTimeout(1) ); it.runIf(process.platform === "win32")( @@ -648,7 +731,7 @@ describe("Windows migration ACL contract", () => { expect(await windowsAclSddl(targetPath, "read")).toBe(expectedSddl); }, - 10_000 + powerShellContractTimeout(3) ); it.runIf(process.platform === "win32")( @@ -674,7 +757,7 @@ describe("Windows migration ACL contract", () => { expect(await windowsAclSddl(targetPath, "read")).toBe(expectedPersistedInheritedDaclSddl(expectedSddl)); }, - privateDirectoryContractTimeoutMs + powerShellContractTimeout(4) ); it.runIf(process.platform === "win32")( @@ -696,7 +779,7 @@ describe("Windows migration ACL contract", () => { await expect(windowsCopyFileSecurityProbe(sourcePath, targetPath)).resolves.toBeUndefined(); expect(await windowsAclSddl(targetPath, "read")).toBe(expectedPersistedInheritedDaclSddl(expectedSddl)); }, - privateDirectoryContractTimeoutMs + powerShellContractTimeout(4) ); it.runIf(process.platform === "win32")( @@ -718,7 +801,7 @@ describe("Windows migration ACL contract", () => { await expect(windowsCopyFileSecurityProbe(sourcePath, targetPath, "verify-access-rules")).resolves.toBeUndefined(); expect(await windowsAclSddl(targetPath, "read")).toBe(expectedPersistedInheritedDaclSddl(expectedSddl)); }, - privateDirectoryContractTimeoutMs + powerShellContractTimeout(4) ); it.runIf(process.platform === "win32")( @@ -740,7 +823,7 @@ describe("Windows migration ACL contract", () => { await expect(windowsCopyFileSecurityProbe(sourcePath, targetPath, "fresh-security")).resolves.toBeUndefined(); expect(await windowsAclSddl(targetPath, "read")).toBe(expectedPersistedInheritedDaclSddl(expectedSddl)); }, - privateDirectoryContractTimeoutMs + powerShellContractTimeout(4) ); it.runIf(process.platform === "win32")( @@ -769,6 +852,6 @@ describe("Windows migration ACL contract", () => { expect(await windowsAclSddl(`${configPath}.bak`, "read")).toBe(expectedSddl); expect(await readFile(`${configPath}.bak`, "utf8")).toBe(source); }, - 20_000 + powerShellContractTimeout(3) ); }); From 898d6d99a2bb15914e8a1012a6278338b276677a Mon Sep 17 00:00:00 2001 From: Mohammed Naji Date: Sun, 9 Aug 2026 19:59:45 +0400 Subject: [PATCH 7/8] fix(cli): preserve retry command arguments (#359) * fix(cli): preserve retry command arguments * fix(cli): label PowerShell retry commands --- src/cli/error-output.ts | 3 +- src/mcp/server/miftah-server.ts | 21 ++++-- src/setup/setup-completion.ts | 18 +---- src/upstream/startup-diagnostic.ts | 7 +- src/utils/shell-command.ts | 17 +++++ tests/cli-error-output.test.ts | 114 ++++++++++++++++++++++++++++- tests/mcp-wrapper.test.ts | 30 ++++++++ tests/package-contract.test.ts | 4 +- 8 files changed, 183 insertions(+), 31 deletions(-) create mode 100644 src/utils/shell-command.ts diff --git a/src/cli/error-output.ts b/src/cli/error-output.ts index e05ef5c0..ac9170f2 100644 --- a/src/cli/error-output.ts +++ b/src/cli/error-output.ts @@ -1,4 +1,5 @@ import { startupDiagnosticFromError, testProfileDiagnosticCommand } from "../upstream/startup-diagnostic.js"; +import { commandInstruction } from "../utils/shell-command.js"; export interface UpstreamFailureCommandContext { readonly configPath: string; @@ -22,6 +23,6 @@ export function formatUpstreamStartupFailure(error: unknown, context: UpstreamFa ...(diagnostic.signal === undefined ? [] : [`Signal: ${diagnostic.signal}`]), ...(diagnostic.truncated ? ["Cause output was truncated."] : []), `Remediation: ${diagnostic.remediation}`, - `Retry: ${testProfileDiagnosticCommand(context.configPath, context.profile)}` + commandInstruction("Retry", testProfileDiagnosticCommand(context.configPath, context.profile)) ].join("\n"); } diff --git a/src/mcp/server/miftah-server.ts b/src/mcp/server/miftah-server.ts index 16a9f2d5..8e958215 100644 --- a/src/mcp/server/miftah-server.ts +++ b/src/mcp/server/miftah-server.ts @@ -69,6 +69,7 @@ import { import { MultiUpstreamProcessManager } from "../../upstream/multi-upstream-process-manager.js"; import type { UpstreamRequestOptions, UpstreamSession } from "../../upstream/upstream-session.js"; import { MiftahError } from "../../utils/errors.js"; +import { commandInstruction } from "../../utils/shell-command.js"; import { MIFTAH_VERSION } from "../../version.js"; import { startupFailureProfile, testProfileDiagnosticCommand } from "../../upstream/startup-diagnostic.js"; import { @@ -270,6 +271,18 @@ const profileSwitchApprovalErrors: ApprovalErrorFactory = { ) }; +/** Keeps the serve warning concise while naming the shell required by its exact retry command. */ +export function formatResourceSubscriptionCapabilityWarning( + safeError: MiftahError, + runtimeConfigPath: string | undefined +): string { + const profile = startupFailureProfile(safeError); + const retry = runtimeConfigPath === undefined || profile === undefined + ? "" + : ` ${commandInstruction("Run", testProfileDiagnosticCommand(runtimeConfigPath, profile))}`; + return `${safeError.message}${retry}`; +} + /** Hosts Miftah's MCP surface and coordinates profile routing, upstream discovery, and client notifications. */ export class MiftahServer { readonly server: Server; @@ -689,11 +702,9 @@ export class MiftahServer { private reportResourceSubscriptionCapabilityFailure(error: unknown): void { const safeError = this.toSafeError(error); - 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" }); + process.emitWarning(formatResourceSubscriptionCapabilityWarning(safeError, this.runtimeConfigPath), { + code: "MIFTAH_RESOURCE_SUBSCRIPTION_CAPABILITY_UNAVAILABLE" + }); } private resetMcpRoots(): void { diff --git a/src/setup/setup-completion.ts b/src/setup/setup-completion.ts index 268bc281..c01df36f 100644 --- a/src/setup/setup-completion.ts +++ b/src/setup/setup-completion.ts @@ -1,4 +1,5 @@ import type { MiftahConfig } from "../config/types.js"; +import { commandInstruction, quoteShellArgument } from "../utils/shell-command.js"; /** * A non-secret statement of what setup actually completed. It deliberately @@ -186,19 +187,6 @@ export function inspectConfigEnvironment( return readiness; } -function quoteForPosixShell(value: string): string { - return `'${value.replaceAll("'", "'\"'\"'")}'`; -} - -function quoteForPowerShell(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -/** Windows completion commands target PowerShell; both forms keep every dynamic value literal. */ -function quoteShellArgument(value: string): string { - return process.platform === "win32" ? quoteForPowerShell(value) : quoteForPosixShell(value); -} - function displayConfigPath(configPath: string | undefined): string { return quoteShellArgument(configPath ?? "CONFIG_PATH"); } @@ -207,10 +195,6 @@ function displayProfile(profile: string): string { return quoteShellArgument(profile); } -function commandInstruction(action: string, command: string): string { - return `${action}${process.platform === "win32" ? " in PowerShell" : ""}: ${command}`; -} - function verificationCompletion(input: SetupCompletionInput): SetupCompletion["verification"] { switch (input.verification) { case "not-declared": diff --git a/src/upstream/startup-diagnostic.ts b/src/upstream/startup-diagnostic.ts index 2b5c7220..5f209e29 100644 --- a/src/upstream/startup-diagnostic.ts +++ b/src/upstream/startup-diagnostic.ts @@ -1,4 +1,5 @@ import { MiftahError, type MiftahErrorCode } from "../utils/errors.js"; +import { quoteShellArgument } from "../utils/shell-command.js"; export type UpstreamStartupDiagnosticKind = "process-exit" | "signal" | "timeout" | "initialization"; @@ -40,11 +41,7 @@ export function startupFailureProfile(error: unknown): string | 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)}`; + return `miftah test-profile --config ${quoteShellArgument(configPath)} --profile ${quoteShellArgument(profile)}`; } diff --git a/src/utils/shell-command.ts b/src/utils/shell-command.ts new file mode 100644 index 00000000..aaa400d0 --- /dev/null +++ b/src/utils/shell-command.ts @@ -0,0 +1,17 @@ +function quoteForPosixShell(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function quoteForPowerShell(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +/** Quotes a literal argument for the shell named by Miftah on the current platform. */ +export function quoteShellArgument(value: string): string { + return process.platform === "win32" ? quoteForPowerShell(value) : quoteForPosixShell(value); +} + +/** Windows command instructions explicitly identify PowerShell, whose quoting rules they use. */ +export function commandInstruction(action: string, command: string): string { + return `${action}${process.platform === "win32" ? " in PowerShell" : ""}: ${command}`; +} diff --git a/tests/cli-error-output.test.ts b/tests/cli-error-output.test.ts index ae558922..e60c0c63 100644 --- a/tests/cli-error-output.test.ts +++ b/tests/cli-error-output.test.ts @@ -1,7 +1,19 @@ +import { execFileSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { formatUpstreamStartupFailure } from "../src/cli/error-output.js"; import { MiftahError } from "../src/utils/errors.js"; +function withPlatform(platform: NodeJS.Platform, callback: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (descriptor === undefined) throw new Error("process.platform descriptor was unavailable"); + Object.defineProperty(process, "platform", { ...descriptor, value: platform }); + try { + return callback(); + } finally { + Object.defineProperty(process, "platform", descriptor); + } +} + describe("CLI upstream error output", () => { it("renders an actionable test-profile failure from safe structured details", () => { const error = new MiftahError( @@ -44,10 +56,108 @@ describe("CLI upstream error output", () => { } }); - expect(formatUpstreamStartupFailure(error, { + const output = withPlatform("linux", () => formatUpstreamStartupFailure(error, { configPath: "/tmp/$HOME/config.json", profile: "team's-profile" - })).toContain("--config '/tmp/$HOME/config.json' --profile 'team'\\\\''s-profile'"); + })); + + expect(output).toContain("--config '/tmp/$HOME/config.json' --profile 'team'\"'\"'s-profile'"); + }); + + it.runIf(process.platform !== "win32")("round-trips diagnostic command arguments through a POSIX shell", () => { + const error = new MiftahError("UPSTREAM_INIT_FAILED", "UPSTREAM_INIT_FAILED", { + startupDiagnostic: { + errorCode: "UPSTREAM_INIT_FAILED", + kind: "initialization", + cause: "safe cause", + truncated: false, + remediation: "Retry." + } + }); + const configPath = "/tmp/$HOME/owner's config.json"; + const profile = "team's-profile"; + const output = formatUpstreamStartupFailure(error, { configPath, profile }); + const retryCommand = output + .split("\n") + .find((line) => line.startsWith("Retry: ")) + ?.slice("Retry: ".length); + + if (retryCommand === undefined) { + throw new Error("Expected a retry command in the formatted diagnostic."); + } + expect(retryCommand).toBe( + "miftah test-profile --config '/tmp/$HOME/owner'\"'\"'s config.json' --profile 'team'\"'\"'s-profile'" + ); + const receivedArguments = execFileSync( + "/bin/sh", + ["-c", ['miftah() { printf "%s\\n" "$@"; }', retryCommand].join("\n")], + { encoding: "utf8" } + ) + .trimEnd() + .split("\n"); + + expect(receivedArguments).toEqual(["test-profile", "--config", configPath, "--profile", profile]); + }); + + it("renders Windows diagnostic command arguments explicitly for PowerShell", () => { + const error = new MiftahError("UPSTREAM_INIT_FAILED", "UPSTREAM_INIT_FAILED", { + startupDiagnostic: { + errorCode: "UPSTREAM_INIT_FAILED", + kind: "initialization", + cause: "safe cause", + truncated: false, + remediation: "Retry." + } + }); + const output = withPlatform("win32", () => formatUpstreamStartupFailure(error, { + configPath: "C:\\Miftah $env:USER owner's config.json", + profile: "team's-profile" + })); + + expect(output).toContain( + "Retry in PowerShell: miftah test-profile --config 'C:\\Miftah $env:USER owner''s config.json' --profile 'team''s-profile'" + ); + }); + + it.runIf(process.platform === "win32")("round-trips diagnostic command arguments through PowerShell", () => { + const error = new MiftahError("UPSTREAM_INIT_FAILED", "UPSTREAM_INIT_FAILED", { + startupDiagnostic: { + errorCode: "UPSTREAM_INIT_FAILED", + kind: "initialization", + cause: "safe cause", + truncated: false, + remediation: "Retry." + } + }); + const configPath = "C:\\Miftah $env:USER owner's config.json"; + const profile = "team's-profile"; + const output = formatUpstreamStartupFailure(error, { configPath, profile }); + const retryCommand = output + .split("\n") + .find((line) => line.startsWith("Retry in PowerShell: ")) + ?.slice("Retry in PowerShell: ".length); + + if (retryCommand === undefined) { + throw new Error("Expected a PowerShell retry command in the formatted diagnostic."); + } + const receivedArguments = execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + [ + "function miftah { $args | ForEach-Object { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$_)) } }", + retryCommand + ].join("; ") + ], + { encoding: "utf8" } + ) + .trim() + .split(/\r?\n/u) + .map((value) => Buffer.from(value, "base64").toString("utf8")); + + expect(receivedArguments).toEqual(["test-profile", "--config", configPath, "--profile", profile]); }); it("falls back to the safe top-level message for unrelated errors", () => { diff --git a/tests/mcp-wrapper.test.ts b/tests/mcp-wrapper.test.ts index f982ff3d..f7654a61 100644 --- a/tests/mcp-wrapper.test.ts +++ b/tests/mcp-wrapper.test.ts @@ -26,6 +26,7 @@ import type { MiftahConfig } from "../src/config/types.js"; import type { AuditScope } from "../src/audit/audit-trail.js"; import { ProfileManager } from "../src/profiles/profile-manager.js"; import { + formatResourceSubscriptionCapabilityWarning, hasCompatibleCachedToolTarget, MiftahServer, resolveClientVisibleToolName @@ -37,11 +38,23 @@ import type { RoutingContextSnapshot } from "../src/routing/routing-types.js"; import { MultiUpstreamProcessManager } from "../src/upstream/multi-upstream-process-manager.js"; import { UpstreamProcessManager } from "../src/upstream/upstream-process-manager.js"; import { IdentityManager } from "../src/identity/identity-manager.js"; +import { MiftahError } from "../src/utils/errors.js"; const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "fake-upstream.mjs"); const toolCollisionPattern = /TOOL_COLLISION/; const managementToolNames = managementToolDescriptors({ delegatedAgentApproval: false }).map((descriptor) => descriptor.name); +function withPlatform(platform: NodeJS.Platform, callback: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (descriptor === undefined) throw new Error("process.platform descriptor was unavailable"); + Object.defineProperty(process, "platform", { ...descriptor, value: platform }); + try { + return callback(); + } finally { + Object.defineProperty(process, "platform", descriptor); + } +} + async function fixtureLifecycleState(initializedPath: string, toolListStartedPath: string) { const [initialized, toolListStarted] = await Promise.all([ access(initializedPath).then(() => true, () => false), @@ -3488,6 +3501,22 @@ describe("Miftah MCP wrapper", () => { } }); + it("labels Windows startup warning commands explicitly for PowerShell", () => { + const error = new MiftahError( + "UPSTREAM_INIT_FAILED", + "UPSTREAM_INIT_FAILED: could not initialize profile", + { profile: "team's-profile" } + ); + const warning = withPlatform("win32", () => formatResourceSubscriptionCapabilityWarning( + error, + "C:\\Miftah $env:USER owner's config.json" + )); + + expect(warning).toBe( + "UPSTREAM_INIT_FAILED: could not initialize profile Run in PowerShell: miftah test-profile --config 'C:\\Miftah $env:USER owner''s config.json' --profile 'team''s-profile'" + ); + }); + 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({ @@ -3530,6 +3559,7 @@ describe("Miftah MCP wrapper", () => { ); const warning = String(emitWarning.mock.calls[0]?.[0]); expect(warning).toContain("UPSTREAM_INIT_FAILED"); + expect(warning).toContain(process.platform === "win32" ? "Run in PowerShell:" : "Run:"); expect(warning).not.toContain("ModuleNotFoundError"); } finally { emitWarning.mockRestore(); diff --git a/tests/package-contract.test.ts b/tests/package-contract.test.ts index 03decb10..ae111b77 100644 --- a/tests/package-contract.test.ts +++ b/tests/package-contract.test.ts @@ -1872,7 +1872,9 @@ describe("packed artifact contract", () => { 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.stderr).toContain( + `${process.platform === "win32" ? "Retry in PowerShell" : "Retry"}: miftah test-profile --config` + ); expect(`${failedInit.stdout}${failedInit.stderr}`).not.toContain(failedInitSecret); expect(await readFile(upstreamShutdownPath, "utf8")).toBe("ended"); From e2f08fab6d22f8c75e1b91442c8b0ef57821fcdd Mon Sep 17 00:00:00 2001 From: Mohammed Naji Date: Sun, 9 Aug 2026 20:25:52 +0400 Subject: [PATCH 8/8] test(cli): run diagnostic output contracts on Windows (#360) * test(cli): run diagnostic output contracts on Windows * test(release): enforce CLI gate order --- package.json | 2 +- tests/release-config.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index dd86a228..0954a469 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "test:package": "vitest run tests/package-contract.test.ts", "test:coverage": "vitest run --coverage", "smoke:cli": "node dist/cli/main.js schema", - "test:cli": "npm run test:package && npm run smoke:cli", + "test:cli": "vitest run tests/cli-error-output.test.ts && npm run test:package && npm run smoke:cli", "test:watch": "vitest", "typecheck": "tsc --noEmit", "lint": "eslint .", diff --git a/tests/release-config.test.ts b/tests/release-config.test.ts index 12fe1acc..1d81ee94 100644 --- a/tests/release-config.test.ts +++ b/tests/release-config.test.ts @@ -101,8 +101,9 @@ describe("continuous integration workflow contract", () => { expect(scripts["test:core"]).toContain("tests/windows-config-migration-acl.test.ts"); expect(scripts["test:package"]).toBe("vitest run tests/package-contract.test.ts"); expect(scripts["smoke:cli"]).toBe("node dist/cli/main.js schema"); - expect(scripts["test:cli"]).toContain("npm run test:package"); - expect(scripts["test:cli"]).toContain("npm run smoke:cli"); + expect(scripts["test:cli"]).toBe( + "vitest run tests/cli-error-output.test.ts && npm run test:package && npm run smoke:cli" + ); expect(scripts["test:coverage"]).toContain("vitest run --coverage"); });