diff --git a/scripts/verify-mcp-spawn.mjs b/scripts/verify-mcp-spawn.mjs index 29b91616..514fb3d2 100755 --- a/scripts/verify-mcp-spawn.mjs +++ b/scripts/verify-mcp-spawn.mjs @@ -243,8 +243,23 @@ const resourcesPath = appResourcesPath(appPath) const { launcherPath } = assertExternalPayload(rootDir, resourcesPath) const commandPath = await resolvePackagedMcpCommand(rootDir, resourcesPath, appExecutablePath(appPath)) -if (commandPath !== launcherPath) { - fail(`resolver emitted ${commandPath}, expected packaged launcher ${launcherPath}`) +if (!existsSync(commandPath)) { + fail(`resolver emitted missing MCP command path: ${commandPath}`) +} +if (!statSync(commandPath).isFile()) { + fail(`resolver emitted non-file MCP command path: ${commandPath}`) +} +if (process.platform !== 'win32' && (statSync(commandPath).mode & 0o111) === 0) { + fail(`resolver emitted non-executable MCP command path: ${commandPath}`) +} +if (commandPath.includes('app.asar')) { + fail(`resolver emitted app.asar MCP command path: ${commandPath}`) +} +if (/\s/.test(commandPath)) { + fail(`resolver emitted MCP command path with whitespace: ${commandPath}`) +} +if (commandPath !== launcherPath && !commandPath.includes(`${sep}pear-agent-relay-mcp${sep}`)) { + fail(`resolver emitted unexpected MCP shim path ${commandPath}, packaged launcher was ${launcherPath}`) } await verifyInitialize(commandPath).catch((error) => fail(error.message)) diff --git a/src/main/broker.test.ts b/src/main/broker.test.ts index 38cfc4b7..fd147437 100644 --- a/src/main/broker.test.ts +++ b/src/main/broker.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -130,6 +130,16 @@ import { BrokerManager, resolveAgentRelayMcpCommand } from './broker' const PROJECT_ID = 'project-1' const originalMcpCommand = process.env.AGENT_RELAY_MCP_COMMAND const originalResourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') +const originalPublicEnv = process.env.PUBLIC +const originalProgramDataEnv = process.env.ProgramData + +function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} function setProcessResourcesPath(resourcesPath: string): void { Object.defineProperty(process, 'resourcesPath', { @@ -195,6 +205,7 @@ async function attachCloud(manager: BrokerManager, agents: string[] = []): Promi describe('resolveAgentRelayMcpCommand', () => { let tempDir: string | null = null + let extraTempDir: string | null = null afterEach(async () => { electronMock.app.isPackaged = false @@ -204,8 +215,23 @@ describe('resolveAgentRelayMcpCommand', () => { process.env.AGENT_RELAY_MCP_COMMAND = originalMcpCommand } restoreProcessResourcesPath() + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } + if (originalPublicEnv === undefined) { + delete process.env.PUBLIC + } else { + process.env.PUBLIC = originalPublicEnv + } + if (originalProgramDataEnv === undefined) { + delete process.env.ProgramData + } else { + process.env.ProgramData = originalProgramDataEnv + } if (tempDir) await rm(tempDir, { recursive: true, force: true }) tempDir = null + if (extraTempDir) await rm(extraTempDir, { recursive: true, force: true }) + extraTempDir = null }) it('rejects asar-internal MCP command overrides in packaged mode', () => { @@ -232,6 +258,48 @@ describe('resolveAgentRelayMcpCommand', () => { expect(command).not.toContain('app.asar') }) + it('uses a no-space shim for packaged MCP launcher paths containing spaces', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'pear mcp resources-')) + const launcherPath = join(tempDir, 'agent-relay-mcp', process.platform === 'win32' ? 'launch.cmd' : 'launch.sh') + await mkdir(dirname(launcherPath), { recursive: true }) + await writeFile(launcherPath, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n') + await chmod(launcherPath, 0o755) + electronMock.app.isPackaged = true + delete process.env.AGENT_RELAY_MCP_COMMAND + setProcessResourcesPath(tempDir) + + const command = resolveAgentRelayMcpCommand() + + expect(command).toMatch(/pear-agent-relay-mcp/) + expect(command).not.toContain('app.asar') + expect(command).not.toMatch(/\s/) + expect(await readFile(command!, 'utf8')).toContain(launcherPath) + }) + + it('escapes percent signs in packaged Windows MCP shims', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'pear mcp %USER% resources-')) + extraTempDir = await mkdtemp(join(tmpdir(), 'pear-public-')) + const publicDir = extraTempDir + const launcherPath = join(tempDir, 'agent-relay-mcp', 'launch.cmd') + await mkdir(dirname(launcherPath), { recursive: true }) + await mkdir(publicDir, { recursive: true }) + await writeFile(launcherPath, '@echo off\r\n') + setProcessPlatform('win32') + process.env.PUBLIC = publicDir + process.env.ProgramData = '' + electronMock.app.isPackaged = true + delete process.env.AGENT_RELAY_MCP_COMMAND + setProcessResourcesPath(tempDir) + + const command = resolveAgentRelayMcpCommand() + + expect(command).toContain('pear-agent-relay-mcp') + expect(command).not.toMatch(/\s/) + const content = await readFile(command!, 'utf8') + expect(content).toContain('%USER%'.replace(/%/g, '%%')) + expect(content).not.toContain('%USER% resources') + }) + it('fails packaged MCP resolution when the external launcher is missing', async () => { tempDir = await mkdtemp(join(tmpdir(), 'pear-mcp-resources-')) electronMock.app.isPackaged = true diff --git a/src/main/mcp-command.ts b/src/main/mcp-command.ts index 1035f746..fec0fec1 100644 --- a/src/main/mcp-command.ts +++ b/src/main/mcp-command.ts @@ -1,6 +1,8 @@ -import { accessSync, constants, existsSync, readFileSync } from 'fs' +import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { execFileSync } from 'child_process' +import { createHash } from 'node:crypto' import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' import { basename, delimiter, dirname, join } from 'path' const requireForResolve = createRequire(import.meta.url) @@ -13,14 +15,16 @@ export interface AgentRelayMcpCommandOptions { resourcesPath?: string } -const PACKAGED_AGENT_RELAY_MCP_LAUNCHER = [ - 'agent-relay-mcp', - process.platform === 'win32' ? 'launch.cmd' : 'launch.sh' -] +function packagedAgentRelayMcpLauncherParts(): string[] { + return [ + 'agent-relay-mcp', + process.platform === 'win32' ? 'launch.cmd' : 'launch.sh' + ] +} export function canExecute(filePath: string): boolean { try { - accessSync(filePath, constants.X_OK) + accessSync(filePath, process.platform === 'win32' ? constants.F_OK : constants.X_OK) return true } catch { return false @@ -97,7 +101,7 @@ function resolvePackagedAgentRelayMcpLauncher(resourcesPath?: string): string { throw new Error('Unable to resolve packaged Agent Relay MCP resources path') } - const candidate = join(resourcesPath, ...PACKAGED_AGENT_RELAY_MCP_LAUNCHER) + const candidate = join(resourcesPath, ...packagedAgentRelayMcpLauncherParts()) if (hasAsarPathSegment(candidate)) { throw new Error(`Packaged Agent Relay MCP launcher resolved inside app.asar: ${candidate}`) } @@ -107,6 +111,58 @@ function resolvePackagedAgentRelayMcpLauncher(resourcesPath?: string): string { return candidate } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'` +} + +function windowsCmdQuote(value: string): string { + return `"${value.replace(/"/g, '""').replace(/%/g, '%%')}"` +} + +function packagedMcpShimRoots(): string[] { + // Use /tmp on POSIX so the MCP command handed to Codex has no spaces even + // when the app is installed as "Pear by Agent Relay.app". + if (process.platform !== 'win32') return ['/tmp'] + + return [ + tmpdir(), + process.env.PUBLIC || 'C:\\Users\\Public', + process.env.ProgramData || 'C:\\ProgramData' + ].filter((candidate, index, candidates) => candidate && !/\s/.test(candidate) && candidates.indexOf(candidate) === index) +} + +function materializePackagedAgentRelayMcpShim(launcherPath: string): string { + if (!/\s/.test(launcherPath)) return launcherPath + + const hash = createHash('sha256').update(launcherPath).digest('hex').slice(0, 16) + const content = process.platform === 'win32' + ? `@echo off\r\n${windowsCmdQuote(launcherPath)} %*\r\n` + : `#!/bin/sh\nexec ${shellQuote(launcherPath)} "$@"\n` + const errors: string[] = [] + + for (const root of packagedMcpShimRoots()) { + const shimDir = join(root, 'pear-agent-relay-mcp', hash) + const shimPath = join(shimDir, process.platform === 'win32' ? 'launch.cmd' : 'launch.sh') + if (/\s/.test(shimPath)) continue + + try { + mkdirSync(shimDir, { recursive: true }) + if (!existsSync(shimPath) || readFileSync(shimPath, 'utf8') !== content) { + writeFileSync(shimPath, content, 'utf8') + } + if (process.platform !== 'win32') chmodSync(shimPath, 0o755) + if (!canExecute(shimPath)) { + throw new Error(`shim is missing or not executable`) + } + return shimPath + } catch (err) { + errors.push(`${shimPath}: ${err instanceof Error ? err.message : String(err)}`) + } + } + + throw new Error(`Unable to create whitespace-free packaged Agent Relay MCP launcher shim for ${launcherPath}${errors.length ? `: ${errors.join('; ')}` : ''}`) +} + function resolveBundledAgentRelayMcpScript(): string | undefined { const packageCommand = resolvePackageBin('agent-relay', 'agent-relay') if (packageCommand) { @@ -130,7 +186,7 @@ export function resolveAgentRelayMcpCommand(options: AgentRelayMcpCommandOptions } if (options.isPackaged) { - return assertNoAsarMcpCommand(resolvePackagedAgentRelayMcpLauncher(options.resourcesPath)) + return assertNoAsarMcpCommand(materializePackagedAgentRelayMcpShim(resolvePackagedAgentRelayMcpLauncher(options.resourcesPath))) } const bundledMcpScript = resolveBundledAgentRelayMcpScript()