diff --git a/src/main/broker-binary.ts b/src/main/broker-binary.ts index 5ac8e1f5..cf59d96a 100644 --- a/src/main/broker-binary.ts +++ b/src/main/broker-binary.ts @@ -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 } @@ -107,6 +110,29 @@ function inspectBrokerInitCliFlags(binaryPath: string): Promise { + 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 || ''}`)) + }) + }) +} + +async function resolveWorkerAgentRelayCLIOverride(brokerBinaryPath: string): Promise { + 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') @@ -203,9 +229,15 @@ async function ensureBrokerBinaryCompatShim(): Promise { // `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( @@ -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) { @@ -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', diff --git a/src/main/broker.test.ts b/src/main/broker.test.ts index cdcb3be4..0eb8a7a5 100644 --- a/src/main/broker.test.ts +++ b/src/main/broker.test.ts @@ -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 @@ -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 { @@ -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 { @@ -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( @@ -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 { @@ -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) @@ -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) diff --git a/src/main/broker.ts b/src/main/broker.ts index b29f738f..a85348b5 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -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) => {