github-copilot-sdk 1.0.0-beta.4

Rust SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC. Technical preview, pre-1.0.
Documentation
use std::path::Path;

use github_copilot_sdk::generated::api_types::{
    ShellExecRequest, ShellKillRequest, ShellKillSignal,
};

use super::support::{wait_for_condition, with_e2e_context};

#[tokio::test]
async fn shell_exec_with_timeout_kills_long_running_command() {
    with_e2e_context(
        "rpc_shell_edge_cases",
        "shell_exec_with_timeout_kills_long_running_command",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let started_path = ctx.work_dir().join("shell-timeout-started.txt");
                let marker_path = ctx.work_dir().join("shell-timeout-marker.txt");
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                let result = session
                    .rpc()
                    .shell()
                    .exec(ShellExecRequest {
                        command: delayed_write_command(&started_path, &marker_path),
                        cwd: Some(ctx.work_dir().display().to_string()),
                        timeout: Some(200),
                    })
                    .await
                    .expect("execute timed command");
                assert!(!result.process_id.trim().is_empty());

                wait_for_exists(&started_path).await;
                wait_for_process_cleanup(&session, result.process_id, "timed-out command").await;
                assert!(
                    !marker_path.exists(),
                    "timeout should kill before marker is written"
                );

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn shell_exec_with_custom_cwd_honors_override() {
    with_e2e_context(
        "rpc_shell_edge_cases",
        "shell_exec_with_custom_cwd_honors_override",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let subdir = ctx.work_dir().join("shell-cwd");
                std::fs::create_dir_all(&subdir).expect("create shell cwd");
                let marker_path = subdir.join("marker.txt");
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                let result = session
                    .rpc()
                    .shell()
                    .exec(ShellExecRequest {
                        command: write_relative_marker_command("shell-cwd-marker"),
                        cwd: Some(subdir.display().to_string()),
                        timeout: None,
                    })
                    .await
                    .expect("execute cwd command");

                assert!(!result.process_id.trim().is_empty());
                wait_for_file_text(&marker_path, "shell-cwd-marker").await;

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn shell_exec_with_nonexistent_command_returns_processid_and_cleans_up() {
    with_e2e_context(
        "rpc_shell_edge_cases",
        "shell_exec_with_nonexistent_command_returns_processid_and_cleans_up",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                let result = session
                    .rpc()
                    .shell()
                    .exec(ShellExecRequest {
                        command: nonexistent_command(),
                        cwd: Some(ctx.work_dir().display().to_string()),
                        timeout: None,
                    })
                    .await
                    .expect("execute nonexistent command");

                assert!(!result.process_id.trim().is_empty());
                wait_for_process_cleanup(&session, result.process_id, "nonexistent command").await;

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn shell_kill_unknown_processid_returns_false() {
    with_e2e_context(
        "rpc_shell_edge_cases",
        "shell_kill_unknown_processid_returns_false",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                let result = session
                    .rpc()
                    .shell()
                    .kill(ShellKillRequest {
                        process_id: "unknown-rust-process".to_string(),
                        signal: None,
                    })
                    .await
                    .expect("kill unknown process");

                assert!(!result.killed);

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn shell_kill_cleans_up_after_terminating_signal() {
    with_e2e_context(
        "rpc_shell_edge_cases",
        "shell_kill_cleans_up_after_terminating_signal",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                let exec = session
                    .rpc()
                    .shell()
                    .exec(ShellExecRequest {
                        command: long_running_command(),
                        cwd: Some(ctx.work_dir().display().to_string()),
                        timeout: None,
                    })
                    .await
                    .expect("start shell");

                let killed = session
                    .rpc()
                    .shell()
                    .kill(ShellKillRequest {
                        process_id: exec.process_id.clone(),
                        signal: Some(ShellKillSignal::SIGTERM),
                    })
                    .await
                    .expect("kill shell");
                assert!(killed.killed);
                wait_for_process_cleanup(&session, exec.process_id, "killed command").await;

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn shell_exec_with_stderr_output_cleans_up() {
    with_e2e_context(
        "rpc_shell_edge_cases",
        "shell_exec_with_stderr_output_cleans_up",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let marker_path = ctx.work_dir().join("shell-stderr-marker.txt");
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                let result = session
                    .rpc()
                    .shell()
                    .exec(ShellExecRequest {
                        command: stderr_command(&marker_path),
                        cwd: Some(ctx.work_dir().display().to_string()),
                        timeout: None,
                    })
                    .await
                    .expect("execute stderr command");

                wait_for_exists(&marker_path).await;
                wait_for_process_cleanup(&session, result.process_id, "stderr command").await;

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

#[tokio::test]
async fn shell_exec_with_large_stdout_cleans_up() {
    with_e2e_context(
        "rpc_shell_edge_cases",
        "shell_exec_with_large_stdout_cleans_up",
        |ctx| {
            Box::pin(async move {
                ctx.set_default_copilot_user();
                let marker_path = ctx.work_dir().join("shell-stdout-marker.txt");
                let client = ctx.start_client().await;
                let session = client
                    .create_session(ctx.approve_all_session_config())
                    .await
                    .expect("create session");

                let result = session
                    .rpc()
                    .shell()
                    .exec(ShellExecRequest {
                        command: large_stdout_command(&marker_path),
                        cwd: Some(ctx.work_dir().display().to_string()),
                        timeout: None,
                    })
                    .await
                    .expect("execute large stdout command");

                wait_for_exists(&marker_path).await;
                wait_for_process_cleanup(&session, result.process_id, "large stdout command").await;

                session.disconnect().await.expect("disconnect session");
                client.stop().await.expect("stop client");
            })
        },
    )
    .await;
}

async fn wait_for_exists(path: &Path) {
    wait_for_condition("shell marker file", || async { path.exists() }).await;
}

async fn wait_for_file_text(path: &Path, expected: &'static str) {
    wait_for_condition("shell marker text", || async {
        match std::fs::read_to_string(path) {
            Ok(content) => content.contains(expected),
            Err(_) => false,
        }
    })
    .await;
}

async fn wait_for_process_cleanup(
    session: &github_copilot_sdk::session::Session,
    process_id: String,
    _scenario: &'static str,
) {
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    let result = session
        .rpc()
        .shell()
        .kill(ShellKillRequest {
            process_id,
            signal: None,
        })
        .await
        .expect("probe process cleanup");
    assert!(!result.killed);
}

#[cfg(windows)]
fn delayed_write_command(started_path: &Path, marker_path: &Path) -> String {
    format!(
        "powershell -NoLogo -NoProfile -Command \"Set-Content -LiteralPath '{}' -Value started; Start-Sleep -Seconds 30; Set-Content -LiteralPath '{}' -Value should-not-exist\"",
        started_path.display(),
        marker_path.display()
    )
}

#[cfg(not(windows))]
fn delayed_write_command(started_path: &Path, marker_path: &Path) -> String {
    format!(
        "sh -c \"printf started > '{}'; sleep 30; printf should-not-exist > '{}'\"",
        started_path.display(),
        marker_path.display()
    )
}

#[cfg(windows)]
fn write_relative_marker_command(marker: &str) -> String {
    format!(
        "powershell -NoLogo -NoProfile -Command \"Set-Content -LiteralPath 'marker.txt' -Value '{marker}'\""
    )
}

#[cfg(not(windows))]
fn write_relative_marker_command(marker: &str) -> String {
    format!("sh -c \"printf '%s' '{marker}' > marker.txt\"")
}

#[cfg(windows)]
fn long_running_command() -> String {
    "powershell -NoLogo -NoProfile -Command \"Start-Sleep -Seconds 60\"".to_string()
}

#[cfg(not(windows))]
fn long_running_command() -> String {
    "sleep 60".to_string()
}

#[cfg(windows)]
fn nonexistent_command() -> String {
    "cmd /C definitely-not-a-real-command-rust-12345".to_string()
}

#[cfg(not(windows))]
fn nonexistent_command() -> String {
    "sh -c 'definitely-not-a-real-command-rust-12345'".to_string()
}

#[cfg(windows)]
fn stderr_command(marker_path: &Path) -> String {
    format!(
        "powershell -NoLogo -NoProfile -Command \"[Console]::Error.WriteLine('boom'); Set-Content -LiteralPath '{}' -Value done; exit 2\"",
        marker_path.display()
    )
}

#[cfg(not(windows))]
fn stderr_command(marker_path: &Path) -> String {
    format!(
        "sh -c \"echo boom 1>&2; printf done > '{}'; exit 2\"",
        marker_path.display()
    )
}

#[cfg(windows)]
fn large_stdout_command(marker_path: &Path) -> String {
    format!(
        "powershell -NoLogo -NoProfile -Command \"Write-Host ('x' * 204800); Set-Content -LiteralPath '{}' -Value done\"",
        marker_path.display()
    )
}

#[cfg(not(windows))]
fn large_stdout_command(marker_path: &Path) -> String {
    format!(
        "sh -c \"python3 - <<'PY'\nprint('x' * 204800)\nPY\nprintf done > '{}'\"",
        marker_path.display()
    )
}