Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 71 additions & 24 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ import {
type SidecarMdMode,
type SkillMaterializationPlan
} from '@agentworkforce/persona-kit';
import {
claudeMcpConfigHasRelayOverride,
codexExistingArgs,
injectClaudeAgentRelayMcpConfig,
injectCodexSubcommandArgs,
resolveAgentRelayBrokerMcpArgs,
resolveRelayMcpFromEnv as resolveRelayMcpFromEnvShared
} from '@agentworkforce/runtime';
import {
listBuiltInPersonas,
personaCatalog,
Expand Down Expand Up @@ -534,27 +542,9 @@ function emitDropWarnings(lines: string[]): void {
* relaycast wiring to inject into the harness's MCP config so the persona can
* message the team — the same capability a non-persona broker spawn gets.
*
* The broker sets `RELAY_API_KEY` (+ optional `RELAY_BASE_URL` /
* `RELAY_DEFAULT_WORKSPACE`) on every worker child, and the broker-assigned
* worker name on `RELAY_AGENT_NAME`. Both the key and the name are required:
* the name must match what the broker routes to so the relaycast identity and
* the PTY worker are the same agent. Absent either, we're not under a broker
* (or it's too old to expose the name) and return undefined — a plain
* `agentworkforce agent` run is unaffected.
* Delegates to the shared `resolveRelayMcpFromEnv` in `@agentworkforce/runtime`.
*/
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 } : {})
};
}
const resolveRelayMcpFromEnv = resolveRelayMcpFromEnvShared;

/**
* Build the `ai-hist` MCP config for a session. Only called when the persona
Expand Down Expand Up @@ -1854,19 +1844,76 @@ async function runInteractive(
const aiHist = aiMemory.enabled
? resolveAiHistConfig(process.env, aiMemory.dbPath)
: undefined;
const spec = buildInteractiveSpec({
const brokerRelayHarness = relayMcp && (harness === 'claude' || harness === 'codex');
const specInput = {
harness,
personaId,
model,
systemPrompt,
harnessSettings,
mcpServers: resolvedMcp,
...(relayMcp ? { relayMcp } : {}),
...(aiHist ? { aiHist } : {}),
permissions: effectiveSelection.permissions,
...(installRoot !== undefined ? { pluginDirs: [installRoot] } : {})
};
const spec = buildInteractiveSpec({
...specInput,
...(relayMcp && !brokerRelayHarness ? { relayMcp } : {})
});
for (const w of spec.warnings) process.stderr.write(`warning: ${w}\n`);
let spawnArgs = [...spec.args];

// Broker-aware relay MCP injection: prefer the broker binary's `mcp-args
// --register` subcommand (pre-mints RELAY_AGENT_TOKEN, sets
// RELAY_SKIP_BOOTSTRAP=1) over the legacy `@relaycast/mcp` self-registration
// path. Falls back to @relaycast/mcp when the broker binary is unavailable.
const cliRelayLog = (level: 'debug' | 'info' | 'warn' | 'error', message: string, attrs?: Record<string, unknown>) => {
if (level === 'warn' || level === 'error') {
process.stderr.write(`warning: ${message}${attrs ? ` ${JSON.stringify(attrs)}` : ''}\n`);
}
};
if (relayMcp && harness === 'claude') {
if (claudeMcpConfigHasRelayOverride(spawnArgs)) {
process.stderr.write('• relay: persona overrides relay MCP config\n');
} else {
const brokerMcpArgs = await resolveAgentRelayBrokerMcpArgs({
cli: 'claude',
env: process.env,
relayMcp,
cwd: process.cwd(),
existingArgs: [],
log: cliRelayLog
});
const mergedArgs = brokerMcpArgs
? injectClaudeAgentRelayMcpConfig(spawnArgs, brokerMcpArgs, cliRelayLog)
: undefined;
if (mergedArgs) {
spawnArgs = mergedArgs;
process.stderr.write('• relay: agent-relay (broker pre-registered)\n');
} else {
const fallbackSpec = buildInteractiveSpec({ ...specInput, relayMcp });
spawnArgs = [...fallbackSpec.args];
process.stderr.write('• relay: @relaycast/mcp (fallback)\n');
}
}
} else if (relayMcp && harness === 'codex') {
const brokerMcpArgs = await resolveAgentRelayBrokerMcpArgs({
cli: 'codex',
env: process.env,
relayMcp,
cwd: process.cwd(),
existingArgs: codexExistingArgs(spawnArgs),
log: cliRelayLog
});
if (brokerMcpArgs) {
spawnArgs = injectCodexSubcommandArgs(spawnArgs, brokerMcpArgs);
process.stderr.write('• relay: agent-relay (broker pre-registered)\n');
} else {
const fallbackSpec = buildInteractiveSpec({ ...specInput, relayMcp });
spawnArgs = [...fallbackSpec.args];
process.stderr.write('• relay: @relaycast/mcp (fallback)\n');
}
}

// Config-file materialization strategy:
// - Mount path (default): write each configFile into the
Expand All @@ -1882,12 +1929,12 @@ async function runInteractive(
// default handles this cleanly).
const hasConfigFiles = spec.configFiles.length > 0;
const degradeConfigFiles = hasConfigFiles && !useClean;
let effectiveArgs: readonly string[] = spec.args;
let effectiveArgs: readonly string[] = spawnArgs;
if (degradeConfigFiles) {
process.stderr.write(
'warning: --install-in-repo cannot safely materialize the persona agent config (would write opencode.json into your repo); launching without --agent. Drop --install-in-repo to apply the persona prompt.\n'
);
effectiveArgs = stripAgentFlag(spec.args);
effectiveArgs = stripAgentFlag(spawnArgs);
}
const finalArgs = spec.initialPrompt ? [...effectiveArgs, spec.initialPrompt] : [...effectiveArgs];

Expand Down
234 changes: 9 additions & 225 deletions packages/runtime/src/cloud-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ import {
type PersonaSpec,
type RelayMcpConfig
} from '@agentworkforce/persona-kit';
import {
claudeMcpConfigHasRelayOverride,
codexExistingArgs,
injectClaudeAgentRelayMcpConfig,
injectCodexSubcommandArgs,
relayOverrideServerNames,
resolveAgentRelayBrokerMcpArgs,
resolveRelayMcpFromEnv
} from './relay-mcp.js';
import { createDefaultLlm } from './cloud-llm.js';
import { SandboxNotAvailableError } from './errors.js';
import type {
Expand Down Expand Up @@ -626,26 +635,6 @@ 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 } : {})
};
}

/**
* Resolve the ai-hist MCP config. Only called when the persona opts into recall
* via `memory.aiMemory`. The "why" read-root mirrors the runtime recorder's
Expand All @@ -668,211 +657,6 @@ function resolveAiHistFromEnv(
};
}

async function resolveAgentRelayBrokerMcpArgs(args: {
cli: 'claude' | 'codex';
env: NodeJS.ProcessEnv;
relayMcp: RelayMcpConfig;
cwd: string;
existingArgs: string[];
log: WorkforceCtx['log'];
}): Promise<string[] | undefined> {
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<string, unknown> = {
...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<string, unknown> } | 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<string, unknown>
): Record<string, unknown> | 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<string, string>;
Expand Down
Loading
Loading