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
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'
}
}

View File

@ -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',

View File

@ -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': '添加参数',

View File

@ -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

View File

@ -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.',
},
})
})
})

View File

@ -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(() => {})

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