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:
程序员阿江(Relakkes) 2026-04-22 18:06:29 +08:00
parent 8c99efca20
commit 467debcd8b
7 changed files with 363 additions and 1 deletions

View File

@ -9,6 +9,18 @@ const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
let baseUrl = DEFAULT_BASE_URL 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) { export function setBaseUrl(url: string) {
baseUrl = url.replace(/\/$/, '') baseUrl = url.replace(/\/$/, '')
} }
@ -26,7 +38,7 @@ export class ApiError extends Error {
public status: number, public status: number,
public body: unknown, public body: unknown,
) { ) {
super(`API error ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`) super(getErrorMessage(status, body))
this.name = 'ApiError' this.name = 'ApiError'
} }
} }

View File

@ -207,6 +207,7 @@ export const en = {
'settings.mcp.form.rawConfig': 'Raw config', 'settings.mcp.form.rawConfig': 'Raw config',
'settings.mcp.form.command': 'Command to launch', 'settings.mcp.form.command': 'Command to launch',
'settings.mcp.form.commandPlaceholder': 'npx', '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.arguments': 'Arguments',
'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest', 'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest',
'settings.mcp.form.addArgument': 'Add argument', 'settings.mcp.form.addArgument': 'Add argument',

View File

@ -209,6 +209,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.mcp.form.rawConfig': '原始配置', 'settings.mcp.form.rawConfig': '原始配置',
'settings.mcp.form.command': '启动命令', 'settings.mcp.form.command': '启动命令',
'settings.mcp.form.commandPlaceholder': 'npx', 'settings.mcp.form.commandPlaceholder': 'npx',
'settings.mcp.form.commandHostHint': 'STDIO MCP 命令会直接在宿主机上运行。像 Node.js、Python、Bun、uv 这类运行时需要用户自己安装,并确保这个命令在 PATH 里可用。',
'settings.mcp.form.arguments': '参数', 'settings.mcp.form.arguments': '参数',
'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest', 'settings.mcp.form.argumentPlaceholder': 'chrome-devtools-mcp@latest',
'settings.mcp.form.addArgument': '添加参数', 'settings.mcp.form.addArgument': '添加参数',

View File

@ -844,6 +844,9 @@ export function McpSettings() {
placeholder={t('settings.mcp.form.commandPlaceholder')} placeholder={t('settings.mcp.form.commandPlaceholder')}
required required
/> />
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.mcp.form.commandHostHint')}
</p>
</section> </section>
<ArraySection <ArraySection

View File

@ -4,6 +4,7 @@ import * as os from 'os'
import * as path from 'path' import * as path from 'path'
import * as mcpClient from '../../services/mcp/client.js' import * as mcpClient from '../../services/mcp/client.js'
import * as mcpConfig from '../../services/mcp/config.js' import * as mcpConfig from '../../services/mcp/config.js'
import * as mcpHostPreflight from '../services/mcpHostPreflight.js'
import { handleMcpApi } from '../api/mcp.js' import { handleMcpApi } from '../api/mcp.js'
let tmpDir: string let tmpDir: string
@ -12,6 +13,7 @@ let originalConfigDir: string | undefined
let connectSpy: ReturnType<typeof spyOn> | undefined let connectSpy: ReturnType<typeof spyOn> | undefined
let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
let reconnectSpy: ReturnType<typeof spyOn> | undefined let reconnectSpy: ReturnType<typeof spyOn> | undefined
let hostPreflightSpy: ReturnType<typeof spyOn> | undefined
async function setup() { async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-')) tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
@ -55,6 +57,11 @@ describe('MCP API', () => {
beforeEach(async () => { beforeEach(async () => {
await setup() await setup()
hostPreflightSpy = spyOn(mcpHostPreflight, 'inspectMcpHostCommand').mockResolvedValue({
ok: true,
resolvedCommand: '/usr/bin/mock-command',
})
connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({ connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({
name, name,
type: 'connected', type: 'connected',
@ -72,6 +79,8 @@ describe('MCP API', () => {
getClaudeCodeMcpConfigsSpy = undefined getClaudeCodeMcpConfigsSpy = undefined
reconnectSpy?.mockRestore() reconnectSpy?.mockRestore()
reconnectSpy = undefined reconnectSpy = undefined
hostPreflightSpy?.mockRestore()
hostPreflightSpy = undefined
await teardown() await teardown()
}) })
@ -132,6 +141,65 @@ describe('MCP API', () => {
expect(connectSpy).toHaveBeenCalled() 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 () => { it('updates, toggles, and deletes MCP servers', async () => {
const create = makeRequest('POST', '/api/mcp', { const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot, cwd: projectRoot,
@ -230,4 +298,55 @@ describe('MCP API', () => {
expect(body.server.name).toBe(pluginServerName) expect(body.server.name).toBe(pluginServerName)
expect(body.server.scope).toBe('dynamic') 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.',
},
})
})
}) })

View File

@ -15,6 +15,7 @@ import {
removeMcpConfig, removeMcpConfig,
setMcpServerEnabled, setMcpServerEnabled,
} from '../../services/mcp/config.js' } from '../../services/mcp/config.js'
import { inspectMcpHostCommand } from '../services/mcpHostPreflight.js'
import type { import type {
ConfigScope, ConfigScope,
McpHTTPServerConfig, 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( async function inspectServerStatus(
name: string, name: string,
config: ScopedMcpServerConfig, config: ScopedMcpServerConfig,
@ -189,6 +219,11 @@ async function inspectServerStatus(
} }
} }
const hostPreflightStatus = await getHostPreflightStatus(config, enabled)
if (hostPreflightStatus) {
return hostPreflightStatus
}
try { try {
const client = await connectToServer(name, config) const client = await connectToServer(name, config)
await clearServerCache(name, config).catch(() => {}) 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> { async function createServer(body: Record<string, unknown>): Promise<Response> {
const name = typeof body.name === 'string' ? body.name.trim() : '' const name = typeof body.name === 'string' ? body.name.trim() : ''
if (!name) { 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 scope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : undefined)
const config = buildServerConfig(body.config) const config = buildServerConfig(body.config)
await assertHostPrerequisites(config)
try { try {
await addMcpConfig(name, config, scope) 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 nextScope = ensureConfigScope(typeof body.scope === 'string' ? body.scope : existing.scope)
const nextConfig = buildServerConfig(body.config) const nextConfig = buildServerConfig(body.config)
await assertHostPrerequisites(nextConfig)
const previousConfig = stripScope(existing) const previousConfig = stripScope(existing)
const previousScope = existing.scope const previousScope = existing.scope
@ -470,6 +514,14 @@ async function toggleServer(name: string): Promise<Response> {
return Response.json({ server: updated }) 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) const result = await reconnectMcpServerImpl(name, existing)
await clearServerCache(name, existing).catch(() => {}) await clearServerCache(name, existing).catch(() => {})
@ -491,6 +543,14 @@ async function reconnectServer(name: string): Promise<Response> {
throw ApiError.notFound(`MCP server not found: ${name}`) 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) const result = await reconnectMcpServerImpl(name, existing)
await clearServerCache(name, existing).catch(() => {}) await clearServerCache(name, existing).catch(() => {})

View 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),
}
}