diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index e3431039c..7288328c6 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1229,6 +1229,13 @@ enum SandboxCommands { #[arg(long, value_hint = ValueHint::FilePath)] policy: Option, + /// Supervisor log level for this sandbox (trace, debug, info, warn, error). + /// Sets `SandboxSpec.log_level`; the driver passes it as + /// `OPENSHELL_LOG_LEVEL` to the in-container supervisor. Debug surfaces + /// the L7 egress request/response header lines in the proxy log. + #[arg(long)] + log_level: Option, + /// Forward a local port to the sandbox before the initial command or shell starts. /// Accepts [`bind_address`:]port (e.g. 8080, 0.0.0.0:8080). Keeps the sandbox alive. #[arg(long, conflicts_with = "no_keep")] @@ -2572,6 +2579,7 @@ async fn main() -> Result<()> { labels, volumes, command, + log_level, } => { // Resolve --tty / --no-tty into an Option override. let tty_override = if no_tty { @@ -2647,6 +2655,7 @@ async fn main() -> Result<()> { auto_providers_override, &labels_map, &parsed_volumes, + log_level.as_deref(), &tls, )) .await?; diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 29647a21b..fd81c0b28 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1683,6 +1683,7 @@ pub async fn sandbox_create( auto_providers_override: Option, labels: &HashMap, volumes: &[BindVolumeSpec], + log_level: Option<&str>, tls: &TlsOptions, ) -> Result<()> { if editor.is_some() && !command.is_empty() { @@ -1771,6 +1772,7 @@ pub async fn sandbox_create( providers: configured_providers, template, volumes: proto_volumes, + log_level: log_level.unwrap_or_default().to_string(), ..SandboxSpec::default() }), name: name.unwrap_or_default().to_string(), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index d0ffd830a..f7575815a 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -812,6 +812,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await @@ -854,6 +855,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await @@ -931,6 +933,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await @@ -988,6 +991,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await @@ -1041,6 +1045,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await @@ -1086,6 +1091,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await @@ -1127,6 +1133,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await @@ -1172,6 +1179,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await @@ -1217,6 +1225,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await @@ -1262,6 +1271,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { Some(false), &HashMap::new(), &[], + None, &tls, ) .await diff --git a/crates/openshell-sandbox/src/l7/rest.rs b/crates/openshell-sandbox/src/l7/rest.rs index c7ad0c9b6..c4210f1d6 100644 --- a/crates/openshell-sandbox/src/l7/rest.rs +++ b/crates/openshell-sandbox/src/l7/rest.rs @@ -472,6 +472,21 @@ where "", )?; + // Egress observability (DEBUG-gated, OFF by default): surface the + // billing-relevant request header `anthropic-beta` (carries + // `oauth-2025-04-20` in Claude subscription mode) for testing. The + // `enabled!` guard avoids parsing the header block when debug is off. + // NEVER logs Authorization / any credential header — only `anthropic-beta`. + if tracing::enabled!(tracing::Level::DEBUG) { + for line in String::from_utf8_lossy(&final_header).lines().skip(1) { + if line.to_ascii_lowercase().starts_with("anthropic-beta:") { + // Embed the value in the message (not a structured field) so it + // renders in the shorthand log that `openlock logs` surfaces. + debug!("l7 egress request header | {}", line.trim()); + } + } + } + if let Some(guard) = options.generation_guard { guard.ensure_current()?; } @@ -1639,6 +1654,26 @@ where "relay_response framing" ); + // Egress observability (DEBUG-gated, OFF by default): surface the + // subscription billing-bucket response headers for testing — `overage-status`, + // `unified-5h-status`, `unified-7d-status`, `representative-claim`, and the + // `anthropic-ratelimit-*` family. These are non-secret routing/quota signals. + if tracing::enabled!(tracing::Level::DEBUG) { + for line in header_str.lines().skip(1) { + let lower = line.to_ascii_lowercase(); + if lower.starts_with("overage-status:") + || lower.starts_with("unified-5h-status:") + || lower.starts_with("unified-7d-status:") + || lower.starts_with("representative-claim:") + || lower.starts_with("anthropic-ratelimit-") + { + // Embed status + value in the message (not structured fields) so + // they render in the shorthand log that `openlock logs` surfaces. + debug!("l7 egress response header [{}] | {}", status_code, line.trim()); + } + } + } + // 101 Switching Protocols: the connection has been upgraded (e.g. to // WebSocket). Forward the 101 headers to the client and signal the // caller to switch to raw bidirectional TCP relay. Any bytes read diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 3c9e21578..748452ceb 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -147,6 +147,20 @@ fn copy_self(dest: &str) -> Result<()> { Ok(()) } +/// Effective log level for the rolling file layer. Always at least `info` +/// (so the OCSF L7 audit lines are always present), but follows the +/// configured level when it is more verbose — so `--log-level debug` +/// surfaces the debug-gated L7 egress headers in the file that +/// `openlock logs` tails. Keyed off the supervisor's `--log-level` +/// (`OPENSHELL_LOG_LEVEL`); RUST_LOG-only activation reaches the console +/// layer but not the file, which is acceptable for our activation path. +fn file_log_level(configured: &str) -> &str { + match configured.to_ascii_lowercase().as_str() { + "debug" | "trace" => configured, + _ => "info", + } +} + fn main() -> Result<()> { // Handle `copy-self ` before clap so it works without any of the // sandbox flags. Kubernetes init containers invoke this path to seed an @@ -226,7 +240,7 @@ fn main() -> Result<()> { // Keep guards alive for the entire process. When a guard is dropped the // non-blocking writer flushes remaining logs. let (_file_guard, _jsonl_guard) = if let Some((file_writer, file_guard)) = file_logging { - let file_filter = EnvFilter::new("info"); + let file_filter = EnvFilter::new(file_log_level(&args.log_level)); // OCSF JSONL file: rolling appender matching the main log file // (daily rotation, 3 files max). Created eagerly but gated by the @@ -319,6 +333,20 @@ mod tests { use super::*; use std::os::unix::fs::PermissionsExt; + #[test] + fn file_log_level_floors_at_info_and_follows_verbose() { + // Below/at info → pinned to info so the file always carries OCSF lines. + assert_eq!(file_log_level("warn"), "info"); + assert_eq!(file_log_level("info"), "info"); + assert_eq!(file_log_level("error"), "info"); + // Debug/trace → follow the configured level so debug-gated L7 egress + // headers reach the file `openlock logs` reads. + assert_eq!(file_log_level("debug"), "debug"); + assert_eq!(file_log_level("trace"), "trace"); + // Case-insensitive match; returns the original spelling. + assert_eq!(file_log_level("DEBUG"), "DEBUG"); + } + /// Drives `copy_self`'s file-copy logic against an arbitrary source path /// so tests don't depend on `current_exe()`. fn copy_executable(src: &Path, dest: &Path) -> Result<()> {