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
86 changes: 58 additions & 28 deletions crates/proxy/src/handler/amp/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ use futures_util::TryStreamExt as _;
use serde_json::Value;
use std::{collections::HashMap, sync::Arc};

use crate::middleware::forward::ForwardedHeaders;
use crate::util::stream::{CodexParser, GeminiParser, response_to_stream, tap_usage_stream};
use crate::util::{bad_gateway, extract_usage, sse_response, upstream_error};
use crate::{AppState, error::ApiError};
Expand Down Expand Up @@ -533,12 +532,34 @@ fn byte_stream_to_gemini_sse(
})
}

/// Hop-by-hop headers per RFC 7230 §6.1 — must not be forwarded by proxies.
const HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
];

/// Transparent proxy to `ampcode.com` — used for both `/api/{*path}` and
/// `/v0/management/{*path}`. Takes the original URI path directly so a
/// single handler covers all non-provider amp routes.
/// `/v0/management/{*path}`.
///
/// Forwards the request verbatim: the client's headers (including
/// `Authorization`, cookies, user-agent, etc.) and body are passed through
/// untouched apart from the HTTP hop-by-hop headers and `host`, which any
/// proxy must drop for correctness. The response body is streamed back
/// rather than buffered so SSE and long-poll endpoints (e.g. thread
/// continuation after a tool result) work end-to-end.
///
/// byokey deliberately does NOT inject its own amp token here: the amp CLI
/// manages its own user session, and these non-AI endpoints
/// (threads, telemetry, auth, management) must run as the logged-in user.
pub async fn ampcode_proxy(
State(state): State<Arc<AppState>>,
axum::extract::Extension(fwd): axum::extract::Extension<ForwardedHeaders>,
headers: axum::http::HeaderMap,
method: Method,
uri: axum::http::Uri,
body: Bytes,
Expand All @@ -549,22 +570,24 @@ pub async fn ampcode_proxy(
None => format!("{AMP_BACKEND}{path}"),
};

let debug = path.ends_with("/internal") && tracing::enabled!(tracing::Level::DEBUG);
if debug {
let req_body = std::str::from_utf8(&body)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.map_or_else(
|| format!("{body:?}"),
|v| serde_json::to_string_pretty(&v).unwrap_or_default(),
);
tracing::debug!(%method, %url, body = %req_body, "ampcode proxy request");
let mut fwd = rquest::header::HeaderMap::new();
for (name, value) in &headers {
let name_str = name.as_str();
if HOP_BY_HOP.contains(&name_str) || name_str == "host" {
continue;
Comment on lines +573 to +577
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore shared-proxy auth injection for amp passthrough

This change makes /api/* and /v0/management/* fully pass through client headers, but it also removes the only runtime use of amp.upstream_key (documented in crates/config/src/schema/amp.rs as enabling shared-proxy mode). In deployments that rely on that setting, requests that previously worked with injected proxy credentials will now be sent with missing or client-scoped auth and start failing with upstream 401/403. If shared-proxy mode is being deprecated, this commit should fail fast when amp.upstream_key is set instead of silently ignoring it.

Useful? React with 👍 / 👎.

}
if let (Ok(n), Ok(v)) = (
rquest::header::HeaderName::from_bytes(name.as_ref()),
rquest::header::HeaderValue::from_bytes(value.as_bytes()),
) {
fwd.insert(n, v);
}
}

let resp = match state
.http
.request(method, url)
.headers(fwd.headers)
.headers(fwd)
.body(body)
.send()
.await
Expand All @@ -575,8 +598,20 @@ pub async fn ampcode_proxy(

let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);

// rquest auto-decompresses gzip/brotli/zstd. The upstream's
// `content-encoding` and `content-length` describe the *compressed*
// payload, so forwarding them alongside the already-decompressed body
// would make the amp CLI try to decode twice (garbage → silent parse
// failure → degraded mode policy, losing read/bash in smart mode).
let mut resp_headers = axum::http::HeaderMap::new();
for (name, value) in resp.headers() {
let name_str = name.as_str();
if HOP_BY_HOP.contains(&name_str)
|| name_str == "content-encoding"
|| name_str == "content-length"
{
continue;
}
if let (Ok(n), Ok(v)) = (
axum::http::HeaderName::from_bytes(name.as_ref()),
axum::http::HeaderValue::from_bytes(value.as_bytes()),
Expand All @@ -585,20 +620,15 @@ pub async fn ampcode_proxy(
}
}

let body_bytes = resp.bytes().await.unwrap_or_default();

if debug {
let resp_body = std::str::from_utf8(&body_bytes)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.map_or_else(
|| format!("{body_bytes:?}"),
|v| serde_json::to_string_pretty(&v).unwrap_or_default(),
);
tracing::debug!(%status, body = %resp_body, "ampcode proxy response");
}
let stream = resp
.bytes_stream()
.map_err(|e| std::io::Error::other(e.to_string()));
let body = axum::body::Body::from_stream(stream);

(status, resp_headers, body_bytes).into_response()
let mut response = Response::new(body);
*response.status_mut() = status;
*response.headers_mut() = resp_headers;
response
}

#[cfg(test)]
Expand Down
97 changes: 0 additions & 97 deletions crates/proxy/src/middleware/forward.rs

This file was deleted.

1 change: 0 additions & 1 deletion crates/proxy/src/middleware/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
//! Axum middleware layers for the proxy.

pub mod dump;
pub mod forward;
15 changes: 5 additions & 10 deletions crates/proxy/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,11 @@ fn common_layers(router: Router) -> Router {
/// `/byokey.amp.AmpService/{Method}` — local byokey management over
/// `ConnectRPC` (fallback service).
///
/// The amp routes are wrapped in [`forward_headers_middleware`] to strip
/// client auth and inject the amp upstream token. The middleware is
/// scoped to that sub-router only via `.layer()` before `.merge()`, so
/// REST and `ConnectRPC` routes are unaffected.
/// `/api/provider/*` is handled locally (byokey's own AI providers);
/// every other `/api/*` and `/v0/management/*` request is a transparent
/// passthrough to `ampcode.com` with the amp CLI's own headers — byokey
/// only touches traffic it generates itself.
pub fn make_router(state: Arc<AppState>) -> Router {
// Amp-specific routes with forward_headers_middleware scoped to them.
let amp_routes = Router::new()
.route("/auth/cli-login", get(amp::cli_login_redirect))
.route("/v1/login", get(amp::login_redirect))
Expand All @@ -122,11 +121,7 @@ pub fn make_router(state: Arc<AppState>) -> Router {
"/api/provider/google/v1beta/models/{action}",
post(amp::provider::gemini_native_passthrough),
)
.route("/api/{*path}", any(amp::provider::ampcode_proxy))
.layer(middleware::from_fn_with_state(
state.clone(),
crate::middleware::forward::forward_headers_middleware,
));
.route("/api/{*path}", any(amp::provider::ampcode_proxy));

// REST AI proxy routes.
let rest_routes = Router::new()
Expand Down
Loading