From 24a714125bb217910fdfa311e79ada671449af12 Mon Sep 17 00:00:00 2001 From: Agent Relay Date: Fri, 30 Jan 2026 16:41:25 +0000 Subject: [PATCH 1/2] fix: remove existsSync gates from socket discovery for cloud support discoverSocket() now returns the determined socket path even when the socket file doesn't exist yet (daemon may not have started). This fixes cloud agents getting null/fallback paths when WORKSPACE_ID is set but the daemon hasn't created the socket yet. Also updates hybrid-client.ts to log socket source for debugging and not blindly fall back to local relay.sock for cloud workspaces. Co-Authored-By: Claude Opus 4.5 --- packages/mcp/src/cloud.ts | 38 +++++++------ packages/mcp/src/hybrid-client.ts | 9 +++- packages/mcp/tests/discover.test.ts | 83 +++++++++++++++++++++++++---- 3 files changed, 98 insertions(+), 32 deletions(-) diff --git a/packages/mcp/src/cloud.ts b/packages/mcp/src/cloud.ts index 9316ccef9..e252d3e29 100644 --- a/packages/mcp/src/cloud.ts +++ b/packages/mcp/src/cloud.ts @@ -131,7 +131,7 @@ function getDataDir(): string { */ export function discoverSocket(options: CloudConnectionOptions = {}): DiscoveryResult | null { // 0. Use override if provided - if (options.socketPath && existsSync(options.socketPath)) { + if (options.socketPath) { const workspace = options.workspace ? ({ workspaceId: options.workspace.workspaceId || 'override', @@ -150,7 +150,7 @@ export function discoverSocket(options: CloudConnectionOptions = {}): DiscoveryR // 1. Explicit socket path from environment const socketEnv = process.env.RELAY_SOCKET; - if (socketEnv && existsSync(socketEnv)) { + if (socketEnv) { const workspace = detectCloudWorkspace(); return { socketPath: socketEnv, @@ -162,18 +162,18 @@ export function discoverSocket(options: CloudConnectionOptions = {}): DiscoveryR } // 2. Cloud workspace socket (highest priority for cloud environments) + // Return the determined path even if the socket file doesn't exist yet + // (daemon may not have started) const workspace = detectCloudWorkspace(); if (workspace) { const cloudSocket = getCloudSocketPath(workspace.workspaceId); - if (existsSync(cloudSocket)) { - return { - socketPath: cloudSocket, - project: workspace.workspaceId, - source: 'cloud', - isCloud: true, - workspace, - }; - } + return { + socketPath: cloudSocket, + project: workspace.workspaceId, + source: 'cloud', + isCloud: true, + workspace, + }; } // 3. Project name → data dir lookup @@ -181,14 +181,12 @@ export function discoverSocket(options: CloudConnectionOptions = {}): DiscoveryR if (projectEnv) { const dataDir = getDataDir(); const projectSocket = join(dataDir, 'projects', projectEnv, 'daemon.sock'); - if (existsSync(projectSocket)) { - return { - socketPath: projectSocket, - project: projectEnv, - source: 'env', - isCloud: false, - }; - } + return { + socketPath: projectSocket, + project: projectEnv, + source: 'env', + isCloud: false, + }; } // 4. Project-local socket (created by daemon in project's .agent-relay directory) @@ -228,7 +226,7 @@ export function discoverSocket(options: CloudConnectionOptions = {}): DiscoveryR if (existsSync(cwdConfig)) { try { const config = JSON.parse(readFileSync(cwdConfig, 'utf-8')); - if (config.socketPath && existsSync(config.socketPath)) { + if (config.socketPath) { return { socketPath: config.socketPath, project: config.project || 'local', diff --git a/packages/mcp/src/hybrid-client.ts b/packages/mcp/src/hybrid-client.ts index b3a7ec16e..be0034a0c 100644 --- a/packages/mcp/src/hybrid-client.ts +++ b/packages/mcp/src/hybrid-client.ts @@ -46,7 +46,14 @@ export function createHybridClient(options: HybridClientOptions): RelayClient { } // Get socket path for queries - const socketPath = options.socketPath || discoverSocket()?.socketPath || join(relayDir, 'relay.sock'); + // Use discoverSocket() which respects cloud workspace config and env overrides. + // Only fall back to relayDir/relay.sock for non-cloud local development. + const discovery = discoverSocket(); + const socketPath = options.socketPath || discovery?.socketPath || join(relayDir, 'relay.sock'); + + if (process.env.DEBUG || process.env.RELAY_DEBUG) { + console.debug('[hybrid-client] Socket path:', socketPath, 'source:', discovery?.source ?? 'fallback', 'isCloud:', discovery?.isCloud ?? false); + } // Create socket client for queries only let socketClient: RelayClient | null = null; diff --git a/packages/mcp/tests/discover.test.ts b/packages/mcp/tests/discover.test.ts index 4b8ad41d8..0b4ca0be0 100644 --- a/packages/mcp/tests/discover.test.ts +++ b/packages/mcp/tests/discover.test.ts @@ -118,7 +118,6 @@ describe('Socket Discovery', () => { describe('discoverSocket', () => { it('uses RELAY_SOCKET env var first', () => { process.env.RELAY_SOCKET = '/tmp/test.sock'; - vi.mocked(existsSync).mockReturnValue(true); const result = discoverSocket(); @@ -127,21 +126,36 @@ describe('Socket Discovery', () => { expect(result?.isCloud).toBe(false); }); - it('uses socketPath option when provided', () => { - vi.mocked(existsSync).mockReturnValue(true); + it('returns RELAY_SOCKET path even if socket does not exist yet', () => { + process.env.RELAY_SOCKET = '/tmp/nonexistent.sock'; + vi.mocked(existsSync).mockReturnValue(false); + + const result = discoverSocket(); + + expect(result?.socketPath).toBe('/tmp/nonexistent.sock'); + expect(result?.source).toBe('env'); + }); + it('uses socketPath option when provided', () => { const result = discoverSocket({ socketPath: '/custom/path.sock' }); expect(result?.socketPath).toBe('/custom/path.sock'); expect(result?.source).toBe('env'); }); + it('returns socketPath option even if socket does not exist yet', () => { + vi.mocked(existsSync).mockReturnValue(false); + + const result = discoverSocket({ socketPath: '/custom/nonexistent.sock' }); + + expect(result?.socketPath).toBe('/custom/nonexistent.sock'); + expect(result?.source).toBe('env'); + }); + it('uses cloud workspace socket when in cloud', () => { process.env.WORKSPACE_ID = 'test-workspace'; process.env.CLOUD_API_URL = 'https://api.example.com'; - vi.mocked(existsSync).mockImplementation((path) => { - return String(path) === '/tmp/relay/test-workspace/sockets/daemon.sock'; - }); + vi.mocked(existsSync).mockReturnValue(false); const result = discoverSocket(); @@ -151,7 +165,21 @@ describe('Socket Discovery', () => { expect(result?.workspace?.workspaceId).toBe('test-workspace'); }); - it('returns null when no socket found', () => { + it('returns cloud socket path even if socket does not exist yet', () => { + process.env.WORKSPACE_ID = 'new-workspace'; + process.env.CLOUD_API_URL = 'https://api.example.com'; + vi.mocked(existsSync).mockReturnValue(false); + + const result = discoverSocket(); + + expect(result).not.toBeNull(); + expect(result?.socketPath).toBe('/tmp/relay/new-workspace/sockets/daemon.sock'); + expect(result?.source).toBe('cloud'); + expect(result?.isCloud).toBe(true); + expect(result?.workspace?.workspaceId).toBe('new-workspace'); + }); + + it('returns null when no socket found and no cloud/env config', () => { vi.mocked(existsSync).mockReturnValue(false); vi.mocked(readdirSync).mockReturnValue([]); @@ -162,9 +190,7 @@ describe('Socket Discovery', () => { it('uses RELAY_PROJECT env var for project lookup', () => { process.env.RELAY_PROJECT = 'myproject'; - vi.mocked(existsSync).mockImplementation((path) => { - return String(path).includes('myproject'); - }); + vi.mocked(existsSync).mockReturnValue(false); const result = discoverSocket(); @@ -173,10 +199,22 @@ describe('Socket Discovery', () => { expect(result?.isCloud).toBe(false); }); + it('returns RELAY_PROJECT socket path even if socket does not exist', () => { + process.env.RELAY_PROJECT = 'myproject'; + vi.mocked(existsSync).mockReturnValue(false); + + const result = discoverSocket(); + + expect(result).not.toBeNull(); + expect(result?.project).toBe('myproject'); + expect(result?.socketPath).toContain('myproject'); + expect(result?.source).toBe('env'); + }); + it('uses cwd config when present', () => { vi.mocked(existsSync).mockImplementation((path) => { const p = String(path); - return p.includes('.relay/config.json') || p === '/my/socket.sock'; + return p.includes('.relay/config.json'); }); vi.mocked(readFileSync).mockReturnValue( JSON.stringify({ @@ -191,5 +229,28 @@ describe('Socket Discovery', () => { expect(result?.project).toBe('local-project'); expect(result?.source).toBe('cwd'); }); + + it('env override takes priority over cloud workspace', () => { + process.env.RELAY_SOCKET = '/explicit/override.sock'; + process.env.WORKSPACE_ID = 'cloud-workspace'; + process.env.CLOUD_API_URL = 'https://api.example.com'; + + const result = discoverSocket(); + + expect(result?.socketPath).toBe('/explicit/override.sock'); + expect(result?.source).toBe('env'); + }); + + it('local development uses .agent-relay/relay.sock when socket exists', () => { + vi.mocked(existsSync).mockImplementation((path) => { + return String(path).endsWith('.agent-relay/relay.sock'); + }); + + const result = discoverSocket(); + + expect(result?.socketPath).toContain('.agent-relay/relay.sock'); + expect(result?.source).toBe('cwd'); + expect(result?.isCloud).toBe(false); + }); }); }); From b2a03ccbc0c2d1501d856eec0f1e8308401a3d24 Mon Sep 17 00:00:00 2001 From: Agent Relay Date: Fri, 30 Jan 2026 17:06:44 +0000 Subject: [PATCH 2/2] refactor: consolidate MCP logic into utils as single source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move duplicated logic from MCP package into @agent-relay/utils so both SDK and MCP share a single implementation: - Socket discovery (discoverSocket, detectCloudWorkspace, getCloudSocketPath, discoverAgentName) moved from MCP cloud.ts → utils/discovery.ts - Error classes (RelayError, DaemonNotRunningError, etc.) moved from MCP errors.ts → utils/errors.ts - MCP cloud.ts and errors.ts now thin re-exports from @agent-relay/utils - SDK discovery.ts and errors.ts re-export from @agent-relay/utils - SDK client.ts now uses discoverSocket() for smart socket resolution instead of hardcoded /tmp/agent-relay.sock - SDK client.ts now uses typed errors (DaemonNotRunningError, ConnectionError) Dependency changes: - @agent-relay/utils now depends on @agent-relay/config (for findProjectRoot) - @agent-relay/sdk now depends on @agent-relay/utils (for discovery + errors) - @agent-relay/mcp now explicitly depends on @agent-relay/utils Tests: 51 new tests for discovery, errors, and consolidation verification. All existing tests pass (206 utils, 50 SDK, 102 MCP = 358 total). No breaking changes - all public APIs preserved, MCP re-exports maintain backwards compatibility. Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 5 +- packages/mcp/package.json | 1 + packages/mcp/src/cloud.ts | 538 ++--------------------- packages/mcp/src/errors.ts | 61 +-- packages/sdk/package.json | 13 +- packages/sdk/src/client.ts | 23 +- packages/sdk/src/discovery.ts | 38 ++ packages/sdk/src/errors.ts | 17 + packages/sdk/src/index.ts | 29 ++ packages/utils/package.json | 13 + packages/utils/src/consolidation.test.ts | 125 ++++++ packages/utils/src/discovery.test.ts | 196 +++++++++ packages/utils/src/discovery.ts | 524 ++++++++++++++++++++++ packages/utils/src/errors.test.ts | 83 ++++ packages/utils/src/errors.ts | 56 +++ 15 files changed, 1161 insertions(+), 561 deletions(-) create mode 100644 packages/sdk/src/discovery.ts create mode 100644 packages/sdk/src/errors.ts create mode 100644 packages/utils/src/consolidation.test.ts create mode 100644 packages/utils/src/discovery.test.ts create mode 100644 packages/utils/src/discovery.ts create mode 100644 packages/utils/src/errors.test.ts create mode 100644 packages/utils/src/errors.ts diff --git a/package-lock.json b/package-lock.json index 66b8811b0..2c440f958 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11254,6 +11254,7 @@ "dependencies": { "@agent-relay/config": "2.1.5", "@agent-relay/protocol": "2.1.5", + "@agent-relay/utils": "2.1.5", "@modelcontextprotocol/sdk": "^1.0.0", "smol-toml": "^1.6.0", "zod": "^3.23.8" @@ -12321,7 +12322,8 @@ "version": "2.1.5", "license": "Apache-2.0", "dependencies": { - "@agent-relay/protocol": "2.1.5" + "@agent-relay/protocol": "2.1.5", + "@agent-relay/utils": "2.1.5" }, "devDependencies": { "@agent-relay/daemon": "*", @@ -14182,6 +14184,7 @@ "name": "@agent-relay/utils", "version": "2.1.5", "dependencies": { + "@agent-relay/config": "2.1.5", "@agent-relay/protocol": "2.1.5", "compare-versions": "^6.1.1" }, diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 67f4de267..a06dc3813 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -49,6 +49,7 @@ "dependencies": { "@agent-relay/config": "2.1.5", "@agent-relay/protocol": "2.1.5", + "@agent-relay/utils": "2.1.5", "@modelcontextprotocol/sdk": "^1.0.0", "smol-toml": "^1.6.0", "zod": "^3.23.8" diff --git a/packages/mcp/src/cloud.ts b/packages/mcp/src/cloud.ts index e252d3e29..21fe7d539 100644 --- a/packages/mcp/src/cloud.ts +++ b/packages/mcp/src/cloud.ts @@ -1,521 +1,41 @@ /** * Cloud Integration for Agent Relay MCP Server * - * Provides cloud workspace detection, remote daemon connection, - * and workspace-aware socket discovery for cloud deployments. - */ - -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { findProjectRoot } from '@agent-relay/config'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface CloudWorkspace { - workspaceId: string; - cloudApiUrl: string; - workspaceToken?: string; - ownerUserId?: string; -} - -export interface DiscoveryResult { - socketPath: string; - project: string; - source: 'env' | 'cloud' | 'cwd' | 'scan'; - isCloud: boolean; - workspace?: CloudWorkspace; -} - -export interface CloudConnectionOptions { - /** Override socket path (for testing) */ - socketPath?: string; - /** Override workspace detection */ - workspace?: Partial; -} - -// ============================================================================ -// Cloud Workspace Detection -// ============================================================================ - -/** - * Detect if running in a cloud workspace environment. + * This module re-exports all cloud/discovery functionality from + * @agent-relay/utils, which is the single source of truth for socket + * discovery, cloud workspace detection, and agent identity discovery. * - * Cloud workspaces set these environment variables: - * - WORKSPACE_ID: The unique workspace identifier - * - CLOUD_API_URL: The cloud API endpoint - * - WORKSPACE_TOKEN: Bearer token for API auth (optional) - * - WORKSPACE_OWNER_USER_ID: The workspace owner's user ID (optional) + * Previously this module contained its own implementation (~520 lines). + * It has been consolidated into @agent-relay/utils to eliminate code + * duplication between MCP and SDK packages. */ -export function detectCloudWorkspace(): CloudWorkspace | null { - const workspaceId = process.env.WORKSPACE_ID; - const cloudApiUrl = process.env.CLOUD_API_URL; - - if (!workspaceId || !cloudApiUrl) { - return null; - } - - return { - workspaceId, - cloudApiUrl, - workspaceToken: process.env.WORKSPACE_TOKEN, - ownerUserId: process.env.WORKSPACE_OWNER_USER_ID, - }; -} - -/** - * Check if we're running in a cloud workspace. - */ -export function isCloudWorkspace(): boolean { - return detectCloudWorkspace() !== null; -} - -// ============================================================================ -// Workspace-Aware Socket Discovery -// ============================================================================ - -/** - * Get the workspace-namespaced socket path. - * - * In cloud workspaces, sockets are stored at: - * /tmp/relay/{WORKSPACE_ID}/sockets/daemon.sock - * - * This provides multi-tenant isolation on shared infrastructure. - */ -export function getCloudSocketPath(workspaceId: string): string { - return `/tmp/relay/${workspaceId}/sockets/daemon.sock`; -} - -/** - * Get the workspace-namespaced outbox path. - * - * In cloud workspaces, outbox directories are at: - * /tmp/relay/{WORKSPACE_ID}/outbox/{agentName}/ - */ -export function getCloudOutboxPath(workspaceId: string, agentName: string): string { - return `/tmp/relay/${workspaceId}/outbox/${agentName}`; -} - -/** - * Get platform-specific data directory. - */ -function getDataDir(): string { - const platform = process.platform; - - if (platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'agent-relay'); - } else if (platform === 'win32') { - return join(process.env.APPDATA || homedir(), 'agent-relay'); - } else { - return join( - process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), - 'agent-relay' - ); - } -} - -/** - * Discover relay daemon socket with cloud-awareness. - * - * Priority order: - * 1. RELAY_SOCKET environment variable (explicit path) - * 2. Cloud workspace socket (if WORKSPACE_ID is set) - * 3. RELAY_PROJECT environment variable (project name → data dir) - * 4. Current working directory .relay/config.json - * 5. Scan data directory for active sockets - * - * @param options - Optional configuration overrides - * @returns Discovery result with socket path, project info, and cloud status - */ -export function discoverSocket(options: CloudConnectionOptions = {}): DiscoveryResult | null { - // 0. Use override if provided - if (options.socketPath) { - const workspace = options.workspace - ? ({ - workspaceId: options.workspace.workspaceId || 'override', - cloudApiUrl: options.workspace.cloudApiUrl || '', - } as CloudWorkspace) - : undefined; - - return { - socketPath: options.socketPath, - project: workspace?.workspaceId || 'override', - source: 'env', - isCloud: !!workspace, - workspace, - }; - } - - // 1. Explicit socket path from environment - const socketEnv = process.env.RELAY_SOCKET; - if (socketEnv) { - const workspace = detectCloudWorkspace(); - return { - socketPath: socketEnv, - project: process.env.RELAY_PROJECT || workspace?.workspaceId || 'unknown', - source: 'env', - isCloud: !!workspace, - workspace: workspace || undefined, - }; - } - - // 2. Cloud workspace socket (highest priority for cloud environments) - // Return the determined path even if the socket file doesn't exist yet - // (daemon may not have started) - const workspace = detectCloudWorkspace(); - if (workspace) { - const cloudSocket = getCloudSocketPath(workspace.workspaceId); - return { - socketPath: cloudSocket, - project: workspace.workspaceId, - source: 'cloud', - isCloud: true, - workspace, - }; - } - - // 3. Project name → data dir lookup - const projectEnv = process.env.RELAY_PROJECT; - if (projectEnv) { - const dataDir = getDataDir(); - const projectSocket = join(dataDir, 'projects', projectEnv, 'daemon.sock'); - return { - socketPath: projectSocket, - project: projectEnv, - source: 'env', - isCloud: false, - }; - } - - // 4. Project-local socket (created by daemon in project's .agent-relay directory) - // This is the primary path for local development - // First try cwd, then scan up to find project root - const projectRoot = findProjectRoot(process.cwd()); - const searchDirs = [process.cwd()]; - if (projectRoot && projectRoot !== process.cwd()) { - searchDirs.push(projectRoot); - } - - for (const dir of searchDirs) { - const projectLocalSocket = join(dir, '.agent-relay', 'relay.sock'); - if (existsSync(projectLocalSocket)) { - // Read project ID from marker file if available - let projectId = 'local'; - const markerPath = join(dir, '.agent-relay', '.project'); - if (existsSync(markerPath)) { - try { - const marker = JSON.parse(readFileSync(markerPath, 'utf-8')); - projectId = marker.projectId || 'local'; - } catch { - // Ignore marker read errors - } - } - return { - socketPath: projectLocalSocket, - project: projectId, - source: 'cwd', - isCloud: false, - }; - } - } - - // 4b. Legacy .relay/config.json support - const cwdConfig = join(process.cwd(), '.relay', 'config.json'); - if (existsSync(cwdConfig)) { - try { - const config = JSON.parse(readFileSync(cwdConfig, 'utf-8')); - if (config.socketPath) { - return { - socketPath: config.socketPath, - project: config.project || 'local', - source: 'cwd', - isCloud: false, - }; - } - } catch (err) { - // Invalid config (malformed JSON, permission error, etc.), continue to next method - if (process.env.DEBUG || process.env.RELAY_DEBUG) { - console.debug('[cloud] Failed to read cwd config:', cwdConfig, err); - } - } - } - - // 5. Scan data directory for active sockets - const dataDir = getDataDir(); - const projectsDir = join(dataDir, 'projects'); - - if (existsSync(projectsDir)) { - try { - const projects = readdirSync(projectsDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); - - for (const project of projects) { - const socketPath = join(projectsDir, project, 'daemon.sock'); - if (existsSync(socketPath)) { - return { - socketPath, - project, - source: 'scan', - isCloud: false, - }; - } - } - } catch (err) { - // Directory read failed (permission error, etc.), return null - if (process.env.DEBUG || process.env.RELAY_DEBUG) { - console.debug('[cloud] Failed to scan projects directory:', projectsDir, err); - } - } - } - - return null; -} - -// ============================================================================ -// Cloud API Helpers -// ============================================================================ - -/** - * Make an authenticated request to the cloud API. - * - * @param workspace - Cloud workspace configuration - * @param path - API path (e.g., '/api/status') - * @param options - Fetch options - * @returns Response from the API - */ -export async function cloudApiRequest( - workspace: CloudWorkspace, - path: string, - options: RequestInit = {} -): Promise { - const url = `${workspace.cloudApiUrl}${path}`; - - const headers: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record), - }; - - if (workspace.workspaceToken) { - headers['Authorization'] = `Bearer ${workspace.workspaceToken}`; - } - - return fetch(url, { - ...options, - headers, - }); -} - -/** - * Get the workspace status from the cloud API. - */ -export async function getWorkspaceStatus( - workspace: CloudWorkspace -): Promise<{ status: string; agents?: string[] } | null> { - try { - const response = await cloudApiRequest( - workspace, - `/api/workspaces/${workspace.workspaceId}/status` - ); - - if (!response.ok) { - return null; - } - - return (await response.json()) as { status: string; agents?: string[] }; - } catch { - return null; - } -} - -// ============================================================================ -// Cloud Connection Factory -// ============================================================================ - -export interface CloudConnectionInfo { - socketPath: string; - project: string; - isCloud: boolean; - workspace?: CloudWorkspace; - daemonUrl?: string; -} - -/** - * Get connection info for the relay daemon. - * - * This function determines the best way to connect to the daemon: - * - In cloud environments: Uses workspace-namespaced socket - * - In local environments: Uses standard socket discovery - * - * @param options - Optional configuration overrides - * @returns Connection info or null if daemon not found - */ -export function getConnectionInfo( - options: CloudConnectionOptions = {} -): CloudConnectionInfo | null { - const discovery = discoverSocket(options); - - if (!discovery) { - return null; - } - - const info: CloudConnectionInfo = { - socketPath: discovery.socketPath, - project: discovery.project, - isCloud: discovery.isCloud, - workspace: discovery.workspace, - }; - - // In cloud environments, we may also have a daemon URL for HTTP API access - if (discovery.workspace?.cloudApiUrl) { - info.daemonUrl = discovery.workspace.cloudApiUrl; - } - - return info; -} - -/** - * Environment variable summary for debugging. - */ -export function getCloudEnvironmentSummary(): Record { - return { - WORKSPACE_ID: process.env.WORKSPACE_ID, - CLOUD_API_URL: process.env.CLOUD_API_URL, - WORKSPACE_TOKEN: process.env.WORKSPACE_TOKEN ? '[set]' : undefined, - WORKSPACE_OWNER_USER_ID: process.env.WORKSPACE_OWNER_USER_ID, - RELAY_SOCKET: process.env.RELAY_SOCKET, - RELAY_PROJECT: process.env.RELAY_PROJECT, - RELAY_AGENT_NAME: process.env.RELAY_AGENT_NAME, - }; -} - -// ============================================================================ -// Agent Identity Discovery -// ============================================================================ - -/** - * Discover the agent name for the MCP server. - * - * Priority order: - * 1. RELAY_AGENT_NAME environment variable (explicit) - * 2. Identity file in .agent-relay directory (written by wrapper) - * 3. Scan outbox directories to find agent's outbox - * - * @param discovery - Optional discovery result with socket path info - * @returns Agent name or null if not found - */ -export function discoverAgentName(discovery?: DiscoveryResult | null): string | null { - // 1. Explicit environment variable - const envName = process.env.RELAY_AGENT_NAME; - if (envName) { - return envName; - } - - // 2. Identity file in .agent-relay directory - // The wrapper creates this file with the agent name - const projectRoot = findProjectRoot(process.cwd()); - const searchDirs = [process.cwd()]; - if (projectRoot && projectRoot !== process.cwd()) { - searchDirs.push(projectRoot); - } - - for (const dir of searchDirs) { - const relayDir = join(dir, '.agent-relay'); - if (!existsSync(relayDir)) continue; - - // First check for per-process identity files - // The orchestrator writes mcp-identity-{orchestrator.pid} - // Try to find one by checking process.ppid and its ancestors - const pidIdentityPath = join(relayDir, `mcp-identity-${process.ppid}`); - if (existsSync(pidIdentityPath)) { - try { - const content = readFileSync(pidIdentityPath, 'utf-8').trim(); - if (content) { - return content; - } - } catch { - // Ignore read errors - } - } - - // Scan all mcp-identity-* files and return the most recently modified one - // This handles the case where MCP server's ppid doesn't match the orchestrator - try { - const files = readdirSync(relayDir, { withFileTypes: true }) - .filter((d) => d.isFile() && d.name.startsWith('mcp-identity-')) - .map((d) => ({ - path: join(relayDir, d.name), - name: d.name, - })); - if (files.length > 0) { - // Sort by mtime (most recent first) to get the latest identity - const sorted = files - .map((f) => { - try { - const stat = statSync(f.path); - return { ...f, mtime: stat.mtimeMs }; - } catch { - return { ...f, mtime: 0 }; - } - }) - .sort((a, b) => b.mtime - a.mtime); +export { + // Types + type CloudWorkspace, + type DiscoveryResult, + type CloudConnectionOptions, + type CloudConnectionInfo, - // Return the most recently modified identity file - const latest = sorted[0]; - if (latest) { - try { - const content = readFileSync(latest.path, 'utf-8').trim(); - if (content) { - return content; - } - } catch { - // Ignore - } - } - } - } catch { - // Ignore scan errors - } + // Cloud workspace detection + detectCloudWorkspace, + isCloudWorkspace, - // Fallback to simple identity file (for single-agent scenarios) - const identityPath = join(relayDir, 'mcp-identity'); - if (existsSync(identityPath)) { - try { - const content = readFileSync(identityPath, 'utf-8').trim(); - if (content) { - return content; - } - } catch { - // Ignore read errors - } - } - } + // Socket discovery + getCloudSocketPath, + getCloudOutboxPath, + discoverSocket, - // 3. Check outbox directories for a match - // If only one agent's outbox exists, assume we're that agent - for (const dir of searchDirs) { - const outboxDir = join(dir, '.agent-relay', 'outbox'); - if (existsSync(outboxDir)) { - try { - const agents = readdirSync(outboxDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); + // Cloud API helpers + cloudApiRequest, + getWorkspaceStatus, - // If there's exactly one outbox, use that agent name - if (agents.length === 1) { - return agents[0]; - } + // Connection factory + getConnectionInfo, - // If there are multiple, we can't determine which one we are - // The wrapper should have created an identity file - } catch { - // Ignore read errors - } - } - } + // Debug helpers + getCloudEnvironmentSummary, - return null; -} + // Agent identity + discoverAgentName, +} from '@agent-relay/utils/discovery'; diff --git a/packages/mcp/src/errors.ts b/packages/mcp/src/errors.ts index e158eff7c..d565d1818 100644 --- a/packages/mcp/src/errors.ts +++ b/packages/mcp/src/errors.ts @@ -1,54 +1,17 @@ /** * Error Types for Agent Relay MCP Server * - * Provides typed error classes for better error handling and messaging. + * Re-exports error classes from @agent-relay/utils, which is the single + * source of truth for error types. Previously this module contained + * its own implementation. */ -export class RelayError extends Error { - constructor(message: string) { - super(message); - this.name = 'RelayError'; - } -} - -export class DaemonNotRunningError extends RelayError { - constructor(message?: string) { - super(message || 'Relay daemon is not running. Start with: agent-relay up'); - this.name = 'DaemonNotRunningError'; - } -} - -export class AgentNotFoundError extends RelayError { - constructor(agentName: string) { - super(`Agent not found: ${agentName}`); - this.name = 'AgentNotFoundError'; - } -} - -export class TimeoutError extends RelayError { - constructor(operation: string, timeoutMs: number) { - super(`Timeout after ${timeoutMs}ms: ${operation}`); - this.name = 'TimeoutError'; - } -} - -export class ConnectionError extends RelayError { - constructor(message: string) { - super(`Connection error: ${message}`); - this.name = 'ConnectionError'; - } -} - -export class ChannelNotFoundError extends RelayError { - constructor(channel: string) { - super(`Channel not found: ${channel}`); - this.name = 'ChannelNotFoundError'; - } -} - -export class SpawnError extends RelayError { - constructor(workerName: string, reason: string) { - super(`Failed to spawn worker "${workerName}": ${reason}`); - this.name = 'SpawnError'; - } -} +export { + RelayError, + DaemonNotRunningError, + AgentNotFoundError, + TimeoutError, + ConnectionError, + ChannelNotFoundError, + SpawnError, +} from '@agent-relay/utils/errors'; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 17aa56768..38b1cbf96 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -24,6 +24,16 @@ "types": "./dist/client.d.ts", "import": "./dist/client.js", "default": "./dist/client.js" + }, + "./discovery": { + "types": "./dist/discovery.d.ts", + "import": "./dist/discovery.js", + "default": "./dist/discovery.js" + }, + "./errors": { + "types": "./dist/errors.d.ts", + "import": "./dist/errors.js", + "default": "./dist/errors.js" } }, "files": [ @@ -55,7 +65,8 @@ "access": "public" }, "dependencies": { - "@agent-relay/protocol": "2.1.5" + "@agent-relay/protocol": "2.1.5", + "@agent-relay/utils": "2.1.5" }, "engines": { "node": ">=18.0.0" diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 47b7c932f..9fffe48d2 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -7,6 +7,8 @@ import net from 'node:net'; import { randomUUID } from 'node:crypto'; +import { discoverSocket } from '@agent-relay/utils/discovery'; +import { DaemonNotRunningError, ConnectionError } from '@agent-relay/utils/errors'; // Import shared protocol types and framing utilities from @agent-relay/protocol import { type Envelope, @@ -107,6 +109,16 @@ export interface ClientConfig { const DEFAULT_SOCKET_PATH = '/tmp/agent-relay.sock'; +/** + * Resolve the socket path using discovery if not explicitly provided. + * Falls back to /tmp/agent-relay.sock if discovery fails. + */ +function resolveSocketPath(configPath?: string): string { + if (configPath) return configPath; + const discovery = discoverSocket(); + return discovery?.socketPath || DEFAULT_SOCKET_PATH; +} + const DEFAULT_CLIENT_CONFIG: ClientConfig = { socketPath: DEFAULT_SOCKET_PATH, agentName: 'agent', @@ -215,6 +227,10 @@ export class RelayClient { constructor(config: Partial = {}) { this.config = { ...DEFAULT_CLIENT_CONFIG, ...config }; + // Use socket discovery if no explicit socketPath was provided + if (!config.socketPath) { + this.config.socketPath = resolveSocketPath(); + } this.parser = new FrameParser(); this.parser.setLegacyMode(true); this.reconnectDelay = this.config.reconnectDelayMs; @@ -268,7 +284,12 @@ export class RelayClient { this.socket.on('error', (err) => { if (this._state === 'CONNECTING') { - settleReject(err); + const errno = (err as NodeJS.ErrnoException).code; + if (errno === 'ECONNREFUSED' || errno === 'ENOENT') { + settleReject(new DaemonNotRunningError(`Cannot connect to daemon at ${this.config.socketPath}`)); + } else { + settleReject(new ConnectionError(err.message)); + } } this.handleError(err); }); diff --git a/packages/sdk/src/discovery.ts b/packages/sdk/src/discovery.ts new file mode 100644 index 000000000..48518558b --- /dev/null +++ b/packages/sdk/src/discovery.ts @@ -0,0 +1,38 @@ +/** + * Socket Discovery & Cloud Workspace Detection + * + * Re-exports all discovery functionality from @agent-relay/utils, + * which is the single source of truth. This module exists so SDK + * consumers can import discovery from either '@agent-relay/sdk' + * or '@agent-relay/sdk/discovery'. + */ + +export { + // Types + type CloudWorkspace, + type DiscoveryResult, + type CloudConnectionOptions, + type CloudConnectionInfo, + + // Cloud workspace detection + detectCloudWorkspace, + isCloudWorkspace, + + // Socket discovery + getCloudSocketPath, + getCloudOutboxPath, + discoverSocket, + + // Cloud API helpers + cloudApiRequest, + getWorkspaceStatus, + + // Connection factory + getConnectionInfo, + + // Debug helpers + getCloudEnvironmentSummary, + + // Agent identity + discoverAgentName, +} from '@agent-relay/utils/discovery'; diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 000000000..b1ee0615e --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,17 @@ +/** + * Error Types for Agent Relay + * + * Re-exports error classes from @agent-relay/utils, which is the single + * source of truth. This module exists so SDK consumers can import errors + * from either '@agent-relay/sdk' or '@agent-relay/sdk/errors'. + */ + +export { + RelayError, + DaemonNotRunningError, + AgentNotFoundError, + TimeoutError, + ConnectionError, + ChannelNotFoundError, + SpawnError, +} from '@agent-relay/utils/errors'; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index afaae46b7..96f8311b2 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -101,3 +101,32 @@ export { type GetLogsOptions, type LogsResult, } from './logs.js'; + +// Discovery (socket discovery, cloud workspace detection, agent identity) +export { + discoverSocket, + discoverAgentName, + detectCloudWorkspace, + isCloudWorkspace, + getCloudSocketPath, + getCloudOutboxPath, + getConnectionInfo, + getCloudEnvironmentSummary, + cloudApiRequest, + getWorkspaceStatus, + type DiscoveryResult, + type CloudWorkspace, + type CloudConnectionOptions, + type CloudConnectionInfo, +} from './discovery.js'; + +// Error types +export { + RelayError, + DaemonNotRunningError, + AgentNotFoundError, + TimeoutError, + ConnectionError, + ChannelNotFoundError, + SpawnError, +} from './errors.js'; diff --git a/packages/utils/package.json b/packages/utils/package.json index f18102f0f..06222d9d0 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -72,6 +72,18 @@ "import": "./dist/client-helpers.js", "default": "./dist/cjs/client-helpers.js" }, + "./discovery": { + "types": "./dist/discovery.d.ts", + "require": "./dist/cjs/discovery.js", + "import": "./dist/discovery.js", + "default": "./dist/cjs/discovery.js" + }, + "./errors": { + "types": "./dist/errors.d.ts", + "require": "./dist/cjs/errors.js", + "import": "./dist/errors.js", + "default": "./dist/cjs/errors.js" + }, "./package.json": "./package.json" }, "files": [ @@ -94,6 +106,7 @@ "vitest": "^3.2.4" }, "dependencies": { + "@agent-relay/config": "2.1.5", "@agent-relay/protocol": "2.1.5", "compare-versions": "^6.1.1" }, diff --git a/packages/utils/src/consolidation.test.ts b/packages/utils/src/consolidation.test.ts new file mode 100644 index 000000000..462c68ddf --- /dev/null +++ b/packages/utils/src/consolidation.test.ts @@ -0,0 +1,125 @@ +/** + * Consolidation Verification Tests + * + * These tests verify that: + * 1. Discovery logic is defined in @agent-relay/utils (single source of truth) + * 2. Error classes are defined in @agent-relay/utils (single source of truth) + * 3. All expected exports are present + * 4. No logic is duplicated - MCP and SDK should re-export from utils + */ + +import { describe, it, expect } from 'vitest'; +import * as discovery from './discovery.js'; +import * as errors from './errors.js'; +import * as clientHelpers from './client-helpers.js'; + +describe('Consolidation: Single Source of Truth', () => { + describe('Discovery exports from utils', () => { + it('exports discoverSocket function', () => { + expect(typeof discovery.discoverSocket).toBe('function'); + }); + + it('exports detectCloudWorkspace function', () => { + expect(typeof discovery.detectCloudWorkspace).toBe('function'); + }); + + it('exports isCloudWorkspace function', () => { + expect(typeof discovery.isCloudWorkspace).toBe('function'); + }); + + it('exports getCloudSocketPath function', () => { + expect(typeof discovery.getCloudSocketPath).toBe('function'); + }); + + it('exports getCloudOutboxPath function', () => { + expect(typeof discovery.getCloudOutboxPath).toBe('function'); + }); + + it('exports getConnectionInfo function', () => { + expect(typeof discovery.getConnectionInfo).toBe('function'); + }); + + it('exports getCloudEnvironmentSummary function', () => { + expect(typeof discovery.getCloudEnvironmentSummary).toBe('function'); + }); + + it('exports cloudApiRequest function', () => { + expect(typeof discovery.cloudApiRequest).toBe('function'); + }); + + it('exports getWorkspaceStatus function', () => { + expect(typeof discovery.getWorkspaceStatus).toBe('function'); + }); + + it('exports discoverAgentName function', () => { + expect(typeof discovery.discoverAgentName).toBe('function'); + }); + }); + + describe('Error classes from utils', () => { + it('exports RelayError class', () => { + expect(errors.RelayError).toBeDefined(); + expect(new errors.RelayError('test')).toBeInstanceOf(Error); + }); + + it('exports DaemonNotRunningError class', () => { + expect(errors.DaemonNotRunningError).toBeDefined(); + expect(new errors.DaemonNotRunningError()).toBeInstanceOf(errors.RelayError); + }); + + it('exports AgentNotFoundError class', () => { + expect(errors.AgentNotFoundError).toBeDefined(); + expect(new errors.AgentNotFoundError('test')).toBeInstanceOf(errors.RelayError); + }); + + it('exports TimeoutError class', () => { + expect(errors.TimeoutError).toBeDefined(); + expect(new errors.TimeoutError('op', 1000)).toBeInstanceOf(errors.RelayError); + }); + + it('exports ConnectionError class', () => { + expect(errors.ConnectionError).toBeDefined(); + expect(new errors.ConnectionError('msg')).toBeInstanceOf(errors.RelayError); + }); + + it('exports ChannelNotFoundError class', () => { + expect(errors.ChannelNotFoundError).toBeDefined(); + expect(new errors.ChannelNotFoundError('#ch')).toBeInstanceOf(errors.RelayError); + }); + + it('exports SpawnError class', () => { + expect(errors.SpawnError).toBeDefined(); + expect(new errors.SpawnError('w', 'r')).toBeInstanceOf(errors.RelayError); + }); + }); + + describe('Client helpers from utils', () => { + it('exports createRequestEnvelope function', () => { + expect(typeof clientHelpers.createRequestEnvelope).toBe('function'); + }); + + it('exports createRequestHandler function', () => { + expect(typeof clientHelpers.createRequestHandler).toBe('function'); + }); + + it('exports generateRequestId function', () => { + expect(typeof clientHelpers.generateRequestId).toBe('function'); + }); + + it('exports toSpawnResult function', () => { + expect(typeof clientHelpers.toSpawnResult).toBe('function'); + }); + + it('exports toReleaseResult function', () => { + expect(typeof clientHelpers.toReleaseResult).toBe('function'); + }); + + it('exports isMatchingResponse function', () => { + expect(typeof clientHelpers.isMatchingResponse).toBe('function'); + }); + + it('exports handleResponse function', () => { + expect(typeof clientHelpers.handleResponse).toBe('function'); + }); + }); +}); diff --git a/packages/utils/src/discovery.test.ts b/packages/utils/src/discovery.test.ts new file mode 100644 index 000000000..073ea5a26 --- /dev/null +++ b/packages/utils/src/discovery.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { + discoverSocket, + detectCloudWorkspace, + isCloudWorkspace, + getCloudSocketPath, + getCloudOutboxPath, + getConnectionInfo, + getCloudEnvironmentSummary, + discoverAgentName, +} from './discovery.js'; + +// Mock the fs module +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + readdirSync: vi.fn(), + readFileSync: vi.fn(), + statSync: vi.fn(), + }; +}); + +// Mock @agent-relay/config +vi.mock('@agent-relay/config', () => ({ + findProjectRoot: vi.fn(() => null), +})); + +describe('Discovery (single source of truth)', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + delete process.env.WORKSPACE_ID; + delete process.env.CLOUD_API_URL; + delete process.env.WORKSPACE_TOKEN; + delete process.env.RELAY_SOCKET; + delete process.env.RELAY_PROJECT; + delete process.env.WORKSPACE_OWNER_USER_ID; + delete process.env.RELAY_AGENT_NAME; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('detectCloudWorkspace', () => { + it('returns null when cloud env vars are not set', () => { + expect(detectCloudWorkspace()).toBeNull(); + }); + + it('returns null when only WORKSPACE_ID is set', () => { + process.env.WORKSPACE_ID = 'test-workspace'; + expect(detectCloudWorkspace()).toBeNull(); + }); + + it('returns null when only CLOUD_API_URL is set', () => { + process.env.CLOUD_API_URL = 'https://api.example.com'; + expect(detectCloudWorkspace()).toBeNull(); + }); + + it('returns workspace info when both vars are set', () => { + process.env.WORKSPACE_ID = 'test-workspace'; + process.env.CLOUD_API_URL = 'https://api.example.com'; + process.env.WORKSPACE_TOKEN = 'secret-token'; + process.env.WORKSPACE_OWNER_USER_ID = 'user-123'; + + const result = detectCloudWorkspace(); + + expect(result).toEqual({ + workspaceId: 'test-workspace', + cloudApiUrl: 'https://api.example.com', + workspaceToken: 'secret-token', + ownerUserId: 'user-123', + }); + }); + }); + + describe('isCloudWorkspace', () => { + it('returns false when not in cloud', () => { + expect(isCloudWorkspace()).toBe(false); + }); + + it('returns true when in cloud', () => { + process.env.WORKSPACE_ID = 'test-workspace'; + process.env.CLOUD_API_URL = 'https://api.example.com'; + expect(isCloudWorkspace()).toBe(true); + }); + }); + + describe('getCloudSocketPath', () => { + it('returns workspace-namespaced socket path', () => { + expect(getCloudSocketPath('my-ws')).toBe('/tmp/relay/my-ws/sockets/daemon.sock'); + }); + }); + + describe('getCloudOutboxPath', () => { + it('returns workspace-namespaced outbox path', () => { + expect(getCloudOutboxPath('my-ws', 'Agent1')).toBe('/tmp/relay/my-ws/outbox/Agent1'); + }); + }); + + describe('discoverSocket', () => { + it('uses override socketPath option', () => { + const result = discoverSocket({ socketPath: '/custom/path.sock' }); + expect(result?.socketPath).toBe('/custom/path.sock'); + expect(result?.source).toBe('env'); + }); + + it('uses RELAY_SOCKET env var', () => { + process.env.RELAY_SOCKET = '/tmp/test.sock'; + const result = discoverSocket(); + expect(result?.socketPath).toBe('/tmp/test.sock'); + expect(result?.source).toBe('env'); + }); + + it('uses cloud workspace socket when in cloud (even without file)', () => { + process.env.WORKSPACE_ID = 'cloud-ws'; + process.env.CLOUD_API_URL = 'https://api.example.com'; + vi.mocked(existsSync).mockReturnValue(false); + + const result = discoverSocket(); + expect(result?.socketPath).toBe('/tmp/relay/cloud-ws/sockets/daemon.sock'); + expect(result?.source).toBe('cloud'); + expect(result?.isCloud).toBe(true); + }); + + it('env override takes priority over cloud workspace', () => { + process.env.RELAY_SOCKET = '/explicit/override.sock'; + process.env.WORKSPACE_ID = 'cloud-ws'; + process.env.CLOUD_API_URL = 'https://api.example.com'; + + const result = discoverSocket(); + expect(result?.socketPath).toBe('/explicit/override.sock'); + expect(result?.source).toBe('env'); + }); + + it('uses RELAY_PROJECT env var', () => { + process.env.RELAY_PROJECT = 'myproject'; + vi.mocked(existsSync).mockReturnValue(false); + + const result = discoverSocket(); + expect(result?.project).toBe('myproject'); + expect(result?.source).toBe('env'); + }); + + it('returns null when nothing found', () => { + vi.mocked(existsSync).mockReturnValue(false); + const result = discoverSocket(); + expect(result).toBeNull(); + }); + }); + + describe('getConnectionInfo', () => { + it('returns null when no socket found', () => { + vi.mocked(existsSync).mockReturnValue(false); + expect(getConnectionInfo()).toBeNull(); + }); + + it('returns connection info with cloud details', () => { + process.env.WORKSPACE_ID = 'ws-123'; + process.env.CLOUD_API_URL = 'https://api.example.com'; + + const result = getConnectionInfo(); + expect(result?.isCloud).toBe(true); + expect(result?.daemonUrl).toBe('https://api.example.com'); + expect(result?.workspace?.workspaceId).toBe('ws-123'); + }); + }); + + describe('getCloudEnvironmentSummary', () => { + it('returns env var summary', () => { + process.env.WORKSPACE_ID = 'test'; + process.env.WORKSPACE_TOKEN = 'secret'; + + const summary = getCloudEnvironmentSummary(); + expect(summary.WORKSPACE_ID).toBe('test'); + expect(summary.WORKSPACE_TOKEN).toBe('[set]'); + }); + }); + + describe('discoverAgentName', () => { + it('returns RELAY_AGENT_NAME env var when set', () => { + process.env.RELAY_AGENT_NAME = 'TestAgent'; + expect(discoverAgentName()).toBe('TestAgent'); + }); + + it('returns null when no identity found', () => { + vi.mocked(existsSync).mockReturnValue(false); + expect(discoverAgentName()).toBeNull(); + }); + }); +}); diff --git a/packages/utils/src/discovery.ts b/packages/utils/src/discovery.ts new file mode 100644 index 000000000..f49769c3d --- /dev/null +++ b/packages/utils/src/discovery.ts @@ -0,0 +1,524 @@ +/** + * Socket Discovery & Cloud Workspace Detection + * + * Single source of truth for discovering relay daemon sockets, + * cloud workspace environments, and agent identity. + * + * Previously duplicated in @agent-relay/mcp (cloud.ts). Now consolidated + * here in the SDK so both SDK and MCP use the same discovery logic. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { findProjectRoot } from '@agent-relay/config'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CloudWorkspace { + workspaceId: string; + cloudApiUrl: string; + workspaceToken?: string; + ownerUserId?: string; +} + +export interface DiscoveryResult { + socketPath: string; + project: string; + source: 'env' | 'cloud' | 'cwd' | 'scan'; + isCloud: boolean; + workspace?: CloudWorkspace; +} + +export interface CloudConnectionOptions { + /** Override socket path (for testing) */ + socketPath?: string; + /** Override workspace detection */ + workspace?: Partial; +} + +export interface CloudConnectionInfo { + socketPath: string; + project: string; + isCloud: boolean; + workspace?: CloudWorkspace; + daemonUrl?: string; +} + +// ============================================================================ +// Cloud Workspace Detection +// ============================================================================ + +/** + * Detect if running in a cloud workspace environment. + * + * Cloud workspaces set these environment variables: + * - WORKSPACE_ID: The unique workspace identifier + * - CLOUD_API_URL: The cloud API endpoint + * - WORKSPACE_TOKEN: Bearer token for API auth (optional) + * - WORKSPACE_OWNER_USER_ID: The workspace owner's user ID (optional) + */ +export function detectCloudWorkspace(): CloudWorkspace | null { + const workspaceId = process.env.WORKSPACE_ID; + const cloudApiUrl = process.env.CLOUD_API_URL; + + if (!workspaceId || !cloudApiUrl) { + return null; + } + + return { + workspaceId, + cloudApiUrl, + workspaceToken: process.env.WORKSPACE_TOKEN, + ownerUserId: process.env.WORKSPACE_OWNER_USER_ID, + }; +} + +/** + * Check if we're running in a cloud workspace. + */ +export function isCloudWorkspace(): boolean { + return detectCloudWorkspace() !== null; +} + +// ============================================================================ +// Workspace-Aware Socket Discovery +// ============================================================================ + +/** + * Get the workspace-namespaced socket path. + * + * In cloud workspaces, sockets are stored at: + * /tmp/relay/{WORKSPACE_ID}/sockets/daemon.sock + * + * This provides multi-tenant isolation on shared infrastructure. + */ +export function getCloudSocketPath(workspaceId: string): string { + return `/tmp/relay/${workspaceId}/sockets/daemon.sock`; +} + +/** + * Get the workspace-namespaced outbox path. + * + * In cloud workspaces, outbox directories are at: + * /tmp/relay/{WORKSPACE_ID}/outbox/{agentName}/ + */ +export function getCloudOutboxPath(workspaceId: string, agentName: string): string { + return `/tmp/relay/${workspaceId}/outbox/${agentName}`; +} + +/** + * Get platform-specific data directory. + */ +function getDataDir(): string { + const platform = process.platform; + + if (platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'agent-relay'); + } else if (platform === 'win32') { + return join(process.env.APPDATA || homedir(), 'agent-relay'); + } else { + return join( + process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), + 'agent-relay' + ); + } +} + +/** + * Discover relay daemon socket with cloud-awareness. + * + * Priority order: + * 1. RELAY_SOCKET environment variable (explicit path) + * 2. Cloud workspace socket (if WORKSPACE_ID is set) + * 3. RELAY_PROJECT environment variable (project name -> data dir) + * 4. Current working directory .relay/config.json + * 5. Scan data directory for active sockets + * + * @param options - Optional configuration overrides + * @returns Discovery result with socket path, project info, and cloud status + */ +export function discoverSocket(options: CloudConnectionOptions = {}): DiscoveryResult | null { + // 0. Use override if provided + if (options.socketPath) { + const workspace = options.workspace + ? ({ + workspaceId: options.workspace.workspaceId || 'override', + cloudApiUrl: options.workspace.cloudApiUrl || '', + } as CloudWorkspace) + : undefined; + + return { + socketPath: options.socketPath, + project: workspace?.workspaceId || 'override', + source: 'env', + isCloud: !!workspace, + workspace, + }; + } + + // 1. Explicit socket path from environment + const socketEnv = process.env.RELAY_SOCKET; + if (socketEnv) { + const workspace = detectCloudWorkspace(); + return { + socketPath: socketEnv, + project: process.env.RELAY_PROJECT || workspace?.workspaceId || 'unknown', + source: 'env', + isCloud: !!workspace, + workspace: workspace || undefined, + }; + } + + // 2. Cloud workspace socket (highest priority for cloud environments) + // Return the determined path even if the socket file doesn't exist yet + // (daemon may not have started) + const workspace = detectCloudWorkspace(); + if (workspace) { + const cloudSocket = getCloudSocketPath(workspace.workspaceId); + return { + socketPath: cloudSocket, + project: workspace.workspaceId, + source: 'cloud', + isCloud: true, + workspace, + }; + } + + // 3. Project name -> data dir lookup + const projectEnv = process.env.RELAY_PROJECT; + if (projectEnv) { + const dataDir = getDataDir(); + const projectSocket = join(dataDir, 'projects', projectEnv, 'daemon.sock'); + return { + socketPath: projectSocket, + project: projectEnv, + source: 'env', + isCloud: false, + }; + } + + // 4. Project-local socket (created by daemon in project's .agent-relay directory) + // This is the primary path for local development + // First try cwd, then scan up to find project root + const projectRoot = findProjectRoot(process.cwd()); + const searchDirs = [process.cwd()]; + if (projectRoot && projectRoot !== process.cwd()) { + searchDirs.push(projectRoot); + } + + for (const dir of searchDirs) { + const projectLocalSocket = join(dir, '.agent-relay', 'relay.sock'); + if (existsSync(projectLocalSocket)) { + // Read project ID from marker file if available + let projectId = 'local'; + const markerPath = join(dir, '.agent-relay', '.project'); + if (existsSync(markerPath)) { + try { + const marker = JSON.parse(readFileSync(markerPath, 'utf-8')); + projectId = marker.projectId || 'local'; + } catch { + // Ignore marker read errors + } + } + return { + socketPath: projectLocalSocket, + project: projectId, + source: 'cwd', + isCloud: false, + }; + } + } + + // 4b. Legacy .relay/config.json support + const cwdConfig = join(process.cwd(), '.relay', 'config.json'); + if (existsSync(cwdConfig)) { + try { + const config = JSON.parse(readFileSync(cwdConfig, 'utf-8')); + if (config.socketPath) { + return { + socketPath: config.socketPath, + project: config.project || 'local', + source: 'cwd', + isCloud: false, + }; + } + } catch (err) { + // Invalid config (malformed JSON, permission error, etc.), continue to next method + if (process.env.DEBUG || process.env.RELAY_DEBUG) { + console.debug('[discovery] Failed to read cwd config:', cwdConfig, err); + } + } + } + + // 5. Scan data directory for active sockets + const dataDir = getDataDir(); + const projectsDir = join(dataDir, 'projects'); + + if (existsSync(projectsDir)) { + try { + const projects = readdirSync(projectsDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + for (const project of projects) { + const socketPath = join(projectsDir, project, 'daemon.sock'); + if (existsSync(socketPath)) { + return { + socketPath, + project, + source: 'scan', + isCloud: false, + }; + } + } + } catch (err) { + // Directory read failed (permission error, etc.), return null + if (process.env.DEBUG || process.env.RELAY_DEBUG) { + console.debug('[discovery] Failed to scan projects directory:', projectsDir, err); + } + } + } + + return null; +} + +// ============================================================================ +// Cloud API Helpers +// ============================================================================ + +/** + * Make an authenticated request to the cloud API. + * + * @param workspace - Cloud workspace configuration + * @param path - API path (e.g., '/api/status') + * @param options - Fetch options + * @returns Response from the API + */ +export async function cloudApiRequest( + workspace: CloudWorkspace, + path: string, + options: RequestInit = {} +): Promise { + const url = `${workspace.cloudApiUrl}${path}`; + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + }; + + if (workspace.workspaceToken) { + headers['Authorization'] = `Bearer ${workspace.workspaceToken}`; + } + + return fetch(url, { + ...options, + headers, + }); +} + +/** + * Get the workspace status from the cloud API. + */ +export async function getWorkspaceStatus( + workspace: CloudWorkspace +): Promise<{ status: string; agents?: string[] } | null> { + try { + const response = await cloudApiRequest( + workspace, + `/api/workspaces/${workspace.workspaceId}/status` + ); + + if (!response.ok) { + return null; + } + + return (await response.json()) as { status: string; agents?: string[] }; + } catch { + return null; + } +} + +// ============================================================================ +// Cloud Connection Factory +// ============================================================================ + +/** + * Get connection info for the relay daemon. + * + * This function determines the best way to connect to the daemon: + * - In cloud environments: Uses workspace-namespaced socket + * - In local environments: Uses standard socket discovery + * + * @param options - Optional configuration overrides + * @returns Connection info or null if daemon not found + */ +export function getConnectionInfo( + options: CloudConnectionOptions = {} +): CloudConnectionInfo | null { + const discovery = discoverSocket(options); + + if (!discovery) { + return null; + } + + const info: CloudConnectionInfo = { + socketPath: discovery.socketPath, + project: discovery.project, + isCloud: discovery.isCloud, + workspace: discovery.workspace, + }; + + // In cloud environments, we may also have a daemon URL for HTTP API access + if (discovery.workspace?.cloudApiUrl) { + info.daemonUrl = discovery.workspace.cloudApiUrl; + } + + return info; +} + +/** + * Environment variable summary for debugging. + */ +export function getCloudEnvironmentSummary(): Record { + return { + WORKSPACE_ID: process.env.WORKSPACE_ID, + CLOUD_API_URL: process.env.CLOUD_API_URL, + WORKSPACE_TOKEN: process.env.WORKSPACE_TOKEN ? '[set]' : undefined, + WORKSPACE_OWNER_USER_ID: process.env.WORKSPACE_OWNER_USER_ID, + RELAY_SOCKET: process.env.RELAY_SOCKET, + RELAY_PROJECT: process.env.RELAY_PROJECT, + RELAY_AGENT_NAME: process.env.RELAY_AGENT_NAME, + }; +} + +// ============================================================================ +// Agent Identity Discovery +// ============================================================================ + +/** + * Discover the agent name for the MCP server. + * + * Priority order: + * 1. RELAY_AGENT_NAME environment variable (explicit) + * 2. Identity file in .agent-relay directory (written by wrapper) + * 3. Scan outbox directories to find agent's outbox + * + * @param _discovery - Optional discovery result (reserved for future use) + * @returns Agent name or null if not found + */ +export function discoverAgentName(_discovery?: DiscoveryResult | null): string | null { + // 1. Explicit environment variable + const envName = process.env.RELAY_AGENT_NAME; + if (envName) { + return envName; + } + + // 2. Identity file in .agent-relay directory + // The wrapper creates this file with the agent name + const projectRoot = findProjectRoot(process.cwd()); + const searchDirs = [process.cwd()]; + if (projectRoot && projectRoot !== process.cwd()) { + searchDirs.push(projectRoot); + } + + for (const dir of searchDirs) { + const relayDir = join(dir, '.agent-relay'); + if (!existsSync(relayDir)) continue; + + // First check for per-process identity files + // The orchestrator writes mcp-identity-{orchestrator.pid} + // Try to find one by checking process.ppid and its ancestors + const pidIdentityPath = join(relayDir, `mcp-identity-${process.ppid}`); + if (existsSync(pidIdentityPath)) { + try { + const content = readFileSync(pidIdentityPath, 'utf-8').trim(); + if (content) { + return content; + } + } catch { + // Ignore read errors + } + } + + // Scan all mcp-identity-* files and return the most recently modified one + // This handles the case where MCP server's ppid doesn't match the orchestrator + try { + const files = readdirSync(relayDir, { withFileTypes: true }) + .filter((d) => d.isFile() && d.name.startsWith('mcp-identity-')) + .map((d) => ({ + path: join(relayDir, d.name), + name: d.name, + })); + + if (files.length > 0) { + // Sort by mtime (most recent first) to get the latest identity + const sorted = files + .map((f) => { + try { + const stat = statSync(f.path); + return { ...f, mtime: stat.mtimeMs }; + } catch { + return { ...f, mtime: 0 }; + } + }) + .sort((a, b) => b.mtime - a.mtime); + + // Return the most recently modified identity file + const latest = sorted[0]; + if (latest) { + try { + const content = readFileSync(latest.path, 'utf-8').trim(); + if (content) { + return content; + } + } catch { + // Ignore + } + } + } + } catch { + // Ignore scan errors + } + + // Fallback to simple identity file (for single-agent scenarios) + const identityPath = join(relayDir, 'mcp-identity'); + if (existsSync(identityPath)) { + try { + const content = readFileSync(identityPath, 'utf-8').trim(); + if (content) { + return content; + } + } catch { + // Ignore read errors + } + } + } + + // 3. Check outbox directories for a match + // If only one agent's outbox exists, assume we're that agent + for (const dir of searchDirs) { + const outboxDir = join(dir, '.agent-relay', 'outbox'); + if (existsSync(outboxDir)) { + try { + const agents = readdirSync(outboxDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + // If there's exactly one outbox, use that agent name + if (agents.length === 1) { + return agents[0]; + } + + // If there are multiple, we can't determine which one we are + // The wrapper should have created an identity file + } catch { + // Ignore read errors + } + } + } + + return null; +} diff --git a/packages/utils/src/errors.test.ts b/packages/utils/src/errors.test.ts new file mode 100644 index 000000000..abf0fb900 --- /dev/null +++ b/packages/utils/src/errors.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { + RelayError, + DaemonNotRunningError, + AgentNotFoundError, + TimeoutError, + ConnectionError, + ChannelNotFoundError, + SpawnError, +} from './errors.js'; + +describe('Error Classes (single source of truth)', () => { + describe('RelayError', () => { + it('creates error with message', () => { + const err = new RelayError('test error'); + expect(err.message).toBe('test error'); + expect(err.name).toBe('RelayError'); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(RelayError); + }); + }); + + describe('DaemonNotRunningError', () => { + it('creates error with default message', () => { + const err = new DaemonNotRunningError(); + expect(err.message).toContain('Relay daemon is not running'); + expect(err.name).toBe('DaemonNotRunningError'); + expect(err).toBeInstanceOf(RelayError); + }); + + it('creates error with custom message', () => { + const err = new DaemonNotRunningError('Custom msg'); + expect(err.message).toBe('Custom msg'); + }); + }); + + describe('AgentNotFoundError', () => { + it('includes agent name in message', () => { + const err = new AgentNotFoundError('MyAgent'); + expect(err.message).toContain('MyAgent'); + expect(err.name).toBe('AgentNotFoundError'); + expect(err).toBeInstanceOf(RelayError); + }); + }); + + describe('TimeoutError', () => { + it('includes operation and timeout in message', () => { + const err = new TimeoutError('spawn', 5000); + expect(err.message).toContain('5000ms'); + expect(err.message).toContain('spawn'); + expect(err.name).toBe('TimeoutError'); + expect(err).toBeInstanceOf(RelayError); + }); + }); + + describe('ConnectionError', () => { + it('includes connection details', () => { + const err = new ConnectionError('refused'); + expect(err.message).toContain('refused'); + expect(err.name).toBe('ConnectionError'); + expect(err).toBeInstanceOf(RelayError); + }); + }); + + describe('ChannelNotFoundError', () => { + it('includes channel name', () => { + const err = new ChannelNotFoundError('#general'); + expect(err.message).toContain('#general'); + expect(err.name).toBe('ChannelNotFoundError'); + expect(err).toBeInstanceOf(RelayError); + }); + }); + + describe('SpawnError', () => { + it('includes worker name and reason', () => { + const err = new SpawnError('Worker1', 'out of resources'); + expect(err.message).toContain('Worker1'); + expect(err.message).toContain('out of resources'); + expect(err.name).toBe('SpawnError'); + expect(err).toBeInstanceOf(RelayError); + }); + }); +}); diff --git a/packages/utils/src/errors.ts b/packages/utils/src/errors.ts new file mode 100644 index 000000000..1cc0d0d01 --- /dev/null +++ b/packages/utils/src/errors.ts @@ -0,0 +1,56 @@ +/** + * Error Types for Agent Relay + * + * Single source of truth for typed error classes. + * Previously duplicated in @agent-relay/mcp (errors.ts). + * Now consolidated here in the SDK for shared use. + */ + +export class RelayError extends Error { + constructor(message: string) { + super(message); + this.name = 'RelayError'; + } +} + +export class DaemonNotRunningError extends RelayError { + constructor(message?: string) { + super(message || 'Relay daemon is not running. Start with: agent-relay up'); + this.name = 'DaemonNotRunningError'; + } +} + +export class AgentNotFoundError extends RelayError { + constructor(agentName: string) { + super(`Agent not found: ${agentName}`); + this.name = 'AgentNotFoundError'; + } +} + +export class TimeoutError extends RelayError { + constructor(operation: string, timeoutMs: number) { + super(`Timeout after ${timeoutMs}ms: ${operation}`); + this.name = 'TimeoutError'; + } +} + +export class ConnectionError extends RelayError { + constructor(message: string) { + super(`Connection error: ${message}`); + this.name = 'ConnectionError'; + } +} + +export class ChannelNotFoundError extends RelayError { + constructor(channel: string) { + super(`Channel not found: ${channel}`); + this.name = 'ChannelNotFoundError'; + } +} + +export class SpawnError extends RelayError { + constructor(workerName: string, reason: string) { + super(`Failed to spawn worker "${workerName}": ${reason}`); + this.name = 'SpawnError'; + } +}