Skip to content
Open
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
7 changes: 7 additions & 0 deletions crates/broker/src/cli_mcp_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ fn side_effect_files_for(cli: &str, existing_args: &[String], cwd: &Path) -> Res
.collect());
}

if cli_lower == "grok" {
return Ok(home_dir_from_env()
.map(|home| home.join(".grok").join("config.toml"))
.into_iter()
.collect());
}

Ok(Vec::new())
}

Expand Down
2 changes: 1 addition & 1 deletion crates/broker/src/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ fn resolve_command_path(command: &str) -> String {
{
let home = env::var("HOME").unwrap_or_else(|_| String::from("/root"));
OsString::from(format!(
"{home}/.local/bin:{home}/.opencode/bin:{home}/.claude/local:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"
"{home}/.local/bin:{home}/.grok/bin:{home}/.opencode/bin:{home}/.claude/local:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin"
))
}
#[cfg(windows)]
Expand Down
170 changes: 167 additions & 3 deletions crates/broker/src/snippets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,8 +557,8 @@ pub fn ensure_opencode_config_with_result(
/// Configure the relaycast MCP server for any supported CLI tool.
///
/// Returns extra CLI arguments to append when spawning the agent.
/// For Gemini/Droid this runs a pre-spawn `mcp add` command (removing first
/// for idempotency). For Opencode this writes `opencode.json` on disk.
/// For Gemini/Droid/Grok this runs a pre-spawn `mcp add` command (removing
/// first for idempotency). For Opencode this writes `opencode.json` on disk.
///
/// # Parameters
/// Write `.cursor/mcp.json` in the given directory with the Agent Relay MCP server
Expand Down Expand Up @@ -646,7 +646,7 @@ pub fn ensure_cursor_mcp_config(
Ok(changed)
}

/// - `cli`: CLI tool name (e.g. "claude", "codex", "gemini", "droid", "opencode", "cursor")
/// - `cli`: CLI tool name (e.g. "claude", "codex", "gemini", "droid", "grok", "opencode", "cursor")
/// - `agent_name`: the name of the agent being spawned
/// - `api_key`: optional relay API key (empty or `None` means omit)
/// - `base_url`: optional relay base URL (empty or `None` means omit)
Expand Down Expand Up @@ -723,6 +723,7 @@ pub async fn configure_relaycast_mcp_with_result(
let is_codex = cli_lower == "codex";
let is_gemini = cli_lower == "gemini";
let is_droid = cli_lower == "droid";
let is_grok = cli_lower == "grok";
let is_opencode = cli_lower == "opencode";
let is_cursor = cli_lower == "cursor" || cli_lower == "cursor-agent" || cli_lower == "agent"; // "agent" is cursor-agent's binary name

Expand Down Expand Up @@ -883,6 +884,17 @@ pub async fn configure_relaycast_mcp_with_result(
None,
)
.await?;
} else if is_grok {
configure_grok_mcp(
cli,
api_key,
base_url,
Some(agent_name),
agent_token,
workspaces_json,
default_workspace,
)
.await?;
} else if is_opencode && !existing_args.iter().any(|a| a == "--agent") {
ensure_opencode_config_with_result(
cwd,
Expand Down Expand Up @@ -1148,6 +1160,127 @@ async fn configure_gemini_droid_mcp(
Ok(())
}

fn grok_manual_mcp_add_cmd(cli: &str) -> String {
format!(
"{cli} mcp add relaycast --command npx --args=-y --args=agent-relay --args=mcp --env RELAY_API_KEY=<key> --env RELAY_BASE_URL=<url>"
)
}

fn grok_mcp_add_args(
api_key: Option<&str>,
base_url: Option<&str>,
agent_name: Option<&str>,
agent_token: Option<&str>,
workspaces_json: Option<&str>,
default_workspace: Option<&str>,
) -> Vec<String> {
let mut args = vec![
"mcp".to_string(),
"add".to_string(),
"relaycast".to_string(),
"--command".to_string(),
"npx".to_string(),
"--args=-y".to_string(),
format!("--args={AGENT_RELAY_MCP_PACKAGE}"),
format!("--args={AGENT_RELAY_MCP_SUBCOMMAND}"),
];

let mut push_env = |key: &str, value: Option<&str>| {
if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) {
args.push("--env".to_string());
args.push(format!("{key}={value}"));
}
};

push_env("RELAY_API_KEY", api_key);
push_env("RELAY_BASE_URL", base_url);
push_env("RELAY_AGENT_NAME", agent_name);
push_env("RELAY_AGENT_TYPE", Some("agent"));
push_env("RELAY_STRICT_AGENT_NAME", Some("1"));
if agent_token
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
push_env("RELAY_AGENT_TOKEN", agent_token);
push_env("RELAY_SKIP_BOOTSTRAP", Some("1"));
}
push_env("RELAY_WORKSPACES_JSON", workspaces_json);
push_env("RELAY_DEFAULT_WORKSPACE", default_workspace);

args
}

async fn configure_grok_mcp(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: configure_grok_mcp duplicates the subprocess orchestration/error-handling logic already implemented in configure_gemini_droid_mcp, which increases maintenance drift risk. Consider extracting a shared helper for "remove existing MCP + run mcp add with timeout + standardized error mapping".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/broker/src/snippets.rs, line 1213:

<comment>`configure_grok_mcp` duplicates the subprocess orchestration/error-handling logic already implemented in `configure_gemini_droid_mcp`, which increases maintenance drift risk. Consider extracting a shared helper for "remove existing MCP + run mcp add with timeout + standardized error mapping".</comment>

<file context>
@@ -1148,6 +1160,127 @@ async fn configure_gemini_droid_mcp(
+    args
+}
+
+async fn configure_grok_mcp(
+    cli: &str,
+    api_key: Option<&str>,
</file context>

cli: &str,
api_key: Option<&str>,
base_url: Option<&str>,
agent_name: Option<&str>,
agent_token: Option<&str>,
workspaces_json: Option<&str>,
default_workspace: Option<&str>,
) -> Result<()> {
let exe = shlex::split(cli)
.and_then(|parts| parts.first().cloned())
.unwrap_or_else(|| cli.trim().to_string());
let manual_cmd = grok_manual_mcp_add_cmd(&exe);

let _ = std::process::Command::new(&exe)
.args(["mcp", "remove", "relaycast"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.and_then(|mut child| child.wait());
Comment on lines +1227 to +1233
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In an asynchronous context, spawning a synchronous process and blocking the thread with std::process::Command and .wait() can block the Tokio executor thread. Since tokio::process::Command is already imported as Command in this file, we should use it to perform the mcp remove operation asynchronously and non-blockingly.

Suggested change
let _ = std::process::Command::new(&exe)
.args(["mcp", "remove", "relaycast"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.and_then(|mut child| child.wait());
let _ = Command::new(&exe)
.args(["mcp", "remove", "relaycast"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;


let mut mcp_cmd = Command::new(&exe);
mcp_cmd.args(grok_mcp_add_args(
api_key,
base_url,
agent_name,
agent_token,
workspaces_json,
default_workspace,
));
mcp_cmd
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());

match mcp_cmd.spawn() {
Ok(mut child) => match tokio::time::timeout(Duration::from_secs(15), child.wait()).await {
Ok(Ok(status)) if !status.success() => {
anyhow::bail!(
"failed to configure relaycast MCP for {cli}: `{cli} mcp add` exited with code {:?}. \
Please configure the relaycast MCP server manually:\n {manual_cmd}",
status.code()
);
}
Ok(Err(error)) => {
anyhow::bail!(
"failed to configure relaycast MCP for {cli}: {error}. \
Please configure the relaycast MCP server manually:\n {manual_cmd}"
);
}
Err(_) => {
let _ = child.kill().await;
anyhow::bail!(
"failed to configure relaycast MCP for {cli}: `{cli} mcp add` timed out after 15s. \
Please configure the relaycast MCP server manually:\n {manual_cmd}"
);
}
_ => {}
},
Err(error) => {
anyhow::bail!(
"failed to configure relaycast MCP for {cli}: could not run `{cli} mcp add`: {error}. \
Please configure the relaycast MCP server manually:\n {manual_cmd}"
);
}
}
Comment on lines +1235 to +1279
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Piping stderr without reading it introduces a deadlock risk if the child process writes more than the OS pipe buffer limit (typically 64KB), causing it to block indefinitely.

To prevent this and provide better error reporting, we can use child.wait_with_output() to safely read the output into memory, and include the captured stderr in the error message when the command fails. Additionally, configuring .kill_on_drop(true) on the command ensures the child process is automatically cleaned up if the timeout expires.

    let mut mcp_cmd = Command::new(&exe);
    mcp_cmd.kill_on_drop(true);
    mcp_cmd.args(grok_mcp_add_args(
        api_key,
        base_url,
        agent_name,
        agent_token,
        workspaces_json,
        default_workspace,
    ));
    mcp_cmd
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped());

    match mcp_cmd.spawn() {
        Ok(child) => match tokio::time::timeout(Duration::from_secs(15), child.wait_with_output()).await {
            Ok(Ok(output)) if !output.status.success() => {
                let stderr = String::from_utf8_lossy(&output.stderr);
                anyhow::bail!(
                    "failed to configure relaycast MCP for {cli}: `{cli} mcp add` exited with code {:?}.\nStderr: {stderr}\n\
                     Please configure the relaycast MCP server manually:\n  {manual_cmd}",
                    output.status.code()
                );
            }
            Ok(Err(error)) => {
                anyhow::bail!(
                    "failed to configure relaycast MCP for {cli}: {error}. \
                     Please configure the relaycast MCP server manually:\n  {manual_cmd}"
                );
            }
            Err(_) => {
                anyhow::bail!(
                    "failed to configure relaycast MCP for {cli}: `{cli} mcp add` timed out after 15s. \
                     Please configure the relaycast MCP server manually:\n  {manual_cmd}"
                );
            }
            _ => {}
        },
        Err(error) => {
            anyhow::bail!(
                "failed to configure relaycast MCP for {cli}: could not run `{cli} mcp add`: {error}. \
                 Please configure the relaycast MCP server manually:\n  {manual_cmd}"
            );
        }
    }


Ok(())
}

fn write_pretty_json(path: &Path, value: &Value) -> io::Result<()> {
let mut body = serde_json::to_string_pretty(value).map_err(|error| {
io::Error::other(format!("failed to serialize {}: {error}", path.display()))
Expand Down Expand Up @@ -1585,6 +1718,37 @@ mod tests {
assert!(args.contains(&"RELAY_AGENT_TOKEN=tok_droid_123".to_string()));
}

#[test]
fn grok_mcp_add_args_use_equals_form_for_dash_args() {
let args = super::grok_mcp_add_args(
Some("rk_live_xyz"),
Some("https://api.relaycast.dev"),
Some("GrokWorker"),
Some("tok_grok_123"),
Some(r#"{"workspaces":["rw_1"]}"#),
Some("rw_1"),
);

assert_eq!(args[0], "mcp");
assert_eq!(args[1], "add");
assert_eq!(args[2], "relaycast");
assert!(args.contains(&"--command".to_string()));
assert!(args.contains(&"npx".to_string()));
assert!(args.contains(&"--args=-y".to_string()));
assert!(args.contains(&"--args=agent-relay".to_string()));
assert!(args.contains(&"--args=mcp".to_string()));
assert!(args.contains(&"--env".to_string()));
assert!(args.contains(&"RELAY_API_KEY=rk_live_xyz".to_string()));
assert!(args.contains(&"RELAY_BASE_URL=https://api.relaycast.dev".to_string()));
assert!(args.contains(&"RELAY_AGENT_NAME=GrokWorker".to_string()));
assert!(args.contains(&"RELAY_AGENT_TYPE=agent".to_string()));
assert!(args.contains(&"RELAY_STRICT_AGENT_NAME=1".to_string()));
assert!(args.contains(&"RELAY_AGENT_TOKEN=tok_grok_123".to_string()));
assert!(args.contains(&"RELAY_SKIP_BOOTSTRAP=1".to_string()));
assert!(args.contains(&r#"RELAY_WORKSPACES_JSON={"workspaces":["rw_1"]}"#.to_string()));
assert!(args.contains(&"RELAY_DEFAULT_WORKSPACE=rw_1".to_string()));
}

#[test]
fn gemini_droid_mcp_add_args_omit_agent_result_env() {
let config = test_agent_result_config();
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/cli/commands/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vi.mock('@agent-relay/cloud', () => ({
defaultApiUrl: () => 'https://cloud.test',
ensureAuthenticated: vi.fn(),
getProviderHelpText: () =>
'anthropic (alias: claude), openai (alias: codex), google (alias: gemini), cursor, opencode, droid',
'anthropic (alias: claude), openai (alias: codex), google (alias: gemini), xai (alias: grok), cursor, opencode, droid',
getRunLogs: vi.fn(),
getRunStatus: (...args: unknown[]) => cloudMocks.getRunStatus(...args),
listWorkflowSchedules: (...args: unknown[]) => cloudMocks.listWorkflowSchedules(...args),
Expand Down Expand Up @@ -89,6 +89,7 @@ describe('registerCloudCommands', () => {
expect(connect?.registeredArguments[0]?.description).toContain('anthropic (alias: claude)');
expect(connect?.registeredArguments[0]?.description).toContain('openai (alias: codex)');
expect(connect?.registeredArguments[0]?.description).toContain('google (alias: gemini)');
expect(connect?.registeredArguments[0]?.description).toContain('xai (alias: grok)');
});

it('run requires a workflow argument', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/lib/auth-ssh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function normalizeProvider(providerArg: string): string {
claude: 'anthropic',
codex: 'openai',
gemini: 'google',
grok: 'xai',
};
return providerMap[providerInput] || providerInput;
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli/lib/connect-daytona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function normalizeProvider(providerArg: string): string {
claude: 'anthropic',
codex: 'openai',
gemini: 'google',
grok: 'xai',
};
return providerMap[providerInput] || providerInput;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cloud/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('normalizeProvider', () => {
expect(normalizeProvider('claude')).toBe('anthropic');
expect(normalizeProvider('codex')).toBe('openai');
expect(normalizeProvider('gemini')).toBe('google');
expect(normalizeProvider('grok')).toBe('xai');
});

it('lowercases and trims unknown values without rewriting them', () => {
Expand All @@ -23,5 +24,6 @@ describe('getProviderHelpText', () => {
expect(help).toContain('anthropic (alias: claude)');
expect(help).toContain('openai (alias: codex)');
expect(help).toContain('google (alias: gemini)');
expect(help).toContain('xai (alias: grok)');
});
});
3 changes: 2 additions & 1 deletion packages/cloud/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const PROVIDER_ALIASES: Record<string, string> = {
claude: 'anthropic',
codex: 'openai',
gemini: 'google',
grok: 'xai',
};

export function getProviderHelpText(): string {
Expand All @@ -42,7 +43,7 @@ export interface ConnectProviderIo {
}

export interface ConnectProviderOptions {
/** Provider id or alias (`anthropic`/`claude`, `openai`/`codex`, `google`/`gemini`, …). */
/** Provider id or alias (`anthropic`/`claude`, `openai`/`codex`, `google`/`gemini`, `xai`/`grok`, …). */
provider: string;
/** Override the Cloud API URL. Defaults to `defaultApiUrl()`. */
apiUrl?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/cloud/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type AgentCli =
| 'goose'
| 'opencode'
| 'droid'
| 'grok'
| 'cursor'
| 'cursor-agent'
| 'agent'
Expand Down
10 changes: 9 additions & 1 deletion packages/cloud/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,15 @@ export type GetPatchesResponse = {
patches: Record<string, { patch: string; hasChanges: boolean }>;
};

export const SUPPORTED_PROVIDERS = ['anthropic', 'openai', 'google', 'cursor', 'opencode', 'droid'] as const;
export const SUPPORTED_PROVIDERS = [
'anthropic',
'openai',
'google',
'xai',
'cursor',
'opencode',
'droid',
] as const;

export const REFRESH_WINDOW_MS = 60_000;
export const AUTH_FILE_PATH = path.join(os.homedir(), '.agent-relay', 'cloud-auth.json');
Expand Down
8 changes: 8 additions & 0 deletions packages/config/src/cli-auth-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,12 @@ describe('CLI auth config', () => {

expect(CLI_AUTH_CONFIG.opencode.successPatterns.some((pattern) => pattern.test(transcript))).toBe(true);
});

it('configures Grok auth through the xAI provider', () => {
expect(CLI_AUTH_CONFIG.xai.command).toBe('grok');
expect(CLI_AUTH_CONFIG.xai.args).toEqual(['login']);
expect(CLI_AUTH_CONFIG.xai.deviceFlowArgs).toEqual(['login', '--device-auth']);
expect(CLI_AUTH_CONFIG.xai.credentialPath).toBe('~/.grok/auth.json');
expect(CLI_AUTH_CONFIG.xai.installCommand).toContain('https://x.ai/cli/install.sh');
});
});
41 changes: 41 additions & 0 deletions packages/config/src/cli-auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,47 @@ export const CLI_AUTH_CONFIG: Record<string, CLIAuthConfig> = {
],
successPatterns: [/success/i, /authenticated/i, /logged\s*in/i, /ready/i],
},
xai: {
command: 'grok',
args: ['login'],
deviceFlowArgs: ['login', '--device-auth'],
supportsDeviceFlow: true,
urlPattern: /(https:\/\/[^\s]+)/,
credentialPath: '~/.grok/auth.json',
displayName: 'Grok',
installCommand:
'mkdir -p "$HOME/.local/bin" && curl -fsSL https://x.ai/cli/install.sh | GROK_BIN_DIR="$HOME/.local/bin" bash',
waitTimeout: 30000,
prompts: [
{
pattern: /open.*browser|press.*enter|sign\s*in|log\s*in|authenticate/i,
response: '\r',
delay: 200,
description: 'Login prompt',
},
{
pattern: /device\s*code|verification\s*code|one-time\s*code/i,
response: '\r',
delay: 200,
description: 'Device code prompt',
},
],
successPatterns: [/success/i, /authenticated/i, /logged\s*in/i, /signed\s*in/i],
errorPatterns: [
{
pattern: /auth.*failed|authentication\s*error|oauth\s*error|invalid\s*(?:code|token)/i,
message: 'Grok authentication failed',
recoverable: true,
hint: 'Please try logging in again. In SSH or container environments, use the device-code flow.',
},
{
pattern: /network\s*error|ENOTFOUND|ECONNREFUSED|timeout/i,
message: 'Network error during Grok authentication',
recoverable: true,
hint: 'Please check network access to auth.x.ai and cli-chat-proxy.grok.com, then try again.',
},
],
},
opencode: {
command: 'opencode',
args: ['auth', 'login'],
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk-py/src/agent_relay/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"review-loop",
]

AgentCli = Literal["claude", "codex", "gemini", "aider", "goose", "opencode", "droid", "cursor", "cursor-agent", "agent"]
AgentCli = Literal["claude", "codex", "gemini", "grok", "aider", "goose", "opencode", "droid", "cursor", "cursor-agent", "agent"]
AgentStatus = Literal["healthy", "restarting", "dead", "released"]
CrashCategory = Literal["oom", "segfault", "error", "signal", "unknown"]
WorkflowOnError = Literal["fail", "skip", "retry"]
Expand Down
Loading
Loading