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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/aver-scope-shim/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ path = "src/lib.rs"
anyhow.workspace = true
axum = "0.8"
clap.workspace = true
futures-util = "0.3"
http = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
sha2 = "0.10"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "process"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "process", "time", "sync"] }
tower = "0.5"

[dev-dependencies]
Expand Down
11 changes: 7 additions & 4 deletions crates/aver-scope-shim/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
//!
//! Layout:
//! - [`derive_scope`] / [`scope_from_git`] — slug derivation rules.
//! - The HTTP proxy lives in `main.rs`; this module exports the building
//! blocks for unit tests.
//! - [`proxy`] — the streaming HTTP proxy (router, config, URL joining);
//! `main.rs` is only CLI parsing and startup wiring.

use std::path::Path;
use std::process::Command;

use sha2::{Digest, Sha256};

pub mod proxy;

/// Per-startup scope decision: either a derived `proj/...` scope, an env-var
/// override, or the hardcoded "global" fallback.
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -55,7 +57,8 @@ pub fn slug_hash(input: &str) -> String {
/// Derive the scope a shim should inject for a given working directory.
///
/// Precedence:
/// 1. `cli_override` if supplied.
/// 1. `cli_override` if supplied (empty/whitespace-only values are ignored,
/// matching the `AVER_DEFAULT_SCOPE` handling below).
/// 2. Git remote origin URL hash, if `cwd` is inside a git repo with origin.
/// 3. Git toplevel absolute path hash (per ADR-0022 amendment / council
/// risk #3), if inside a git repo without origin.
Expand All @@ -66,7 +69,7 @@ pub fn derive_scope(
cli_override: Option<&str>,
env_default: Option<&str>,
) -> DerivedScope {
if let Some(s) = cli_override {
if let Some(s) = cli_override.filter(|v| !v.trim().is_empty()) {
return DerivedScope {
scope: s.to_string(),
source: ScopeSource::CliOverride,
Expand Down
147 changes: 18 additions & 129 deletions crates/aver-scope-shim/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,28 @@
//!
//! Binds to `127.0.0.1:0` (ephemeral TCP per council verdict 2026-05-10),
//! prints the bound URL on stdout, and forwards every HTTP request to the
//! upstream aver-server with `X-Aver-Scope` injected.
//! upstream aver-server with `X-Aver-Scope` injected. The proxy itself lives
//! in [`aver_scope_shim::proxy`]; this binary is CLI parsing and startup
//! wiring only.

use std::net::SocketAddr;
use std::path::PathBuf;

use anyhow::Context;
use axum::body::Body;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode, Uri};
use axum::response::IntoResponse;
use axum::routing::any;
use clap::Parser;

use aver_scope_shim::derive_scope;
use aver_scope_shim::proxy::{DEFAULT_UPSTREAM_TIMEOUT, ShimConfig, router, scope_header_value};

#[derive(Debug, Parser)]
#[command(
name = "aver-scope-shim",
about = "Per-workspace HTTP MCP proxy that injects X-Aver-Scope (ADR-0022)"
)]
struct Cli {
/// Upstream aver-server MCP URL.
/// Upstream aver-server MCP URL. Request paths are appended to this base;
/// a duplicated `/mcp` prefix is collapsed so the shim URL works with or
/// without a trailing `/mcp`.
#[arg(
long,
env = "AVER_UPSTREAM_URL",
Expand All @@ -41,13 +41,6 @@ struct Cli {
bind: SocketAddr,
}

#[derive(Clone)]
struct AppState {
upstream: String,
scope: String,
client: reqwest::Client,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
Expand All @@ -58,6 +51,17 @@ async fn main() -> anyhow::Result<()> {
let env_default = std::env::var("AVER_DEFAULT_SCOPE").ok();
let derived = derive_scope(&cwd, cli.scope.as_deref(), env_default.as_deref());

// Validate the scope as a legal header value once at startup so a
// misconfigured scope fails fast instead of producing a 502 per request.
let scope = scope_header_value(&derived.scope)
.with_context(|| format!("scope {:?} is not a legal HTTP header value", derived.scope))?;

let app = router(ShimConfig {
upstream: cli.upstream.clone(),
scope: Some(scope),
upstream_timeout: DEFAULT_UPSTREAM_TIMEOUT,
})?;

let listener = tokio::net::TcpListener::bind(cli.bind).await?;
let bound = listener.local_addr()?;
eprintln!(
Expand All @@ -66,121 +70,6 @@ async fn main() -> anyhow::Result<()> {
);
println!("http://{bound}");

let state = AppState {
upstream: cli.upstream,
scope: derived.scope,
client: reqwest::Client::builder()
.pool_idle_timeout(std::time::Duration::from_secs(30))
.build()
.context("building reqwest client")?,
};

let app = axum::Router::new()
.route("/{*rest}", any(forward))
.route("/", any(forward))
.with_state(state);

axum::serve(listener, app.into_make_service()).await?;
Ok(())
}

async fn forward(State(state): State<AppState>, request: Request<Body>) -> Response<Body> {
match forward_inner(state, request).await {
Ok(resp) => resp,
Err(err) => {
eprintln!("aver-scope-shim: forward error: {err:#}");
(StatusCode::BAD_GATEWAY, format!("upstream error: {err}")).into_response()
}
}
}

async fn forward_inner(state: AppState, request: Request<Body>) -> anyhow::Result<Response<Body>> {
let (parts, body) = request.into_parts();
let method = parts.method.clone();
let uri: Uri = parts.uri.clone();
let path_and_query = uri.path_and_query().map(|p| p.as_str()).unwrap_or("");

// Build target URL: upstream base + path/query of incoming request.
// ADR-0022: every forwarded request is rewritten to land under upstream.
let upstream_base: Uri = state.upstream.parse().context("parsing upstream URL")?;
let upstream_str = if path_and_query.is_empty() || path_and_query == "/" {
state.upstream.clone()
} else {
// If upstream path has a tail like `/mcp`, append the request's tail.
// Path prefixing: upstream_base.path() + path_and_query — but axum's
// captured `*rest` is the suffix after `/`. Simpler: concat strings.
let base = upstream_base.to_string();
let base = base.trim_end_matches('/');
format!("{base}{path_and_query}")
};

let body_bytes = axum::body::to_bytes(body, usize::MAX)
.await
.context("buffering request body")?;
let mut req_builder = state
.client
.request(method, &upstream_str)
.body(body_bytes.to_vec());

let mut forward_headers = HeaderMap::new();
for (name, value) in parts.headers.iter() {
// Don't forward hop-by-hop or host headers.
let lower = name.as_str().to_ascii_lowercase();
if matches!(
lower.as_str(),
"host"
| "content-length"
| "connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "x-aver-scope"
) {
continue;
}
forward_headers.insert(name.clone(), value.clone());
}
forward_headers.insert(
HeaderName::from_static("x-aver-scope"),
HeaderValue::from_str(&state.scope).context("constructing X-Aver-Scope header value")?,
);
req_builder = req_builder.headers(forward_headers);

let upstream_resp = req_builder
.send()
.await
.context("sending request to upstream")?;
let status = upstream_resp.status();
let resp_headers = upstream_resp.headers().clone();
let resp_bytes = upstream_resp
.bytes()
.await
.context("buffering upstream response body")?;
let mut response = Response::builder().status(status);
if let Some(hs) = response.headers_mut() {
for (name, value) in resp_headers.iter() {
let lower = name.as_str().to_ascii_lowercase();
if matches!(
lower.as_str(),
"connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
) {
continue;
}
hs.insert(name.clone(), value.clone());
}
}
response
.body(Body::from(resp_bytes))
.context("building forwarded response")
}
Loading
Loading