Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions scripts/verify-mcp-spawn.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
70 changes: 69 additions & 1 deletion src/main/broker.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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
Expand All @@ -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', () => {
Expand All @@ -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
Expand Down
72 changes: 64 additions & 8 deletions src/main/mcp-command.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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}`)
}
Expand All @@ -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, '%%')}"`
}
Comment on lines +118 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Windows batch (.cmd/.bat) files, percent signs % are special characters used for variable and parameter expansion (e.g., %1, %VAR%). If the launcherPath contains a % character (for example, in a URL-encoded path or a username containing %), CMD will attempt to expand it, which can corrupt the path and cause execution to fail.

To prevent this, any literal % in the path must be escaped by doubling it to %% inside the batch file.

Suggested change
function windowsCmdQuote(value: string): string {
return `"${value.replace(/"/g, '""')}"`
}
function windowsCmdQuote(value: string): string {
return `"${value.replace(/"/g, '""').replace(/%/g, '%%')}"`
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b693858: literal percent signs are now doubled in generated .cmd shims, with a regression test covering a %USER% launcher path.


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) {
Expand All @@ -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()
Expand Down
Loading