supercode-cli 0.4.6

supercode — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! P5-2 (COMPOSABLE-HARNESS-DESIGN.md §2 module 15 D7 row 2 "OAuth";
//! §2.1's "trust-grade token storage" applied to MCP): CLI-level proof that
//! `supercode mcp login`/`mcp logout` actually persist/remove OAuth tokens
//! at the trust-grade location (`$SUPERCODE_HOME/mcp_oauth.json`, 0600,
//! never the project directory) — spawns the real built `supercode` binary
//! against a LOCAL mock OAuth device-authorization server (never a real
//! provider, per `live-agent-test-safety`), same idiom as
//! `mcp_import_cli.rs`.

use std::path::{Path, PathBuf};
use std::process::{Command, Output};

fn bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_supercode"))
}

fn fresh_dir(tag: &str) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let dir = std::env::temp_dir().join(format!(
        "supercode-mcpoauth-{tag}-{}-{nanos}",
        std::process::id()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

fn run(supercode_home: &Path, args: &[&str]) -> Output {
    Command::new(bin())
        .env("SUPERCODE_HOME", supercode_home)
        .env("HOME", supercode_home) // no sibling-harness fixtures needed for this file
        .env_remove("OPENROUTER_API_KEY")
        .env_remove("OPENAI_API_KEY")
        .env_remove("ANTHROPIC_API_KEY")
        .args(args)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .output()
        .expect("failed to spawn the supercode binary")
}

fn stderr(out: &Output) -> String {
    String::from_utf8_lossy(&out.stderr).into_owned()
}

/// A minimal RFC 8628 mock server: `POST /device` returns a device/user
/// code pair with `interval=1` (fast test), `POST /token` immediately
/// returns success on the FIRST poll — no `authorization_pending` loop
/// needed, keeping this test fast and deterministic. Raw-socket loopback
/// server, same idiom as `crates/harness/tests/mcp_remote.rs`.
async fn spawn_mock_oauth_server() -> std::net::SocketAddr {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        loop {
            let Ok((mut sock, _)) = listener.accept().await else {
                break;
            };
            tokio::spawn(async move {
                let mut buf = vec![0u8; 4096];
                let n = match sock.read(&mut buf).await {
                    Ok(n) => n,
                    Err(_) => return,
                };
                let text = String::from_utf8_lossy(&buf[..n]);
                let path = text
                    .lines()
                    .next()
                    .and_then(|l| l.split_whitespace().nth(1))
                    .unwrap_or("");
                let body = if path.starts_with("/device") {
                    serde_json::json!({
                        "device_code": "devcode123",
                        "user_code": "ABCD-EFGH",
                        "verification_uri": "http://example.invalid/verify",
                        "interval": 1,
                        "expires_in": 60
                    })
                    .to_string()
                } else {
                    serde_json::json!({
                        "access_token": "mock-access-token-xyz",
                        "refresh_token": "mock-refresh-token-abc",
                        "expires_in": 3600
                    })
                    .to_string()
                };
                let resp = format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
                    body.len(),
                    body
                );
                let _ = sock.write_all(resp.as_bytes()).await;
                let _ = sock.flush().await;
            });
        }
    });
    addr
}

fn seed_oauth_server_def(supercode_home: &Path, addr: std::net::SocketAddr) {
    let mcp_json = serde_json::json!({
        "mcpServers": {
            "testsrv": {
                "transport": "http",
                "url": format!("http://127.0.0.1:{}/mcp", addr.port()),
                "oauth": {
                    "device_authorization_endpoint": format!("http://127.0.0.1:{}/device", addr.port()),
                    "token_endpoint": format!("http://127.0.0.1:{}/token", addr.port()),
                    "client_id": "test-client"
                }
            }
        }
    });
    std::fs::write(
        supercode_home.join("mcp.json"),
        serde_json::to_string_pretty(&mcp_json).unwrap(),
    )
    .unwrap();
}

#[tokio::test(flavor = "multi_thread")]
async fn mcp_login_stores_tokens_at_supercode_home_with_owner_only_permissions() {
    let home = fresh_dir("login");
    let addr = spawn_mock_oauth_server().await;
    seed_oauth_server_def(&home, addr);

    let out = run(&home, &["mcp", "login", "testsrv"]);
    assert!(
        out.status.success(),
        "mcp login should succeed against the mock server: {}",
        stderr(&out)
    );

    let token_path = home.join("mcp_oauth.json");
    assert!(
        token_path.exists(),
        "tokens must be written to $SUPERCODE_HOME/mcp_oauth.json"
    );
    let contents = std::fs::read_to_string(&token_path).unwrap();
    assert!(contents.contains("mock-access-token-xyz"));
    // The access token must never leak into stdout/stderr in plaintext —
    // only progress/status text.
    assert!(
        !stderr(&out).contains("mock-access-token-xyz"),
        "the raw access token must not be printed: {}",
        stderr(&out)
    );

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mode = std::fs::metadata(&token_path).unwrap().permissions().mode() & 0o777;
        assert_eq!(
            mode, 0o600,
            "mcp_oauth.json must be owner-only (0600), got {mode:o}"
        );
    }

    std::fs::remove_dir_all(&home).ok();
}

#[tokio::test(flavor = "multi_thread")]
async fn mcp_logout_removes_stored_tokens() {
    let home = fresh_dir("logout");
    let addr = spawn_mock_oauth_server().await;
    seed_oauth_server_def(&home, addr);

    let login_out = run(&home, &["mcp", "login", "testsrv"]);
    assert!(login_out.status.success(), "{}", stderr(&login_out));
    let token_path = home.join("mcp_oauth.json");
    let before = std::fs::read_to_string(&token_path).unwrap();
    assert!(before.contains("mock-access-token-xyz"));

    let logout_out = run(&home, &["mcp", "logout", "testsrv"]);
    assert!(logout_out.status.success(), "{}", stderr(&logout_out));
    let after = std::fs::read_to_string(&token_path).unwrap();
    assert!(
        !after.contains("mock-access-token-xyz"),
        "logout must remove the stored token: {after}"
    );

    std::fs::remove_dir_all(&home).ok();
}

#[tokio::test(flavor = "multi_thread")]
async fn mcp_login_on_a_server_with_no_oauth_block_fails_cleanly_without_writing_a_token_file() {
    let home = fresh_dir("nooauth");
    let mcp_json = serde_json::json!({
        "mcpServers": { "plain": { "command": "echo", "args": ["hi"] } }
    });
    std::fs::write(
        home.join("mcp.json"),
        serde_json::to_string_pretty(&mcp_json).unwrap(),
    )
    .unwrap();

    let out = run(&home, &["mcp", "login", "plain"]);
    assert!(!out.status.success());
    assert!(
        stderr(&out).contains("no `oauth` block"),
        "{}",
        stderr(&out)
    );
    assert!(
        !home.join("mcp_oauth.json").exists(),
        "a failed login must never create a token file"
    );

    std::fs::remove_dir_all(&home).ok();
}