diff --git a/.gitignore b/.gitignore index 2001269d..44935582 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ packages/*/dist/ .agent-relay # workforce deploy bundle output (staged per-persona by `workforce deploy`) **/.workforce/ +# RelayFile workspace sync sidecar state (regenerated per-tick in sandbox) +**/.relay/ diff --git a/packages/runtime/src/cloud-defaults.ts b/packages/runtime/src/cloud-defaults.ts index ce1bee88..8c4e513c 100644 --- a/packages/runtime/src/cloud-defaults.ts +++ b/packages/runtime/src/cloud-defaults.ts @@ -9,7 +9,8 @@ import { resolveMcpServersLenient, resolvePersonaInputs, resolveStringMapLenient, - type PersonaSpec + type PersonaSpec, + type RelayMcpConfig } from '@agentworkforce/persona-kit'; import { createDefaultLlm } from './cloud-llm.js'; import { SandboxNotAvailableError } from './errors.js'; @@ -459,7 +460,8 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { log: args.log }); const task = run.prompt; - const spec = buildNonInteractiveSpec({ + const relayMcp = resolveRelayMcpFromEnv(args.env); + const specInput = { harness, personaId: args.persona.id, model: personaModel, @@ -470,7 +472,66 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { task, name: args.persona.id, workingDirectory: cwd + }; + const brokerRelayHarness = relayMcp && (harness === 'claude' || harness === 'codex'); + let spec = buildNonInteractiveSpec({ + ...specInput, + ...(relayMcp && !brokerRelayHarness ? { relayMcp } : {}) }); + let spawnArgs = [...spec.args]; + if (relayMcp && harness === 'codex') { + const brokerMcpArgs = await resolveAgentRelayBrokerMcpArgs({ + cli: 'codex', + env: args.env, + relayMcp, + cwd, + existingArgs: codexExistingArgs(spawnArgs), + log: args.log + }); + if (brokerMcpArgs) { + spawnArgs = injectCodexSubcommandArgs(spawnArgs, brokerMcpArgs); + } else { + // Legacy compatibility fallback. The broker's `agent-relay` MCP server + // is preferred for Codex because `mcp-args --register` pre-mints + // RELAY_AGENT_TOKEN and sets RELAY_SKIP_BOOTSTRAP=1; the older + // `@relaycast/mcp` server self-registers during MCP initialize. + spec = buildNonInteractiveSpec({ ...specInput, relayMcp }); + spawnArgs = [...spec.args]; + } + } else if (relayMcp && harness === 'claude') { + if (claudeMcpConfigHasRelayOverride(spawnArgs)) { + args.log('debug', 'harness.relay_mcp.persona_override', { + harness, + serverNames: relayOverrideServerNames(spawnArgs) + }); + } else { + const brokerMcpArgs = await resolveAgentRelayBrokerMcpArgs({ + cli: 'claude', + env: args.env, + relayMcp, + cwd, + // Claude persona specs already contain --mcp-config; the broker + // treats that as user-managed MCP and returns no injection args. + // Ask for the canonical broker payload, then merge agent-relay into + // the persona's strict config below. + existingArgs: [], + log: args.log + }); + const mergedArgs = brokerMcpArgs + ? injectClaudeAgentRelayMcpConfig(spawnArgs, brokerMcpArgs, args.log) + : undefined; + if (mergedArgs) { + spawnArgs = mergedArgs; + } else { + // Legacy compatibility fallback. The broker-generated `agent-relay` + // MCP server is preferred because it comes from the Relay SDK broker + // helper and carries the pre-registered token fast path; the older + // `@relaycast/mcp` server self-registers during MCP initialize. + spec = buildNonInteractiveSpec({ ...specInput, relayMcp }); + spawnArgs = [...spec.args]; + } + } + } for (const warning of spec.warnings) { args.log('warn', 'harness.spec.warning', { warning }); } @@ -491,7 +552,7 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { }; const result = await spawnAndCapture({ bin: spec.bin, - args: [...spec.args], + args: spawnArgs, cwd, env: childEnv, timeoutMs: args.persona.harnessSettings.timeoutSeconds @@ -518,6 +579,231 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & { }; } +interface BrokerMcpArgsOutput { + args: string[]; + sideEffectFiles?: string[]; + agentToken?: string | null; +} + +function resolveRelayMcpFromEnv(env: NodeJS.ProcessEnv): RelayMcpConfig | undefined { + const apiKey = env.RELAY_API_KEY?.trim(); + const agentName = env.RELAY_AGENT_NAME?.trim(); + if (!apiKey || !agentName) return undefined; + const baseUrl = env.RELAY_BASE_URL?.trim(); + const defaultWorkspace = env.RELAY_DEFAULT_WORKSPACE?.trim(); + return { + apiKey, + agentName, + ...(baseUrl ? { baseUrl } : {}), + ...(defaultWorkspace ? { defaultWorkspace } : {}) + }; +} + +async function resolveAgentRelayBrokerMcpArgs(args: { + cli: 'claude' | 'codex'; + env: NodeJS.ProcessEnv; + relayMcp: RelayMcpConfig; + cwd: string; + existingArgs: string[]; + log: WorkforceCtx['log']; +}): Promise { + const broker = resolveAgentRelayBrokerBinary(args.env); + const brokerArgs = [ + 'mcp-args', + '--cli', + args.cli, + '--agent-name', + args.relayMcp.agentName, + '--api-key', + args.relayMcp.apiKey, + ...(args.relayMcp.baseUrl ? ['--base-url', args.relayMcp.baseUrl] : []), + '--register', + '--cwd', + args.cwd, + '--existing-args', + JSON.stringify(args.existingArgs) + ]; + const workspacesJson = args.env.RELAY_WORKSPACES_JSON?.trim(); + if (workspacesJson) brokerArgs.push('--workspaces-json', workspacesJson); + if (args.relayMcp.defaultWorkspace) { + brokerArgs.push('--default-workspace', args.relayMcp.defaultWorkspace); + } + + const result = await spawnAndCapture({ + bin: broker, + args: brokerArgs, + cwd: args.cwd, + env: args.env, + timeoutMs: 15_000 + }); + if (result.exitCode !== 0) { + args.log('warn', 'harness.relay_mcp.broker_args_failed', { + broker, + exitCode: result.exitCode, + stderr: redactRelayBrokerOutput(result.stderr.trim(), args.relayMcp) + }); + return undefined; + } + + let parsed: unknown; + try { + parsed = JSON.parse(result.output); + } catch (err) { + args.log('warn', 'harness.relay_mcp.broker_args_invalid_json', { + broker, + error: err instanceof Error ? err.message : String(err) + }); + return undefined; + } + if (!isBrokerMcpArgsOutput(parsed) || parsed.args.length === 0) { + args.log('warn', 'harness.relay_mcp.broker_args_invalid_shape', { broker }); + return undefined; + } + if (parsed.sideEffectFiles?.length) { + args.log('debug', 'harness.relay_mcp.side_effect_files', { + files: parsed.sideEffectFiles + }); + } + return parsed.args; +} + +function resolveAgentRelayBrokerBinary(env: NodeJS.ProcessEnv): string { + const configured = env.AGENT_RELAY_BIN?.trim() || env.BROKER_BINARY_PATH?.trim(); + if (configured) return configured; + const sandboxBroker = resolveSandboxAgentRelayBrokerBinary(); + return sandboxBroker ?? 'agent-relay-broker'; +} + +function resolveSandboxAgentRelayBrokerBinary(): string | undefined { + const suffix = agentRelayBrokerPlatformSuffix(); + if (!suffix) return undefined; + // Daytona cloud images install the Relay SDK smoke dependency here. This is + // a compatibility fallback; env overrides and PATH remain the general SDK + // contract for locating agent-relay-broker. + const candidate = path.join( + '/opt/relay-smoke/node_modules/@agent-relay/sdk/bin', + `agent-relay-broker-${suffix}` + ); + return canExecuteFileSync(candidate) ? candidate : undefined; +} + +function agentRelayBrokerPlatformSuffix(): string | undefined { + const arch = process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : undefined; + if (!arch) return undefined; + if (process.platform === 'linux') return `linux-${arch}`; + if (process.platform === 'darwin') return `darwin-${arch}`; + if (process.platform === 'win32') return 'win32-x64.exe'; + return undefined; +} + +function canExecuteFileSync(candidate: string): boolean { + try { + accessSync(candidate, constants.R_OK | constants.X_OK); + return statSync(candidate).isFile(); + } catch { + return false; + } +} + +function codexExistingArgs(args: string[]): string[] { + return args[0] === 'exec' ? args.slice(1, -1) : [...args]; +} + +function redactRelayBrokerOutput(value: string, relayMcp: RelayMcpConfig): string { + let redacted = value; + for (const secret of [relayMcp.apiKey]) { + if (secret) redacted = redacted.replaceAll(secret, '[REDACTED]'); + } + return redacted; +} + +function injectCodexSubcommandArgs(args: string[], injected: string[]): string[] { + if (args[0] === 'exec') return ['exec', ...injected, ...args.slice(1)]; + if (args.length === 0 || args[0]?.startsWith('-')) return [...injected, ...args]; + return [...args]; +} + +function injectClaudeAgentRelayMcpConfig( + args: string[], + injected: string[], + log: WorkforceCtx['log'] +): string[] | undefined { + const base = parseClaudeMcpConfigArg(args); + const broker = parseClaudeMcpConfigArg(injected); + if (!base || !broker) { + log('warn', 'harness.relay_mcp.claude_mcp_config_missing'); + return undefined; + } + const baseServers = readMcpServersRecord(base.payload); + const brokerServers = readMcpServersRecord(broker.payload); + const agentRelay = brokerServers?.['agent-relay']; + if (!baseServers || !brokerServers || agentRelay === undefined) { + log('warn', 'harness.relay_mcp.claude_mcp_config_invalid'); + return undefined; + } + const mergedServers: Record = { + ...baseServers, + 'agent-relay': agentRelay + }; + delete mergedServers.relaycast; + const nextPayload = { + ...base.payload, + mcpServers: mergedServers + }; + const next = [...args]; + next[base.valueIndex] = JSON.stringify(nextPayload); + return next; +} + +function claudeMcpConfigHasRelayOverride(args: string[]): boolean { + return relayOverrideServerNames(args).length > 0; +} + +function relayOverrideServerNames(args: string[]): string[] { + const parsed = parseClaudeMcpConfigArg(args); + const servers = parsed ? readMcpServersRecord(parsed.payload) : undefined; + if (!servers) return []; + return ['agent-relay', 'relaycast'].filter((name) => servers[name] !== undefined); +} + +function parseClaudeMcpConfigArg( + args: string[] +): { valueIndex: number; payload: Record } | undefined { + const flagIndex = args.indexOf('--mcp-config'); + if (flagIndex < 0) return undefined; + const valueIndex = flagIndex + 1; + const raw = args[valueIndex]; + if (typeof raw !== 'string') return undefined; + try { + const payload = JSON.parse(raw); + return isRecord(payload) ? { valueIndex, payload } : undefined; + } catch { + return undefined; + } +} + +function readMcpServersRecord( + payload: Record +): Record | undefined { + const servers = payload.mcpServers; + return isRecord(servers) ? servers : undefined; +} + +function isBrokerMcpArgsOutput(value: unknown): value is BrokerMcpArgsOutput { + if (!isRecord(value) || !Array.isArray(value.args)) return false; + if (!value.args.every((arg) => typeof arg === 'string')) return false; + if ( + value.sideEffectFiles !== undefined && + (!Array.isArray(value.sideEffectFiles) || + !value.sideEffectFiles.every((file) => typeof file === 'string')) + ) { + return false; + } + return value.agentToken === undefined || + value.agentToken === null || + typeof value.agentToken === 'string'; +} + async function materializeSidecar(args: { persona: PersonaSpec; inputValues: Record; diff --git a/packages/runtime/src/runner.test.ts b/packages/runtime/src/runner.test.ts index ffeafc33..a7ca13c8 100644 --- a/packages/runtime/src/runner.test.ts +++ b/packages/runtime/src/runner.test.ts @@ -204,6 +204,495 @@ async function writeFakeHarness(binDir: string, name: string, stdout: string): P await chmod(path.join(binDir, name), 0o755); } +async function writeArgCaptureHarness( + binDir: string, + name: string, + capturePath: string +): Promise { + await mkdir(binDir, { recursive: true }); + await writeFile( + path.join(binDir, name), + [ + '#!/usr/bin/env node', + "const fs = require('node:fs');", + `fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ argv: process.argv.slice(2) }, null, 2));`, + "process.stdout.write('ok\\n');" + ].join('\n'), + 'utf8' + ); + await chmod(path.join(binDir, name), 0o755); +} + +async function writeFakeBroker(binDir: string, capturePath: string): Promise { + await mkdir(binDir, { recursive: true }); + await writeFile( + path.join(binDir, 'agent-relay-broker'), + [ + '#!/usr/bin/env node', + "const fs = require('node:fs');", + 'const argv = process.argv.slice(2);', + "const cli = argv[argv.indexOf('--cli') + 1];", + `fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify({ argv }, null, 2));`, + 'const brokerArgs = cli === "claude" ? [', + ' "--mcp-config",', + ' JSON.stringify({', + ' mcpServers: {', + ' "agent-relay": {', + ' type: "stdio",', + ' command: "npx",', + ' args: ["-y", "agent-relay", "mcp"],', + ' env: {', + ' RELAY_API_KEY: "rk_live_test",', + ' RELAY_BASE_URL: "https://relay.example.test",', + ' RELAY_AGENT_NAME: "claude-1",', + ' RELAY_AGENT_TYPE: "agent",', + ' RELAY_STRICT_AGENT_NAME: "1",', + ' RELAY_AGENT_TOKEN: "at_live_test",', + ' RELAY_SKIP_BOOTSTRAP: "1"', + ' }', + ' }', + ' }', + ' })', + '] : [', + ' "--config",', + ' "check_for_update_on_startup=false",', + ' "--config",', + ' "mcp_servers.agent-relay.command=\\"npx\\"",', + ' "--config",', + ' "mcp_servers.agent-relay.args=[\\"-y\\", \\"agent-relay\\", \\"mcp\\"]",', + ' "--config",', + ' "mcp_servers.agent-relay.env.RELAY_AGENT_TOKEN=\\"at_live_test\\"",', + ' "--config",', + ' "mcp_servers.agent-relay.env.RELAY_SKIP_BOOTSTRAP=\\"1\\""', + '];', + 'process.stdout.write(JSON.stringify({', + ' args: brokerArgs,', + ' sideEffectFiles: [],', + ' agentToken: "at_live_test"', + '}));' + ].join('\n'), + 'utf8' + ); + await chmod(path.join(binDir, 'agent-relay-broker'), 0o755); +} + +async function writeEmptyArgsBroker(binDir: string): Promise { + await mkdir(binDir, { recursive: true }); + await writeFile( + path.join(binDir, 'agent-relay-broker'), + [ + '#!/usr/bin/env node', + 'process.stdout.write(JSON.stringify({ args: [], sideEffectFiles: [] }));' + ].join('\n'), + 'utf8' + ); + await chmod(path.join(binDir, 'agent-relay-broker'), 0o755); +} + +async function writeFailingBroker(binDir: string): Promise { + await mkdir(binDir, { recursive: true }); + await writeFile( + path.join(binDir, 'agent-relay-broker'), + [ + '#!/usr/bin/env node', + "process.stderr.write(`failed for ${process.argv.join(' ')}\\n`);", + 'process.exit(2);' + ].join('\n'), + 'utf8' + ); + await chmod(path.join(binDir, 'agent-relay-broker'), 0o755); +} + +test('cloud default codex harness injects agent-relay MCP args from broker helper', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'workforce-runtime-')); + try { + const binDir = path.join(tempDir, 'bin'); + const workspaceRoot = path.join(tempDir, 'workspace'); + const capturePath = path.join(tempDir, 'codex-argv.json'); + const brokerCapturePath = path.join(tempDir, 'broker-argv.json'); + await mkdir(workspaceRoot, { recursive: true }); + await writeArgCaptureHarness(binDir, 'codex', capturePath); + await writeFakeBroker(binDir, brokerCapturePath); + + const defaults = createCloudRuntimeDefaults({ + persona: { + ...persona, + id: 'autonomous-actor', + harness: 'codex', + model: 'openai/gpt-5', + systemPrompt: 'coordinate with the team', + harnessSettings: { + reasoning: 'medium', + dangerouslyBypassApprovalsAndSandbox: true, + timeoutSeconds: 5 + } + }, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + log: () => {}, + env: { + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKFORCE_SANDBOX_ROOT: workspaceRoot, + RELAY_API_KEY: 'rk_live_test', + RELAY_AGENT_NAME: 'codex-1', + RELAY_BASE_URL: 'https://relay.example.test', + RELAY_DEFAULT_WORKSPACE: 'ws-relay', + RELAY_WORKSPACES_JSON: '{"workspaces":[{"id":"ws-relay"}]}' + } + }); + + const result = await defaults.harnessRunner({ prompt: 'do the work' }); + assert.equal(result.exitCode, 0); + + const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { argv: string[] }; + assert.deepEqual(captured.argv.slice(0, 9), [ + 'exec', + '--config', + 'check_for_update_on_startup=false', + '--config', + 'mcp_servers.agent-relay.command="npx"', + '--config', + 'mcp_servers.agent-relay.args=["-y", "agent-relay", "mcp"]', + '--config', + 'mcp_servers.agent-relay.env.RELAY_AGENT_TOKEN="at_live_test"' + ]); + assert.equal(captured.argv[9], '--config'); + assert.equal(captured.argv[10], 'mcp_servers.agent-relay.env.RELAY_SKIP_BOOTSTRAP="1"'); + assert.equal(captured.argv[11], '-m'); + assert.equal(captured.argv[12], 'gpt-5'); + assert.ok(captured.argv.includes('--dangerously-bypass-approvals-and-sandbox')); + assert.ok(captured.argv.includes('--skip-git-repo-check')); + + const brokerCaptured = JSON.parse(await readFile(brokerCapturePath, 'utf8')) as { + argv: string[]; + }; + assert.deepEqual(brokerCaptured.argv.slice(0, 11), [ + 'mcp-args', + '--cli', + 'codex', + '--agent-name', + 'codex-1', + '--api-key', + 'rk_live_test', + '--base-url', + 'https://relay.example.test', + '--register', + '--cwd' + ]); + assert.equal(brokerCaptured.argv[11], workspaceRoot); + const existingArgsIdx = brokerCaptured.argv.indexOf('--existing-args'); + assert.notEqual(existingArgsIdx, -1); + assert.deepEqual( + JSON.parse(brokerCaptured.argv[existingArgsIdx + 1]), + captured.argv.slice(11, -1) + ); + assert.equal(captured.argv.at(-1), 'coordinate with the team\n\nUser task:\ndo the work'); + assert.equal( + brokerCaptured.argv[brokerCaptured.argv.indexOf('--workspaces-json') + 1], + '{"workspaces":[{"id":"ws-relay"}]}' + ); + assert.equal( + brokerCaptured.argv[brokerCaptured.argv.indexOf('--default-workspace') + 1], + 'ws-relay' + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('cloud default broker args omit base URL when RELAY_BASE_URL is unset', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'workforce-runtime-')); + try { + const binDir = path.join(tempDir, 'bin'); + const workspaceRoot = path.join(tempDir, 'workspace'); + const capturePath = path.join(tempDir, 'codex-argv.json'); + const brokerCapturePath = path.join(tempDir, 'broker-argv.json'); + await mkdir(workspaceRoot, { recursive: true }); + await writeArgCaptureHarness(binDir, 'codex', capturePath); + await writeFakeBroker(binDir, brokerCapturePath); + + const defaults = createCloudRuntimeDefaults({ + persona: { + ...persona, + harness: 'codex', + model: 'openai/gpt-5', + systemPrompt: 'coordinate with the team', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 5 } + }, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + log: () => {}, + env: { + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKFORCE_SANDBOX_ROOT: workspaceRoot, + RELAY_API_KEY: 'rk_live_test', + RELAY_AGENT_NAME: 'codex-1' + } + }); + + const result = await defaults.harnessRunner({ prompt: 'do the work' }); + assert.equal(result.exitCode, 0); + + const brokerCaptured = JSON.parse(await readFile(brokerCapturePath, 'utf8')) as { + argv: string[]; + }; + assert.equal(brokerCaptured.argv.includes('--base-url'), false); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('cloud default broker failure logs redact Relay API key', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'workforce-runtime-')); + try { + const binDir = path.join(tempDir, 'bin'); + const workspaceRoot = path.join(tempDir, 'workspace'); + const capturePath = path.join(tempDir, 'codex-argv.json'); + const logs: Array<{ level: string; message: string; data?: unknown }> = []; + await mkdir(workspaceRoot, { recursive: true }); + await writeArgCaptureHarness(binDir, 'codex', capturePath); + await writeFailingBroker(binDir); + + const defaults = createCloudRuntimeDefaults({ + persona: { + ...persona, + harness: 'codex', + model: 'openai/gpt-5', + systemPrompt: 'coordinate with the team', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 5 } + }, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + log: (level, message, data) => logs.push({ level, message, data }), + env: { + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKFORCE_SANDBOX_ROOT: workspaceRoot, + RELAY_API_KEY: 'rk_live_test', + RELAY_AGENT_NAME: 'codex-1', + RELAY_BASE_URL: 'https://relay.example.test' + } + }); + + const result = await defaults.harnessRunner({ prompt: 'do the work' }); + assert.equal(result.exitCode, 0); + const failure = logs.find((log) => log.message === 'harness.relay_mcp.broker_args_failed'); + assert.ok(failure); + const stderr = (failure.data as { stderr: string }).stderr; + assert.match(stderr, /\[REDACTED\]/); + assert.doesNotMatch(stderr, /rk_live_test/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('cloud default codex harness falls back when broker returns no args', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'workforce-runtime-')); + try { + const binDir = path.join(tempDir, 'bin'); + const workspaceRoot = path.join(tempDir, 'workspace'); + const capturePath = path.join(tempDir, 'codex-argv.json'); + await mkdir(workspaceRoot, { recursive: true }); + await writeArgCaptureHarness(binDir, 'codex', capturePath); + await writeEmptyArgsBroker(binDir); + + const defaults = createCloudRuntimeDefaults({ + persona: { + ...persona, + harness: 'codex', + model: 'openai/gpt-5', + systemPrompt: 'coordinate with the team', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 5 } + }, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + log: () => {}, + env: { + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKFORCE_SANDBOX_ROOT: workspaceRoot, + RELAY_API_KEY: 'rk_live_test', + RELAY_AGENT_NAME: 'codex-1', + RELAY_BASE_URL: 'https://relay.example.test' + } + }); + + const result = await defaults.harnessRunner({ prompt: 'do the work' }); + assert.equal(result.exitCode, 0); + + const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { argv: string[] }; + assert.ok(captured.argv.includes('mcp_servers.relaycast.command="npx"')); + assert.equal(captured.argv.includes('mcp_servers.agent-relay.command="npx"'), false); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('cloud default claude harness merges agent-relay MCP config from broker helper', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'workforce-runtime-')); + try { + const binDir = path.join(tempDir, 'bin'); + const workspaceRoot = path.join(tempDir, 'workspace'); + const capturePath = path.join(tempDir, 'claude-argv.json'); + const brokerCapturePath = path.join(tempDir, 'broker-argv.json'); + await mkdir(workspaceRoot, { recursive: true }); + await writeArgCaptureHarness(binDir, 'claude', capturePath); + await writeFakeBroker(binDir, brokerCapturePath); + + const defaults = createCloudRuntimeDefaults({ + persona: { + ...persona, + id: 'autonomous-actor', + harness: 'claude', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'coordinate with the team', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 5 }, + mcpServers: { + filesystem: { type: 'stdio', command: 'filesystem-mcp' } + } + }, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + log: () => {}, + env: { + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKFORCE_SANDBOX_ROOT: workspaceRoot, + RELAY_API_KEY: 'rk_live_test', + RELAY_AGENT_NAME: 'claude-1', + RELAY_BASE_URL: 'https://relay.example.test', + RELAY_DEFAULT_WORKSPACE: 'ws-relay', + RELAY_WORKSPACES_JSON: '{"workspaces":[{"id":"ws-relay"}]}' + } + }); + + const result = await defaults.harnessRunner({ prompt: 'do the work' }); + assert.equal(result.exitCode, 0); + + const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { argv: string[] }; + assert.equal(captured.argv.filter((arg) => arg === '--mcp-config').length, 1); + assert.ok(captured.argv.includes('--strict-mcp-config')); + assert.ok(captured.argv.includes('--print')); + assert.ok(captured.argv.includes('--output-format')); + assert.equal(captured.argv.at(-1), 'do the work'); + + const mcpConfigIdx = captured.argv.indexOf('--mcp-config'); + const payload = JSON.parse(captured.argv[mcpConfigIdx + 1]) as { + mcpServers: Record< + string, + { type: string; command?: string; args?: string[]; env?: Record } + >; + }; + assert.deepEqual(payload.mcpServers.filesystem, { + type: 'stdio', + command: 'filesystem-mcp' + }); + assert.equal(payload.mcpServers.relaycast, undefined); + assert.equal(payload.mcpServers['agent-relay'].command, 'npx'); + assert.deepEqual(payload.mcpServers['agent-relay'].args, ['-y', 'agent-relay', 'mcp']); + assert.equal(payload.mcpServers['agent-relay'].env?.RELAY_AGENT_TOKEN, 'at_live_test'); + assert.equal(payload.mcpServers['agent-relay'].env?.RELAY_SKIP_BOOTSTRAP, '1'); + assert.equal(payload.mcpServers['agent-relay'].env?.RELAY_STRICT_AGENT_NAME, '1'); + + const brokerCaptured = JSON.parse(await readFile(brokerCapturePath, 'utf8')) as { + argv: string[]; + }; + assert.deepEqual(brokerCaptured.argv.slice(0, 11), [ + 'mcp-args', + '--cli', + 'claude', + '--agent-name', + 'claude-1', + '--api-key', + 'rk_live_test', + '--base-url', + 'https://relay.example.test', + '--register', + '--cwd' + ]); + assert.equal(brokerCaptured.argv[11], workspaceRoot); + const existingArgsIdx = brokerCaptured.argv.indexOf('--existing-args'); + assert.notEqual(existingArgsIdx, -1); + assert.deepEqual(JSON.parse(brokerCaptured.argv[existingArgsIdx + 1]), []); + assert.equal( + brokerCaptured.argv[brokerCaptured.argv.indexOf('--workspaces-json') + 1], + '{"workspaces":[{"id":"ws-relay"}]}' + ); + assert.equal( + brokerCaptured.argv[brokerCaptured.argv.indexOf('--default-workspace') + 1], + 'ws-relay' + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('cloud default claude harness preserves explicit relay MCP overrides', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'workforce-runtime-')); + try { + const binDir = path.join(tempDir, 'bin'); + const workspaceRoot = path.join(tempDir, 'workspace'); + const capturePath = path.join(tempDir, 'claude-argv.json'); + const brokerCapturePath = path.join(tempDir, 'broker-argv.json'); + const logs: Array<{ level: string; message: string; data?: unknown }> = []; + await mkdir(workspaceRoot, { recursive: true }); + await writeArgCaptureHarness(binDir, 'claude', capturePath); + await writeFakeBroker(binDir, brokerCapturePath); + + const defaults = createCloudRuntimeDefaults({ + persona: { + ...persona, + id: 'autonomous-actor', + harness: 'claude', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'coordinate with the team', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 5 }, + mcpServers: { + relaycast: { type: 'stdio', command: 'custom-relaycast' } + } + }, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + log: (level, message, data) => logs.push({ level, message, data }), + env: { + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + WORKFORCE_SANDBOX_ROOT: workspaceRoot, + RELAY_API_KEY: 'rk_live_test', + RELAY_AGENT_NAME: 'claude-1', + RELAY_BASE_URL: 'https://relay.example.test' + } + }); + + const result = await defaults.harnessRunner({ prompt: 'do the work' }); + assert.equal(result.exitCode, 0); + + const captured = JSON.parse(await readFile(capturePath, 'utf8')) as { argv: string[] }; + const mcpConfigIdx = captured.argv.indexOf('--mcp-config'); + const payload = JSON.parse(captured.argv[mcpConfigIdx + 1]) as { + mcpServers: Record; + }; + assert.deepEqual(payload.mcpServers.relaycast, { + type: 'stdio', + command: 'custom-relaycast' + }); + assert.equal(payload.mcpServers['agent-relay'], undefined); + await assert.rejects(readFile(brokerCapturePath, 'utf8'), /ENOENT/); + assert.deepEqual( + logs.find((log) => log.message === 'harness.relay_mcp.persona_override'), + { + level: 'debug', + message: 'harness.relay_mcp.persona_override', + data: { harness: 'claude', serverNames: ['relaycast'] } + } + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + function snapshotEnv(keys: string[]): Record { const out: Record = {}; for (const key of keys) out[key] = process.env[key];