Skip to content
Merged
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
752 changes: 745 additions & 7 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ agent-client-protocol = { path = "crates/vendor/agent-client-protocol" }
[workspace.package]
version = "0.17.4"
edition = "2024"
rust-version = "1.90"
rust-version = "1.92"
license = "MIT OR Apache-2.0"
repository = "https://github.com/jmagar/lab"
authors = ["jmagar"]
Expand Down
10 changes: 9 additions & 1 deletion config/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
# ─── Stage 1: Build ───────────────────────────────────────────────────────────
# Requires Rust 1.90+ (Cargo.toml: rust-version = "1.90", edition = "2024")
# Requires Rust 1.92+ (Cargo.toml: rust-version = "1.92", edition = "2024")
FROM rust:1-slim AS builder

WORKDIR /build

# GitHub's container builder has less memory than the release-smoke runners.
# Keep the container release binary optimized but avoid ThinLTO/codegen-units=1
# peak memory during the final link.
ENV CARGO_PROFILE_RELEASE_LTO=false \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16

# System build deps:
# pkg-config — lets -sys crates find system headers
# build-essential — C compiler for rusqlite's bundled SQLite + other -sys crates
# libssl-dev — headers/pkg-config metadata for crates pulling openssl-sys
# libclang-dev — libclang for rquickjs-sys bindgen during Code Mode builds
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
pkg-config \
build-essential \
libssl-dev \
libclang-dev \
&& rm -rf /var/lib/apt/lists/*

# ── Dependency-caching layer ──────────────────────────────────────────────────
Expand Down
10 changes: 9 additions & 1 deletion crates/lab/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ anyhow.workspace = true
thiserror.workspace = true
jiff.workspace = true
boa_engine = "0.21.1"
javy = { version = "7.0.0", optional = true }
wasmtime = { version = "44.0.1", optional = true, default-features = false, features = [
"cranelift",
"pooling-allocator",
"runtime",
"wat",
] }

tracing.workspace = true
tracing-subscriber.workspace = true
Expand Down Expand Up @@ -128,7 +135,8 @@ path = "src/lib.rs"
[features]
acp_registry = ["lab-apis/acp_registry"]
node-runtime = []
all = ["lab-apis/all", "lab-admin", "acp_registry", "deploy", "extract", "mcpregistry", "gateway", "marketplace"]
all = ["lab-apis/all", "lab-admin", "acp_registry", "deploy", "extract", "mcpregistry", "gateway", "marketplace", "code_mode_wasm"]
code_mode_wasm = ["dep:javy", "dep:wasmtime"]
gateway = []
marketplace = ["mcpregistry"]
services-all = [
Expand Down
4 changes: 3 additions & 1 deletion crates/lab/src/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ impl IntoResponse for ToolError {
| "path_traversal_rejected"
| "invalid_encoding" => StatusCode::UNPROCESSABLE_ENTITY,
"content_too_large" => StatusCode::PAYLOAD_TOO_LARGE,
"install_timeout" | "timeout" => StatusCode::GATEWAY_TIMEOUT,
"install_timeout" | "timeout" | "code_mode_timeout" | "code_mode_fuel_exhausted" => {
StatusCode::GATEWAY_TIMEOUT
}
"oauth_needs_reauth" => StatusCode::UNAUTHORIZED,
"oauth_state_invalid" => StatusCode::BAD_REQUEST,
"forbidden" | "dev_preview_read_only" => StatusCode::FORBIDDEN,
Expand Down
121 changes: 62 additions & 59 deletions crates/lab/src/cli/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ use crate::cli::helpers::{run_action_command, run_confirmable_action_command};
use crate::config::{LabConfig, ProtectedMcpRouteConfig, config_toml_path};
use crate::dispatch::clients::SharedServiceClients;
use crate::dispatch::gateway::SHARED_GATEWAY_OAUTH_SUBJECT;
use crate::dispatch::gateway::code_mode::{
CodeModeBroker, CodeModeCaller, CodeModeSurface, CodeModeToolId, CodeModeToolRef,
};
use crate::dispatch::gateway::code_mode::{CodeModeBroker, CodeModeCaller, CodeModeSurface};
use crate::dispatch::gateway::install_gateway_manager;
use crate::dispatch::gateway::manager::{GatewayManager, GatewayRuntimeHandle};
use crate::dispatch::upstream::pool::UpstreamPool;
Expand Down Expand Up @@ -60,14 +58,13 @@ pub struct GatewayCodeArgs {

#[derive(Debug, Subcommand)]
pub enum GatewayCodeCommand {
/// Search Code Mode tool IDs by natural-language query
/// Filter the inlined Code Mode tool catalog with JavaScript
Search {
query: String,
#[arg(long, default_value_t = 10)]
top_k: usize,
#[arg(long, conflicts_with = "file")]
code: Option<String>,
#[arg(long)]
file: Option<std::path::PathBuf>,
},
/// Show the schema and generated bindings for one Code Mode tool ID
Schema { id: String },
/// Execute a sandboxed JavaScript snippet that calls callTool(id, params)
Exec {
#[arg(long, conflicts_with = "file")]
Expand Down Expand Up @@ -417,13 +414,7 @@ pub async fn run(args: GatewayArgs, format: OutputFormat, config: &LabConfig) ->
command: GatewayMcpAuthCommand::Status(_) | GatewayMcpAuthCommand::Clear(_),
}),
})
) || matches!(&args.command, GatewayCommand::ProtectedRoute(_)))
&& !matches!(
&args.command,
GatewayCommand::Code(GatewayCodeArgs {
command: GatewayCodeCommand::Schema { id },
}) if code_mode_schema_is_builtin(id)
);
) || matches!(&args.command, GatewayCommand::ProtectedRoute(_)));
let manager = build_manager(config, discover_upstreams).await;
let cli_origin = format!("cli:{}", std::process::id());
let cli_owner = json!({
Expand Down Expand Up @@ -729,16 +720,6 @@ pub async fn run(args: GatewayArgs, format: OutputFormat, config: &LabConfig) ->
}
}

fn code_mode_schema_is_builtin(id: &str) -> bool {
matches!(
CodeModeToolId::parse(id),
Ok(CodeModeToolId {
reference: CodeModeToolRef::LabAction { .. },
..
})
)
}

async fn run_gateway_code(
manager: Arc<GatewayManager>,
args: GatewayCodeArgs,
Expand All @@ -752,29 +733,13 @@ async fn run_gateway_code(
let surface = CodeModeSurface::Cli;

match args.command {
GatewayCodeCommand::Search { query, top_k } => {
let candidates = broker.search(&query, top_k, caller, surface).await?;
crate::output::print(&candidates, format)?;
}
GatewayCodeCommand::Schema { id } => {
let schema = broker.schema(&id, caller, surface).await?;
crate::output::print(&schema, format)?;
GatewayCodeCommand::Search { code, file } => {
let code = read_code_mode_source(code, file, CODE_MODE_CLI_MAX_SOURCE_BYTES)?;
let response = broker.search(&code, caller, surface).await?;
crate::output::print(&response, format)?;
}
GatewayCodeCommand::Exec { code, file } => {
let code = match (code, file) {
(Some(code), None) => code,
(None, Some(path)) => {
let metadata = std::fs::metadata(&path)?;
if metadata.len() > CODE_MODE_CLI_MAX_SOURCE_BYTES {
anyhow::bail!("Code Mode source file exceeds 20480 bytes");
}
std::fs::read_to_string(path)?
}
_ => anyhow::bail!("provide exactly one of --code or --file"),
};
if code.len() as u64 > CODE_MODE_CLI_MAX_SOURCE_BYTES {
anyhow::bail!("Code Mode source exceeds 20480 bytes");
}
let code = read_code_mode_source(code, file, CODE_MODE_CLI_MAX_SOURCE_BYTES)?;
let config = manager.code_mode_config().await;
let max_tool_calls = config.max_tool_calls;
let response = broker
Expand All @@ -787,6 +752,28 @@ async fn run_gateway_code(
Ok(ExitCode::SUCCESS)
}

fn read_code_mode_source(
code: Option<String>,
file: Option<std::path::PathBuf>,
max_source_bytes: u64,
) -> Result<String> {
let code = match (code, file) {
(Some(code), None) => code,
(None, Some(path)) => {
let metadata = std::fs::metadata(&path)?;
if metadata.len() > max_source_bytes {
anyhow::bail!("Code Mode source file exceeds {max_source_bytes} bytes");
}
std::fs::read_to_string(path)?
}
_ => anyhow::bail!("provide exactly one of --code or --file"),
};
if code.len() as u64 > max_source_bytes {
anyhow::bail!("Code Mode source exceeds {max_source_bytes} bytes");
}
Ok(code)
}

async fn run_gateway_oauth_start(
manager: Arc<GatewayManager>,
args: GatewayOauthUpstreamArgs,
Expand Down Expand Up @@ -909,10 +896,7 @@ fn open_in_browser(url: &str) -> Result<()> {
/// Format inspired by `claude mcp list` (status icon + one-line per server)
/// and `codex mcp list` (column alignment). JSON mode preserves the full
/// `ServerView` shape for downstream consumers.
async fn run_gateway_list(
manager: Arc<GatewayManager>,
format: OutputFormat,
) -> Result<ExitCode> {
async fn run_gateway_list(manager: Arc<GatewayManager>, format: OutputFormat) -> Result<ExitCode> {
let servers = match manager.list().await {
Ok(s) => s,
Err(err) => {
Expand Down Expand Up @@ -982,10 +966,8 @@ fn render_gateway_list_human(
servers.sort_by_key(|s| {
if !s.enabled {
2u8
} else if s.connected {
0u8
} else {
1u8
u8::from(!s.connected)
}
});
let servers = servers.as_slice();
Expand Down Expand Up @@ -1027,7 +1009,7 @@ fn render_gateway_list_human(
let transport = theme.tertiary(&transport_padded);

let status_detail = if !s.enabled {
theme.muted("disabled".to_string())
theme.muted("disabled")
} else if s.connected {
let mut parts = Vec::new();
if s.exposed_tool_count > 0 {
Expand Down Expand Up @@ -1198,25 +1180,46 @@ mod tests {
])
.is_ok()
);
assert!(Cli::try_parse_from(["lab", "gateway", "code", "search", "movie.search"]).is_ok());
assert!(
Cli::try_parse_from([
"lab",
"gateway",
"code",
"schema",
"lab::radarr.movie.search",
"search",
"--code",
"async () => tools.slice(0, 3)",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"lab",
"gateway",
"code",
"search",
"--code",
"async () => tools.filter(t => /github/i.test(t.id)).slice(0, 3)",
])
.is_ok()
);
assert!(
Cli::try_parse_from([
"lab",
"gateway",
"code",
"schema",
"upstream::github::search_issues"
])
.is_err()
);
assert!(
Cli::try_parse_from([
"lab",
"gateway",
"code",
"exec",
"--code",
"await callTool(\"lab::gateway.gateway.servers\", {})",
"await callTool(\"upstream::github::search_issues\", {query:\"repo\"})",
])
.is_ok()
);
Expand Down
Loading
Loading