mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat: add a desktop setup terminal without blocking shell restarts
The desktop Settings flow needed a real shell for bundled CLI setup, but the restart path could hang behind old PTY teardown. Wire an xterm.js panel to a portable-pty backend, inject the bundled CLI into the shell bootstrap, and switch to the new session before cleaning up the previous one so restart work stays off the frontend critical path. Constraint: The desktop app must ship its own CLI entrypoint instead of depending on a global Claude install Constraint: PTY teardown must not block the Tauri invoke path Rejected: Reuse the install chat for arbitrary shell commands | it does not provide a real interactive PTY Rejected: Close the old PTY before adopting the new session | it keeps restart vulnerable to hung child shutdown Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep PTY teardown off the invoke path and preserve session handoff ordering when changing terminal lifecycle code Tested: bunx vitest run src/__tests__/terminalPanel.test.tsx src/components/settings/TerminalPanel.restart.test.tsx; bun run lint; cargo check Not-tested: End-to-end command echo inside the packaged desktop terminal still needs follow-up runtime verification
This commit is contained in:
parent
8c99efca20
commit
467debcd8b
@ -9,6 +9,18 @@ const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
|
||||
|
||||
let baseUrl = DEFAULT_BASE_URL
|
||||
|
||||
function getErrorMessage(status: number, body: unknown) {
|
||||
if (body && typeof body === 'object' && 'message' in body && typeof body.message === 'string') {
|
||||
return body.message
|
||||
}
|
||||
|
||||
if (typeof body === 'string' && body.trim().length > 0) {
|
||||
return body
|
||||
}
|
||||
|
||||
return `API error ${status}`
|
||||
}
|
||||
|
||||
export function setBaseUrl(url: string) {
|
||||
baseUrl = url.replace(/\/$/, '')
|
||||
}
|
||||
@ -26,7 +38,7 @@ export class ApiError extends Error {
|
||||
public status: number,
|
||||
public body: unknown,
|
||||
) {
|
||||
super(`API error ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`)
|
||||
super(getErrorMessage(status, body))
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
@ -207,6 +207,7 @@ export const en = {
|
||||
'settings.mcp.form.rawConfig': 'Raw config',
|
||||
'settings.mcp.form.command': 'Command to launch',
|
||||
'settings.mcp.form.commandPlaceholder': 'npx',
|
||||
'settings.mcp.form.commandHostHint': 'STDIO MCP commands run on the host machine. Install runtimes like Node.js, Python, Bun, or uv yourself and make sure the command is available in PATH.',
|
||||
'settings.mcp.form.arguments': 'Arguments',
|
||||
'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest',
|
||||
'settings.mcp.form.addArgument': 'Add argument',
|
||||
|
||||
@ -209,6 +209,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.mcp.form.rawConfig': '原始配置',
|
||||
'settings.mcp.form.command': '启动命令',
|
||||
'settings.mcp.form.commandPlaceholder': 'npx',
|
||||
'settings.mcp.form.commandHostHint': 'STDIO MCP 命令会直接在宿主机上运行。像 Node.js、Python、Bun、uv 这类运行时需要用户自己安装,并确保这个命令在 PATH 里可用。',
|
||||
'settings.mcp.form.arguments': '参数',
|
||||
'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest',
|
||||
'settings.mcp.form.addArgument': '添加参数',
|
||||
|
||||
@ -844,6 +844,9 @@ export function McpSettings() {
|
||||
placeholder={t('settings.mcp.form.commandPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.mcp.form.commandHostHint')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ArraySection
|
||||
|
||||
@ -4,6 +4,7 @@ import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as mcpClient from '../../services/mcp/client.js'
|
||||
import * as mcpConfig from '../../services/mcp/config.js'
|
||||
import * as mcpHostPreflight from '../services/mcpHostPreflight.js'
|
||||
import { handleMcpApi } from '../api/mcp.js'
|
||||
|
||||
let tmpDir: string
|
||||
@ -12,6 +13,7 @@ let originalConfigDir: string | undefined
|
||||
let connectSpy: ReturnType<typeof spyOn> | undefined
|
||||
let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
|
||||
let reconnectSpy: ReturnType<typeof spyOn> | undefined
|
||||
let hostPreflightSpy: ReturnType<typeof spyOn> | undefined
|
||||
|
||||
async function setup() {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
|
||||
@ -55,6 +57,11 @@ describe('MCP API', () => {
|
||||
beforeEach(async () => {
|
||||
await setup()
|
||||
|
||||
hostPreflightSpy = spyOn(mcpHostPreflight, 'inspectMcpHostCommand').mockResolvedValue({
|
||||
ok: true,
|
||||
resolvedCommand: '/usr/bin/mock-command',
|
||||
})
|
||||
|
||||
connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({
|
||||
name,
|
||||
type: 'connected',
|
||||
@ -72,6 +79,8 @@ describe('MCP API', () => {
|
||||
getClaudeCodeMcpConfigsSpy = undefined
|
||||
reconnectSpy?.mockRestore()
|
||||
reconnectSpy = undefined
|
||||
hostPreflightSpy?.mockRestore()
|
||||
hostPreflightSpy = undefined
|
||||
await teardown()
|
||||
})
|
||||
|
||||
@ -132,6 +141,65 @@ describe('MCP API', () => {
|
||||
expect(connectSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects stdio MCP creation when the host command is unavailable', async () => {
|
||||
hostPreflightSpy?.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
message: 'Host command "npx" is not available in PATH.',
|
||||
})
|
||||
|
||||
const create = makeRequest('POST', '/api/mcp', {
|
||||
cwd: projectRoot,
|
||||
name: 'chrome-devtools',
|
||||
scope: 'local',
|
||||
config: {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['chrome-devtools-mcp@latest'],
|
||||
env: {},
|
||||
},
|
||||
})
|
||||
|
||||
const createRes = await handleMcpApi(create.req, create.url, create.segments)
|
||||
|
||||
expect(createRes.status).toBe(400)
|
||||
await expect(createRes.json()).resolves.toMatchObject({
|
||||
message: 'Host command "npx" is not available in PATH.',
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces host preflight failures in live status checks without connecting', async () => {
|
||||
const create = makeRequest('POST', '/api/mcp', {
|
||||
cwd: projectRoot,
|
||||
name: 'chrome-devtools',
|
||||
scope: 'local',
|
||||
config: {
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['chrome-devtools-mcp@latest'],
|
||||
env: {},
|
||||
},
|
||||
})
|
||||
await handleMcpApi(create.req, create.url, create.segments)
|
||||
|
||||
hostPreflightSpy?.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
message: 'Host command "npx" is not available in PATH.',
|
||||
})
|
||||
|
||||
const status = makeRequest('GET', `/api/mcp/chrome-devtools/status?cwd=${encodeURIComponent(projectRoot)}`)
|
||||
const statusRes = await handleMcpApi(status.req, status.url, status.segments)
|
||||
|
||||
expect(statusRes.status).toBe(200)
|
||||
await expect(statusRes.json()).resolves.toMatchObject({
|
||||
server: {
|
||||
name: 'chrome-devtools',
|
||||
status: 'failed',
|
||||
statusDetail: 'Host command "npx" is not available in PATH.',
|
||||
},
|
||||
})
|
||||
expect(connectSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates, toggles, and deletes MCP servers', async () => {
|
||||
const create = makeRequest('POST', '/api/mcp', {
|
||||
cwd: projectRoot,
|
||||
@ -230,4 +298,55 @@ describe('MCP API', () => {
|
||||
expect(body.server.name).toBe(pluginServerName)
|
||||
expect(body.server.scope).toBe('dynamic')
|
||||
})
|
||||
|
||||
it('returns a failed server state when reconnect preflight fails on the host machine', async () => {
|
||||
const pluginServerName = 'plugin:telegram:telegram'
|
||||
const pluginServerConfig = {
|
||||
scope: 'dynamic',
|
||||
type: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['telegram-mcp'],
|
||||
env: {},
|
||||
pluginSource: 'telegram@claude-plugins-official',
|
||||
} as const
|
||||
|
||||
getClaudeCodeMcpConfigsSpy = spyOn(mcpConfig, 'getClaudeCodeMcpConfigs').mockResolvedValue({
|
||||
servers: {
|
||||
[pluginServerName]: pluginServerConfig,
|
||||
},
|
||||
errors: [],
|
||||
})
|
||||
|
||||
hostPreflightSpy?.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
message: 'Host command "npx" is not available in PATH.',
|
||||
})
|
||||
|
||||
reconnectSpy = spyOn(mcpClient, 'reconnectMcpServerImpl').mockResolvedValue({
|
||||
name: pluginServerName,
|
||||
client: {
|
||||
name: pluginServerName,
|
||||
type: 'connected',
|
||||
client: {} as never,
|
||||
capabilities: {},
|
||||
config: pluginServerConfig,
|
||||
cleanup: mock(async () => {}),
|
||||
},
|
||||
})
|
||||
|
||||
const reconnect = makeRequest('POST', `/api/mcp/${encodeURIComponent(pluginServerName)}/reconnect`, {
|
||||
cwd: projectRoot,
|
||||
})
|
||||
const reconnectRes = await handleMcpApi(reconnect.req, reconnect.url, reconnect.segments)
|
||||
|
||||
expect(reconnectRes.status).toBe(200)
|
||||
expect(reconnectSpy).not.toHaveBeenCalled()
|
||||
await expect(reconnectRes.json()).resolves.toMatchObject({
|
||||
server: {
|
||||
name: pluginServerName,
|
||||
status: 'failed',
|
||||
statusDetail: 'Host command "npx" is not available in PATH.',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
removeMcpConfig,
|
||||
setMcpServerEnabled,
|
||||
} from '../../services/mcp/config.js'
|
||||
import { inspectMcpHostCommand } from '../services/mcpHostPreflight.js'
|
||||
import type {
|
||||
ConfigScope,
|
||||
McpHTTPServerConfig,
|
||||
@ -176,6 +177,35 @@ function getInitialStatus(
|
||||
}
|
||||
}
|
||||
|
||||
async function getHostPreflightStatus(
|
||||
config: ScopedMcpServerConfig | McpServerConfig,
|
||||
enabled: boolean,
|
||||
): Promise<Pick<McpServerDto, 'status' | 'statusDetail' | 'statusLabel'> | null> {
|
||||
if (!enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
if ((config.type ?? 'stdio') !== 'stdio') {
|
||||
return null
|
||||
}
|
||||
|
||||
const stdioConfig = config as McpStdioServerConfig
|
||||
const result = await inspectMcpHostCommand(
|
||||
stdioConfig.command,
|
||||
getCwd(),
|
||||
stdioConfig.env,
|
||||
)
|
||||
if (result.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
statusLabel: getStatusLabel('failed'),
|
||||
statusDetail: result.message,
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectServerStatus(
|
||||
name: string,
|
||||
config: ScopedMcpServerConfig,
|
||||
@ -189,6 +219,11 @@ async function inspectServerStatus(
|
||||
}
|
||||
}
|
||||
|
||||
const hostPreflightStatus = await getHostPreflightStatus(config, enabled)
|
||||
if (hostPreflightStatus) {
|
||||
return hostPreflightStatus
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await connectToServer(name, config)
|
||||
await clearServerCache(name, config).catch(() => {})
|
||||
@ -371,6 +406,13 @@ async function getServerStatus(name: string): Promise<Response> {
|
||||
})
|
||||
}
|
||||
|
||||
async function assertHostPrerequisites(config: McpServerConfig) {
|
||||
const hostPreflightStatus = await getHostPreflightStatus(config, true)
|
||||
if (hostPreflightStatus?.statusDetail) {
|
||||
throw ApiError.badRequest(hostPreflightStatus.statusDetail)
|
||||
}
|
||||
}
|
||||
|
||||
async function createServer(body: Record<string, unknown>): Promise<Response> {
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (!name) {
|
||||
@ -379,6 +421,7 @@ async function createServer(body: Record<string, unknown>): Promise<Response> {
|
||||
|
||||
const scope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : undefined)
|
||||
const config = buildServerConfig(body.config)
|
||||
await assertHostPrerequisites(config)
|
||||
|
||||
try {
|
||||
await addMcpConfig(name, config, scope)
|
||||
@ -410,6 +453,7 @@ async function updateServer(name: string, body: Record<string, unknown>): Promis
|
||||
|
||||
const nextScope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : existing.scope)
|
||||
const nextConfig = buildServerConfig(body.config)
|
||||
await assertHostPrerequisites(nextConfig)
|
||||
const previousConfig = stripScope(existing)
|
||||
const previousScope = existing.scope
|
||||
|
||||
@ -470,6 +514,14 @@ async function toggleServer(name: string): Promise<Response> {
|
||||
return Response.json({ server: updated })
|
||||
}
|
||||
|
||||
const hostPreflightStatus = await getHostPreflightStatus(existing, true)
|
||||
if (hostPreflightStatus) {
|
||||
await clearServerCache(name, existing).catch(() => {})
|
||||
return Response.json({
|
||||
server: buildServerDto(name, existing, hostPreflightStatus),
|
||||
})
|
||||
}
|
||||
|
||||
const result = await reconnectMcpServerImpl(name, existing)
|
||||
await clearServerCache(name, existing).catch(() => {})
|
||||
|
||||
@ -491,6 +543,14 @@ async function reconnectServer(name: string): Promise<Response> {
|
||||
throw ApiError.notFound(`MCP server not found: ${name}`)
|
||||
}
|
||||
|
||||
const hostPreflightStatus = await getHostPreflightStatus(existing, !isMcpServerDisabled(name))
|
||||
if (hostPreflightStatus) {
|
||||
await clearServerCache(name, existing).catch(() => {})
|
||||
return Response.json({
|
||||
server: buildServerDto(name, existing, hostPreflightStatus),
|
||||
})
|
||||
}
|
||||
|
||||
const result = await reconnectMcpServerImpl(name, existing)
|
||||
await clearServerCache(name, existing).catch(() => {})
|
||||
|
||||
|
||||
166
src/server/services/mcpHostPreflight.ts
Normal file
166
src/server/services/mcpHostPreflight.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { access } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { which } from '../../utils/which.js'
|
||||
|
||||
type HostCommandCheckResult =
|
||||
| {
|
||||
ok: true
|
||||
resolvedCommand: string
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
message: string
|
||||
}
|
||||
|
||||
function getPathSearchList(envPath?: string) {
|
||||
return (envPath ?? process.env.PATH ?? '')
|
||||
.split(path.delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getWindowsExecutableCandidates(command: string) {
|
||||
if (process.platform !== 'win32') {
|
||||
return [command]
|
||||
}
|
||||
|
||||
const ext = path.extname(command)
|
||||
if (ext) {
|
||||
return [command]
|
||||
}
|
||||
|
||||
const pathext = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM')
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return [command, ...pathext.map((entry) => `${command}${entry.toLowerCase()}`)]
|
||||
}
|
||||
|
||||
function isPathLikeCommand(command: string) {
|
||||
return (
|
||||
path.isAbsolute(command) ||
|
||||
command.startsWith('./') ||
|
||||
command.startsWith('../') ||
|
||||
command.startsWith('.\\') ||
|
||||
command.startsWith('..\\') ||
|
||||
command.includes('/') ||
|
||||
command.includes('\\')
|
||||
)
|
||||
}
|
||||
|
||||
function buildRuntimeHint(command: string) {
|
||||
const normalized = path.basename(command).toLowerCase()
|
||||
|
||||
if (['node', 'npm', 'npx', 'pnpm', 'yarn'].includes(normalized)) {
|
||||
return `Install Node.js on this machine so "${command}" is available in PATH, then retry.`
|
||||
}
|
||||
|
||||
if (['python', 'python3', 'pip', 'pip3'].includes(normalized)) {
|
||||
return `Install Python on this machine so "${command}" is available in PATH, then retry.`
|
||||
}
|
||||
|
||||
if (normalized === 'uv') {
|
||||
return 'Install uv on this machine so "uv" is available in PATH, then retry.'
|
||||
}
|
||||
|
||||
if (normalized === 'bun') {
|
||||
return 'Install Bun on this machine so "bun" is available in PATH, then retry.'
|
||||
}
|
||||
|
||||
return `Install "${command}" on this machine or update PATH, then retry.`
|
||||
}
|
||||
|
||||
function buildMissingCommandMessage(command: string) {
|
||||
return `Host command "${command}" is not available in PATH. This STDIO MCP runs on the host machine. ${buildRuntimeHint(command)}`
|
||||
}
|
||||
|
||||
function buildMissingPathMessage(command: string, resolvedPath: string) {
|
||||
return `Host command path "${command}" could not be found at "${resolvedPath}". This STDIO MCP runs on the host machine, so the configured executable path must exist locally.`
|
||||
}
|
||||
|
||||
function buildNonExecutablePathMessage(command: string, resolvedPath: string) {
|
||||
return `Host command path "${command}" exists at "${resolvedPath}" but is not executable. This STDIO MCP runs on the host machine, so the configured executable must be runnable by the local OS user.`
|
||||
}
|
||||
|
||||
async function resolveCommandFromPath(
|
||||
command: string,
|
||||
envPath?: string,
|
||||
): Promise<string | null> {
|
||||
const pathEntries = getPathSearchList(envPath)
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
for (const candidate of getWindowsExecutableCandidates(command)) {
|
||||
const resolvedPath = path.join(entry, candidate)
|
||||
try {
|
||||
await access(
|
||||
resolvedPath,
|
||||
process.platform === 'win32' ? constants.F_OK : constants.X_OK,
|
||||
)
|
||||
return resolvedPath
|
||||
} catch {
|
||||
// Continue searching other PATH entries.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function inspectMcpHostCommand(
|
||||
command: string,
|
||||
cwd: string = getCwd(),
|
||||
env?: Record<string, string>,
|
||||
): Promise<HostCommandCheckResult> {
|
||||
const trimmedCommand = command.trim()
|
||||
if (!trimmedCommand) {
|
||||
return {
|
||||
ok: false,
|
||||
message: 'STDIO MCP command is empty.',
|
||||
}
|
||||
}
|
||||
|
||||
if (isPathLikeCommand(trimmedCommand)) {
|
||||
const resolvedPath = path.isAbsolute(trimmedCommand)
|
||||
? trimmedCommand
|
||||
: path.resolve(cwd, trimmedCommand)
|
||||
|
||||
try {
|
||||
await access(
|
||||
resolvedPath,
|
||||
process.platform === 'win32' ? constants.F_OK : constants.X_OK,
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
resolvedCommand: resolvedPath,
|
||||
}
|
||||
} catch (error) {
|
||||
const maybeErr = error as NodeJS.ErrnoException
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
maybeErr.code === 'ENOENT'
|
||||
? buildMissingPathMessage(trimmedCommand, resolvedPath)
|
||||
: buildNonExecutablePathMessage(trimmedCommand, resolvedPath),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedCommand =
|
||||
env?.PATH
|
||||
? await resolveCommandFromPath(trimmedCommand, env.PATH)
|
||||
: await which(trimmedCommand)
|
||||
if (resolvedCommand) {
|
||||
return {
|
||||
ok: true,
|
||||
resolvedCommand,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: buildMissingCommandMessage(trimmedCommand),
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user