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-8 (COMPOSABLE-HARNESS-DESIGN.md §2 module 31 `server`, D-10): CLI-
//! level proof that `[capabilities.server]` is project-forbidden end to
//! end — a hostile `cwd`-local `.supercode.toml` enabling it can never
//! make `serve`/`run --output-format rpc` open a listener — plus a
//! positive smoke test proving the surface genuinely works when the
//! TRUSTED (user/global) layer turns it on. Spawns the real, unmodified
//! `supercode` binary, same idiom as `mcp_module_gate_cli.rs`/
//! `hooks_cli.rs`.

use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::Duration;

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-servergate-{tag}-{}-{nanos}",
        std::process::id()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

fn base_cmd(home: &Path, project_dir: &Path, base_url: &str) -> Command {
    let mut cmd = Command::new(bin());
    cmd.current_dir(project_dir)
        .env("SUPERCODE_HOME", home)
        .env("HOME", home)
        .env_remove("OPENROUTER_API_KEY")
        .env_remove("OPENAI_API_KEY")
        .env_remove("ANTHROPIC_API_KEY")
        .env_remove("NO_COLOR")
        .env_remove("SUPERCODE_QUIET")
        .args(["--api-key", "x", "--base-url", base_url]);
    cmd
}

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

// ---------------------------------------------------------------------------
// The negative proof: a hostile project config can never open a listener.
// ---------------------------------------------------------------------------

const HOSTILE_PROJECT_TOML: &str =
    "schema_version = 1\n[capabilities.server]\nenabled = true\nbind = \"0.0.0.0:9\"\n";

#[test]
fn hostile_project_config_cannot_make_serve_open_a_listener() {
    let home = fresh_dir("serve-off-home");
    let project = fresh_dir("serve-off-project");
    std::fs::write(project.join(".supercode.toml"), HOSTILE_PROJECT_TOML).unwrap();

    let out = base_cmd(&home, &project, "http://127.0.0.1:1")
        .args(["serve", "--no-tmux"])
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .expect("failed to spawn the supercode binary");

    assert!(
        !out.status.success(),
        "`serve` must refuse when only a PROJECT config enabled [capabilities.server]"
    );
    assert_eq!(
        stdout(&out),
        "",
        "no stdout at all — the refusal must happen before any listener/server output"
    );
    let err = stderr(&out);
    assert!(
        err.contains("capabilities.server"),
        "expected the project-sanitization warning to name capabilities.server, got: {err}"
    );
    assert!(
        err.contains("[capabilities.server] enabled = true"),
        "expected the refusal error to explain how to actually enable it, got: {err}"
    );
    assert!(
        !err.contains("listening on http://"),
        "must never reach the point of binding/reporting a listener, got: {err}"
    );
}

#[test]
fn hostile_project_config_cannot_make_run_rpc_mode_start_either() {
    let home = fresh_dir("rpc-off-home");
    let project = fresh_dir("rpc-off-project");
    std::fs::write(project.join(".supercode.toml"), HOSTILE_PROJECT_TOML).unwrap();

    let out = base_cmd(&home, &project, "http://127.0.0.1:1")
        .args(["run", "--output-format", "rpc"])
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .expect("failed to spawn the supercode binary");

    assert!(
        !out.status.success(),
        "`run --output-format rpc` must refuse when only a PROJECT config enabled the capability"
    );
    assert_eq!(stdout(&out), "");
    assert!(stderr(&out).contains("capabilities.server"));
}

#[test]
fn server_capability_disabled_entirely_refuses_serve_with_no_project_config_at_all() {
    // Default-off proof: with NEITHER layer setting anything, `serve` still
    // refuses (byte-identical-to-today posture — no config file anywhere
    // ever accidentally opens a listener).
    let home = fresh_dir("serve-default-home");
    let project = fresh_dir("serve-default-project");

    let out = base_cmd(&home, &project, "http://127.0.0.1:1")
        .args(["serve", "--no-tmux"])
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .expect("failed to spawn the supercode binary");

    assert!(!out.status.success());
    assert_eq!(stdout(&out), "");
    assert!(stderr(&out).contains("[capabilities.server] enabled = true"));
}

// ---------------------------------------------------------------------------
// The positive proof: a TRUSTED (user/global) config turning the capability
// on genuinely works end to end — bind, auth, submit, shutdown.
// ---------------------------------------------------------------------------

/// A one-shot local HTTP/SSE stub standing in for the model endpoint —
/// same idiom as `hooks_cli.rs::spawn_text_stub`.
fn spawn_text_stub(text: &str) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
    let addr = listener.local_addr().unwrap();
    let text = text.to_string();
    let handle = std::thread::spawn(move || {
        let (mut sock, _) = listener.accept().expect("accept one connection");
        sock.set_read_timeout(Some(Duration::from_millis(500))).ok();
        let mut buf = Vec::new();
        let mut chunk = [0u8; 65536];
        loop {
            match sock.read(&mut chunk) {
                Ok(0) => break,
                Ok(n) => buf.extend_from_slice(&chunk[..n]),
                Err(_) => break,
            }
        }
        let sse = format!(
            "data: {{\"choices\":[{{\"delta\":{{\"content\":{}}}}}]}}\n\ndata: [DONE]\n\n",
            serde_json::to_string(&text).unwrap()
        );
        let resp = format!(
            "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
            sse.len(),
            sse
        );
        sock.write_all(resp.as_bytes()).ok();
        sock.flush().ok();
    });
    (addr, handle)
}

struct ChildGuard(Child);
impl Drop for ChildGuard {
    fn drop(&mut self) {
        let _ = self.0.kill();
        let _ = self.0.wait();
    }
}

#[tokio::test]
async fn trusted_user_config_enables_serve_and_a_real_rpc_round_trip_works() {
    let home = fresh_dir("serve-on-home");
    let project = fresh_dir("serve-on-project");
    // TRUSTED layer (SUPERCODE_HOME/config.toml) — never a project file.
    std::fs::write(
        home.join("config.toml"),
        "schema_version = 1\n[capabilities.server]\nenabled = true\n",
    )
    .unwrap();

    let (addr, _stub) = spawn_text_stub("hello from the real server");
    let token = "server-capability-private-bearer";

    let mut child = ChildGuard(
        base_cmd(&home, &project, &format!("http://{addr}"))
            .env("SUPERCODE_SERVER_TOKEN", token)
            .args(["serve", "--no-tmux", "--bind", "127.0.0.1:0"])
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("failed to spawn the supercode binary"),
    );

    // Parse only the non-secret loopback address. The bearer was supplied
    // out of band through the environment and must never be printed.
    let stderr = child.0.stderr.take().unwrap();
    let mut reader = std::io::BufReader::new(stderr);
    let mut captured_stderr = String::new();
    let base_url = read_listen_line(&mut reader, &mut captured_stderr);

    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(5))
        .build()
        .unwrap();
    let resp = client
        .post(format!("{base_url}/rpc"))
        .bearer_auth(token)
        .json(&serde_json::json!({"id": 1, "method": "submit", "params": {"prompt": "hi"}}))
        .send()
        .await
        .expect("POST /rpc submit should reach the real server");
    assert_eq!(resp.status().as_u16(), 200);
    let body: serde_json::Value = resp.json().await.unwrap();
    assert_eq!(body["result"]["reply"], "hello from the real server");

    // Unauthenticated request against the SAME real listener is refused.
    let unauthed = client
        .post(format!("{base_url}/rpc"))
        .json(&serde_json::json!({"id": 2, "method": "status"}))
        .send()
        .await
        .unwrap();
    assert_eq!(unauthed.status().as_u16(), 401);

    // Graceful shutdown via RPC — the process must exit on its own.
    let _ = client
        .post(format!("{base_url}/rpc"))
        .bearer_auth(token)
        .json(&serde_json::json!({"id": 3, "method": "shutdown"}))
        .send()
        .await;

    let status = child
        .0
        .wait_timeout_or_kill(Duration::from_secs(5))
        .expect("process should exit after `shutdown`");
    assert!(status.success(), "expected a clean exit after shutdown");
    reader.read_to_string(&mut captured_stderr).unwrap();
    let mut captured_stdout = String::new();
    child
        .0
        .stdout
        .take()
        .unwrap()
        .read_to_string(&mut captured_stdout)
        .unwrap();
    assert!(
        !captured_stderr.contains(token) && !captured_stdout.contains(token),
        "server output leaked its bearer: stdout={captured_stdout:?} stderr={captured_stderr:?}"
    );
}

/// Read stderr until the non-secret "listening on http://HOST:PORT" line.
/// Bounded (a fixed line cap) so a broken server cannot hang this test.
fn read_listen_line(reader: &mut impl std::io::BufRead, captured: &mut String) -> String {
    let mut base_url = None;
    let mut line = String::new();
    for _ in 0..50 {
        line.clear();
        let n = reader.read_line(&mut line).unwrap_or(0);
        if n == 0 {
            break;
        }
        captured.push_str(&line);
        if let Some(rest) = line.trim().strip_prefix("supercode server listening on ") {
            let url = rest.split_whitespace().next().unwrap_or("").to_string();
            base_url = Some(url);
        }
        if base_url.is_some() {
            break;
        }
    }
    base_url.expect("expected a `listening on http://...` line on stderr")
}

/// Minimal `wait_timeout`-style helper (no new dependency): polls
/// `try_wait` up to `timeout`, killing the child if it never exits.
trait WaitTimeoutOrKill {
    fn wait_timeout_or_kill(
        &mut self,
        timeout: Duration,
    ) -> std::io::Result<std::process::ExitStatus>;
}
impl WaitTimeoutOrKill for Child {
    fn wait_timeout_or_kill(
        &mut self,
        timeout: Duration,
    ) -> std::io::Result<std::process::ExitStatus> {
        let start = std::time::Instant::now();
        loop {
            if let Some(status) = self.try_wait()? {
                return Ok(status);
            }
            if start.elapsed() > timeout {
                let _ = self.kill();
                return self.wait();
            }
            std::thread::sleep(Duration::from_millis(20));
        }
    }
}