diff --git a/src/cli/error-output.ts b/src/cli/error-output.ts index e05ef5c..ac9170f 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 16a9f2d..8e95821 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 268bc28..c01df36 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 2b5c722..5f209e2 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 0000000..aaa400d --- /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 ae55892..e60c0c6 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 f982ff3..f7654a6 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 03decb1..ae111b7 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");