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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli/error-output.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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");
}
21 changes: 16 additions & 5 deletions src/mcp/server/miftah-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 1 addition & 17 deletions src/setup/setup-completion.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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");
}
Expand All @@ -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":
Expand Down
7 changes: 2 additions & 5 deletions src/upstream/startup-diagnostic.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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)}`;
}
17 changes: 17 additions & 0 deletions src/utils/shell-command.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
114 changes: 112 additions & 2 deletions tests/cli-error-output.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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(
Expand Down Expand Up @@ -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", () => {
Expand Down
30 changes: 30 additions & 0 deletions tests/mcp-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T>(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),
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion tests/package-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down