feat: add native OpenCode proxy for TUI/config endpoints (#129)

This commit is contained in:
NathanFlurry 2026-02-07 07:56:06 +00:00
parent c54f83e1a6
commit 77f741ff62
No known key found for this signature in database
GPG key ID: 6A5F43A4F3241BCA
3 changed files with 408 additions and 44 deletions

View file

@ -113,6 +113,7 @@ for await (const event of events.stream) {
- **CORS**: When using the web UI from a different origin, configure `--cors-allow-origin`
- **Provider Selection**: Use the provider/model selector in the UI to choose which backing agent to use (claude, codex, opencode, amp)
- **Models & Variants**: Providers are grouped by backing agent (e.g. Claude Code, Codex, Amp). OpenCode models are grouped by `OpenCode (<provider>)` to preserve their native provider grouping. Each model keeps its real model ID, and variants are exposed when available (Codex/OpenCode/Amp).
- **Optional Native Proxy for TUI/Config Endpoints**: Set `OPENCODE_COMPAT_PROXY_URL` (for example `http://127.0.0.1:4096`) to proxy select OpenCode-native endpoints to a real OpenCode server. This currently applies to `/command`, `/config`, `/global/config`, and `/tui/*`. If not set, sandbox-agent uses its built-in compatibility handlers.
## Endpoint Coverage
@ -134,10 +135,15 @@ See the full endpoint compatibility table below. Most endpoints are functional f
| `GET /question` | ✓ | List pending questions |
| `POST /question/{id}/reply` | ✓ | Answer agent questions |
| `GET /provider` | ✓ | Returns provider metadata |
| `GET /command` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise stub response |
| `GET /config` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise stub response |
| `PATCH /config` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise local compatibility behavior |
| `GET /global/config` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise stub response |
| `PATCH /global/config` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise local compatibility behavior |
| `/tui/*` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise local compatibility behavior |
| `GET /agent` | | Returns agent list |
| `GET /config` | | Returns config |
| *other endpoints* | | Return empty/stub responses |
✓ Functional &nbsp;&nbsp; Stubbed
✓ Functional &nbsp;&nbsp; ↔ Proxied (optional) &nbsp;&nbsp; Stubbed
</Accordion>

View file

@ -50,14 +50,20 @@ fmt:
[group('dev')]
install-fast-sa:
cargo build --release -p sandbox-agent
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo build --release -p sandbox-agent
rm -f ~/.cargo/bin/sandbox-agent
cp target/release/sandbox-agent ~/.cargo/bin/sandbox-agent
[group('dev')]
install-fast-gigacode:
cargo build --release -p gigacode
install-gigacode:
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo build --release -p gigacode
rm -f ~/.cargo/bin/gigacode
cp target/release/gigacode ~/.cargo/bin/gigacode
[group('dev')]
run-gigacode *ARGS:
SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p gigacode -- {{ ARGS }}
[group('dev')]
dev-docs:
cd docs && pnpm dlx mintlify dev
@ -77,4 +83,3 @@ install-release:
pnpm build --filter @sandbox-agent/inspector...
cargo install --path server/packages/sandbox-agent
cargo install --path gigacode

View file

@ -10,13 +10,15 @@ use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::sse::{Event, KeepAlive};
use axum::response::{IntoResponse, Sse};
use axum::response::{IntoResponse, Response, Sse};
use axum::routing::{get, patch, post, put};
use axum::{Json, Router};
use futures::stream;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::sync::{broadcast, Mutex};
@ -56,6 +58,7 @@ struct OpenCodeCompatConfig {
fixed_state: Option<String>,
fixed_config: Option<String>,
fixed_branch: Option<String>,
proxy_base_url: Option<String>,
}
impl OpenCodeCompatConfig {
@ -70,6 +73,9 @@ impl OpenCodeCompatConfig {
fixed_state: std::env::var("OPENCODE_COMPAT_STATE").ok(),
fixed_config: std::env::var("OPENCODE_COMPAT_CONFIG").ok(),
fixed_branch: std::env::var("OPENCODE_COMPAT_BRANCH").ok(),
proxy_base_url: std::env::var("OPENCODE_COMPAT_PROXY_URL")
.ok()
.and_then(normalize_proxy_base_url),
}
}
@ -84,6 +90,19 @@ impl OpenCodeCompatConfig {
}
}
fn normalize_proxy_base_url(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let normalized = trimmed.trim_end_matches('/').to_string();
if normalized.starts_with("http://") || normalized.starts_with("https://") {
Some(normalized)
} else {
None
}
}
#[derive(Clone, Debug)]
struct OpenCodeSessionRecord {
id: String,
@ -369,6 +388,10 @@ impl OpenCodeState {
.unwrap_or_else(|| "main".to_string())
}
fn proxy_base_url(&self) -> Option<&str> {
self.config.proxy_base_url.as_deref()
}
async fn update_runtime(
&self,
session_id: &str,
@ -387,6 +410,7 @@ impl OpenCodeState {
pub struct OpenCodeAppState {
pub inner: Arc<AppState>,
pub opencode: Arc<OpenCodeState>,
proxy_http_client: Client,
}
impl OpenCodeAppState {
@ -394,6 +418,7 @@ impl OpenCodeAppState {
Arc::new(Self {
inner,
opencode: Arc::new(OpenCodeState::new()),
proxy_http_client: Client::new(),
})
}
}
@ -1091,6 +1116,91 @@ fn bool_ok(value: bool) -> (StatusCode, Json<Value>) {
(StatusCode::OK, Json(json!(value)))
}
async fn proxy_native_opencode(
state: &Arc<OpenCodeAppState>,
method: reqwest::Method,
path: &str,
headers: &HeaderMap,
body: Option<Value>,
) -> Option<Response> {
let Some(base_url) = state.opencode.proxy_base_url() else {
return None;
};
let mut request = state
.proxy_http_client
.request(method, format!("{base_url}{path}"));
for header_name in [
header::AUTHORIZATION,
header::ACCEPT,
HeaderName::from_static("x-opencode-directory"),
] {
if let Some(value) = headers.get(&header_name) {
request = request.header(header_name.as_str(), value.as_bytes());
}
}
if let Some(body) = body {
request = request.json(&body);
}
let response = match request.send().await {
Ok(response) => response,
Err(err) => {
warn!(path, ?err, "failed proxy request to native opencode");
return Some(
(
StatusCode::BAD_GATEWAY,
Json(json!({
"data": {},
"errors": [{"message": format!("failed to proxy to native opencode: {err}")}],
"success": false,
})),
)
.into_response(),
);
}
};
let status =
StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string());
let body_bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(err) => {
warn!(path, ?err, "failed to read proxied response body");
return Some(
(
StatusCode::BAD_GATEWAY,
Json(json!({
"data": {},
"errors": [{"message": format!("failed to read proxied response: {err}")}],
"success": false,
})),
)
.into_response(),
);
}
};
let mut proxied = Response::new(Body::from(body_bytes));
*proxied.status_mut() = status;
if let Some(content_type) = content_type {
if let Ok(header_value) = HeaderValue::from_str(&content_type) {
proxied
.headers_mut()
.insert(header::CONTENT_TYPE, header_value);
}
}
Some(proxied)
}
fn build_user_message(
session_id: &str,
message_id: &str,
@ -2676,8 +2786,16 @@ async fn oc_agent_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoR
responses((status = 200)),
tag = "opencode"
)]
async fn oc_command_list() -> impl IntoResponse {
(StatusCode::OK, Json(json!([])))
async fn oc_command_list(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
) -> Response {
if let Some(response) =
proxy_native_opencode(&state, reqwest::Method::GET, "/command", &headers, None).await
{
return response;
}
(StatusCode::OK, Json(json!([]))).into_response()
}
#[utoipa::path(
@ -2686,8 +2804,13 @@ async fn oc_command_list() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_config_get() -> impl IntoResponse {
(StatusCode::OK, Json(json!({})))
async fn oc_config_get(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap) -> Response {
if let Some(response) =
proxy_native_opencode(&state, reqwest::Method::GET, "/config", &headers, None).await
{
return response;
}
(StatusCode::OK, Json(json!({}))).into_response()
}
#[utoipa::path(
@ -2697,8 +2820,23 @@ async fn oc_config_get() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_config_patch(Json(body): Json<Value>) -> impl IntoResponse {
(StatusCode::OK, Json(body))
async fn oc_config_patch(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::PATCH,
"/config",
&headers,
Some(body.clone()),
)
.await
{
return response;
}
(StatusCode::OK, Json(body)).into_response()
}
#[utoipa::path(
@ -2906,8 +3044,22 @@ async fn oc_global_health() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_global_config_get() -> impl IntoResponse {
(StatusCode::OK, Json(json!({})))
async fn oc_global_config_get(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::GET,
"/global/config",
&headers,
None,
)
.await
{
return response;
}
(StatusCode::OK, Json(json!({}))).into_response()
}
#[utoipa::path(
@ -2917,8 +3069,23 @@ async fn oc_global_config_get() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_global_config_patch(Json(body): Json<Value>) -> impl IntoResponse {
(StatusCode::OK, Json(body))
async fn oc_global_config_patch(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::PATCH,
"/global/config",
&headers,
Some(body.clone()),
)
.await
{
return response;
}
(StatusCode::OK, Json(body)).into_response()
}
#[utoipa::path(
@ -4563,8 +4730,19 @@ async fn oc_skill_list() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_next() -> impl IntoResponse {
(StatusCode::OK, Json(json!({"path": "", "body": {}})))
async fn oc_tui_next(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::GET,
"/tui/control/next",
&headers,
None,
)
.await
{
return response;
}
(StatusCode::OK, Json(json!({"path": "", "body": {}}))).into_response()
}
#[utoipa::path(
@ -4574,8 +4752,23 @@ async fn oc_tui_next() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_response() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_response(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
body: Option<Json<Value>>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/control/response",
&headers,
body.map(|json| json.0),
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4585,8 +4778,23 @@ async fn oc_tui_response() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_append_prompt() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_append_prompt(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
body: Option<Json<Value>>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/append-prompt",
&headers,
body.map(|json| json.0),
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4595,8 +4803,22 @@ async fn oc_tui_append_prompt() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_open_help() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_open_help(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/open-help",
&headers,
None,
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4605,8 +4827,22 @@ async fn oc_tui_open_help() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_open_sessions() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_open_sessions(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/open-sessions",
&headers,
None,
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4615,8 +4851,22 @@ async fn oc_tui_open_sessions() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_open_themes() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_open_themes(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/open-themes",
&headers,
None,
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4625,8 +4875,22 @@ async fn oc_tui_open_themes() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_open_models() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_open_models(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/open-models",
&headers,
None,
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4636,8 +4900,23 @@ async fn oc_tui_open_models() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_submit_prompt() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_submit_prompt(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
body: Option<Json<Value>>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/submit-prompt",
&headers,
body.map(|json| json.0),
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4646,8 +4925,22 @@ async fn oc_tui_submit_prompt() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_clear_prompt() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_clear_prompt(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/clear-prompt",
&headers,
None,
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4657,8 +4950,23 @@ async fn oc_tui_clear_prompt() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_execute_command() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_execute_command(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
body: Option<Json<Value>>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/execute-command",
&headers,
body.map(|json| json.0),
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4668,8 +4976,23 @@ async fn oc_tui_execute_command() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_show_toast() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_show_toast(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
body: Option<Json<Value>>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/show-toast",
&headers,
body.map(|json| json.0),
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4679,8 +5002,23 @@ async fn oc_tui_show_toast() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_publish() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_publish(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
body: Option<Json<Value>>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/publish",
&headers,
body.map(|json| json.0),
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[utoipa::path(
@ -4690,8 +5028,23 @@ async fn oc_tui_publish() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_select_session() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_select_session(
State(state): State<Arc<OpenCodeAppState>>,
headers: HeaderMap,
body: Option<Json<Value>>,
) -> Response {
if let Some(response) = proxy_native_opencode(
&state,
reqwest::Method::POST,
"/tui/select-session",
&headers,
body.map(|json| json.0),
)
.await
{
return response;
}
bool_ok(true).into_response()
}
#[derive(OpenApi)]