noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);

struct Fixture {
    root: PathBuf,
}

impl Fixture {
    fn new() -> Self {
        let root = std::env::temp_dir().join(format!(
            "noxid-wo22-base-path-{}-{}",
            std::process::id(),
            NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(root.join("server/api")).expect("create agent-surface fixture");
        fs::write(
            root.join("Noxid.toml"),
            "[app]\ntitle = \"Agent doors\"\nbase = \"/shop\"\n\n[server]\napi_docs = true\nmcp = true\n",
        )
        .expect("write manifest");
        fs::write(root.join("package.json"), "{\"type\":\"module\"}\n")
            .expect("write package marker");
        fs::write(
            root.join("server/api/status.get.nox"),
            "endpoint Status { result: String handler { return \"ready\" } }\n",
        )
        .expect("write endpoint");
        Self { root }
    }

    fn noxid(&self, arguments: &[&str]) -> Output {
        Command::new(env!("CARGO_BIN_EXE_noxid"))
            .args(arguments)
            .current_dir(&self.root)
            .env_remove("DATABASE_URL")
            .env_remove("VERCEL")
            .env_remove("NETLIFY")
            .env_remove("CF_PAGES")
            .output()
            .expect("run noxid")
    }
}

impl Drop for Fixture {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.root);
    }
}

fn assert_success(output: &Output, context: &str) {
    assert!(
        output.status.success(),
        "{context}\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn shop_base_prefixes_openapi_and_mcp_in_the_emitted_handler() {
    let fixture = Fixture::new();
    let build = fixture.noxid(&["build", ".", "--out-dir", "dist"]);
    assert_success(&build, "build /shop agent-surface fixture");
    fs::write(
        fixture.root.join("dist/check.mjs"),
        r#"import { fetch as handle } from "./server/handler.js";

let response = await handle(new Request("http://noxid.test/shop/_noxid/openapi.json"));
if (response.status !== 200) throw new Error(`prefixed OpenAPI returned ${response.status}`);
response = await handle(new Request("http://noxid.test/_noxid/openapi.json"));
if (response.status !== 404 || (await response.json()).error?.code !== "BOUNDARY_NOT_FOUND") throw new Error(`unprefixed OpenAPI returned ${response.status}`);

const mcp = (path) => new Request(`http://noxid.test${path}`, {
  method: "POST",
  headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
response = await handle(mcp("/shop/_noxid/mcp"));
if (response.status !== 200 || !(await response.json()).result?.tools?.some((tool) => tool.name === "Status")) throw new Error("prefixed MCP did not expose Status");
response = await handle(mcp("/_noxid/mcp"));
if (response.status !== 404 || (await response.json()).error?.code !== "BOUNDARY_NOT_FOUND") throw new Error(`unprefixed MCP returned ${response.status}`);
"#,
    )
    .expect("write handler check");
    let node = Command::new("node")
        .arg("check.mjs")
        .current_dir(fixture.root.join("dist"))
        .output()
        .expect("execute emitted handler");
    assert_success(&node, "exercise base-prefixed handler doors");
}

#[test]
fn every_server_adapter_emits_the_base_aware_agent_surface_matcher() {
    let fixture = Fixture::new();
    fs::remove_file(fixture.root.join("server/api/status.get.nox"))
        .expect("remove server-only endpoint from cross-adapter fixture");
    fs::create_dir_all(fixture.root.join("src/routes")).expect("create SSR route directory");
    fs::write(
        fixture.root.join("src/routes/+page.nox"),
        "component Home { route { render: ssr } view { <main>Shop</main> } }\n",
    )
    .expect("write cross-adapter SSR route");
    fs::write(
        fixture.root.join("server/host.js"),
        "export const endpoints = Object.freeze({});\n",
    )
    .expect("write SSR host module");
    let adapters = [
        ("node", "server.mjs"),
        ("deno", "server.ts"),
        ("cloudflare", "_worker.js"),
        ("vercel", ".vercel/output/functions/noxid.func/index.mjs"),
        ("netlify", "netlify/functions/noxid/index.mjs"),
    ];
    for (adapter, artifact) in adapters {
        let output = format!("dist-{adapter}");
        let adapted = fixture.noxid(&["adapt", ".", "--adapter", adapter, "--out-dir", &output]);
        assert_success(&adapted, &format!("adapt /shop project for {adapter}"));
        let source = fs::read_to_string(fixture.root.join(&output).join(artifact))
            .unwrap_or_else(|error| panic!("read {adapter} matcher {artifact}: {error}"));
        assert!(source.contains("isAgentSurfacePath"), "{adapter}: {source}");
        assert!(source.contains("isAgentSurfaceDoor"), "{adapter}: {source}");
        assert!(source.contains("/shop"), "{adapter}: {source}");
        assert!(
            source.contains("/_noxid/openapi.json") && source.contains("/_noxid/mcp"),
            "{adapter}: {source}"
        );
        if matches!(adapter, "node" | "deno") {
            assert!(
                source.contains("isReservedStaticPath"),
                "{adapter} omitted the base-aware reserved static-path guard: {source}"
            );
        }
    }
}

/// Checkpoint-3 security read F3: the front doors forwarded only
/// `.../runs/<id>/resume`, so `POST /_noxid/agents/<name>/runs` — the door
/// that *starts* a run — fell through to the SPA document on every adapter.
/// The two doors are now one matcher, and this drives that matcher out of each
/// of the five emitted artifacts and runs it, rather than reading the regex
/// back.
#[test]
fn every_server_adapter_forwards_both_agent_run_doors_under_its_base() {
    let fixture = Fixture::new();
    fs::remove_file(fixture.root.join("server/api/status.get.nox"))
        .expect("remove server-only endpoint from cross-adapter fixture");
    fs::create_dir_all(fixture.root.join("src/routes")).expect("create SSR route directory");
    fs::write(
        fixture.root.join("src/routes/+page.nox"),
        "component Home { route { render: ssr } view { <main>Shop</main> } }\n",
    )
    .expect("write cross-adapter SSR route");
    fs::write(
        fixture.root.join("server/host.js"),
        "export const endpoints = Object.freeze({});\n",
    )
    .expect("write SSR host module");

    // `/shop` is this fixture's base. The root spellings are included because
    // the front door forwards a superset by design and the server refuses the
    // wrong base inside, where the refusal can be structured.
    let cases: [(&str, bool); 9] = [
        ("/shop/_noxid/agents/Support/runs", true),
        (
            "/shop/_noxid/agents/Support/runs/aaaaaaaaaaaaaaaa/resume",
            true,
        ),
        ("/shop/_noxid/agents/Support/runs/", true),
        ("/_noxid/agents/Support/runs", true),
        ("/_noxid/agents/Support/runs/aaaaaaaaaaaaaaaa/resume", true),
        ("/shop/_noxid/agents/Support/runs/aaaaaaaaaaaaaaaa", false),
        ("/shop/_noxid/agents/Support", false),
        ("/shop/_noxid/agents", false),
        ("/shop/_noxid/agentsx/Support/runs", false),
    ];

    for (adapter, artifact) in [
        ("node", "server.mjs"),
        ("deno", "server.ts"),
        ("cloudflare", "_worker.js"),
        ("vercel", ".vercel/output/functions/noxid.func/index.mjs"),
        ("netlify", "netlify/functions/noxid/index.mjs"),
    ] {
        let output = format!("runs-{adapter}");
        let adapted = fixture.noxid(&["adapt", ".", "--adapter", adapter, "--out-dir", &output]);
        assert_success(&adapted, &format!("adapt /shop project for {adapter}"));
        let source = fs::read_to_string(fixture.root.join(&output).join(artifact))
            .unwrap_or_else(|error| panic!("read {adapter} artifact {artifact}: {error}"));
        assert!(
            source.contains("isAgentRunDoor(pathname)"),
            "{adapter} does not forward the agent run doors through the shared matcher:\n{source}"
        );

        let (_, tail) = source
            .split_once("const isAgentRunDoor = (pathname) => {")
            .unwrap_or_else(|| panic!("{adapter} carries no run-door matcher:\n{source}"));
        let (body, _) = tail.split_once("};").expect("matcher body is closed");
        let matcher = format!("const isAgentRunDoor = (pathname) => {{{body}}};");

        let probes = cases
            .iter()
            .map(|(path, _)| {
                format!(
                    "console.log(`{path} ${{isAgentRunDoor({path:?}) ? \"FORWARD\" : \"MISS\"}}`);"
                )
            })
            .collect::<Vec<_>>()
            .join("\n");
        let probed = Command::new("node")
            .args([
                "--input-type=module",
                "--eval",
                &format!("{matcher}\n{probes}\n"),
            ])
            .output()
            .expect("run the emitted run-door matcher");
        assert_success(&probed, &format!("evaluate the {adapter} run-door matcher"));
        let observed = String::from_utf8_lossy(&probed.stdout).into_owned();
        for (path, forwarded) in cases {
            let expected = format!("{path} {}", if forwarded { "FORWARD" } else { "MISS" });
            assert!(
                observed.lines().any(|line| line == expected),
                "the {adapter} front door disagrees about `{path}` (expected \
                 {expected:?}):\n{observed}"
            );
        }
    }
}