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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
292 changes: 289 additions & 3 deletions packages/runtime/src/cloud-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -459,7 +460,8 @@ function createProcessHarnessRunner(args: CloudDefaultOptions & {
log: args.log
});
const task = run.prompt;
const spec = buildNonInteractiveSpec({
const relayMcp = resolveRelayMcpFromEnv(args.env);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Relay MCP detection is using only the static launcher environment, so per-run env overrides on ctx.harness.run() are ignored. If a caller supplies RELAY_API_KEY / RELAY_AGENT_NAME (or overrides them) in run.env, relay wiring will not be activated for that run. Build Relay config from the effective run environment (including run-level overrides) before deciding whether to call the broker. [api mismatch]

Severity Level: Major ⚠️
- ❌ Relay MCP never enabled from run.env overrides.
- ⚠️ Handlers cannot pass per-run Relay config safely.
- ⚠️ Personas relying on dynamic Relay fail silently.
Steps of Reproduction ✅
1. Build the cloud runtime defaults by calling `createCloudRuntimeDefaults` from
`packages/runtime/src/cloud-defaults.ts:67-96`, which computes `env = options.env ??
process.env` and passes it into `createProcessHarnessRunner` as `args.env`.

2. Wire this runtime into a handler context using `buildCtx` so that `ctx.harness.run` is
backed by `options.harnessRunner`, as shown in `packages/runtime/src/ctx.ts:20-22` where
`harness: { run: options.harnessRunner }` is set.

3. From a handler, invoke `ctx.harness.run` with per-run environment overrides containing
Relay credentials, e.g. `ctx.harness.run({ prompt: '...', env: { RELAY_API_KEY: 'k',
RELAY_AGENT_NAME: 'agent' } })`, which is supported by `HarnessRunArgs.env` ("Environment
overrides merged on top of the persona's env") in `packages/runtime/src/types.ts:62-71`.

4. Follow the execution into `createProcessHarnessRunner` in
`packages/runtime/src/cloud-defaults.ts:14-146`: `relayMcp` is computed from the static
launcher env via `const relayMcp = resolveRelayMcpFromEnv(args.env);` at line 64, while
the child process environment merges `...(run.env ?? {})` at lines 108-114. If
`options.env` (and thus `args.env`) does not contain `RELAY_API_KEY` / `RELAY_AGENT_NAME`
but `run.env` does, `resolveRelayMcpFromEnv` (lines 154-165) returns `undefined`, no Relay
wiring/broker call occurs, yet the spawned harness still receives the RELAY_* variables in
`childEnv`. Relay MCP integration silently remains disabled for runs configured only via
`run.env`.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/runtime/src/cloud-defaults.ts
**Line:** 463:463
**Comment:**
	*Api Mismatch: Relay MCP detection is using only the static launcher environment, so per-run `env` overrides on `ctx.harness.run()` are ignored. If a caller supplies `RELAY_API_KEY` / `RELAY_AGENT_NAME` (or overrides them) in `run.env`, relay wiring will not be activated for that run. Build Relay config from the effective run environment (including run-level overrides) before deciding whether to call the broker.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

const specInput = {
harness,
personaId: args.persona.id,
model: personaModel,
Expand All @@ -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 });
}
Expand All @@ -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
Expand All @@ -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<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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The Windows broker suffix is hardcoded to x64 even when running on arm64, so bundled broker resolution will pick the wrong binary name on Windows arm64 hosts and fall back unexpectedly. Include architecture in the Windows suffix logic the same way Linux/macOS do. [incorrect condition logic]

Severity Level: Major ⚠️
- ❌ Windows arm64 cannot use bundled broker binary.
- ⚠️ Relay Codex sessions rely on external broker installation.
- ⚠️ Fallback path may break Relay-enabled personas.
Steps of Reproduction ✅
1. On a Windows arm64 host, install the Relay SDK broker into the Daytona path expected by
`resolveSandboxAgentRelayBrokerBinary`, e.g.
`/opt/relay-smoke/node_modules/@agent-relay/sdk/bin/agent-relay-broker-win32-arm64.exe`,
matching the platform-specific naming scheme used for Linux/macOS in
`agentRelayBrokerPlatformSuffix` (`packages/runtime/src/cloud-defaults.ts:254-259`).

2. Configure a Codex persona with Relay enabled and build cloud defaults with
`createCloudRuntimeDefaults` (`packages/runtime/src/cloud-defaults.ts:67-96`), then run a
handler that calls `ctx.harness.run`, flowing into `createProcessHarnessRunner`
(`packages/runtime/src/cloud-defaults.ts:14-146`) and its call to
`resolveCodexAgentRelayMcpArgs` (lines 82-88) for Codex Relay wiring.

3. Inside `resolveCodexAgentRelayMcpArgs`
(`packages/runtime/src/cloud-defaults.ts:168-235`), the broker path is resolved by
`resolveAgentRelayBrokerBinary` at line 175, which delegates to
`resolveSandboxAgentRelayBrokerBinary` (lines 237-245); that function uses
`agentRelayBrokerPlatformSuffix` (lines 254-260) to compute a suffix based on
`process.platform` and `process.arch`.

4. On Windows arm64, `agentRelayBrokerPlatformSuffix` sets `arch` to `'arm64'` but returns
`'win32-x64.exe'` for `process.platform === 'win32'` at line 259, yielding a candidate
path ending in `agent-relay-broker-win32-x64.exe` that does not exist;
`canExecuteFileSync` (lines 263-270) fails, `resolveSandboxAgentRelayBrokerBinary` returns
`undefined`, and `resolveAgentRelayBrokerBinary` falls back to `'agent-relay-broker'`
(lines 237-241). If no matching `agent-relay-broker` is on PATH, the subsequent spawn in
`resolveCodexAgentRelayMcpArgs` fails and logs `harness.relay_mcp.broker_args_failed`,
breaking Codex Relay integration specifically on Windows arm64.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** packages/runtime/src/cloud-defaults.ts
**Line:** 659:659
**Comment:**
	*Incorrect Condition Logic: The Windows broker suffix is hardcoded to x64 even when running on arm64, so bundled broker resolution will pick the wrong binary name on Windows arm64 hosts and fall back unexpectedly. Include architecture in the Windows suffix logic the same way Linux/macOS do.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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