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
41 changes: 37 additions & 4 deletions src/main/broker-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ import { app } from 'electron'
import { canExecute } from './mcp-command'

function resolveBrokerBinaryOverride(): string | null {
const configured = process.env.AGENT_RELAY_BIN?.trim()
const brokerOverride = process.env.BROKER_BINARY_PATH?.trim()
const legacyOverride = process.env.AGENT_RELAY_BIN?.trim()
const configured = brokerOverride || legacyOverride
if (!configured) return null
if (canExecute(configured)) return configured
console.warn('[broker] Ignoring AGENT_RELAY_BIN because it is not executable:', configured)
const variable = brokerOverride ? 'BROKER_BINARY_PATH' : 'AGENT_RELAY_BIN'
console.warn(`[broker] Ignoring ${variable} because it is not executable:`, configured)
return null
}

Expand Down Expand Up @@ -107,6 +110,29 @@ function inspectBrokerInitCliFlags(binaryPath: string): Promise<BrokerInitCliFla
})
}

function inspectAgentRelayCloudCLI(binaryPath: string): Promise<boolean> {
return new Promise((resolve) => {
execFile(binaryPath, ['cloud', 'session', '--help'], {
encoding: 'utf8',
timeout: 2_000,
windowsHide: true,
// npm exposes global CLIs as .cmd wrappers on Windows.
shell: process.platform === 'win32'
}, (_err, stdout, stderr) => {
// Help may be written to stderr and some wrappers exit non-zero after
// printing it. The command signature, not the exit code, is the
// capability contract we need to establish.
resolve(/agent-relay cloud session/i.test(`${stdout || ''}${stderr || ''}`))
})
})
}
Comment thread
khaliqgant marked this conversation as resolved.

async function resolveWorkerAgentRelayCLIOverride(brokerBinaryPath: string): Promise<string | undefined> {
const configured = process.env.AGENT_RELAY_BIN?.trim()
if (!configured || configured === brokerBinaryPath || !canExecute(configured)) return undefined
return await inspectAgentRelayCloudCLI(configured) ? configured : undefined
}

function brokerBinaryCompatShimSource(): string {
return `#!/usr/bin/env node
const { spawn } = require('node:child_process')
Expand Down Expand Up @@ -203,9 +229,15 @@ async function ensureBrokerBinaryCompatShim(): Promise<string> {
// `binaryPath` is what Pear launches (possibly the legacy compat shim);
// `realBinaryPath` is always the actual broker binary, for callers that hand
// the binary itself to other processes.
export async function resolveHarnessBrokerBinary(workspaceKey?: string): Promise<{ binaryPath: string; realBinaryPath: string; env: NodeJS.ProcessEnv }> {
export async function resolveHarnessBrokerBinary(workspaceKey?: string): Promise<{
binaryPath: string
realBinaryPath: string
agentRelayCLIPath: string | undefined
env: NodeJS.ProcessEnv
}> {
const binaryPath = resolveBundledBrokerBinary()
const flags = await inspectBrokerInitCliFlags(binaryPath)
const agentRelayCLIPath = await resolveWorkerAgentRelayCLIOverride(binaryPath)

if (workspaceKey && !flags.supportsWorkspaceKey) {
throw new Error(
Expand All @@ -214,7 +246,7 @@ export async function resolveHarnessBrokerBinary(workspaceKey?: string): Promise
}

if (flags.supportsInstanceName) {
return { binaryPath, realBinaryPath: binaryPath, env: {} }
return { binaryPath, realBinaryPath: binaryPath, agentRelayCLIPath, env: {} }
}

if (!flags.supportsName) {
Expand All @@ -226,6 +258,7 @@ export async function resolveHarnessBrokerBinary(workspaceKey?: string): Promise
return {
binaryPath: shimPath,
realBinaryPath: binaryPath,
agentRelayCLIPath,
env: {
PEAR_AGENT_RELAY_BROKER_BINARY: binaryPath,
PEAR_AGENT_RELAY_BROKER_SUPPORTS_INSTANCE_NAME: flags.supportsInstanceName ? '1' : '0',
Expand Down
85 changes: 85 additions & 0 deletions src/main/broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ import {
const PROJECT_ID = 'project-1'
const originalMcpCommand = process.env.AGENT_RELAY_MCP_COMMAND
const originalAgentRelayBin = process.env.AGENT_RELAY_BIN
const originalBrokerBinaryPath = process.env.BROKER_BINARY_PATH
const originalResourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath')
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
const originalPublicEnv = process.env.PUBLIC
Expand Down Expand Up @@ -508,6 +509,7 @@ describe('resolveBundledBrokerBinary', () => {

beforeEach(() => {
electronMock.app.isPackaged = false
delete process.env.BROKER_BINARY_PATH
if (originalAgentRelayBin === undefined) {
delete process.env.AGENT_RELAY_BIN
} else {
Expand All @@ -517,6 +519,11 @@ describe('resolveBundledBrokerBinary', () => {

afterEach(async () => {
electronMock.app.isPackaged = false
if (originalBrokerBinaryPath === undefined) {
delete process.env.BROKER_BINARY_PATH
} else {
process.env.BROKER_BINARY_PATH = originalBrokerBinaryPath
}
if (originalAgentRelayBin === undefined) {
delete process.env.AGENT_RELAY_BIN
} else {
Expand Down Expand Up @@ -560,6 +567,7 @@ describe('resolveBundledBrokerBinary', () => {
})

it('checks the packaged optional broker binary in app.asar.unpacked', async () => {
delete process.env.AGENT_RELAY_BIN
tempDir = await mkdtemp(join(tmpdir(), 'pear-broker-asar-'))
const baseDir = join(tempDir, 'Resources', 'app.asar', 'out', 'main')
const brokerPath = join(
Expand Down Expand Up @@ -601,6 +609,7 @@ describe('BrokerManager local + cloud coexistence', () => {
Object.defineProperty(process, 'platform', originalPlatformDescriptor)
}
delete process.env.AGENT_RELAY_WORKSPACE_KEY
delete process.env.BROKER_BINARY_PATH
if (originalAgentRelayBin === undefined) {
delete process.env.AGENT_RELAY_BIN
} else {
Expand Down Expand Up @@ -634,6 +643,16 @@ describe('BrokerManager local + cloud coexistence', () => {
} else {
process.env.AGENT_RELAY_WORKSPACE_KEY = inheritedWorkspaceKey
}
if (originalAgentRelayBin === undefined) {
delete process.env.AGENT_RELAY_BIN
} else {
process.env.AGENT_RELAY_BIN = originalAgentRelayBin
}
if (originalBrokerBinaryPath === undefined) {
delete process.env.BROKER_BINARY_PATH
} else {
process.env.BROKER_BINARY_PATH = originalBrokerBinaryPath
}
electronMock.app.isPackaged = false
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor)
Expand Down Expand Up @@ -819,6 +838,72 @@ describe('BrokerManager local + cloud coexistence', () => {
}
})

it('does not expose the native broker as the spawned worker Agent Relay CLI', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'pear-worker-broker-env-'))
const brokerPath = join(tempDir, 'agent-relay-broker')
await writeFile(brokerPath, `#!/bin/sh
if [ "$*" = "init --help" ]; then
echo "--instance-name --workspace-key"
fi
exit 0
`)
await chmod(brokerPath, 0o755)
process.env.AGENT_RELAY_BIN = brokerPath
delete process.env.BROKER_BINARY_PATH
const manager = new BrokerManager()

try {
await manager.start(PROJECT_ID, '/tmp/project-1', 'pear-project-1', undefined as never, [])

const spawnOptions = mock.HarnessDriverClient.spawn.mock.calls[0]?.[0] as {
env?: NodeJS.ProcessEnv
} | undefined
expect(spawnOptions?.env?.BROKER_BINARY_PATH).toBe(brokerPath)
expect(spawnOptions?.env).not.toHaveProperty('AGENT_RELAY_BIN')
expect(spawnOptions?.env?.AGENT_RELAY_BIN).toBeUndefined()
} finally {
await manager.shutdown()
await rm(tempDir, { recursive: true, force: true })
}
})

it('preserves an intentional Agent Relay CLI override separately from the broker', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'pear-worker-cli-env-'))
const brokerPath = join(tempDir, 'agent-relay-broker')
const cliPath = join(tempDir, 'agent-relay')
await writeFile(brokerPath, `#!/bin/sh
if [ "$*" = "init --help" ]; then
echo "--instance-name --workspace-key"
fi
exit 0
`)
await writeFile(cliPath, `#!/bin/sh
if [ "$*" = "cloud session --help" ]; then
echo "Usage: agent-relay cloud session [options]" >&2
exit 2
fi
exit 2
`)
await chmod(brokerPath, 0o755)
await chmod(cliPath, 0o755)
process.env.BROKER_BINARY_PATH = brokerPath
process.env.AGENT_RELAY_BIN = cliPath
const manager = new BrokerManager()

try {
await manager.start(PROJECT_ID, '/tmp/project-1', 'pear-project-1', undefined as never, [])

const spawnOptions = mock.HarnessDriverClient.spawn.mock.calls[0]?.[0] as {
env?: NodeJS.ProcessEnv
} | undefined
expect(spawnOptions?.env?.BROKER_BINARY_PATH).toBe(brokerPath)
expect(spawnOptions?.env?.AGENT_RELAY_BIN).toBe(cliPath)
} finally {
await manager.shutdown()
await rm(tempDir, { recursive: true, force: true })
}
})

it('reads the local broker workspace key for cloud provisioning', async () => {
const manager = new BrokerManager()
const local = await startLocal(manager)
Expand Down
14 changes: 7 additions & 7 deletions src/main/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1491,13 +1491,13 @@ export class BrokerManager {
env: {
PATH: augmentedPath(),
...brokerBinary.env,
// Spawned workers inherit the broker's env. AGENT_RELAY_BIN is the
// ecosystem-standard broker-binary override (harness-driver
// broker-path, workforce runtime relay-mcp); it lets the workforce
// CLI's `mcp-args --register` relay-MCP injection find the same
// broker binary Pear runs — a PATH lookup fails in packaged
// installs and can hit a version-skewed global binary in dev.
AGENT_RELAY_BIN: brokerBinary.realBinaryPath,
// Spawned workers inherit the broker's env. Keep the native broker
// on its unambiguous override: Relayfile separately defines
// AGENT_RELAY_BIN as the external Agent Relay CLI that owns cloud
// session refresh. Passing the broker there makes delegated-token
// re-mint execute `agent-relay-broker cloud session` and fail.
BROKER_BINARY_PATH: brokerBinary.realBinaryPath,
...(brokerBinary.agentRelayCLIPath ? { AGENT_RELAY_BIN: brokerBinary.agentRelayCLIPath } : {}),
...(agentRelayMcpCommand ? { AGENT_RELAY_MCP_COMMAND: agentRelayMcpCommand } : {})
},
onStderr: (line: string) => {
Expand Down
Loading