diff --git a/electron-builder.yml b/electron-builder.yml index 977d732d..52242426 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -19,6 +19,8 @@ asarUnpack: - resources/** - bin/** - node_modules/@agent-relay/sdk/bin/** + - node_modules/@agent-relay/broker-*/bin/** + - node_modules/agent-relay/node_modules/@agent-relay/broker-*/bin/** mac: category: public.app-category.developer-tools diff --git a/src/main/broker.test.ts b/src/main/broker.test.ts index 2abada13..7d0b3758 100644 --- a/src/main/broker.test.ts +++ b/src/main/broker.test.ts @@ -190,7 +190,7 @@ vi.mock('./burn', () => ({ getPearBurnAgentKey: vi.fn((projectId: string, name: string) => `${projectId}:${name}`) })) -import { BrokerManager, resolveAgentRelayMcpCommand } from './broker' +import { BrokerManager, parseBrokerInitCliFlags, resolveAgentRelayMcpCommand } from './broker' const PROJECT_ID = 'project-1' const originalMcpCommand = process.env.AGENT_RELAY_MCP_COMMAND @@ -407,6 +407,52 @@ describe('resolveAgentRelayMcpCommand', () => { }) }) +describe('parseBrokerInitCliFlags', () => { + it('detects current broker init flags', () => { + const flags = parseBrokerInitCliFlags(` +Usage: agent-relay-broker init [OPTIONS] + +Options: + --name Legacy broker instance name flag. Prefer --instance-name [default: ] + --instance-name Stable broker instance name within the Relay workspace + --workspace-key Join an existing Relay workspace instead of creating a fresh one + --channels [default: general] +`) + + expect(flags).toEqual({ + supportsInstanceName: true, + supportsName: true, + supportsWorkspaceKey: true + }) + }) + + it('detects legacy broker init flags', () => { + const flags = parseBrokerInitCliFlags(` +Usage: agent-relay-broker init [OPTIONS] + +Options: + --name [default: ] + --channels [default: general] + --persist Enable persistence +`) + + expect(flags).toEqual({ + supportsInstanceName: false, + supportsName: true, + supportsWorkspaceKey: false + }) + }) +}) + +describe('electron-builder broker packaging', () => { + it('unpacks bundled broker binaries outside app.asar', async () => { + const config = await readFile('electron-builder.yml', 'utf8') + + expect(config).toContain('node_modules/@agent-relay/broker-*/bin/**') + expect(config).toContain('node_modules/agent-relay/node_modules/@agent-relay/broker-*/bin/**') + }) +}) + describe('BrokerManager local + cloud coexistence', () => { let personaTempDir: string | null = null const inheritedWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY @@ -834,6 +880,28 @@ describe('BrokerManager local + cloud coexistence', () => { await manager.shutdown() }) + it('lists workforce personas from a project root before the relay workspace starts', async () => { + personaTempDir = await mkdtemp(join(tmpdir(), 'pear-persona-list-')) + await writeAgentWorkforceFixture(personaTempDir) + + const manager = new BrokerManager() + + await expect(manager.listPersonas(PROJECT_ID, personaTempDir)).resolves.toEqual([ + { id: 'autonomous-actor', harness: 'claude' } + ]) + expect(mock.HarnessDriverClient.spawn).not.toHaveBeenCalled() + + await manager.shutdown() + }) + + it('returns an empty persona list instead of throwing when no session or root is available', async () => { + const manager = new BrokerManager() + + await expect(manager.listPersonas(PROJECT_ID)).resolves.toEqual([]) + + await manager.shutdown() + }) + it('coalesces concurrent duplicate workforce persona spawn requests', async () => { personaTempDir = await mkdtemp(join(tmpdir(), 'pear-persona-spawn-')) await writeAgentWorkforceFixture(personaTempDir) diff --git a/src/main/broker.ts b/src/main/broker.ts index 844dcc36..e7ce1f1d 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -1,9 +1,9 @@ -import { existsSync, readFileSync } from 'fs' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { rm } from 'fs/promises' import { randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { delimiter, basename, isAbsolute, join } from 'path' -import { execFileSync } from 'child_process' +import { execFile, execFileSync } from 'child_process' import { app, BrowserWindow } from 'electron' import { HarnessDriverClient as AgentRelayClient, @@ -306,6 +306,154 @@ function resolveBundledBrokerBinary(): string { return unpackIfPackaged(brokerBinary) } +interface BrokerInitCliFlags { + supportsInstanceName: boolean + supportsName: boolean + supportsWorkspaceKey: boolean +} + +export function parseBrokerInitCliFlags(helpText: string): BrokerInitCliFlags { + return { + supportsInstanceName: /(?:^|\s)--instance-name(?:\s|[<=[,]|$)/.test(helpText), + supportsName: /(?:^|\s)--name(?:\s|[<=[,]|$)/.test(helpText), + supportsWorkspaceKey: /(?:^|\s)--workspace-key(?:\s|[<=[,]|$)/.test(helpText) + } +} + +function inspectBrokerInitCliFlags(binaryPath: string): Promise { + return new Promise((resolve) => { + execFile(binaryPath, ['init', '--help'], { + encoding: 'utf8', + timeout: 2_000, + windowsHide: true + }, (err, stdout) => { + if (err) { + console.warn(`[broker] Failed to inspect broker init CLI for ${binaryPath}:`, err) + resolve({ + supportsInstanceName: true, + supportsName: true, + supportsWorkspaceKey: true + }) + return + } + resolve(parseBrokerInitCliFlags(stdout)) + }) + }) +} + +function brokerBinaryCompatShimSource(): string { + return `#!/usr/bin/env node +const { spawn } = require('node:child_process') + +const realBinary = process.env.PEAR_AGENT_RELAY_BROKER_BINARY +if (!realBinary) { + console.error('[pear-broker-compat] PEAR_AGENT_RELAY_BROKER_BINARY is required') + process.exit(127) +} + +const supportsInstanceName = process.env.PEAR_AGENT_RELAY_BROKER_SUPPORTS_INSTANCE_NAME === '1' +const supportsWorkspaceKey = process.env.PEAR_AGENT_RELAY_BROKER_SUPPORTS_WORKSPACE_KEY === '1' +const inputArgs = process.argv.slice(2) +const outputArgs = [] + +for (let index = 0; index < inputArgs.length; index += 1) { + const arg = inputArgs[index] + if (!supportsInstanceName && arg === '--instance-name') { + outputArgs.push('--name') + if (index + 1 < inputArgs.length) outputArgs.push(inputArgs[++index]) + continue + } + if (!supportsInstanceName && arg.startsWith('--instance-name=')) { + outputArgs.push('--name=' + arg.slice('--instance-name='.length)) + continue + } + if (!supportsWorkspaceKey && arg === '--workspace-key') { + index += 1 + continue + } + if (!supportsWorkspaceKey && arg.startsWith('--workspace-key=')) { + continue + } + outputArgs.push(arg) +} + +const child = spawn(realBinary, outputArgs, { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit' +}) + +child.on('error', (error) => { + console.error('[pear-broker-compat] failed to launch broker:', error instanceof Error ? error.message : String(error)) + process.exit(127) +}) + +child.on('exit', (code, signal) => { + if (signal) { + try { + process.kill(process.pid, signal) + } catch { + process.exit(1) + } + return + } + process.exit(code ?? 0) +}) +` +} + +function ensureBrokerBinaryCompatShim(): string { + const shimDir = join(app.getPath('userData'), 'broker-compat') + const shimPath = join(shimDir, process.platform === 'win32' ? 'agent-relay-broker-compat.cmd' : 'agent-relay-broker-compat') + const source = process.platform === 'win32' + ? `@echo off\r\nnode "%~dp0agent-relay-broker-compat.js" %*\r\n` + : brokerBinaryCompatShimSource() + + mkdirSync(shimDir, { recursive: true }) + if (!existsSync(shimPath) || readFileSync(shimPath, 'utf8') !== source) { + writeFileSync(shimPath, source) + chmodSync(shimPath, 0o755) + } + if (process.platform === 'win32') { + const jsPath = join(shimDir, 'agent-relay-broker-compat.js') + const jsSource = brokerBinaryCompatShimSource() + if (!existsSync(jsPath) || readFileSync(jsPath, 'utf8') !== jsSource) { + writeFileSync(jsPath, jsSource) + } + } + return shimPath +} + +async function resolveHarnessBrokerBinary(workspaceKey?: string): Promise<{ binaryPath: string; env: NodeJS.ProcessEnv }> { + const binaryPath = resolveBundledBrokerBinary() + const flags = await inspectBrokerInitCliFlags(binaryPath) + + if (workspaceKey && !flags.supportsWorkspaceKey) { + throw new Error( + `Broker binary does not support --workspace-key: ${binaryPath}. Rebuild or update agent-relay-broker, or unset AGENT_RELAY_WORKSPACE_KEY.` + ) + } + + if (flags.supportsInstanceName) { + return { binaryPath, env: {} } + } + + if (!flags.supportsName) { + throw new Error(`Broker binary supports neither --instance-name nor --name: ${binaryPath}`) + } + + const shimPath = ensureBrokerBinaryCompatShim() + console.warn(`[broker] Broker binary uses legacy --name flag; launching through compatibility shim: ${binaryPath}`) + return { + binaryPath: shimPath, + env: { + PEAR_AGENT_RELAY_BROKER_BINARY: binaryPath, + PEAR_AGENT_RELAY_BROKER_SUPPORTS_INSTANCE_NAME: flags.supportsInstanceName ? '1' : '0', + PEAR_AGENT_RELAY_BROKER_SUPPORTS_WORKSPACE_KEY: flags.supportsWorkspaceKey ? '1' : '0' + } + } +} + type TerminalAttachMode = 'view' | 'drive' | 'passthrough' export interface CloudAgentSandboxHandle { @@ -1396,15 +1544,17 @@ export class BrokerManager { // Phase 1 of #125: the local broker stays the workspace creator, so the // key is only threaded when explicitly pinned via env. + const brokerBinary = await resolveHarnessBrokerBinary(explicitWorkspaceKey) const opts: AgentRelaySpawnOptions = { cwd, brokerName: name, channels: nextChannels, binaryArgs: { persist: true }, - binaryPath: resolveBundledBrokerBinary(), + binaryPath: brokerBinary.binaryPath, ...(explicitWorkspaceKey ? { workspaceKey: explicitWorkspaceKey } : {}), env: { PATH: augmentedPath(), + ...brokerBinary.env, ...(agentRelayMcpCommand ? { AGENT_RELAY_MCP_COMMAND: agentRelayMcpCommand } : {}) }, onStderr: (line: string) => { @@ -2595,8 +2745,24 @@ export class BrokerManager { throw new Error(`Unable to allocate an agent name for ${relayAwareInput.name}`) } - async listPersonas(projectId: string): Promise { - const session = this.getSessionForProject(projectId) + async listPersonas(projectId: string, cwd?: string): Promise { + const personaCwd = cwd?.trim() + if (personaCwd) { + try { + return listWorkforcePersonas(personaCwd) + } catch (err) { + console.warn(`[broker] Failed to list workforce personas for project ${projectId} at ${personaCwd}:`, err) + return [] + } + } + + let session: BrokerSession + try { + ;[session] = await this.getOrAwaitSessionsForProject(projectId) + } catch (err) { + if (isWorkspaceNotStartedError(err)) return [] + throw err + } try { return listWorkforcePersonas(session.cwd) diff --git a/src/main/ipc-handlers.test.ts b/src/main/ipc-handlers.test.ts index a95b83c4..19ba1227 100644 --- a/src/main/ipc-handlers.test.ts +++ b/src/main/ipc-handlers.test.ts @@ -141,6 +141,8 @@ vi.mock('./cli', () => ({ })) import { registerIpcHandlers } from './ipc-handlers' +import { findProjectForPath } from './cli' +import { isDirectory } from './path-utils' describe('registerIpcHandlers broker:start', () => { beforeEach(() => { @@ -199,6 +201,42 @@ describe('registerIpcHandlers broker:spawn-agent', () => { }) }) +describe('registerIpcHandlers broker:list-personas', () => { + beforeEach(() => { + mock.handlers.clear() + mock.ipcMain.handle.mockClear() + mock.ipcMain.on.mockClear() + mock.brokerManager.listPersonas.mockReset() + vi.mocked(findProjectForPath).mockReset() + vi.mocked(isDirectory).mockReset() + vi.mocked(isDirectory).mockReturnValue(true) + registerIpcHandlers() + }) + + it('lists personas for a cwd inside the requested project root', async () => { + const handler = mock.handlers.get('broker:list-personas') + expect(handler).toBeTypeOf('function') + vi.mocked(findProjectForPath).mockReturnValue({ id: 'project-1' } as never) + mock.brokerManager.listPersonas.mockResolvedValueOnce([{ id: 'autonomous-actor' }]) + + const result = await handler?.({}, 'project-1', '/tmp/project-1') + + expect(result).toEqual([{ id: 'autonomous-actor' }]) + expect(mock.brokerManager.listPersonas).toHaveBeenCalledWith('project-1', '/tmp/project-1') + }) + + it('rejects persona cwd outside the requested project root', async () => { + const handler = mock.handlers.get('broker:list-personas') + expect(handler).toBeTypeOf('function') + vi.mocked(findProjectForPath).mockReturnValue({ id: 'project-2' } as never) + + await expect(handler?.({}, 'project-1', '/tmp/project-2')).rejects.toThrow( + /Path is outside project roots for project project-1/ + ) + expect(mock.brokerManager.listPersonas).not.toHaveBeenCalled() + }) +}) + describe('registerIpcHandlers broker:spawn-persona', () => { beforeEach(() => { mock.handlers.clear() diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 9ddfa190..d7b77f30 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -265,7 +265,19 @@ export function registerIpcHandlers(): void { return toBrokerSpawnAgentResult(result) }) - ipcMain.handle('broker:list-personas', async (_, projectId: string) => { + ipcMain.handle('broker:list-personas', async (_, projectId: string, cwd?: string) => { + const personaCwd = cwd?.trim() + if (personaCwd) { + const resolved = resolve(personaCwd) + const pathProjectId = getProjectIdForPath(resolved) + if (!pathProjectId || pathProjectId !== projectId.trim()) { + throw new Error(`Path is outside project roots for project ${projectId}: ${resolved}`) + } + if (!isDirectory(resolved)) { + return [] + } + return brokerManager.listPersonas(projectId, resolved) + } return brokerManager.listPersonas(projectId) }) diff --git a/src/preload/index.ts b/src/preload/index.ts index f5c89850..5b2a418e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -240,8 +240,8 @@ const api = { connectCloud: () => invoke('broker:connect-cloud'), spawnAgent: (projectId: string, input: BrokerSpawnAgentInput) => invoke('broker:spawn-agent', projectId, input), - listPersonas: (projectId: string) => - invoke('broker:list-personas', projectId), + listPersonas: (projectId: string, cwd?: string) => + invoke('broker:list-personas', projectId, cwd), spawnPersona: (projectId: string, personaId: string) => invoke('broker:spawn-persona', projectId, personaId), attachTerminal: (input: BrokerAttachTerminalInput) => diff --git a/src/renderer/src/lib/spawn-agent.ts b/src/renderer/src/lib/spawn-agent.ts index 4f20bb76..05cb52b7 100644 --- a/src/renderer/src/lib/spawn-agent.ts +++ b/src/renderer/src/lib/spawn-agent.ts @@ -110,14 +110,13 @@ export async function listProjectPersonas(project: Project, rootOverride?: Proje if (rootOverride && !rootOverride.pathExists) { return [] } - await ensureLocalBroker(project, rootOverride, { detachCloud: false }) const root = rootOverride ?? useProjectStore.getState().getActiveRoot() if (!root?.pathExists) { return [] } - return pear.broker.listPersonas(project.id) + return pear.broker.listPersonas(project.id, root.path) } export async function spawnProjectPersona(project: Project, personaId: string, rootOverride?: ProjectRoot): Promise { @@ -151,7 +150,7 @@ export async function spawnProjectPersona(project: Project, personaId: string, r ) } - const personas = await pear.broker.listPersonas(project.id).catch((): WorkforcePersona[] => []) + const personas = await pear.broker.listPersonas(project.id, root.path).catch((): WorkforcePersona[] => []) const persona = personas.find((candidate) => candidate.id === personaId) const spawned = await pear.broker.spawnPersona(project.id, personaId) const name = spawned.name || personaId diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 19516bb9..1b3a6204 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -858,7 +858,7 @@ export interface PearAPI { ) => Promise<{ removed: string[] }> connectCloud: () => Promise spawnAgent: (projectId: string, input: BrokerSpawnAgentInput) => Promise - listPersonas: (projectId: string) => Promise + listPersonas: (projectId: string, cwd?: string) => Promise spawnPersona: (projectId: string, personaId: string) => Promise attachTerminal: (input: BrokerAttachTerminalInput) => Promise sendInput: (