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
9 changes: 9 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,13 @@ enum SandboxCommands {
#[arg(long, value_hint = ValueHint::FilePath)]
policy: Option<String>,

/// 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<String>,

/// 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")]
Expand Down Expand Up @@ -2572,6 +2579,7 @@ async fn main() -> Result<()> {
labels,
volumes,
command,
log_level,
} => {
// Resolve --tty / --no-tty into an Option<bool> override.
let tty_override = if no_tty {
Expand Down Expand Up @@ -2647,6 +2655,7 @@ async fn main() -> Result<()> {
auto_providers_override,
&labels_map,
&parsed_volumes,
log_level.as_deref(),
&tls,
))
.await?;
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1683,6 +1683,7 @@ pub async fn sandbox_create(
auto_providers_override: Option<bool>,
labels: &HashMap<String, String>,
volumes: &[BindVolumeSpec],
log_level: Option<&str>,
tls: &TlsOptions,
) -> Result<()> {
if editor.is_some() && !command.is_empty() {
Expand Down Expand Up @@ -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(),
Expand Down
10 changes: 10 additions & 0 deletions crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down Expand Up @@ -854,6 +855,7 @@ async fn sandbox_create_sends_cpu_and_memory_limits_only() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down Expand Up @@ -931,6 +933,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down Expand Up @@ -988,6 +991,7 @@ async fn sandbox_create_returns_vm_error_without_waiting_for_timeout() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down Expand Up @@ -1041,6 +1045,7 @@ async fn sandbox_create_keeps_waiting_while_vm_progress_arrives() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down Expand Up @@ -1086,6 +1091,7 @@ async fn sandbox_create_times_out_when_only_logs_arrive() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down Expand Up @@ -1127,6 +1133,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down Expand Up @@ -1172,6 +1179,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down Expand Up @@ -1217,6 +1225,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down Expand Up @@ -1262,6 +1271,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() {
Some(false),
&HashMap::new(),
&[],
None,
&tls,
)
.await
Expand Down
35 changes: 35 additions & 0 deletions crates/openshell-sandbox/src/l7/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
}
Expand Down Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion crates/openshell-sandbox/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <DEST>` before clap so it works without any of the
// sandbox flags. Kubernetes init containers invoke this path to seed an
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<()> {
Expand Down