diff --git a/config/config.example.toml b/config/config.example.toml index e09124eaf..564891448 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -191,7 +191,7 @@ # `callTool(id, params)`, and typed `codemode..(params)` # helpers generated from connected servers' inputSchemas. # timeout_ms = 30000 # valid range: 1..=60000 (wall-clock budget) -# max_source_bytes = 131072 # valid range: 1024..=1048576 (JavaScript source) +# max_source_bytes = 1048576 # valid range: 1024..=1048576 (JavaScript source) # max_response_bytes = 24576 # valid range: 1024..=1048576 # max_response_tokens = 6000 # valid range: 256..=256000 # token_estimate_divisor = 4 # valid range: 1..=64 (lower is more conservative) diff --git a/crates/labby-codemode/src/config.rs b/crates/labby-codemode/src/config.rs index 74cc58034..09cd09319 100644 --- a/crates/labby-codemode/src/config.rs +++ b/crates/labby-codemode/src/config.rs @@ -27,7 +27,10 @@ pub(crate) const MAX_SNIPPET_RESOLVES_PER_RUN: usize = 32; pub(crate) const MAX_INTERNAL_CALLS_PER_RUN: usize = 32; /// Maximum total bytes of resolved snippet source allowed in a single run. -pub(crate) const MAX_SNIPPET_RESOLVED_BYTES_PER_RUN: usize = 256 * 1024; +/// Keep nested/composed snippets on the same hard byte budget as a direct Code +/// Mode source so reusable helpers can carry normal agent context without +/// inheriting a smaller legacy ceiling. +pub(crate) const MAX_SNIPPET_RESOLVED_BYTES_PER_RUN: usize = MAX_SOURCE_BYTES; /// Default per-run `callTool` fan-out budget. const DEFAULT_MAX_CALLTOOL_PER_RUN: u64 = 512; @@ -129,4 +132,9 @@ mod tests { fn max_source_bytes_is_stable() { assert_eq!(MAX_SOURCE_BYTES, 1024 * 1024); } + + #[test] + fn composed_snippet_budget_matches_code_mode_hard_source_budget() { + assert_eq!(MAX_SNIPPET_RESOLVED_BYTES_PER_RUN, MAX_SOURCE_BYTES); + } } diff --git a/crates/labby-codemode/src/execute.rs b/crates/labby-codemode/src/execute.rs index 5b72134f2..5df34b329 100644 --- a/crates/labby-codemode/src/execute.rs +++ b/crates/labby-codemode/src/execute.rs @@ -12,6 +12,7 @@ use crate::host::{CodeModeHost, ExecCtx, ToolCallOutcome, ToolsRender}; use labby_runtime::{CodeModeConfig, CodeModeResultShapePolicy}; use super::CodeModeBroker; +use super::config::MAX_SOURCE_BYTES; use super::normalize_user_code; use super::shape::shape_final_result; use super::truncate::{response_within_budget, truncate_execution_response}; @@ -101,11 +102,20 @@ impl CodeModeBroker<'_, H> { } .into()); } + let max_source_bytes = config.max_source_bytes.min(MAX_SOURCE_BYTES); + if code.len() > max_source_bytes { + return Err(ToolError::InvalidParam { + message: format!("code exceeds max length {max_source_bytes} bytes"), + param: "code".to_string(), + } + .into()); + } let started = std::time::Instant::now(); let mut response = self .execute_sandboxed( code, execution_timeout(config.timeout_ms), + max_source_bytes, caller, surface, config.max_log_entries, @@ -258,6 +268,7 @@ impl CodeModeBroker<'_, H> { &self, code: &str, timeout: Duration, + snippet_max_bytes: usize, caller: CodeModeCaller, surface: CodeModeSurface, max_log_entries: usize, @@ -325,6 +336,7 @@ impl CodeModeBroker<'_, H> { max_log_bytes, trace_params, scope, + snippet_max_bytes, execution_id, ) .await @@ -877,6 +889,31 @@ mod tests { ); } + #[tokio::test] + async fn broker_enforces_configured_source_limit_before_runner_start() { + let host = NoopHost::default(); + let broker = CodeModeBroker::new(Some(&host)); + let config = CodeModeConfig { + max_source_bytes: 1024, + ..CodeModeConfig::default() + }; + let code = format!("async () => \"{}\"", "x".repeat(2048)); + + let error = broker + .execute_with_raw_response( + &code, + CodeModeCaller::TrustedLocal, + CodeModeSurface::Cli, + config, + ToolScope::default(), + None, + ) + .await + .expect_err("configured lower source limit must reject before runner start"); + + assert!(format!("{error}").contains("code exceeds max length 1024 bytes")); + } + #[test] fn execution_timeout_reserves_response_delivery_margin() { assert_eq!(execution_timeout(30_000), Duration::from_millis(29_500)); @@ -1150,6 +1187,59 @@ mod tests { } } + #[tokio::test] + async fn raw_tool_call_boundary_rejects_undeclared_and_out_of_route_tools() { + let host = FixtureHost::new(vec![ + CatalogDescriptor::tool("alpha", "tool1", "allowed", None, None), + CatalogDescriptor::tool("alpha", "other_tool", "undeclared sibling", None, None), + CatalogDescriptor::tool("beta", "tool2", "out of route", None, None), + ]); + let broker = CodeModeBroker::new(Some(&host)); + let scope = ToolScope::scoped_namespaces( + vec!["alpha".to_string()], + vec!["alpha::tool1".to_string()], + ); + + for id in ["alpha::other_tool", "beta::tool2"] { + let error = broker + .call_tool_id( + id, + json!({}), + CodeModeCaller::TrustedLocal, + CodeModeSurface::Cli, + &scope, + ExecCtx::none(), + ) + .await + .expect_err("raw callTool target outside the effective snippet scope must fail"); + assert_eq!(error.kind(), "unknown_tool"); + assert!( + error + .to_string() + .contains("outside this Code Mode execution capability set"), + "scope rejection must happen before host dispatch: {error:?}" + ); + } + + let allowed = broker + .call_tool_id( + "alpha::tool1", + json!({}), + CodeModeCaller::TrustedLocal, + CodeModeSurface::Cli, + &scope, + ExecCtx::none(), + ) + .await + .expect_err("fixture host intentionally rejects real dispatch after scope admission"); + assert!( + allowed + .to_string() + .contains("FixtureHost does not dispatch real tool calls"), + "the declared in-route tool must pass the scope boundary and reach the host" + ); + } + #[tokio::test] async fn dispatch_internal_call_resource_discovery_round_trips_uri() { let host = FixtureHost::new(Vec::new()); diff --git a/crates/labby-codemode/src/runner.rs b/crates/labby-codemode/src/runner.rs index b8776f1cb..6912884f8 100644 --- a/crates/labby-codemode/src/runner.rs +++ b/crates/labby-codemode/src/runner.rs @@ -8,6 +8,7 @@ use serde_json::Value; use crate::CodeModeCallError; +use super::config::MAX_SNIPPET_RESOLVED_BYTES_PER_RUN; use super::protocol::CODE_MODE_STACK_SIZE_LIMIT; use super::protocol::{ CodeModeRunnerInput, CodeModeRunnerOutput, CodeModeRunnerResult, CodeModeRunnerState, @@ -444,7 +445,7 @@ globalThis.__labSnippetResolveCount = 0; globalThis.__labSnippetResolvedBytes = 0; globalThis.__labSnippetMaxDepth = 8; globalThis.__labSnippetMaxResolves = 32; -globalThis.__labSnippetMaxBytes = 262144; +globalThis.__labSnippetMaxBytes = {snippet_max_bytes}; {codec} globalThis.callTool = (id, params = {{}}) => {{ if (typeof id !== "string" || id.trim() === "") {{ @@ -599,6 +600,7 @@ globalThis.__labMainPromise = (async () => {{ codec = CODE_MODE_VALUE_CODEC_JS, invoker = invoker, proxy = proxy, + snippet_max_bytes = MAX_SNIPPET_RESOLVED_BYTES_PER_RUN, ) } @@ -956,3 +958,16 @@ fn runner_read_input() -> Result { serde_json::from_str(&line).map_err(|err| RunnerReadError::Other(err.to_string())) }) } + +#[cfg(test)] +mod wrapper_tests { + use super::*; + + #[test] + fn generated_wrapper_uses_the_shared_composed_snippet_budget() { + let wrapped = wrap_code_mode("async () => ({ ok: true })", ""); + assert!(wrapped.contains(&format!( + "globalThis.__labSnippetMaxBytes = {MAX_SNIPPET_RESOLVED_BYTES_PER_RUN};" + ))); + } +} diff --git a/crates/labby-codemode/src/runner_drive.rs b/crates/labby-codemode/src/runner_drive.rs index 38ec83306..afe362562 100644 --- a/crates/labby-codemode/src/runner_drive.rs +++ b/crates/labby-codemode/src/runner_drive.rs @@ -191,6 +191,8 @@ pub(crate) struct RunnerConfig { pub max_log_bytes: usize, pub trace_params: bool, pub capability_filter: ToolScope, + /// Effective total byte budget for source resolved through `codemode.run`. + pub snippet_max_bytes: usize, /// Durable-run execution id, minted by the caller (binary/gateway). `None` /// on the write-free/standalone path; flows into every [`ExecCtx`] so the /// host's `record_step` can key its per-execution journal buffer. @@ -283,6 +285,7 @@ impl CodeModeBroker<'_, H> { max_log_bytes: usize, trace_params: bool, capability_filter: ToolScope, + snippet_max_bytes: usize, execution_id: Option>, ) -> Result { // Read the openapi registry/client from the host at the config-build site @@ -318,6 +321,7 @@ impl CodeModeBroker<'_, H> { max_log_bytes, trace_params, capability_filter, + snippet_max_bytes: snippet_max_bytes.min(MAX_SNIPPET_RESOLVED_BYTES_PER_RUN), execution_id, openapi_registry, openapi_http_client, @@ -1468,6 +1472,7 @@ mod tests { max_log_bytes: 4096, trace_params: false, capability_filter: ToolScope::default(), + snippet_max_bytes: MAX_SNIPPET_RESOLVED_BYTES_PER_RUN, execution_id: None, openapi_registry: labby_openapi::OpenApiRegistry::default(), openapi_http_client: labby_openapi::http::build_dispatch_client() diff --git a/crates/labby-codemode/src/runner_drive/artifacts.rs b/crates/labby-codemode/src/runner_drive/artifacts.rs index 369cdf4a9..f3715953e 100644 --- a/crates/labby-codemode/src/runner_drive/artifacts.rs +++ b/crates/labby-codemode/src/runner_drive/artifacts.rs @@ -108,6 +108,10 @@ pub(super) async fn handle_snippet_resolve_event( } } +fn snippet_resolution_scope_allowed(caller: &CodeModeCaller, scope: &ToolScope) -> bool { + !scope.is_scoped() || matches!(caller, CodeModeCaller::TrustedLocal) +} + async fn resolve_snippet_for_runner( broker: &CodeModeBroker<'_, H>, name: &str, @@ -121,7 +125,7 @@ async fn resolve_snippet_for_runner( required_scopes: vec!["lab:admin".to_string()], }); } - if cfg.capability_filter.is_scoped() { + if !snippet_resolution_scope_allowed(&cfg.caller, &cfg.capability_filter) { return Err(ToolError::Forbidden { message: "codemode.run is not available on route-scoped Code Mode surfaces".to_string(), required_scopes: vec!["lab:admin".to_string()], @@ -144,7 +148,7 @@ async fn resolve_snippet_for_runner( let resolved = host.resolve_snippet(name, input).await?; let (name, code, input) = (resolved.name, resolved.code, resolved.input); state.snippet_resolved_bytes = state.snippet_resolved_bytes.saturating_add(code.len()); - if state.snippet_resolved_bytes > MAX_SNIPPET_RESOLVED_BYTES_PER_RUN { + if state.snippet_resolved_bytes > cfg.snippet_max_bytes { return Err(ToolError::Sdk { sdk_kind: "snippet_budget_exceeded".to_string(), message: "resolved snippet code budget exceeded".to_string(), @@ -262,8 +266,35 @@ fn artifact_call( #[cfg(test)] mod tests { - use super::artifact_writes_allowed; + use super::{artifact_writes_allowed, snippet_resolution_scope_allowed}; use crate::ToolScope; + use crate::types::{CodeModeCaller, CodeModeCallerCapabilities}; + + #[test] + fn trusted_local_saved_snippets_may_compose_inside_declared_tool_scope() { + let scope = ToolScope::scoped_namespaces( + vec!["claude-macpoo".to_string()], + vec!["claude-macpoo::Bash".to_string()], + ); + assert!(snippet_resolution_scope_allowed( + &CodeModeCaller::TrustedLocal, + &scope + )); + + let route_scoped_admin = CodeModeCaller::Scoped { + capabilities: CodeModeCallerCapabilities { + can_read: true, + can_execute: true, + can_use_snippets: true, + is_admin: true, + }, + sub: Some("admin".to_string()), + }; + assert!( + !snippet_resolution_scope_allowed(&route_scoped_admin, &scope), + "route-scoped callers must not use nested snippet resolution to widen authority" + ); + } #[test] fn artifact_writes_are_blocked_for_read_only_runs() { diff --git a/crates/labby-codemode/src/snippet/store.rs b/crates/labby-codemode/src/snippet/store.rs index 2a1a176b2..2c8b6bd3f 100644 --- a/crates/labby-codemode/src/snippet/store.rs +++ b/crates/labby-codemode/src/snippet/store.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; use std::collections::BTreeMap; @@ -15,17 +16,18 @@ mod tool_declaration_tests; const SNIPPET_EXTENSIONS: &[&str] = &["md", "js"]; -/// Maximum size of a snippet's *executable* code — the extracted ```js block, -/// or the whole file for bare `.js` snippets. This is what actually runs in -/// code-mode, so it mirrors the host CLI source-size cap. -const MAX_SNIPPET_CODE_BYTES: usize = 20 * 1024; +/// Hard storage ceiling for a snippet's *executable* code — the extracted +/// ```js block, or the whole file for bare `.js` snippets. Keep this aligned +/// with Code Mode's hard source ceiling instead of a smaller snippet-only +/// legacy cap. Hosts may still configure a lower `code_mode.max_source_bytes`, +/// which is enforced when the snippet executes. +const MAX_SNIPPET_CODE_BYTES: usize = crate::config::MAX_SOURCE_BYTES; -/// Generous upper bound on the whole snippet markdown file (frontmatter + prose -/// + fenced code). Tutorial-format snippets carry substantial prose that never -/// executes, so the file bound is intentionally loose; only the extracted code -/// is held to `MAX_SNIPPET_CODE_BYTES`. The file bound exists purely to reject -/// pathological inputs before they are read fully into memory and parsed. -const MAX_SNIPPET_FILE_BYTES: usize = 256 * 1024; +/// Upper bound on the whole snippet markdown file (frontmatter + prose + fenced +/// code). Snippets often front-load substantial agent context in prose that +/// never executes, so give that context a full extra Code Mode source budget +/// while still rejecting pathological files before parsing. +const MAX_SNIPPET_FILE_BYTES: usize = 2 * crate::config::MAX_SOURCE_BYTES; /// Origin of a reusable Code Mode snippet. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -40,7 +42,7 @@ pub enum SnippetSource { /// Discovery metadata for a built-in or user Code Mode snippet. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnippetInfo { - /// Optional exact-tool declaration; currently descriptive, not enforced. + /// Optional exact-tool declaration used to scope native saved-snippet execution. #[serde(default, skip_serializing_if = "Option::is_none")] pub tools: Option, /// Stable snippet name. @@ -63,8 +65,8 @@ pub struct SnippetInfo { /// Fully resolved snippet including its source body. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResolvedSnippet { - /// Optional declaration; an empty list expresses intended deny-all access. - /// Execution does not yet enforce this metadata. + /// Optional declaration; an empty list expresses deny-all upstream access. + /// Host saved-snippet execution intersects this with the caller policy. #[serde(default, skip_serializing_if = "Option::is_none")] pub tools: Option, /// Stable snippet name. @@ -232,10 +234,16 @@ pub fn code_for_snippet(snippet: &ResolvedSnippet) -> Result } else { snippet.body.trim().to_string() }; + let code = normalize_snippet_code(&code).to_string(); validate_snippet_code(&code)?; Ok(code) } +fn normalize_snippet_code(code: &str) -> &str { + let code = code.trim(); + code.strip_suffix(';').map_or(code, str::trim_end) +} + /// Validate and atomically create or replace a user Markdown snippet. pub fn create_user_snippet( lab_home: &Path, @@ -336,6 +344,12 @@ pub fn resolve_snippet( if let Some(path) = find_snippet_file(builtin_dir, name) { return read_resolved(name, SnippetSource::Builtin, path); } + tracing::debug!( + snippet = %name, + user_dir = %user_dir.display(), + builtin_dir = %builtin_dir.display(), + "saved snippet was not found in configured authorities" + ); Err(ToolError::Sdk { sdk_kind: "not_found".to_string(), message: format!("snippet `{name}` not found"), @@ -392,7 +406,7 @@ fn collect_snippets( continue; } names.insert(stem.to_string()); - let body = match fs::read_to_string(&path) { + let body = match read_snippet_body(&path) { Ok(body) => body, Err(_) => continue, }; @@ -431,13 +445,36 @@ fn has_snippet_extension(path: &Path) -> bool { .is_some_and(|ext| SNIPPET_EXTENSIONS.contains(&ext)) } +fn read_snippet_body(path: &Path) -> Result { + let file = fs::File::open(path).map_err(|e| io_error("open snippet", path, e))?; + let mut bytes = Vec::new(); + file.take((MAX_SNIPPET_FILE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|e| io_error("read snippet", path, e))?; + if bytes.len() > MAX_SNIPPET_FILE_BYTES { + return Err(ToolError::InvalidParam { + message: format!("snippet file exceeds {MAX_SNIPPET_FILE_BYTES} bytes"), + param: "body".to_string(), + }); + } + String::from_utf8(bytes).map_err(|_| ToolError::InvalidParam { + message: "snippet file must contain valid UTF-8".to_string(), + param: "body".to_string(), + }) +} + fn read_resolved( name: &str, source: SnippetSource, path: PathBuf, ) -> Result { - let body = fs::read_to_string(&path).map_err(|e| io_error("read snippet", &path, e))?; - validate_snippet_body(name, &body)?; + let body = read_snippet_body(&path)?; + // Resolution is a host-side lookup, not a second execution validator. Keep + // the file/frontmatter/size contract here, then let code_for_snippet() do + // the single authoritative Javy parse immediately before execution (or an + // explicit existing-snippet validation). This avoids compiling the same + // saved program twice per invocation. + validate_snippet_body_structure(name, &body)?; let (description, tags, inputs, tools) = snippet_metadata_fields(frontmatter(&body)?.filter(|m| m.name == name)); Ok(ResolvedSnippet { @@ -486,8 +523,7 @@ fn snippet_metadata_fields( .unwrap_or_default() } -/// Validate snippet source size, syntax envelope, and frontmatter/name consistency. -pub fn validate_snippet_body(name: &str, body: &str) -> Result<(), ToolError> { +fn validate_snippet_body_structure(name: &str, body: &str) -> Result<(), ToolError> { if body.len() > MAX_SNIPPET_FILE_BYTES { return Err(ToolError::InvalidParam { message: format!("snippet file exceeds {MAX_SNIPPET_FILE_BYTES} bytes"), @@ -516,12 +552,23 @@ pub fn validate_snippet_body(name: &str, body: &str) -> Result<(), ToolError> { param: "body".to_string(), }); } + Ok(()) +} + +/// Validate snippet source size, syntax, and frontmatter/name consistency. +pub fn validate_snippet_body(name: &str, body: &str) -> Result<(), ToolError> { + validate_snippet_body_structure(name, body)?; + let code = if has_frontmatter(body) || body.contains("```") { + extract_javascript_block(body)? + } else { + body.trim().to_string() + }; validate_snippet_code(&code) } /// Validate executable snippet JavaScript against the Code Mode source-size contract. pub fn validate_snippet_code(code: &str) -> Result<(), ToolError> { - let code = code.trim(); + let code = normalize_snippet_code(code); if code.is_empty() { return Err(ToolError::InvalidParam { message: "snippet code is empty".to_string(), @@ -536,6 +583,39 @@ pub fn validate_snippet_code(code: &str) -> Result<(), ToolError> { param: "body".to_string(), }); } + + // Parse the exact expression with the same QuickJS/Javy engine used by + // Code Mode, but never evaluate it. `compile_to_bytecode` declares a + // module and serializes bytecode only, so catalog discovery and explicit + // validation cannot execute snippet side effects. This closes the gap + // where a string could satisfy the cheap `async`/`=>` envelope check but + // still fail only when a real Code Mode execution tried to parse it. + let mut config = javy::Config::default(); + config.memory_limit(64 * 1024 * 1024); + let runtime = javy::Runtime::new(config).map_err(|error| ToolError::Sdk { + sdk_kind: "internal_error".to_string(), + message: format!("unable to initialize JavaScript validator: {error}"), + })?; + // Keep generated delimiters on their own lines. A valid snippet may end + // in a // comment, which must not consume the validator's closing `);`. + let source = format!("export default (\n{code}\n);"); + runtime + .compile_to_bytecode("snippet-validation.js", &source) + .map_err(|error| { + let mut message = error.to_string(); + if message.len() > 1024 { + let mut end = 1024; + while !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); + message.push_str("..."); + } + ToolError::InvalidParam { + message: format!("snippet JavaScript is invalid: {message}"), + param: "body".to_string(), + } + })?; Ok(()) } @@ -987,6 +1067,54 @@ mod tests { assert!(validate_snippet_body("demo", valid_body()).is_ok()); } + #[test] + fn validate_snippet_code_accepts_formatter_trailing_semicolon() { + let code = "async () => ({ ok: true });"; + assert!(validate_snippet_code(code).is_ok()); + assert_eq!(normalize_snippet_code(code), "async () => ({ ok: true })"); + } + + #[test] + fn validate_snippet_code_accepts_trailing_line_comment() { + let code = "async () => ({ ok: true }) // formatter note"; + assert!( + validate_snippet_code(code).is_ok(), + "the validator's generated closing delimiter must not be swallowed by a trailing // comment" + ); + } + + #[test] + fn validate_snippet_body_rejects_malformed_javascript_before_execution() { + let body = "---\nname: demo\ndescription: Broken snippet\ntags: []\n---\n\n```js\nasync () => { const broken = ; return broken; }\n```\n"; + let error = validate_snippet_body("demo", body) + .expect_err("malformed JavaScript must fail static validation"); + assert!( + format!("{error}").contains("snippet JavaScript is invalid"), + "syntax failure should explain that the JavaScript is invalid: {error}" + ); + } + + #[test] + fn validate_snippet_code_parses_without_executing_function_body() { + let code = "async () => { throw new Error(\"validation must not execute me\"); }"; + assert!( + validate_snippet_code(code).is_ok(), + "validation should compile the function expression without invoking it" + ); + } + + #[test] + fn resolve_snippet_not_found_does_not_expose_filesystem_authorities() { + let lab_home = tempfile::tempdir().expect("lab home"); + let builtin = tempfile::tempdir().expect("builtin snippets"); + let error = resolve_snippet(lab_home.path(), builtin.path(), "missing") + .expect_err("missing snippet must fail"); + let message = format!("{error}"); + assert!(message.contains("missing")); + assert!(!message.contains(&user_snippet_dir(lab_home.path()).display().to_string())); + assert!(!message.contains(&builtin.path().display().to_string())); + } + #[test] fn atomic_write_snippet_rejects_overwrite_without_force_under_lock() { // The authoritative no-overwrite guard lives INSIDE atomic_write_snippet, @@ -1030,6 +1158,118 @@ mod tests { assert!(validate_snippet_body("demo", body).is_ok()); } + #[test] + fn snippet_catalog_exposes_metadata_without_saved_source() { + const SOURCE_SENTINEL: &str = "SOURCE_SENTINEL_MUST_STAY_EXECUTION_SIDE"; + let lab_home = tempfile::tempdir().expect("temp lab home"); + let builtin_dir = tempfile::tempdir().expect("temp builtin dir"); + let code = format!( + "async () => {{ const marker = \"{SOURCE_SENTINEL}\"; return {{ ok: marker.length > 0 }}; }}" + ); + create_user_snippet( + lab_home.path(), + "metadata-only", + &code, + Some("Metadata-only catalog oracle"), + false, + ) + .expect("create user snippet"); + + let listed = list_snippets(lab_home.path(), builtin_dir.path()) + .expect("list saved snippet metadata"); + let serialized = serde_json::to_string(&listed).expect("serialize catalog metadata"); + assert!(serialized.contains("metadata-only")); + assert!(serialized.contains("Metadata-only catalog oracle")); + assert!( + !serialized.contains(SOURCE_SENTINEL), + "saved source must not enter model-facing snippet catalog metadata" + ); + + let resolved = resolve_snippet(lab_home.path(), builtin_dir.path(), "metadata-only") + .expect("host-side source resolution"); + assert!( + resolved.body.contains(SOURCE_SENTINEL), + "source must remain available to the execution plane" + ); + } + + #[test] + fn docker_host_inventory_uses_per_run_log_markers() { + let lab_home = tempfile::tempdir().expect("temp lab home"); + let snippet = resolve_snippet( + lab_home.path(), + &builtin_snippet_dir(), + "docker-host-inventory", + ) + .expect("resolve docker host inventory"); + let code = code_for_snippet(&snippet).expect("valid docker inventory source"); + + assert!( + code.contains("Math.random()"), + "log framing must carry a per-run nonce" + ); + assert!( + code.contains("__LABBY_DOCKER_LOG_SECTION_${markerNonce}__"), + "section marker must incorporate the per-run nonce" + ); + assert!( + !code.contains("__LABBY_DOCKER_LOG_SECTION_9D81__"), + "static framing lets container output spoof parser boundaries" + ); + } + + #[test] + fn homelab_inventory_snippets_pin_ssh_safety_and_artifact_redaction() { + let lab_home = tempfile::tempdir().expect("temp lab home"); + let builtin = builtin_snippet_dir(); + let ssh = code_for_snippet( + &resolve_snippet(lab_home.path(), &builtin, "homelab-ssh-targets") + .expect("resolve ssh targets"), + ) + .expect("valid ssh targets source"); + let docker = code_for_snippet( + &resolve_snippet(lab_home.path(), &builtin, "docker-host-inventory") + .expect("resolve docker host inventory"), + ) + .expect("valid docker host source"); + let aggregate = code_for_snippet( + &resolve_snippet(lab_home.path(), &builtin, "homelab-docker-inventory") + .expect("resolve aggregate inventory"), + ) + .expect("valid aggregate source"); + + for code in [&ssh, &docker] { + assert!(code.contains("-o ForwardAgent=no")); + assert!(code.contains("-o ClearAllForwardings=yes")); + assert!(code.contains("ssh ") && code.contains(" -- ")); + assert!(code.contains("docker_path=%s")); + assert!(code.contains("timeout_path=%s")); + } + assert!( + ssh.contains("-F "), + "custom SSH config must reach ssh -G and live probes" + ); + assert!(ssh.contains(r#"const slash = from.lastIndexOf("/")"#)); + assert!(ssh.contains(r#"slash >= 0 ? from.slice(0, slash + 1) : """#)); + assert!( + !ssh.contains(r#"Math.max(0, from.lastIndexOf("/"))"#), + "bare config filenames must not prefix relative Includes with the first filename character" + ); + assert!(ssh.contains("config_file_limit_reached")); + assert!(ssh.contains("config_truncated")); + assert!(docker.contains("ssh_config")); + assert!( + docker.contains("-F "), + "one-host inventory must reuse a supplied custom SSH config" + ); + assert!(aggregate.contains("ssh_config: input.ssh_config")); + assert!(aggregate.contains("delete artifactInput.ssh_config")); + assert!(aggregate.contains("parsed_config_file_count")); + assert!(aggregate.contains("identity_files_configured")); + assert!(aggregate.contains("artifactTargets")); + assert!(aggregate.contains("ssh_config_supplied: Boolean(input.ssh_config)")); + } + #[test] fn repo_status_gh_pulse_builtin_is_discoverable_and_executable() { let lab_home = tempfile::tempdir().expect("temp lab home"); @@ -1106,7 +1346,10 @@ mod tests { assert!(validate_snippet_body("demo", &body).is_ok()); // A code block that itself exceeds the code limit must fail. - let big_code = format!("async () => {{\n{}\nreturn 1;\n}}", "// pad\n".repeat(4096)); + let big_code = format!( + "async () => {{\n{}\nreturn 1;\n}}", + "x".repeat(MAX_SNIPPET_CODE_BYTES) + ); assert!(big_code.len() > MAX_SNIPPET_CODE_BYTES); let body = format!( "---\nname: demo\ndescription: Demo snippet\ntags: []\n---\n\n```js\n{big_code}\n```\n" @@ -1116,6 +1359,23 @@ mod tests { assert!(format!("{error}").contains("snippet code exceeds")); } + #[test] + fn validate_snippet_body_accepts_context_sized_code_beyond_legacy_20k() { + let context = "x".repeat(64 * 1024); + let code = format!( + "async () => {{ const context = \"{context}\"; return {{ ok: context.length > 0 }}; }}" + ); + assert!( + code.len() > 20 * 1024, + "oracle must exceed the retired 20 KiB cap" + ); + assert!( + code.len() < MAX_SNIPPET_CODE_BYTES, + "oracle should fit the Code Mode source budget" + ); + assert!(validate_snippet_body("demo", &code).is_ok()); + } + #[test] fn validate_snippet_body_bounds_bare_code_without_fences() { // A bare snippet body (no frontmatter, no fences) is its own code, so @@ -1123,7 +1383,10 @@ mod tests { let small = "async () => ({ ok: true })"; assert!(validate_snippet_body("demo", small).is_ok()); - let big = format!("async () => {{\n{}\nreturn 1;\n}}", "// pad\n".repeat(4096)); + let big = format!( + "async () => {{\n{}\nreturn 1;\n}}", + "x".repeat(MAX_SNIPPET_CODE_BYTES) + ); assert!(big.len() > MAX_SNIPPET_CODE_BYTES); let error = validate_snippet_body("demo", &big) .expect_err("oversized bare code should be rejected"); @@ -1161,6 +1424,29 @@ mod tests { assert!(format!("{error}").contains("snippet file exceeds")); } + #[test] + fn read_resolved_bounds_file_bytes_before_full_read() { + let dir = tempfile::tempdir().expect("temp snippets"); + let path = dir.path().join("demo.js"); + fs::write(&path, vec![b'x'; MAX_SNIPPET_FILE_BYTES + 4096]) + .expect("write oversized fixture"); + + let error = read_resolved("demo", SnippetSource::User, path) + .expect_err("oversized on-disk snippet must fail before a full read"); + assert!(format!("{error}").contains("snippet file exceeds")); + } + + #[test] + fn read_resolved_rejects_non_utf8_snippet_files() { + let dir = tempfile::tempdir().expect("temp snippets"); + let path = dir.path().join("demo.js"); + fs::write(&path, [0xff, 0xfe, 0xfd]).expect("write non-UTF8 fixture"); + + let error = read_resolved("demo", SnippetSource::User, path) + .expect_err("saved snippets are UTF-8 text"); + assert!(format!("{error}").contains("valid UTF-8")); + } + #[test] fn merge_snippet_input_rejects_unknown_declared_inputs() { let body = "---\nname: demo\ndescription: Demo snippet\ninputs:\n host:\n type: string\n default: node-a\n---\n\n```js\nasync (input) => input\n```\n"; diff --git a/crates/labby-codemode/src/snippet/tool_declarations.rs b/crates/labby-codemode/src/snippet/tool_declarations.rs index 691cc5a93..ba832c73b 100644 --- a/crates/labby-codemode/src/snippet/tool_declarations.rs +++ b/crates/labby-codemode/src/snippet/tool_declarations.rs @@ -13,9 +13,10 @@ pub const MAX_DECLARED_TOOLS: usize = 128; /// Bound one exact tool identifier independently of the source-file limit. pub const MAX_DECLARED_TOOL_ID_BYTES: usize = 1_024; -/// Validated descriptive upstream-tool metadata, not an execution restriction. -/// `Some(empty)` expresses an intended deny-all declaration; `None` records no -/// declaration. Neither changes the caller's existing execution policy. +/// Validated exact upstream-tool dependencies for saved snippets. +/// `Some(empty)` expresses deny-all upstream access; `None` records no extra +/// restriction. Host surfaces may intersect this declaration with the caller's +/// existing policy, so it can only narrow authority and never grant it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "Vec")] pub struct SnippetToolDeclarations(Vec); diff --git a/crates/labby-codemode/src/truncate.rs b/crates/labby-codemode/src/truncate.rs index 9107a8f77..23a80e1f5 100644 --- a/crates/labby-codemode/src/truncate.rs +++ b/crates/labby-codemode/src/truncate.rs @@ -31,11 +31,32 @@ pub(crate) fn truncate_execution_response( return response; } - // calls[] carries lightweight metadata only (no result payloads), so there - // is nothing per-call to truncate. Cap the FINAL result first — but only - // when doing so actually shrinks the envelope. The marker has a ~1 KB - // preview, so markering an already-small result (e.g. `{"ok":true}`) - // would *grow* it; in a logs-dominant response the result is innocent and + // Preserve the model-requested final result before optional debug detail. + // High-fan-out executions can make redacted call params dominate an otherwise + // compact response (for example, dozens of SSH command strings). Params are + // explicitly optional trace metadata, so drop them first under envelope + // pressure while preserving every call record and its timing/error fields. + // This keeps a useful compact result from being replaced by a truncation + // marker merely because tracing was enabled. + if response.calls.iter().any(|call| call.params.is_some()) { + for call in &mut response.calls { + call.params = None; + } + if response_within_budget( + &response, + max_response_bytes, + max_response_tokens, + token_estimate_divisor, + ) { + return response; + } + } + + // Cap the FINAL result next, but only when doing so actually shrinks the + // envelope. The marker has a ~1 KB preview, so markering an already-small + // result (e.g. `{"ok":true}`) would *grow* it. In a logs-dominant response + // the result therefore remains intact and the log trimming path below gets + // the next opportunity to reclaim space. if let Some(result) = response.result.as_ref() { let original_len = serde_json::to_string(result).map(|s| s.len()).unwrap_or(0); // Prefer the complete example, then trade preview bytes for guidance. @@ -355,8 +376,9 @@ mod tests { /// FR-5 (issue #210, lab-41e7m.2): the DEFAULT truncation path replaces an /// over-budget result with an OBJECT marker — `truncated: true`, - /// `next_action`, a bounded `preview` — while `calls[]` metadata survives - /// verbatim. Structure must never collapse to a bare string here; the + /// `next_action`, a bounded `preview` — while required `calls[]` metadata + /// survives after optional trace params are shed. Structure must never + /// collapse to a bare string here; the /// string-marker path is `shape.rs`, which only runs under a non-`Off` /// result-shape policy. #[test] @@ -407,9 +429,13 @@ mod tests { marker["preview"].as_str().is_some_and(|s| s.len() <= 1024), "preview is bounded" ); + let mut expected_calls = calls; + for call in &mut expected_calls { + call.params = None; + } assert_eq!( - truncated.calls, calls, - "structured calls[] metadata must survive result truncation" + truncated.calls, expected_calls, + "required calls[] metadata must survive after optional trace params are dropped" ); assert!( truncated.result_shaping.is_none(), @@ -417,6 +443,101 @@ mod tests { ); } + #[test] + fn high_fanout_trace_params_are_dropped_before_compact_result() { + let calls = (0..76) + .map(|i| CodeModeExecutedCall { + id: format!("ssh::{i}"), + ok: true, + elapsed_ms: 25, + start_ms: Some(i * 3), + params: Some(json!({ + "command": format!("ssh host-{i} {}", "x".repeat(2048)), + "timeout": 20_000 + })), + error_kind: None, + ui: None, + }) + .collect::>(); + let expected_result = json!({ + "ok": true, + "artifact": "homelab/docker-inventory.json", + "containers": 138 + }); + let mut response = response_with_logs(expected_result.clone(), Vec::new()); + response.calls = calls; + assert!( + !response_within_budget(&response, 24 * 1024, 6_000, 4), + "oracle must begin over budget" + ); + + let truncated = truncate_execution_response(response, 24 * 1024, 6_000, 4); + + assert_eq!(truncated.result, Some(expected_result)); + assert_eq!(truncated.calls.len(), 76, "call records must survive"); + assert!( + truncated.calls.iter().all(|call| call.params.is_none()), + "optional trace params should be the first pressure valve" + ); + assert!(response_within_budget(&truncated, 24 * 1024, 6_000, 4)); + } + + #[test] + fn high_fanout_trace_params_stay_dropped_when_result_also_needs_truncation() { + let calls = (0..24) + .map(|i| CodeModeExecutedCall { + id: format!("ssh::{i}"), + ok: true, + elapsed_ms: 25, + start_ms: Some(i * 3), + params: Some(json!({ + "command": format!("ssh host-{i} {}", "x".repeat(2048)), + "timeout": 20_000 + })), + error_kind: None, + ui: None, + }) + .collect::>(); + let mut response = + response_with_logs(json!({"rows": vec!["r".repeat(96); 180]}), Vec::new()); + response.calls = calls; + let max_bytes = 12 * 1024; + let max_tokens = 100_000; + let divisor = 4; + assert!( + !response_within_budget(&response, max_bytes, max_tokens, divisor), + "oracle must begin over budget" + ); + let mut without_params = response.clone(); + for call in &mut without_params.calls { + call.params = None; + } + assert!( + !response_within_budget(&without_params, max_bytes, max_tokens, divisor), + "the result must still need truncation after optional trace params are removed" + ); + + let truncated = truncate_execution_response(response, max_bytes, max_tokens, divisor); + + assert_eq!(truncated.calls.len(), 24, "call records must survive"); + assert!( + truncated.calls.iter().all(|call| call.params.is_none()), + "optional trace params must remain dropped while later pressure valves run" + ); + assert_eq!( + truncated + .result + .as_ref() + .and_then(Value::as_object) + .and_then(|marker| marker.get("truncated")), + Some(&json!(true)), + "the independently oversized result should still become a truncation marker" + ); + assert!(response_within_budget( + &truncated, max_bytes, max_tokens, divisor + )); + } + #[test] fn large_log_set_cut_matches_probe_serialized_contract() { // Varied line lengths plus JSON-escaped and multibyte characters so the diff --git a/crates/labby-gateway/src/gateway/code_mode/search.rs b/crates/labby-gateway/src/gateway/code_mode/search.rs index 8a73e5137..604f61c74 100644 --- a/crates/labby-gateway/src/gateway/code_mode/search.rs +++ b/crates/labby-gateway/src/gateway/code_mode/search.rs @@ -131,7 +131,7 @@ pub(crate) async fn build_tools_render( ) -> Result { let raw_tools = if use_cache { manager - .code_mode_catalog_tools_cached(Some(owner), oauth_subject) + .code_mode_catalog_tools_cached_allowed(Some(owner), oauth_subject, allowed_upstreams) .await? } else { manager @@ -152,12 +152,13 @@ pub(crate) async fn build_tools_render( } fn filter_tools_for_access(tools: Vec, scope: &ToolScope) -> Vec { - if !scope.is_read_only() { - return tools; - } tools .into_iter() - .filter(super::code_mode_host::tool_is_explicitly_read_only) + .filter(|tool| { + scope.allows(tool.upstream_name.as_ref(), tool.tool.name.as_ref()) + && (!scope.is_read_only() + || super::code_mode_host::tool_is_explicitly_read_only(tool)) + }) .collect() } @@ -780,6 +781,31 @@ mod tests { ); } + #[test] + fn exact_tool_scope_filters_model_facing_catalog_within_allowed_upstream() { + let named = Arc::::from("fixture"); + let make = |name: &str| UpstreamTool { + tool: rmcp::model::Tool::new( + name.to_string(), + "fixture", + Arc::new(serde_json::Map::new()), + ), + input_schema: None, + output_schema: None, + upstream_name: Arc::clone(&named), + destructive: false, + }; + let scope = ToolScope::scoped_namespaces( + vec!["fixture".to_string()], + vec!["fixture::query".to_string()], + ); + + let filtered = filter_tools_for_access(vec![make("query"), make("mutate")], &scope); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].tool.name.as_ref(), "query"); + } + #[test] fn read_only_catalog_filter_is_fail_closed() { let named = Arc::::from("fixture"); diff --git a/crates/labby-gateway/src/gateway/enrichment/provider.rs b/crates/labby-gateway/src/gateway/enrichment/provider.rs index 0fa8754d6..2c4739300 100644 --- a/crates/labby-gateway/src/gateway/enrichment/provider.rs +++ b/crates/labby-gateway/src/gateway/enrichment/provider.rs @@ -613,6 +613,12 @@ mod tests { use super::*; + // These tests assert provider process behavior, not scheduler latency. A loaded + // workspace shard can delay a newly spawned shell beyond one second, so keep + // classification tests comfortably above that noise floor. Timeout behavior + // itself is covered separately with an intentionally tiny deadline. + const PROCESS_TEST_TIMEOUT_MS: u64 = 5_000; + fn sample_input() -> UpstreamEnrichmentInput { UpstreamEnrichmentInput { name: "github".to_string(), @@ -680,7 +686,7 @@ printf '{"proposals":[{"upstream":"github","hint":"capabilities: repository issu let proposals = run_provider_preview( GatewayEnrichmentProvider::Codex, &[sample_input()], - &runner(script, 1_000, 1_024), + &runner(script, PROCESS_TEST_TIMEOUT_MS, 1_024), ) .await .expect("provider succeeds with isolated environment"); @@ -704,7 +710,7 @@ head -c 256 /dev/zero | tr '\0' x run_provider_preview( GatewayEnrichmentProvider::Codex, &[sample_input()], - &runner(script, 1_000, 64), + &runner(script, PROCESS_TEST_TIMEOUT_MS, 64), ) .await, ); @@ -724,10 +730,7 @@ head -c 256 /dev/zero | tr '\0' x >&2 run_provider_preview( GatewayEnrichmentProvider::Codex, &[sample_input()], - // Process startup approached the one-second deadline under a loaded full-suite - // run. Keep the content cap exact while giving this process-bound assertion a - // deadline that measures output classification rather than scheduler latency. - &runner(script, 5_000, 64), + &runner(script, PROCESS_TEST_TIMEOUT_MS, 64), ) .await, ); @@ -748,7 +751,7 @@ exit 9 run_provider_preview( GatewayEnrichmentProvider::Codex, &[sample_input()], - &runner(script, 1_000, 1_024), + &runner(script, PROCESS_TEST_TIMEOUT_MS, 1_024), ) .await, ); @@ -769,7 +772,7 @@ exit 9 run_provider_preview( GatewayEnrichmentProvider::Codex, &[sample_input()], - &runner(script, 1_000, 1_024), + &runner(script, PROCESS_TEST_TIMEOUT_MS, 1_024), ) .await, ); diff --git a/crates/labby-gateway/src/gateway/manager/code_mode_runtime.rs b/crates/labby-gateway/src/gateway/manager/code_mode_runtime.rs index 88855428c..0d2f7e3a8 100644 --- a/crates/labby-gateway/src/gateway/manager/code_mode_runtime.rs +++ b/crates/labby-gateway/src/gateway/manager/code_mode_runtime.rs @@ -454,6 +454,16 @@ impl GatewayManager { &self, owner: Option<&UpstreamRuntimeOwner>, oauth_subject: Option<&str>, + ) -> Result, ToolError> { + self.code_mode_catalog_tools_cached_allowed(owner, oauth_subject, None) + .await + } + + pub async fn code_mode_catalog_tools_cached_allowed( + &self, + owner: Option<&UpstreamRuntimeOwner>, + oauth_subject: Option<&str>, + allowed_upstreams: Option<&BTreeSet>, ) -> Result, ToolError> { use crate::gateway::code_mode::catalog_cache; @@ -461,6 +471,9 @@ impl GatewayManager { if !cfg.code_mode.enabled { return Ok(Vec::new()); } + if allowed_upstreams.is_some_and(BTreeSet::is_empty) { + return Ok(Vec::new()); + } let cache_path = self.code_mode_catalog_cache_path(); let cache = catalog_cache::CatalogCache::load_from(&cache_path); @@ -474,7 +487,11 @@ impl GatewayManager { // fresh tools are stored under (`None` for subject-scoped OAuth probes, // which are never cached). let mut pending: Vec<(UpstreamConfig, Option)> = Vec::new(); - for upstream in cfg.upstream.iter().filter(|u| u.enabled) { + for upstream in cfg + .upstream + .iter() + .filter(|u| u.enabled && upstream_allowed(&u.name, allowed_upstreams)) + { if upstream.oauth.is_some() { if oauth_subject.is_some() { pending.push((upstream.clone(), None)); @@ -496,6 +513,13 @@ impl GatewayManager { if pending.is_empty() { if !suppressed.is_empty() { if cache_hits == 0 { + tracing::warn!( + surface = "dispatch", + service = "gateway", + action = "code_mode.catalog_cache", + suppressed_upstreams = ?suppressed, + "one-shot Code Mode catalog has no usable upstreams" + ); return Err(ToolError::Sdk { sdk_kind: "upstream_connect_error".to_string(), message: format!( @@ -582,6 +606,7 @@ impl GatewayManager { let mut updates = Vec::new(); let mut connected = 0usize; let mut failures = Vec::new(); + let mut failed_upstream_names = Vec::new(); let mut failed_probes = Vec::new(); let budget_exhausted = loop { match tokio::time::timeout_at(deadline, probes.next()).await { @@ -599,7 +624,7 @@ impl GatewayManager { } Ok(Some((upstream, _, Err(error)))) => { outstanding.remove(&upstream.name); - tracing::warn!( + tracing::debug!( surface = "dispatch", service = "gateway", action = "code_mode.catalog_cache", @@ -608,6 +633,7 @@ impl GatewayManager { "upstream connect failed; omitting from codemode proxy and \ suppressing retries briefly" ); + failed_upstream_names.push(upstream.name.clone()); failures.push(format!("{}: {error}", upstream.name)); // Only this arm is a real failure. The budget-exhausted // paths below are not, and must not be suppressed. @@ -700,6 +726,17 @@ impl GatewayManager { not_attempted.join(", ") )); } + tracing::warn!( + surface = "dispatch", + service = "gateway", + action = "code_mode.catalog_cache", + failed_upstreams = ?failed_upstream_names, + suppressed_upstreams = ?suppressed, + in_flight_upstreams = ?in_flight, + not_attempted_upstreams = ?not_attempted, + budget_ms = budget.as_millis(), + "one-shot Code Mode catalog has no usable upstreams" + ); return Err(ToolError::Sdk { sdk_kind: "upstream_connect_error".to_string(), message: format!( @@ -708,11 +745,20 @@ impl GatewayManager { ), }); } + if !failed_upstream_names.is_empty() { + tracing::warn!( + surface = "dispatch", + service = "gateway", + action = "code_mode.catalog_cache", + failed_upstreams = ?failed_upstream_names, + "one-shot Code Mode catalog is partial because upstream probes failed" + ); + } if !suppressed.is_empty() { warn_suppressed(&suppressed); } if !in_flight.is_empty() || !not_attempted.is_empty() { - tracing::warn!( + tracing::info!( surface = "dispatch", service = "gateway", action = "code_mode.catalog_cache", @@ -1017,16 +1063,17 @@ impl GatewayManager { self.semantic_search_available_locked().await } - /// Record a TEI failure, starting/refreshing the cooldown window. Logs a - /// `tracing::warn!` only on the healthy→failing transition so repeated - /// failures during an active cooldown don't spam the log. + /// Record a TEI failure, starting/refreshing the cooldown window. This is a + /// recovered optional-dependency degradation, so log the healthy→failing + /// transition at INFO; repeated failures during an active cooldown stay + /// silent and normal CLI output is not polluted by a fallback that worked. pub(crate) async fn record_semantic_search_failure(&self, reason: &str) { let mut guard = self.semantic_search_last_failure.write().await; let was_healthy = guard.is_none(); *guard = Some(Instant::now()); drop(guard); if was_healthy { - tracing::warn!( + tracing::info!( surface = "dispatch", service = "code_mode", action = "semantic_search", @@ -1194,7 +1241,7 @@ impl GatewayManager { /// Separate from the budget-exhaustion warning: those upstreams may be perfectly /// healthy and merely slow, while these are known to have failed. fn warn_suppressed(suppressed: &[String]) { - tracing::warn!( + tracing::info!( surface = "dispatch", service = "gateway", action = "code_mode.catalog_cache", diff --git a/crates/labby-gateway/src/gateway/manager/tests/code_mode.rs b/crates/labby-gateway/src/gateway/manager/tests/code_mode.rs index fb055e685..41d36d88e 100644 --- a/crates/labby-gateway/src/gateway/manager/tests/code_mode.rs +++ b/crates/labby-gateway/src/gateway/manager/tests/code_mode.rs @@ -2505,7 +2505,7 @@ fn budget_warning(logs: &str) -> Option<&str> { /// in flight. Only that it never lands in the catalog or the cache matters /// here; the failure-reporting path is pinned by /// `one_shot_cli_catalog_errors_when_every_uncached_upstream_fails_fast`. -#[tokio::test] +#[tokio::test(flavor = "current_thread")] async fn one_shot_cli_catalog_bounds_cold_connects_and_persists_completed_upstreams() { let stalled = stalled_http_upstream("alpha").await; // `fixture_http_upstream` points at 127.0.0.1:9, which nothing listens on. @@ -2562,6 +2562,64 @@ async fn one_shot_cli_catalog_bounds_cold_connects_and_persists_completed_upstre } } +/// A scoped saved snippet must not wake unrelated upstreams on the one-shot +/// cached catalog path. The live catalog already honors `allowed_upstreams`; +/// this pins the CLI/snippet cache path to the same contract. +#[tokio::test] +async fn one_shot_cli_cached_catalog_honors_allowed_upstreams() { + let alpha_responder = OneShotHttpResponder::new("ping", Duration::ZERO); + let omega_responder = OneShotHttpResponder::new("ping", Duration::ZERO); + let (_alpha_server, alpha) = cold_http_upstream("alpha", alpha_responder.clone()).await; + let (_omega_server, omega) = cold_http_upstream("omega", omega_responder.clone()).await; + let cache_dir = tempfile::tempdir().expect("tempdir"); + let (manager, _pool) = one_shot_manager_at( + vec![alpha, omega], + 4_000, + cache_dir.path().join("codemode-catalog.json"), + ) + .await; + let allowed = std::collections::BTreeSet::from(["omega".to_string()]); + + let tools = manager + .code_mode_catalog_tools_cached_allowed(None, None, Some(&allowed)) + .await + .expect("scoped cached catalog"); + + assert_eq!(tool_ids(&tools), vec!["omega::ping"]); + assert_eq!(omega_responder.list_tools_requests(), 1); + assert_eq!( + alpha_responder.list_tools_requests(), + 0, + "unrelated upstream must not be cold-probed for a scoped saved snippet" + ); +} + +#[tokio::test] +async fn one_shot_cli_cached_catalog_accepts_explicit_deny_all_scope() { + let alpha_responder = OneShotHttpResponder::new("ping", Duration::ZERO); + let (_alpha_server, alpha) = cold_http_upstream("alpha", alpha_responder.clone()).await; + let cache_dir = tempfile::tempdir().expect("tempdir"); + let (manager, _pool) = one_shot_manager_at( + vec![alpha], + 4_000, + cache_dir.path().join("codemode-catalog.json"), + ) + .await; + let allowed = std::collections::BTreeSet::new(); + + let tools = manager + .code_mode_catalog_tools_cached_allowed(None, None, Some(&allowed)) + .await + .expect("deny-all scope needs no upstream catalog"); + + assert!(tools.is_empty()); + assert_eq!( + alpha_responder.list_tools_requests(), + 0, + "deny-all scope must not probe any upstream" + ); +} + /// Partial means partial, not empty: when the budget ends before any upstream /// connected and nothing was served from cache, the one-shot catalog is an /// error naming what was still connecting, never a silently empty proxy. @@ -2611,13 +2669,16 @@ async fn one_shot_cli_catalog_errors_when_every_uncached_upstream_fails_fast() { ) .await; - let error = tokio::time::timeout( - BUDGET_GUARD, - manager.code_mode_catalog_tools_cached(None, None), - ) - .await - .expect("connection refused fails fast") - .expect_err("all-failed with an empty cache is an error"); + let (error, logs) = with_captured_logs(|| { + tokio::time::timeout( + BUDGET_GUARD, + manager.code_mode_catalog_tools_cached(None, None), + ) + }) + .await; + let error = error + .expect("connection refused fails fast") + .expect_err("all-failed with an empty cache is an error"); match error { ToolError::Sdk { sdk_kind, message } => { assert_eq!(sdk_kind, "upstream_connect_error"); @@ -2628,6 +2689,13 @@ async fn one_shot_cli_catalog_errors_when_every_uncached_upstream_fails_fast() { } other => panic!("expected upstream_connect_error, got {other:?}"), } + assert_eq!( + logs.matches("one-shot Code Mode catalog has no usable upstreams") + .count(), + 1, + "an all-failed cold catalog must emit one aggregate actionable WARN: {logs}" + ); + assert!(logs.contains("beta") && logs.contains("gamma")); } /// Concurrent probes settle out of order, yet the catalog must follow the @@ -2684,7 +2752,7 @@ async fn one_shot_cli_catalog_keeps_config_order_and_serves_repeat_runs_from_cac /// A cached upstream keeps the run partial rather than failed when a /// straggler misses the budget: the cached tools are served without a /// connect and the straggler is named. -#[tokio::test] +#[tokio::test(flavor = "current_thread")] async fn one_shot_cli_catalog_serves_cached_upstreams_when_a_straggler_misses_the_budget() { let responder = OneShotHttpResponder::new("ping", Duration::ZERO); let (_server, healthy) = cold_http_upstream("omega", responder.clone()).await; @@ -2727,7 +2795,7 @@ async fn one_shot_cli_catalog_serves_cached_upstreams_when_a_straggler_misses_th /// An upstream whose tools landed before the budget ended is connected even if /// the connect's trailing prompt-cache refresh is what the deadline cut off: /// its tools are served and cached, and nothing is reported as unfinished. -#[tokio::test] +#[tokio::test(flavor = "current_thread")] async fn one_shot_cli_catalog_keeps_an_upstream_whose_tools_landed_before_the_cutoff() { let responder = OneShotHttpResponder::new("ping", Duration::ZERO) .with_prompts_delay(Duration::from_mins(2)); @@ -2858,7 +2926,7 @@ async fn one_shot_cli_catalog_names_unattempted_upstreams_when_stalled_probes_fi /// A fresh cache entry with zero tools (a resource- or prompt-only upstream) /// still counts as served from cache: a straggler missing the budget leaves /// the run partial with an empty tool list, not failed. -#[tokio::test] +#[tokio::test(flavor = "current_thread")] async fn one_shot_cli_catalog_treats_a_cached_zero_tool_upstream_as_served() { let quiet = fixture_http_upstream("omega"); let cache_dir = tempfile::tempdir().expect("tempdir"); @@ -2899,7 +2967,7 @@ async fn one_shot_cli_catalog_treats_a_cached_zero_tool_upstream_as_served() { /// `fixture_http_upstream` points at the discard port, so the probe fails fast /// rather than stalling — this is the failure path, distinct from the /// budget-exhaustion paths, and the only one the negative cache may suppress. -#[tokio::test] +#[tokio::test(flavor = "current_thread")] async fn a_failed_probe_is_suppressed_on_the_next_one_shot_run() { let cache_dir = tempfile::tempdir().expect("tempdir"); let cache_path = cache_dir.path().join("codemode-catalog.json"); @@ -2910,10 +2978,9 @@ async fn a_failed_probe_is_suppressed_on_the_next_one_shot_run() { let (manager, _pool) = one_shot_manager_at(vec![dead.clone(), healthy], 4_000, cache_path.clone()).await; - let first = manager - .code_mode_catalog_tools_cached(None, None) - .await - .expect("first run should serve the healthy upstream"); + let (first, first_logs) = + with_captured_logs(|| manager.code_mode_catalog_tools_cached(None, None)).await; + let first = first.expect("first run should serve the healthy upstream"); assert_eq!( first .iter() @@ -2921,6 +2988,18 @@ async fn a_failed_probe_is_suppressed_on_the_next_one_shot_run() { .collect::>(), vec!["ping"] ); + assert!( + first_logs.contains("catalog is partial because upstream probes failed") + && first_logs.contains("dead"), + "partial one-shot catalog must emit one actionable failed-upstream warning: {first_logs}" + ); + assert_eq!( + first_logs + .matches("one-shot Code Mode catalog is partial because upstream probes failed") + .count(), + 1, + "one failed probe plus one healthy upstream must produce exactly one aggregate warning: {first_logs}" + ); // The failure is now on disk, and the healthy upstream is cached, so the // second run must reach neither the network nor the dead upstream. diff --git a/crates/labby-gateway/src/security/spawn_guard.rs b/crates/labby-gateway/src/security/spawn_guard.rs index 08544b74a..2fc52cb72 100644 --- a/crates/labby-gateway/src/security/spawn_guard.rs +++ b/crates/labby-gateway/src/security/spawn_guard.rs @@ -12,6 +12,9 @@ //! - [`DANGEROUS_DOCKER_FLAGS`] / [`DANGEROUS_NODE_FLAGS`] / [`DANGEROUS_BUN_FLAGS`] — argv flags that //! are rejected for the corresponding runtime families. +use std::collections::{BTreeSet, VecDeque}; +use std::sync::{Mutex, OnceLock}; + use labby_runtime::error::ToolError; /// Runtime hints / commands the gateway is allowed to execute as stdio upstreams. @@ -79,7 +82,7 @@ pub const DANGEROUS_PYTHON_FLAGS: &[&str] = &["-c", "--command", "-"]; /// Deno subcommands/flags that eval inline code or grant blanket permissions. pub const DANGEROUS_DENO_FLAGS: &[&str] = &["eval", "--allow-all", "-A"]; -/// Validate that a stdio `command` string is in the runtime-hint allowlist. +/// Configuration rationale for spawn-guard bypass warning deduplication. /// /// This is the primary S1/S6 guard: only known safe runtimes may be persisted /// as the `command` of a stdio upstream. Callers that receive a raw command @@ -96,9 +99,54 @@ pub const DANGEROUS_DENO_FLAGS: &[&str] = &["eval", "--allow-all", "-A"]; /// skip the command allowlist **entirely**. This is a coarse, global escape /// hatch — with it set, `bash`, `/bin/sh -c`, and arbitrary binaries like /// `/tmp/evil` all become spawnable. Prefer `extra_stdio_commands` and leave -/// the guard on. When the bypass is active, every skipped validation emits a -/// `WARN` so the weakened posture is visible in logs. -/// +/// the guard on. When the bypass is active, the first validation of a recently +/// unseen command emits a `WARN`; repeats are suppressed while retained in a +/// fixed-size history, so arbitrary command strings cannot grow process memory +/// without bound. Evicted old commands may warn again. +const SPAWN_GUARD_WARNING_DEDUPE_CAPACITY: usize = 32; + +#[derive(Default)] +struct SpawnGuardWarningDedupe { + seen: BTreeSet, + order: VecDeque, +} + +impl SpawnGuardWarningDedupe { + fn should_warn(&mut self, command: &str) -> bool { + if self.seen.contains(command) { + return false; + } + if self.order.len() == SPAWN_GUARD_WARNING_DEDUPE_CAPACITY + && let Some(evicted) = self.order.pop_front() + { + self.seen.remove(&evicted); + } + let command = command.to_string(); + self.seen.insert(command.clone()); + self.order.push_back(command); + true + } +} + +fn warn_spawn_guard_bypass(command: &str) { + static WARNED_COMMANDS: OnceLock> = OnceLock::new(); + let warned = WARNED_COMMANDS.get_or_init(|| Mutex::new(SpawnGuardWarningDedupe::default())); + let first_for_recent_command = warned + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .should_warn(command); + if first_for_recent_command { + tracing::warn!( + service = "upstream.pool", + command = %command, + "SECURITY: spawn-guard bypass active — command allowlist NOT enforced \ + (disable_spawn_guard = true); prefer scoping with [gateway] extra_stdio_commands; \ + repeated warnings for this recently seen command are suppressed with bounded history" + ); + } +} + +/// Validate that a stdio `command` string is in the runtime-hint allowlist. /// Returns `invalid_param` if the command is not in either allowlist. pub fn validate_stdio_command( command: &str, @@ -106,12 +154,7 @@ pub fn validate_stdio_command( bypass: bool, ) -> Result<(), ToolError> { if bypass { - tracing::warn!( - service = "upstream.pool", - command = %command, - "SECURITY: spawn-guard bypass active — command allowlist NOT enforced \ - (disable_spawn_guard = true); prefer scoping with [gateway] extra_stdio_commands" - ); + warn_spawn_guard_bypass(command); return Ok(()); } @@ -442,10 +485,11 @@ mod tests { .without_time(), ); + let command = "/tmp/spawn-guard-warn-oracle"; { let _guard = tracing::subscriber::set_default(subscriber); // The bypass path must allow the otherwise-rejected command... - assert!(validate_stdio_command("bash", &[], true).is_ok()); + assert!(validate_stdio_command(command, &[], true).is_ok()); } // ...AND emit a WARN documenting the weakened posture (Sec-M2). @@ -459,11 +503,58 @@ mod tests { "WARN must identify the spawn-guard bypass; captured logs: {logs}" ); assert!( - logs.contains("bash"), + logs.contains(command), "WARN should record the bypassed command; captured logs: {logs}" ); } + #[test] + fn command_bypass_warn_is_deduplicated_per_command() { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::{EnvFilter, fmt}; + + let _tracing_lock = TRACING_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let buf = SharedBuf::default(); + let subscriber = tracing_subscriber::registry() + .with(EnvFilter::new("labby_gateway=warn")) + .with( + fmt::layer() + .json() + .with_writer(buf.clone()) + .with_ansi(false) + .without_time(), + ); + let command = "/tmp/spawn-guard-dedupe-oracle"; + + { + let _guard = tracing::subscriber::set_default(subscriber); + assert!(validate_stdio_command(command, &[], true).is_ok()); + assert!(validate_stdio_command(command, &[], true).is_ok()); + assert!(validate_stdio_command(command, &[], true).is_ok()); + } + + let logs = captured_logs(&buf); + assert_eq!( + logs.matches("spawn-guard bypass active").count(), + 1, + "repeat validations of one command must not spam WARN output: {logs}" + ); + } + + #[test] + fn spawn_guard_warning_dedupe_stays_bounded_under_unique_commands() { + let mut dedupe = SpawnGuardWarningDedupe::default(); + for index in 0..(SPAWN_GUARD_WARNING_DEDUPE_CAPACITY * 4) { + assert!(dedupe.should_warn(&format!("/tmp/unique-{index}"))); + assert!(dedupe.order.len() <= SPAWN_GUARD_WARNING_DEDUPE_CAPACITY); + } + assert_eq!(dedupe.order.len(), SPAWN_GUARD_WARNING_DEDUPE_CAPACITY); + assert!( + dedupe.should_warn("/tmp/unique-0"), + "an evicted old command may warn again, keeping memory bounded instead of remembering arbitrary input forever" + ); + } + #[test] fn command_no_bypass_emits_no_warn() { use tracing_subscriber::layer::SubscriberExt; diff --git a/crates/labby-gateway/src/upstream/pool.rs b/crates/labby-gateway/src/upstream/pool.rs index 729594c6e..84f6659d6 100644 --- a/crates/labby-gateway/src/upstream/pool.rs +++ b/crates/labby-gateway/src/upstream/pool.rs @@ -10,6 +10,8 @@ use std::sync::{ }; use dashmap::DashMap; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; use std::time::{Duration, Instant}; use arc_swap::ArcSwap; @@ -419,6 +421,12 @@ pub struct UpstreamPool { /// Shared per-upstream SEP-2243 recovery metrics. Gateway-managed pools /// inherit one process-lifetime store across pool replacement. header_recovery_metrics_store: HeaderRecoveryMetricsStore, + /// Test-local serialization counters live on the pool so parallel catalog + /// tests cannot contaminate each other's measurement oracles. + #[cfg(test)] + pub(super) merged_prompt_measurements: Arc, + #[cfg(test)] + pub(super) merged_resource_measurements: Arc, } /// Type-erased-over-lifecycle running client service. @@ -635,6 +643,10 @@ impl UpstreamPool { shared_http_client, usage_store: None, header_recovery_metrics_store: HeaderRecoveryMetricsStore::default(), + #[cfg(test)] + merged_prompt_measurements: Arc::new(AtomicUsize::new(0)), + #[cfg(test)] + merged_resource_measurements: Arc::new(AtomicUsize::new(0)), } } diff --git a/crates/labby-gateway/src/upstream/pool/connect.rs b/crates/labby-gateway/src/upstream/pool/connect.rs index 8759f8b64..a6ce3402d 100644 --- a/crates/labby-gateway/src/upstream/pool/connect.rs +++ b/crates/labby-gateway/src/upstream/pool/connect.rs @@ -450,7 +450,7 @@ pub(super) async fn connect_upstream_with_handler_and_notifications tracing::warn!( + Err(error) => tracing::info!( surface = "dispatch", service = "upstream.pool", action = "upstream.connect", diff --git a/crates/labby-gateway/src/upstream/pool/lifecycle_compat.rs b/crates/labby-gateway/src/upstream/pool/lifecycle_compat.rs index 8a27682e2..f36291f7a 100644 --- a/crates/labby-gateway/src/upstream/pool/lifecycle_compat.rs +++ b/crates/labby-gateway/src/upstream/pool/lifecycle_compat.rs @@ -173,7 +173,7 @@ pub(super) fn log_fallback( attempt: LifecycleAttempt, error: &anyhow::Error, ) { - tracing::warn!( + tracing::info!( surface = "dispatch", service = "upstream.pool", action = "upstream.lifecycle.fallback", diff --git a/crates/labby-gateway/src/upstream/pool/probe.rs b/crates/labby-gateway/src/upstream/pool/probe.rs index c11cc0c49..1e5e78fab 100644 --- a/crates/labby-gateway/src/upstream/pool/probe.rs +++ b/crates/labby-gateway/src/upstream/pool/probe.rs @@ -270,7 +270,10 @@ impl UpstreamPool { started: Instant, ) -> Heartbeat { let Some(observed) = self.observe_connection_catalog_entry(&config.name).await else { - tracing::warn!( + // Cold one-shot catalog construction legitimately reprobes before a + // connection exists, then immediately falls through to reconnect. + // That is expected control flow, not an operator-actionable failure. + tracing::info!( surface = "dispatch", service = "upstream.pool", action = "upstream.reprobe", @@ -280,7 +283,7 @@ impl UpstreamPool { transport = upstream_transport(config), elapsed_ms = started.elapsed().as_millis(), kind = "upstream_not_connected", - "upstream reprobe found no existing connection" + "upstream reprobe found no existing connection; reconnecting" ); return Heartbeat::Reconnect { previous: None }; }; diff --git a/crates/labby-gateway/src/upstream/pool/prompts_list.rs b/crates/labby-gateway/src/upstream/pool/prompts_list.rs index 97d912c2f..7d5c3d05c 100644 --- a/crates/labby-gateway/src/upstream/pool/prompts_list.rs +++ b/crates/labby-gateway/src/upstream/pool/prompts_list.rs @@ -21,12 +21,6 @@ use super::helpers::merge_upstream_prompts; use super::logging::is_capability_unsupported; use super::tools::MAX_UPSTREAM_PROMPTS; -/// Number of prompt serializations performed while bounding the merged -/// envelope; tests assert each prompt is measured once. -#[cfg(test)] -pub(super) static MERGED_PROMPT_MEASUREMENTS: std::sync::atomic::AtomicUsize = - std::sync::atomic::AtomicUsize::new(0); - /// One regular non-OAuth upstream Prompt with exact pre-namespace provenance. /// This is observational listing metadata, not prompt execution authority. #[derive(Clone, Debug, PartialEq)] @@ -224,7 +218,8 @@ impl UpstreamPool { super::helpers::max_response_bytes(), |prompt| { #[cfg(test)] - MERGED_PROMPT_MEASUREMENTS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.merged_prompt_measurements + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); serde_json::to_vec(prompt).map_or(usize::MAX, |body| body.len() + 1) }, ); @@ -519,12 +514,12 @@ mod tests { for index in 1..5 { attach_prompt_server(&pool, &format!("many-{index}"), ManyPromptsServer).await; } - MERGED_PROMPT_MEASUREMENTS.store(0, Ordering::SeqCst); + pool.merged_prompt_measurements.store(0, Ordering::SeqCst); let prompts = pool.list_upstream_prompts(&[]).await; assert_eq!(prompts.len(), 3000.min(MAX_UPSTREAM_PROMPTS)); - let measurements = MERGED_PROMPT_MEASUREMENTS.load(Ordering::SeqCst); + let measurements = pool.merged_prompt_measurements.load(Ordering::SeqCst); assert!( measurements <= 3000, "each prompt must be measured once while bounding the merged envelope; measured {measurements} times" diff --git a/crates/labby-gateway/src/upstream/pool/resources_list.rs b/crates/labby-gateway/src/upstream/pool/resources_list.rs index 4502177d8..16f7a4c7d 100644 --- a/crates/labby-gateway/src/upstream/pool/resources_list.rs +++ b/crates/labby-gateway/src/upstream/pool/resources_list.rs @@ -46,12 +46,6 @@ use super::tools::MAX_UPSTREAM_RESOURCES; /// stalls every queued OAuth writer behind one slow upstream. const CATALOG_LISTING_TIMEOUT: Duration = Duration::from_secs(10); -/// Number of resource serializations performed while bounding the merged -/// envelope; tests assert each resource is measured once. -#[cfg(test)] -pub(super) static MERGED_RESOURCE_MEASUREMENTS: std::sync::atomic::AtomicUsize = - std::sync::atomic::AtomicUsize::new(0); - /// One regular upstream Resource with its exact pre-rewrite provenance. /// /// This is observational listing metadata, not read authority or a grant. @@ -515,7 +509,8 @@ impl UpstreamPool { let mut bytes = 2usize; resources.retain(|item| { #[cfg(test)] - MERGED_RESOURCE_MEASUREMENTS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.merged_resource_measurements + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); bytes = bytes.saturating_add( serde_json::to_vec(&item.resource).map_or(usize::MAX, |body| body.len() + 1), ); @@ -1057,12 +1052,12 @@ mod tests { .expect("connection identity"); pool.resource_upstreams.write().await.push(name); } - MERGED_RESOURCE_MEASUREMENTS.store(0, Ordering::SeqCst); + pool.merged_resource_measurements.store(0, Ordering::SeqCst); let resources = pool.list_upstream_resources_allowed(None).await; assert_eq!(resources.len(), 3000.min(MAX_UPSTREAM_RESOURCES)); - let measurements = MERGED_RESOURCE_MEASUREMENTS.load(Ordering::SeqCst); + let measurements = pool.merged_resource_measurements.load(Ordering::SeqCst); assert!( measurements <= 3000, "each resource must be measured once while bounding the merged envelope; measured {measurements} times" diff --git a/crates/labby-gateway/src/upstream/pool/stdio_transport.rs b/crates/labby-gateway/src/upstream/pool/stdio_transport.rs index 81534fef9..3d01ee2f5 100644 --- a/crates/labby-gateway/src/upstream/pool/stdio_transport.rs +++ b/crates/labby-gateway/src/upstream/pool/stdio_transport.rs @@ -232,6 +232,18 @@ fn exit_signal(_status: Option<&ExitStatus>) -> Option { None } +const fn termination_is_clean( + expected: bool, + wait_error_present: bool, + invalidated_count: usize, +) -> bool { + // Owner-initiated shutdown is expected even when the child reports a + // signal/non-zero status: process wrappers may terminate the child while + // dropping its transport. What remains actionable is a teardown/reap + // failure or a request that was still in flight. + expected && !wait_error_present && invalidated_count == 0 +} + async fn log_termination( upstream: String, generation: u64, @@ -249,7 +261,7 @@ async fn log_termination( let success = status.is_some_and(ExitStatus::success); let invalidated_count = invalidated_requests.len(); - if expected && success && invalidated_count == 0 { + if termination_is_clean(expected, exit.wait_error.is_some(), invalidated_count) { tracing::info!( surface = "dispatch", service = "upstream.pool", @@ -261,10 +273,12 @@ async fn log_termination( expected, exit_code = ?code, exit_signal = ?signal, + wait_error = exit.wait_error.as_deref(), killed_after_timeout = exit.killed_after_timeout, invalidated_count, stderr_tail = %stderr_tail, - "stdio upstream child terminated" + success, + "stdio upstream child terminated cleanly" ); } else { tracing::warn!( @@ -283,7 +297,8 @@ async fn log_termination( invalidated_count, invalidated_requests = ?invalidated_requests, stderr_tail = %stderr_tail, - "stdio upstream child terminated with affected requests" + success, + "stdio upstream child terminated unexpectedly or with affected requests" ); } } @@ -411,8 +426,11 @@ impl Drop for DiagnosticChildTransport { upstream, generation, pid, + // Dropping the transport is owner-initiated teardown. A child that + // exits on its own is observed by `receive*` as `transport_eof` + // before Drop and remains unexpected. "transport_drop", - false, + true, diagnostics, invalidated, exit, @@ -468,6 +486,20 @@ mod tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + #[test] + fn termination_severity_distinguishes_expected_teardown_from_unexpected_exit() { + assert!( + termination_is_clean(true, false, 0), + "owner-initiated teardown with no stranded requests is expected even when the child is terminated by the transport owner" + ); + assert!( + !termination_is_clean(true, true, 0), + "a teardown/reap error remains actionable even during expected shutdown" + ); + assert!(!termination_is_clean(false, false, 0)); + assert!(!termination_is_clean(true, false, 1)); + } + #[test] fn inflight_registry_is_scoped_to_connection_generation() { let upstream = format!( diff --git a/crates/labby-runtime/src/gateway_config.rs b/crates/labby-runtime/src/gateway_config.rs index a24aff90d..3ff030458 100644 --- a/crates/labby-runtime/src/gateway_config.rs +++ b/crates/labby-runtime/src/gateway_config.rs @@ -57,7 +57,7 @@ fn default_code_mode_timeout_ms() -> u64 { } fn default_code_mode_max_source_bytes() -> usize { - 128 * 1024 + 1024 * 1024 } fn default_code_mode_max_response_bytes() -> usize { diff --git a/crates/labby/src/api/services/snippets.rs b/crates/labby/src/api/services/snippets.rs index 041f14a1d..084a979df 100644 --- a/crates/labby/src/api/services/snippets.rs +++ b/crates/labby/src/api/services/snippets.rs @@ -77,7 +77,19 @@ async fn handle( let request_id = headers.get("x-request-id").and_then(|v| v.to_str().ok()); require_snippets_admin(&req.action, request_id, auth.as_ref())?; let manager = state.gateway_manager.clone(); - let promotion_context = crate::dispatch::snippets::dispatch::SnippetPromotionContext { + let execution_caller = auth.as_ref().map_or( + crate::dispatch::gateway::code_mode::CodeModeCaller::TrustedLocal, + |value| crate::dispatch::gateway::code_mode::CodeModeCaller::Scoped { + capabilities: labby_codemode::CodeModeCallerCapabilities { + can_read: true, + can_execute: true, + can_use_snippets: true, + is_admin: true, + }, + sub: Some(value.0.sub.clone()), + }, + ); + let dispatch_context = crate::dispatch::snippets::dispatch::SnippetDispatchContext { actor_key: auth .as_ref() .and_then(|value| value.0.actor_key.as_deref()) @@ -86,6 +98,9 @@ async fn handle( route_scope: "root".to_string(), capability_filter_fingerprint: crate::dispatch::gateway::code_mode::ToolScope::default() .fingerprint(), + execution_scope: crate::dispatch::gateway::code_mode::ToolScope::default(), + execution_caller, + execution_surface: crate::dispatch::gateway::code_mode::CodeModeSurface::Api, }; handle_action_with_meta( @@ -106,13 +121,11 @@ async fn handle( let manager = manager .as_ref() .ok_or_else(|| ToolError::internal_message("gateway manager not wired"))?; - let promotion_context = - (action == "snippets.promote").then(|| promotion_context.clone()); return crate::dispatch::snippets::dispatch::dispatch_with_manager_and_context( manager, &action, params, - promotion_context, + Some(dispatch_context.clone()), ) .await; } diff --git a/crates/labby/src/cli/gateway/code.rs b/crates/labby/src/cli/gateway/code.rs index bec5819b2..d3ff5b1f7 100644 --- a/crates/labby/src/cli/gateway/code.rs +++ b/crates/labby/src/cli/gateway/code.rs @@ -177,9 +177,6 @@ mod tests { #[test] fn cli_source_read_uses_shared_hard_ceiling() { let max_source_bytes = labby_codemode::MAX_SOURCE_BYTES; - let above_default = - "a".repeat(crate::config::CodeModeConfig::default().max_source_bytes + 1); - assert!(read_code_mode_source(Some(above_default), None, max_source_bytes as u64).is_ok()); let at_limit = "a".repeat(max_source_bytes); assert!(read_code_mode_source(Some(at_limit), None, max_source_bytes as u64).is_ok()); diff --git a/crates/labby/src/config.rs b/crates/labby/src/config.rs index 00f503a79..fab0ca6f9 100644 --- a/crates/labby/src/config.rs +++ b/crates/labby/src/config.rs @@ -4625,7 +4625,7 @@ url = "https://acme.example.com/mcp" fn code_mode_is_root_level_config_with_default_limits() { let default_cfg = LabConfig::default(); assert_eq!(default_cfg.code_mode.timeout_ms, 30_000); - assert_eq!(default_cfg.code_mode.max_source_bytes, 128 * 1024); + assert_eq!(default_cfg.code_mode.max_source_bytes, 1024 * 1024); assert_eq!(default_cfg.code_mode.max_response_bytes, 24 * 1024); assert_eq!(default_cfg.code_mode.max_response_tokens, 6000); diff --git a/crates/labby/src/dispatch/setup/settings.rs b/crates/labby/src/dispatch/setup/settings.rs index 6231171fd..405290adc 100644 --- a/crates/labby/src/dispatch/setup/settings.rs +++ b/crates/labby/src/dispatch/setup/settings.rs @@ -777,7 +777,7 @@ pub fn settings_fields() -> Vec { SettingsApplyMode::Partial, 1024, 1_048_576, - Some("131072"), + Some("1048576"), ), number_editable( "advanced", @@ -1941,7 +1941,7 @@ mod tests { assert_eq!(field.apply_mode, SettingsApplyMode::Partial); assert_eq!(field.min, Some(1024)); assert_eq!(field.max, Some(1_048_576)); - assert_eq!(field.example, Some("131072")); + assert_eq!(field.example, Some("1048576")); } #[test] diff --git a/crates/labby/src/dispatch/snippets/dispatch.rs b/crates/labby/src/dispatch/snippets/dispatch.rs index ada316866..64b0d344a 100644 --- a/crates/labby/src/dispatch/snippets/dispatch.rs +++ b/crates/labby/src/dispatch/snippets/dispatch.rs @@ -6,7 +6,7 @@ use crate::dispatch::gateway::code_mode::{ CodeModeBroker, CodeModeCaller, CodeModeSourceLookup, CodeModeSurface, ToolScope, }; use crate::dispatch::helpers::{action_schema, help_payload, lab_home, require_str, to_json}; -use labby_codemode::CodeModeExecutionResponse; +use labby_codemode::{CodeModeExecutionResponse, MAX_SOURCE_BYTES}; use super::catalog::ACTIONS; use super::store::{ @@ -51,11 +51,19 @@ struct PromoteParams { } #[derive(Debug, Clone)] -pub struct SnippetPromotionContext { +pub struct SnippetDispatchContext { pub actor_key: Option, pub is_admin: bool, pub route_scope: String, pub capability_filter_fingerprint: String, + /// Caller/route authority inherited by saved snippet execution. A snippet's + /// own declaration may only narrow this scope, never widen it. + pub execution_scope: ToolScope, + /// Preserve the initiating surface's identity/capabilities all the way into + /// Code Mode. Treating remote snippet calls as trusted-local would erase + /// runtime ownership and OAuth-subject semantics. + pub execution_caller: CodeModeCaller, + pub execution_surface: CodeModeSurface, } struct SnippetExecutionOutcome { @@ -63,7 +71,7 @@ struct SnippetExecutionOutcome { display_response: CodeModeExecutionResponse, } -impl SnippetPromotionContext { +impl SnippetDispatchContext { #[must_use] pub fn trusted_local() -> Self { Self { @@ -71,6 +79,9 @@ impl SnippetPromotionContext { is_admin: true, route_scope: "root".to_string(), capability_filter_fingerprint: ToolScope::default().fingerprint(), + execution_scope: ToolScope::default(), + execution_caller: CodeModeCaller::TrustedLocal, + execution_surface: CodeModeSurface::Cli, } } } @@ -84,17 +95,29 @@ pub async fn dispatch_with_manager_and_context( manager: &crate::dispatch::gateway::manager::GatewayManager, action: &str, params: Value, - promotion_context: Option, + dispatch_context: Option, ) -> Result { - dispatch_inner(Some(manager), action, params, promotion_context).await + dispatch_inner(Some(manager), action, params, dispatch_context).await } async fn dispatch_inner( manager: Option<&crate::dispatch::gateway::manager::GatewayManager>, action: &str, params: Value, - promotion_context: Option, + dispatch_context: Option, ) -> Result { + let execution_scope = dispatch_context + .as_ref() + .map(|context| context.execution_scope.clone()) + .unwrap_or_default(); + let execution_caller = dispatch_context + .as_ref() + .map(|context| context.execution_caller.clone()) + .unwrap_or(CodeModeCaller::TrustedLocal); + let execution_surface = dispatch_context + .as_ref() + .map(|context| context.execution_surface) + .unwrap_or(CodeModeSurface::Cli); match action { "help" => Ok(help_payload("snippets", ACTIONS)), "schema" => { @@ -121,7 +144,7 @@ async fn dispatch_inner( } "snippets.promote" => { let params: PromoteParams = parse_params(params)?; - promote_snippet(manager, params, promotion_context).await + promote_snippet(manager, params, dispatch_context).await } "snippets.validate" => { let params: ValidateParams = parse_params(params)?; @@ -140,13 +163,27 @@ async fn dispatch_inner( let Some(name) = params.name else { return Err(missing_param("missing required parameter `name`", "name")); }; - let outcome = execute_snippet_outcome(manager, &name, params.params).await?; + let outcome = execute_snippet_outcome( + manager, + &name, + params.params, + &execution_scope, + &execution_caller, + execution_surface, + ) + .await?; to_json(outcome.display_response) } "snippets.test" => { let params: ExecParams = parse_params(params)?; if params.all { - return test_all_snippets(manager).await; + return test_all_snippets( + manager, + &execution_scope, + &execution_caller, + execution_surface, + ) + .await; } let Some(name) = params.name else { return Err(missing_param( @@ -154,7 +191,15 @@ async fn dispatch_inner( "name", )); }; - let outcome = execute_snippet_outcome(manager, &name, params.params).await?; + let outcome = execute_snippet_outcome( + manager, + &name, + params.params, + &execution_scope, + &execution_caller, + execution_surface, + ) + .await?; snippet_test_result(name, outcome) } unknown => Err(ToolError::UnknownAction { @@ -168,14 +213,14 @@ async fn dispatch_inner( async fn promote_snippet( manager: Option<&crate::dispatch::gateway::manager::GatewayManager>, params: PromoteParams, - promotion_context: Option, + dispatch_context: Option, ) -> Result { validate_snippet_name(¶ms.name)?; let manager = manager.ok_or_else(|| ToolError::Sdk { sdk_kind: "gateway_unavailable".to_string(), message: "snippets.promote requires the live gateway manager source store".to_string(), })?; - let context = promotion_context.unwrap_or_else(SnippetPromotionContext::trusted_local); + let context = dispatch_context.unwrap_or_else(SnippetDispatchContext::trusted_local); let source = manager .resolve_code_mode_source( ¶ms.execution_id, @@ -244,12 +289,22 @@ fn validate_snippet(name: Option<&str>, body: Option<&str>) -> Result, + caller_scope: &ToolScope, + caller: &CodeModeCaller, + surface: CodeModeSurface, ) -> Result { let snippets = list_snippets(&lab_home(), &builtin_snippet_dir())?; let mut results = Vec::with_capacity(snippets.len()); for snippet in snippets { - match execute_snippet_outcome(manager, &snippet.name, Value::Object(Default::default())) - .await + match execute_snippet_outcome( + manager, + &snippet.name, + Value::Object(Default::default()), + caller_scope, + caller, + surface, + ) + .await { Ok(outcome) => { let passed = snippet_response_passed(&outcome.raw_response); @@ -298,10 +353,24 @@ fn snippet_test_result(name: String, outcome: SnippetExecutionOutcome) -> Result })) } +fn snippet_execution_scope( + snippet: &super::store::ResolvedSnippet, + caller_scope: &ToolScope, +) -> ToolScope { + snippet + .tools + .as_ref() + .map(|declared| declared.intersect(caller_scope)) + .unwrap_or_else(|| caller_scope.clone()) +} + async fn execute_snippet_outcome( manager: Option<&crate::dispatch::gateway::manager::GatewayManager>, name: &str, input: Value, + caller_scope: &ToolScope, + caller: &CodeModeCaller, + surface: CodeModeSurface, ) -> Result { let owned_manager; let manager = if let Some(manager) = manager { @@ -315,16 +384,22 @@ async fn execute_snippet_outcome( let snippet = resolve_snippet(&lab_home(), &builtin_snippet_dir(), name)?; let code = code_for_snippet(&snippet)?; let input = merge_snippet_input(&snippet, input)?; - let code = wrap_snippet_with_input(&code, &input)?; + let max_source_bytes = config.max_source_bytes.min(MAX_SOURCE_BYTES); + let code = wrap_snippet_with_input_bounded(&code, &input, max_source_bytes)?; + // Saved snippet source stays entirely on the execution plane. When the + // snippet declares exact upstream dependencies, use that declaration to + // narrow the caller/route authority instead of cold-probing every configured + // upstream. Snippets without declarations inherit the caller scope exactly. + let scope = snippet_execution_scope(&snippet, caller_scope); let outcome = broker .execute_with_raw_response( &code, - CodeModeCaller::TrustedLocal, - CodeModeSurface::Cli, + caller.clone(), + surface, config, - ToolScope::default(), - // Snippet execution is a local trusted-CLI path with no durable-run - // execution id; `None` keeps `record_step` write-free here. + scope, + // Saved-snippet dispatch does not mint a durable execution id on + // this path; `None` keeps `record_step` write-free here. None, ) .await @@ -342,14 +417,28 @@ async fn execute_snippet_outcome( }) } -fn wrap_snippet_with_input(code: &str, input: &Value) -> Result { +fn wrap_snippet_with_input_bounded( + code: &str, + input: &Value, + max_source_bytes: usize, +) -> Result { let input = serde_json::to_string(input).map_err(|e| ToolError::InvalidParam { message: format!("snippet params must be JSON-serializable: {e}"), param: "params".to_string(), })?; - Ok(format!( + let wrapped = format!( "async () => {{\n const __labSnippetInput = {input};\n return await ({code})(__labSnippetInput);\n}}" - )) + ); + if wrapped.len() > max_source_bytes { + return Err(ToolError::InvalidParam { + message: format!( + "saved snippet invocation exceeds Code Mode source limit {max_source_bytes} bytes after serializing params ({} bytes)", + wrapped.len() + ), + param: "params".to_string(), + }); + } + Ok(wrapped) } fn parse_params(params: Value) -> Result { @@ -405,6 +494,90 @@ mod tests { } } + fn resolved_snippet_with_tools( + tools: Option>, + ) -> crate::dispatch::snippets::store::ResolvedSnippet { + use std::collections::BTreeMap; + use std::path::PathBuf; + + use crate::dispatch::snippets::store::{ResolvedSnippet, SnippetSource}; + use labby_codemode::snippet::tool_declarations::SnippetToolDeclarations; + + ResolvedSnippet { + tools: tools.map(|tools| { + SnippetToolDeclarations::try_from( + tools.into_iter().map(ToOwned::to_owned).collect::>(), + ) + .expect("valid exact tool declarations") + }), + name: "scoped".to_string(), + description: None, + tags: Vec::new(), + inputs: BTreeMap::new(), + source: SnippetSource::User, + path: PathBuf::from("scoped.md"), + body: "async () => ({ ok: true })".to_string(), + } + } + + #[test] + fn saved_snippet_tool_declarations_narrow_but_never_widen_route_authority() { + let snippet = resolved_snippet_with_tools(Some(vec!["alpha::tool1", "beta::tool2"])); + let caller_scope = ToolScope::scoped_namespaces(vec!["alpha".to_string()], Vec::new()); + + let scope = snippet_execution_scope(&snippet, &caller_scope); + + assert!(scope.is_scoped()); + assert!(scope.allows("alpha", "tool1")); + assert!( + !scope.allows("alpha", "other_tool"), + "an exact snippet declaration must deny undeclared siblings on an allowed upstream" + ); + assert!( + !scope.allows("beta", "tool2"), + "a snippet declaration must never restore an upstream removed by the route" + ); + } + + #[test] + fn saved_snippet_without_declarations_inherits_route_scope_exactly() { + let snippet = resolved_snippet_with_tools(None); + let caller_scope = ToolScope::scoped_namespaces(vec!["alpha".to_string()], Vec::new()); + + let scope = snippet_execution_scope(&snippet, &caller_scope); + + assert_eq!(scope, caller_scope); + assert!(scope.allows("alpha", "other_tool")); + assert!(!scope.allows("beta", "tool2")); + } + + #[test] + fn trusted_local_saved_snippet_retains_declared_exact_tool_scope() { + let snippet = + resolved_snippet_with_tools(Some(vec!["claude-macpoo::Bash", "claude-macpoo::Read"])); + + let scope = snippet_execution_scope(&snippet, &ToolScope::default()); + + assert!(scope.is_scoped()); + assert!(scope.allows("claude-macpoo", "Bash")); + assert!(scope.allows("claude-macpoo", "Read")); + assert!(!scope.allows("github", "search_issues")); + } + + #[test] + fn saved_snippet_invocation_checks_final_wrapped_source_size() { + let code = "async () => ({ ok: true })"; + let input = json!({ "payload": "x".repeat(256) }); + + let error = wrap_snippet_with_input_bounded(code, &input, 128) + .expect_err("serialized params must count toward the runtime source limit"); + + assert_eq!(error.kind(), "invalid_param"); + let message = format!("{error}"); + assert!(message.contains("saved snippet invocation")); + assert!(message.contains("128")); + } + #[test] fn snippets_test_uses_raw_result_for_pass_fail_and_returns_shaped_display() { let pass = snippet_test_result( diff --git a/crates/labby/src/mcp/call_tool.rs b/crates/labby/src/mcp/call_tool.rs index a7d703fa8..e28b79943 100644 --- a/crates/labby/src/mcp/call_tool.rs +++ b/crates/labby/src/mcp/call_tool.rs @@ -1774,11 +1774,14 @@ impl LabMcpServer { unreachable!("Depot publishing is gateway-only") } else if cfg!(feature = "gateway") && service == "snippets" - && action == "snippets.promote" + && matches!( + action.as_str(), + "snippets.exec" | "snippets.test" | "snippets.promote" + ) { #[cfg(feature = "gateway")] return self - .call_snippets_promote_impl( + .call_snippets_contextual_impl( &action, params, &args, start, &subject, actor_key, &context, ) .await diff --git a/crates/labby/src/mcp/call_tool_codemode.rs b/crates/labby/src/mcp/call_tool_codemode.rs index 1bb499489..1fe44791c 100644 --- a/crates/labby/src/mcp/call_tool_codemode.rs +++ b/crates/labby/src/mcp/call_tool_codemode.rs @@ -960,7 +960,7 @@ impl LabMcpServer { } } -fn code_mode_capabilities_for_scopes(scopes: &[String]) -> CodeModeCallerCapabilities { +pub(crate) fn code_mode_capabilities_for_scopes(scopes: &[String]) -> CodeModeCallerCapabilities { let is_admin = scopes.iter().any(|scope| scope == "lab:admin"); CodeModeCallerCapabilities { can_read: scopes diff --git a/crates/labby/src/mcp/services/snippets.rs b/crates/labby/src/mcp/services/snippets.rs index a756f052d..6617838e5 100644 --- a/crates/labby/src/mcp/services/snippets.rs +++ b/crates/labby/src/mcp/services/snippets.rs @@ -1,9 +1,8 @@ //! MCP adapter for snippets-specific request context. //! -//! Normal snippets operations dispatch through `crate::dispatch::snippets`. -//! Promotion is the MCP-specific exception because it must resolve the live -//! gateway manager source store against the caller actor, admin state, route -//! scope, and route-scoped capability filter. +//! Saved snippet execution and promotion must preserve the MCP route authority. +//! This adapter threads the live gateway manager plus the route-derived Code Mode +//! capability scope into dispatch so snippets can only narrow caller authority. use std::time::Instant; @@ -21,8 +20,44 @@ use crate::mcp::result_format::{ }; use crate::mcp::server::LabMcpServer; +fn execution_scope_for_route( + route_scope: &crate::mcp::route_scope::McpRouteScope, +) -> crate::dispatch::gateway::code_mode::ToolScope { + route_scope + .allowed_upstreams() + .map(|allowed| { + crate::dispatch::gateway::code_mode::ToolScope::scoped_namespaces( + allowed.iter().cloned().collect(), + Vec::new(), + ) + }) + .unwrap_or_default() +} + +fn execution_caller_for_mcp( + scopes: Option<&[String]>, + sub: Option, + host_provider: Option<(&str, &str)>, +) -> crate::dispatch::gateway::code_mode::CodeModeCaller { + let Some(scopes) = scopes else { + return crate::dispatch::gateway::code_mode::CodeModeCaller::TrustedLocal; + }; + let capabilities = super::super::call_tool_codemode::code_mode_capabilities_for_scopes(scopes); + match host_provider { + Some((provider_token, provider_request_id)) => { + crate::dispatch::gateway::code_mode::CodeModeCaller::ScopedHostProvider { + capabilities, + sub, + provider_token: provider_token.to_string(), + provider_request_id: provider_request_id.to_string(), + } + } + None => crate::dispatch::gateway::code_mode::CodeModeCaller::Scoped { capabilities, sub }, + } +} + impl LabMcpServer { - pub(crate) async fn call_snippets_promote_impl( + pub(crate) async fn call_snippets_contextual_impl( &self, action: &str, params: Value, @@ -44,31 +79,34 @@ impl LabMcpServer { }; let auth = auth_context_from_extensions(&context.extensions); - let capability_filter_fingerprint = self - .route_scope - .allowed_upstreams() - .map(|allowed| { - crate::dispatch::gateway::code_mode::ToolScope::scoped_namespaces( - allowed.iter().cloned().collect(), - Vec::new(), - ) - .fingerprint() - }) - .unwrap_or_else(|| { - crate::dispatch::gateway::code_mode::ToolScope::default().fingerprint() - }); - let promotion_context = crate::dispatch::snippets::dispatch::SnippetPromotionContext { + let execution_scope = execution_scope_for_route(&self.route_scope); + let capability_filter_fingerprint = execution_scope.fingerprint(); + let sub = self + .route_oauth_subject( + self.request_subject(context) + .map(std::borrow::Cow::Borrowed), + ) + .map(std::borrow::Cow::into_owned); + let host_provider = self + .request_host_provider_token(context) + .zip(self.request_host_provider_request_id(context)); + let execution_caller = + execution_caller_for_mcp(auth.map(|auth| auth.scopes.as_slice()), sub, host_provider); + let dispatch_context = crate::dispatch::snippets::dispatch::SnippetDispatchContext { actor_key: actor_key.map(ToOwned::to_owned), is_admin: auth.is_none_or(|auth| auth.scopes.iter().any(|scope| scope == "lab:admin")), route_scope: self.route_scope.label(), capability_filter_fingerprint, + execution_scope, + execution_caller, + execution_surface: crate::dispatch::gateway::code_mode::CodeModeSurface::Mcp, }; let result = crate::dispatch::snippets::dispatch::dispatch_with_manager_and_context( manager, action, params, - Some(promotion_context), + Some(dispatch_context), ) .await .map_err(|te| anyhow::Error::from(DispatchError::from(te))); @@ -88,3 +126,55 @@ impl LabMcpServer { Ok(result) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::route_scope::McpRouteScope; + + #[test] + fn protected_route_becomes_a_namespace_scoped_snippet_authority() { + let route = McpRouteScope::protected_subset("team-route", ["alpha"], ["snippets"], true); + + let scope = execution_scope_for_route(&route); + + assert!(scope.is_scoped()); + assert!(scope.allows("alpha", "tool1")); + assert!(!scope.allows("beta", "tool2")); + } + + #[test] + fn root_route_keeps_trusted_local_snippet_authority_unscoped() { + let scope = execution_scope_for_route(&McpRouteScope::Root); + + assert!(!scope.is_scoped()); + assert!(scope.allows("alpha", "tool1")); + assert!(scope.allows("beta", "tool2")); + } + + #[test] + fn authenticated_mcp_snippet_caller_preserves_identity_instead_of_becoming_trusted_local() { + let scopes = vec!["lab:admin".to_string()]; + let caller = execution_caller_for_mcp(Some(&scopes), Some("alice".to_string()), None); + + assert!(!matches!( + caller, + crate::dispatch::gateway::code_mode::CodeModeCaller::TrustedLocal + )); + assert_eq!(caller.subject(), Some("alice")); + assert!(caller.is_admin()); + } + + #[test] + fn authenticated_mcp_snippet_caller_preserves_host_provider_context() { + let scopes = vec!["lab:admin".to_string()]; + let caller = execution_caller_for_mcp( + Some(&scopes), + Some("alice".to_string()), + Some(("provider-secret", "request-7")), + ); + + assert_eq!(caller.subject(), Some("alice")); + assert_eq!(caller.host_provider_token(), Some("provider-secret")); + } +} diff --git a/docs/dev/CODE_MODE.md b/docs/dev/CODE_MODE.md index 424655948..9cf0bceb0 100644 --- a/docs/dev/CODE_MODE.md +++ b/docs/dev/CODE_MODE.md @@ -304,9 +304,17 @@ async () => { `codemode.run()` lazily resolves snippet source through the host, then evaluates `return await ()(input)` inside the same Javy/QuickJS runtime as the -caller. A snippet can call `codemode..()`, `callTool()`, -`writeArtifact()`, and other snippets, bounded by the same Code Mode timeout plus -per-run snippet depth/count/byte budgets. +caller. The saved source stays on the execution plane: model-facing discovery +exposes only snippet metadata and the invocation helper, and execution responses +do not echo the resolved source. An invoking model therefore pays context for the +name, input schema/arguments, and returned result, not for the stored program on +every run. Source enters model context only when a caller explicitly reads, edits, +reviews, or authors it. + +A snippet can call `codemode..()`, `callTool()`, `writeArtifact()`, +and other snippets, bounded by the same Code Mode timeout plus per-run snippet +depth/count/byte budgets. Those byte limits are parser/sandbox/storage safety +limits, not an assertion that saved source must fit in the invoking LLM context. `writeArtifact()` defaults `contentType` to `text/plain` when omitted or blank. When provided, it must be a simple ASCII `type/subtype` media type, up to 256 @@ -535,7 +543,7 @@ their JSON MCP representation. Defaults: -- `max_source_bytes = 131072` +- `max_source_bytes = 1048576` - `max_response_bytes = 24576` - `max_response_tokens = 6000` diff --git a/docs/services/SNIPPETS.md b/docs/services/SNIPPETS.md index a7c947e82..9c4877491 100644 --- a/docs/services/SNIPPETS.md +++ b/docs/services/SNIPPETS.md @@ -14,7 +14,7 @@ The generated [action catalog](../generated/action-catalog.md) is authoritative `snippets.list`, `help`, and `schema` are discovery operations. Built-in snippets are loaded from the checked-in snippet directory and user snippets are resolved from the Labby home. -## Tool Declaration Metadata +## Tool Declaration Scope Markdown frontmatter may include `tools` as a JSON string array or an indented list of exact `::` identifiers. Storage and Code Mode discovery @@ -22,11 +22,16 @@ preserve omission separately from an explicit empty array. Declarations are bounded to 128 unique identifiers of at most 1,024 bytes each; reserved local capabilities, malformed identifiers, and duplicate declaration keys are rejected. -**This is descriptive metadata, not an execution restriction.** Omission records -no declaration; `[]` expresses an intended deny-all declaration; a nonempty list -records intended dependencies. Current execution still uses the existing caller -policy regardless of this metadata. Do not rely on `tools` to limit a snippet's -authority until host-side enforcement is implemented and qualified. +For native saved-snippet execution (`snippets.exec` / `snippets.test`), Labby +intersects a declaration with the caller's existing Code Mode policy before +building the catalog. The declaration can narrow authority but never grant it: +omission keeps the legacy caller scope, `[]` denies all upstream tools, and a +nonempty list exposes only those exact dependencies. This also keeps one-shot +snippet runs from cold-probing unrelated gateway upstreams. + +Nested `codemode.run()` inherits the already-established execution scope. Trusted +local saved snippets may compose inside that declared scope; route-scoped callers +still cannot use nested snippet resolution to widen their authority. ## Administrative Actions diff --git a/docs/snippets/README.md b/docs/snippets/README.md index 44adfb124..b8d83271d 100644 --- a/docs/snippets/README.md +++ b/docs/snippets/README.md @@ -245,7 +245,17 @@ async () => { `codemode.run()` resolves snippet source lazily through the live gateway and evaluates it inside the same Javy/QuickJS runtime as the caller. Snippet source -is not injected into search/describe metadata. +is not injected into search/describe metadata and is not echoed in the normal +execution response. A saved snippet invocation should cost the model context for +the snippet name, inputs, and compact result, not the stored JavaScript body. +The source only needs to enter an LLM context when the user explicitly asks an +agent to author, inspect, debug, or edit that source. + +When frontmatter declares exact `tools`, native saved-snippet execution uses that +declaration to scope catalog construction and dispatch. This avoids waking or +probing unrelated gateway upstreams for a workflow whose dependencies are +already known. Snippets without declarations retain the legacy unscoped catalog +behavior for compatibility. Successful admin/trusted-local Code Mode executions return an `execution_id`. Promote a prior execution through the live gateway snippets action, not a diff --git a/docs/snippets/docker-host-inventory.md b/docs/snippets/docker-host-inventory.md new file mode 100644 index 000000000..bcb384297 --- /dev/null +++ b/docs/snippets/docker-host-inventory.md @@ -0,0 +1,343 @@ +--- +name: docker-host-inventory +title: "Docker Host Inventory" +created: "2026-09-16" +updated: "2026-09-16" +description: Inventory one Docker host over key-only SSH with compact inspect data, recent logs, mounts, networks, ports, state, and image digest drift +tags: [homelab, docker, ssh, inventory, readonly] +tools: + - claude-macpoo::Bash +inputs: + alias: + type: string + required: true + ssh_config: + type: string + default: "" + required: false + connect_timeout_seconds: + type: integer + default: 4 + required: false + command_timeout_ms: + type: integer + default: 20000 + required: false + log_lines: + type: integer + default: 20 + required: false + containers_per_call: + type: integer + default: 4 + required: false + check_updates: + type: boolean + default: true + required: false + update_timeout_seconds: + type: integer + default: 3 + required: false + update_parallelism: + type: integer + default: 24 + required: false +--- + +# Docker Host Inventory + +Reusable one-host primitive. Host identity is resolved before Docker enumeration. Container detail groups fan out concurrently. Image drift checks depend on the actual image references returned by the detail stage. Update checks never pull images. + +```js +async (o = {}) => { + const i = { + alias: String(o.alias || "").trim(), + ssh_config: o.ssh_config ?? "", + connect_timeout_seconds: Math.max( + 1, + Math.min(30, Number(o.connect_timeout_seconds ?? 4)), + ), + command_timeout_ms: Math.max( + 5000, + Math.min(60000, Number(o.command_timeout_ms ?? 20000)), + ), + log_lines: Math.max(0, Math.min(200, Number(o.log_lines ?? 20))), + containers_per_call: Math.max( + 1, + Math.min(20, Number(o.containers_per_call ?? 4)), + ), + check_updates: o.check_updates !== false, + update_timeout_seconds: Math.max( + 1, + Math.min(15, Number(o.update_timeout_seconds ?? 3)), + ), + update_parallelism: Math.max( + 1, + Math.min(32, Number(o.update_parallelism ?? 24)), + ), + }; + if (!i.alias) throw new Error("docker-host-inventory requires alias"); + const q = (v) => "'" + String(v).split("'").join("'\"'\"'") + "'", + txt = (v) => + typeof v === "string" + ? v + : typeof v?.stdout === "string" + ? v.stdout + : Array.isArray(v?.content) + ? v.content.map((x) => x?.text || "").join("\n") + : "", + clean = (e) => { + const s = String(e ?? ""), + m = "Original tool error:", + n = s.indexOf(m); + return (n >= 0 ? s.slice(n + m.length) : s).trim().slice(0, 1200); + }, + chunk = (a, n) => { + const x = []; + for (let p = 0; p < a.length; p += n) x.push(a.slice(p, p + n)); + return x; + }; + const bash = "claude-macpoo::Bash"; + await callTool(bash, { + command: "hostname; whoami; uname -s; pwd", + timeout: i.command_timeout_ms, + }); + const opts = [ + "-o BatchMode=yes", + "-o NumberOfPasswordPrompts=0", + "-o PreferredAuthentications=publickey", + "-o PasswordAuthentication=no", + "-o KbdInteractiveAuthentication=no", + "-o ForwardAgent=no", + "-o ClearAllForwardings=yes", + "-o ConnectionAttempts=1", + "-o ConnectTimeout=" + i.connect_timeout_seconds, + ].join(" "), + configOpt = i.ssh_config ? "-F " + q(i.ssh_config) + " " : "", + ssh = (r) => + callTool(bash, { + command: "ssh " + configOpt + opts + " -- " + q(i.alias) + " " + q(r), + timeout: i.command_timeout_ms, + }); + const hostProbe = [ + 'printf "hostname=%s\n" "$(hostname)"', + 'printf "user=%s\n" "$(whoami)"', + 'printf "platform=%s\n" "$(uname -s)"', + 'printf "docker_path=%s\n" "$(command -v docker 2>/dev/null || true)"', + 'printf "docker_version=%s\n" "$(docker version --format \'{{.Server.Version}}\' 2>/dev/null || true)"', + 'printf "timeout_path=%s\n" "$(command -v timeout 2>/dev/null || true)"', + ].join("; "); + const fields = {}; + for (const line of txt(await ssh(hostProbe)).split("\n")) { + const n = line.indexOf("="); + if (n < 1) continue; + fields[line.slice(0, n)] = line.slice(n + 1).trim(); + } + const host = { + alias: i.alias, + hostname: fields.hostname || null, + user: fields.user || null, + platform: fields.platform || null, + docker_path: fields.docker_path || null, + docker_version: fields.docker_version || null, + timeout_path: fields.timeout_path || null, + }; + if (!host.hostname) + throw new Error("SSH probe returned no hostname for " + i.alias); + if (!host.docker_path) + return { + schema_version: "labby.docker.host_inventory.v1", + ok: true, + host, + containers: [], + errors: [], + summary: { + containers: 0, + container_states: {}, + image_update_states: {}, + updates_available: 0, + docker_available: false, + }, + }; + const ids = txt(await ssh("docker ps -aq --no-trunc")) + .split("\n") + .map((x) => x.trim()) + .filter((x) => /^[0-9a-f]{12,64}$/i.test(x)); + const fmt = + '{"id":{{json .Id}},"name":{{json .Name}},"image_ref":{{json .Config.Image}},"image_id":{{json .Image}},"created":{{json .Created}},"restart_count":{{json .RestartCount}},"state":{"status":{{json .State.Status}},"running":{{json .State.Running}},"paused":{{json .State.Paused}},"restarting":{{json .State.Restarting}},"oom_killed":{{json .State.OOMKilled}},"dead":{{json .State.Dead}},"pid":{{json .State.Pid}},"exit_code":{{json .State.ExitCode}},"error":{{json .State.Error}},"started_at":{{json .State.StartedAt}},"finished_at":{{json .State.FinishedAt}},"health":{{with index .State "Health"}}{{json .Status}}{{else}}null{{end}}},"ports":{{json .NetworkSettings.Ports}},"networks":{{json .NetworkSettings.Networks}},"mounts":{{json .Mounts}}}', + markerNonce = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`, + sec = `__LABBY_DOCKER_LOG_SECTION_${markerNonce}__`, + beg = `__LABBY_DOCKER_LOG_BEGIN_${markerNonce}__:`, + end = `__LABBY_DOCKER_LOG_END_${markerNonce}__:`; + const detail = await codemode.batch( + chunk(ids, i.containers_per_call).map((g) => async () => { + const list = g.join(" "), + r = [ + "docker inspect --format " + q(fmt) + " " + list, + "printf " + q("\n" + sec + "\n"), + "for id in " + list + "; do", + "printf " + q(beg + "%s\n") + ' "$id"', + "docker logs --tail " + + i.log_lines + + ' --timestamps "$id" 2>&1 || true', + "printf " + q(end + "%s\n") + ' "$id"', + "done", + ].join("\n"), + out = txt(await ssh(r)), + at = out.indexOf("\n" + sec + "\n"); + if (at < 0) throw new Error("inventory marker missing"); + const meta = out + .slice(0, at) + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + .map(JSON.parse), + logs = {}; + let active = null; + for (const line of out.slice(at + sec.length + 2).split("\n")) { + if (line.startsWith(beg)) { + active = line.slice(beg.length); + logs[active] = []; + continue; + } + if (line.startsWith(end)) { + active = null; + continue; + } + if (active) logs[active].push(line); + } + return meta.map((c) => ({ + id: c.id, + name: String(c.name || "").replace(/^\/+/, ""), + image_ref: c.image_ref || null, + image_id: c.image_id || null, + created: c.created || null, + restart_count: c.restart_count ?? 0, + state: c.state || {}, + ports: Object.entries(c.ports || {}).map(([container_port, b]) => ({ + container_port, + bindings: (b || []).map((x) => ({ + host_ip: x.HostIp, + host_port: x.HostPort, + })), + })), + networks: Object.entries(c.networks || {}).map(([name, x]) => ({ + name, + ip_address: x.IPAddress || "", + global_ipv6_address: x.GlobalIPv6Address || "", + gateway: x.Gateway || "", + mac_address: x.MacAddress || "", + aliases: x.Aliases || [], + })), + mounts: (c.mounts || []).map((x) => ({ + type: x.Type, + name: x.Name || null, + source: x.Source, + destination: x.Destination, + mode: x.Mode, + rw: x.RW, + propagation: x.Propagation, + })), + logs: { requested_lines: i.log_lines, lines: logs[c.id] || [] }, + })); + }), + ); + const containers = [], + errors = []; + for (const x of detail.ok || []) containers.push(...x.value); + for (const x of detail.failed || []) + errors.push({ stage: "inspect_logs", batch: x.i, error: clean(x.error) }); + const updates = {}; + if (i.check_updates && containers.length) { + const images = Array.from( + new Set(containers.map((c) => c.image_ref).filter(Boolean)), + ), + w = [ + 'img="$1"', + "if printf '%s' \"$img\" | grep -Eq '^[0-9a-f]{12,64}$'; then printf '%s\tunknown\t\t\tlocal_image_id\n' \"$img\"; exit 0; fi", + 'case "$img" in *@sha256:*) d="$(printf \'%s\' "$img" | cut -d@ -f2-)"; printf \'%s\tpinned\t%s\t%s\tpinned_digest\n\' "$img" "$d" "$d"; exit 0 ;; esac', + 'local_digests="$(docker image inspect --format \'{{range .RepoDigests}}{{println .}}{{end}}\' "$img" 2>/dev/null || true)"', + 'if [ -z "$local_digests" ]; then printf \'%s\tunknown\t\t\tno_local_repo_digest\n\' "$img"; exit 0; fi', + 'remote_digest="$(timeout ' + + i.update_timeout_seconds + + " docker buildx imagetools inspect \"$img\" 2>/dev/null | grep -m1 '^Digest:' | tr -s ' ' | cut -d' ' -f2 || true)\"", + "local_csv=\"$(printf '%s\n' \"$local_digests\" | tr '\n' ',' )\"", + 'if [ -z "$remote_digest" ]; then printf \'%s\tunknown\t%s\t\tregistry_digest_unavailable\n\' "$img" "$local_csv"; exit 0; fi', + 'if printf \'%s\n\' "$local_digests" | grep -Fq "@$remote_digest"; then status=current; else status=available; fi', + 'printf \'%s\t%s\t%s\t%s\tcompared_registry_digest\n\' "$img" "$status" "$local_csv" "$remote_digest"', + ].join("\n"); + try { + const out = txt( + await ssh( + "printf '%s\n' " + + images.map(q).join(" ") + + " | xargs -n1 -P" + + i.update_parallelism + + " sh -c " + + q(w) + + " sh", + ), + ); + for (const line of out.split("\n")) { + if (!line.trim()) continue; + const [image, status, localRaw, remote_digest, reason] = + line.split("\t"); + updates[image] = { + status: status || "unknown", + local_digests: String(localRaw || "") + .split(",") + .map((x) => x.trim()) + .filter(Boolean) + .map((x) => + x.includes("@") ? x.slice(x.lastIndexOf("@") + 1) : x, + ), + remote_digest: remote_digest || null, + reason: reason || null, + }; + } + } catch (e) { + errors.push({ stage: "updates", error: clean(e) }); + } + } + for (const c of containers) + c.update = i.check_updates + ? updates[c.image_ref] || { + status: "unknown", + local_digests: [], + remote_digest: null, + reason: "image_not_checked", + } + : { + status: "not_checked", + local_digests: [], + remote_digest: null, + reason: "disabled_by_input", + }; + containers.sort((a, b) => String(a.name).localeCompare(String(b.name))); + const states = {}, + imageStates = {}; + for (const c of containers) { + const s = c.state?.status || "unknown", + u = c.update?.status || "unknown"; + states[s] = (states[s] || 0) + 1; + imageStates[u] = (imageStates[u] || 0) + 1; + } + return { + schema_version: "labby.docker.host_inventory.v1", + ok: errors.length === 0, + host, + containers, + errors, + summary: { + containers: containers.length, + container_states: states, + image_update_states: imageStates, + updates_available: containers.filter( + (c) => c.update?.status === "available", + ).length, + docker_available: true, + }, + }; +} +``` diff --git a/docs/snippets/homelab-docker-inventory.md b/docs/snippets/homelab-docker-inventory.md new file mode 100644 index 000000000..520abbba2 --- /dev/null +++ b/docs/snippets/homelab-docker-inventory.md @@ -0,0 +1,279 @@ +--- +name: homelab-docker-inventory +title: "Homelab Docker Inventory" +created: "2026-09-15" +updated: "2026-09-16" +description: Discover key-auth homelab hosts, inventory Docker across reachable machines, and return a structured artifact and compact summary +tags: [homelab, docker, ssh, inventory, ops, readonly] +tools: + - claude-macpoo::Bash + - claude-macpoo::Read +inputs: + ssh_config: + type: string + default: "" + required: false + exclude_hosts: + type: array + default: ["github.com", "orb"] + required: false + extra_hosts: + type: array + default: [] + required: false + connect_timeout_seconds: + type: integer + default: 4 + required: false + command_timeout_ms: + type: integer + default: 20000 + required: false + log_lines: + type: integer + default: 20 + required: false + containers_per_call: + type: integer + default: 4 + required: false + check_updates: + type: boolean + default: true + required: false + update_timeout_seconds: + type: integer + default: 3 + required: false + update_parallelism: + type: integer + default: 24 + required: false +--- + +# Homelab Docker Inventory + +Thin orchestration snippet. SSH discovery is a required dependency, then independent Docker hosts fan out through the reusable one-host inventory snippet. The complete machine-readable report is written as an artifact while the direct result stays compact. + +```js +async (o = {}) => { + const input = { + ssh_config: o.ssh_config ?? "", + exclude_hosts: Array.isArray(o.exclude_hosts) + ? o.exclude_hosts + : ["github.com", "orb"], + extra_hosts: Array.isArray(o.extra_hosts) ? o.extra_hosts : [], + connect_timeout_seconds: Math.max( + 1, + Math.min(30, Number(o.connect_timeout_seconds ?? 4)), + ), + command_timeout_ms: Math.max( + 5000, + Math.min(60000, Number(o.command_timeout_ms ?? 20000)), + ), + log_lines: Math.max(0, Math.min(200, Number(o.log_lines ?? 20))), + containers_per_call: Math.max( + 1, + Math.min(20, Number(o.containers_per_call ?? 4)), + ), + check_updates: o.check_updates !== false, + update_timeout_seconds: Math.max( + 1, + Math.min(15, Number(o.update_timeout_seconds ?? 3)), + ), + update_parallelism: Math.max( + 1, + Math.min(32, Number(o.update_parallelism ?? 24)), + ), + }; + const discovery = await codemode.run("homelab-ssh-targets", { + ssh_config: input.ssh_config, + exclude_hosts: input.exclude_hosts, + extra_hosts: input.extra_hosts, + connect_timeout_seconds: input.connect_timeout_seconds, + command_timeout_ms: input.command_timeout_ms, + }); + if (!discovery?.ok) throw new Error("homelab SSH discovery failed"); + const byDevice = new Map(); + for (const t of discovery.targets || []) { + if (!t?.ssh_reachable || !t.remote?.hostname || !t.remote?.docker_path) + continue; + const key = + String(t.remote.hostname).toLowerCase() + + "|" + + String(t.remote.user || "").toLowerCase(); + if (!byDevice.has(key)) + byDevice.set(key, { + key, + hostname: t.remote.hostname, + user: t.remote.user, + platform: t.remote.platform, + aliases: [], + preferred_alias: t.alias, + docker_version: t.remote.docker_version || null, + }); + byDevice.get(key).aliases.push(t.alias); + } + const devices = Array.from(byDevice.values()), + jobs = devices.map( + (d) => () => + codemode.run("docker-host-inventory", { + alias: d.preferred_alias, + ssh_config: input.ssh_config, + connect_timeout_seconds: input.connect_timeout_seconds, + command_timeout_ms: input.command_timeout_ms, + log_lines: input.log_lines, + containers_per_call: input.containers_per_call, + check_updates: input.check_updates, + update_timeout_seconds: input.update_timeout_seconds, + update_parallelism: input.update_parallelism, + }), + ), + batch = await codemode.batch(jobs), + inventories = new Array(devices.length), + hostFailures = []; + for (const x of batch.ok || []) inventories[x.i] = x.value; + for (const x of batch.failed || []) + hostFailures.push({ + device: devices[x.i], + error: String(x.error).slice(0, 1200), + }); + const states = {}, + updates = {}; + let total = 0, + updatesAvailable = 0, + hostsWithErrors = hostFailures.length; + for (let n = 0; n < devices.length; n++) { + const inv = inventories[n]; + if (!inv) continue; + devices[n].inventory = inv; + total += inv.summary?.containers || 0; + updatesAvailable += inv.summary?.updates_available || 0; + if ((inv.errors || []).length) hostsWithErrors++; + for (const [k, v] of Object.entries(inv.summary?.container_states || {})) + states[k] = (states[k] || 0) + v; + for (const [k, v] of Object.entries(inv.summary?.image_update_states || {})) + updates[k] = (updates[k] || 0) + v; + } + const artifactInput = { ...input }; + delete artifactInput.ssh_config; + artifactInput.ssh_config_supplied = Boolean(input.ssh_config); + const sourceController = discovery.controller || {}; + const artifactController = { + upstream: sourceController.upstream || null, + hostname: sourceController.hostname || null, + user: sourceController.user || null, + platform: sourceController.platform || null, + ssh_path: sourceController.ssh_path || null, + ssh_config_supplied: Boolean(input.ssh_config), + }; + const sanitizeEffective = (effective) => + effective + ? { + hostname: effective.hostname || null, + user: effective.user || null, + port: effective.port || 22, + identity_files_configured: (effective.identity_files || []).length, + identity_agent_configured: Boolean( + effective.identity_agent && effective.identity_agent !== "none", + ), + } + : null; + const redactLocal = (value) => { + let text = String(value || ""); + for (const local of [input.ssh_config, sourceController.home, sourceController.cwd]) + if (local) text = text.split(String(local)).join(""); + return text; + }; + const sanitizeTarget = (target) => ({ + alias: target.alias, + source_count: (target.sources || []).length, + effective: sanitizeEffective(target.effective), + key_auth_configured: target.key_auth_configured, + key_auth_working: target.key_auth_working, + ssh_reachable: target.ssh_reachable, + failure_kind: target.failure_kind || null, + error: target.error ? redactLocal(target.error) : null, + remote: target.remote || null, + }); + const artifactDiscovery = { ...(discovery.discovery || {}) }; + artifactDiscovery.parsed_config_file_count = ( + artifactDiscovery.parsed_config_files || [] + ).length; + delete artifactDiscovery.parsed_config_files; + artifactDiscovery.warnings = (artifactDiscovery.warnings || []).map((warning) => ({ + type: warning.type, + limit: warning.limit, + remaining: warning.remaining, + })); + const artifactTargets = (discovery.targets || []).map(sanitizeTarget); + const report = { + schema_version: "labby.homelab.docker_inventory.v1", + generated_at: new Date().toISOString(), + input: artifactInput, + controller: artifactController, + discovery: { + ...artifactDiscovery, + unique_reachable_devices: new Set( + (discovery.targets || []) + .filter((t) => t?.ssh_reachable && t.remote?.hostname) + .map( + (t) => + String(t.remote.hostname).toLowerCase() + + "|" + + String(t.remote.user || "").toLowerCase(), + ), + ).size, + docker_hosts: devices.length, + }, + ssh_targets: artifactTargets, + devices, + host_failures: hostFailures, + summary: { + containers: total, + container_states: states, + image_update_states: updates, + updates_available: updatesAvailable, + hosts_with_inventory_errors: hostsWithErrors, + }, + }; + const artifact = await writeArtifact( + "homelab/docker-inventory.json", + JSON.stringify(report, null, 2), + { contentType: "application/json" }, + ); + return { + schema_version: report.schema_version, + ok: hostsWithErrors === 0, + partial: + discovery.partial === true || + (report.discovery.ssh_unreachable || 0) > 0 || + hostsWithErrors > 0 || + (updates.unknown || 0) > 0, + generated_at: report.generated_at, + artifact, + summary: report.summary, + discovery: report.discovery, + devices: devices.map((d) => ({ + hostname: d.hostname, + aliases: d.aliases, + user: d.user, + platform: d.platform, + docker_version: d.docker_version, + container_count: d.inventory?.summary?.containers || 0, + running_count: d.inventory?.summary?.container_states?.running || 0, + updates_available: d.inventory?.summary?.updates_available || 0, + inventory_errors: d.inventory?.errors || [], + })), + unreachable_targets: (discovery.targets || []) + .filter((t) => !t.ssh_reachable) + .map((t) => ({ + alias: t.alias, + effective: sanitizeEffective(t.effective), + key_auth_configured: t.key_auth_configured, + failure_kind: t.failure_kind, + error: t.error ? redactLocal(t.error) : null, + })), + }; +} +``` diff --git a/docs/snippets/homelab-ssh-targets.md b/docs/snippets/homelab-ssh-targets.md new file mode 100644 index 000000000..409603d5e --- /dev/null +++ b/docs/snippets/homelab-ssh-targets.md @@ -0,0 +1,329 @@ +--- +name: homelab-ssh-targets +title: "Homelab SSH Targets" +created: "2026-09-16" +updated: "2026-09-16" +description: Discover concrete SSH aliases, effective key-auth configuration, reachability, and remote host capabilities through the macpoo Claude MCP control plane +tags: [homelab, ssh, discovery, readonly] +tools: + - claude-macpoo::Bash + - claude-macpoo::Read +inputs: + ssh_config: + type: string + default: "" + required: false + exclude_hosts: + type: array + default: ["github.com", "orb"] + required: false + extra_hosts: + type: array + default: [] + required: false + connect_timeout_seconds: + type: integer + default: 4 + required: false + command_timeout_ms: + type: integer + default: 20000 + required: false +--- + +# Homelab SSH Targets + +Reusable read-only discovery primitive for homelab snippets. It uses the declared `claude-macpoo` control plane, parses concrete SSH aliases, expands non-glob includes, checks effective key-auth configuration with `ssh -G`, and probes reachable hosts using public-key-only authentication. Independent target checks fan out concurrently. + +`include_glob_skipped` warnings are explicit because wildcard Include expansion is intentionally left to a future filesystem-aware parser rather than guessed. + +```js +async (o = {}) => { + const input = { + ssh_config: o.ssh_config ?? "", + exclude_hosts: Array.isArray(o.exclude_hosts) + ? o.exclude_hosts + : ["github.com", "orb"], + extra_hosts: Array.isArray(o.extra_hosts) ? o.extra_hosts : [], + connect_timeout_seconds: Math.max( + 1, + Math.min(30, Number(o.connect_timeout_seconds ?? 4)), + ), + command_timeout_ms: Math.max( + 5000, + Math.min(60000, Number(o.command_timeout_ms ?? 20000)), + ), + }; + const quote = (v) => "'" + String(v).split("'").join("'\"'\"'") + "'"; + const text = (v) => + typeof v === "string" + ? v + : typeof v?.stdout === "string" + ? v.stdout + : typeof v?.file?.content === "string" + ? v.file.content + : Array.isArray(v?.content) + ? v.content.map((x) => x?.text || "").join("\n") + : ""; + const err = (e) => { + const s = String(e ?? ""), + m = "Original tool error:", + i = s.indexOf(m); + return (i >= 0 ? s.slice(i + m.length) : s).trim().slice(0, 1200); + }; + const kind = (s) => { + s = String(s || "").toLowerCase(); + if (s.includes("permission denied")) return "auth_failed"; + if (s.includes("timed out") || s.includes("timeout")) return "timeout"; + if (s.includes("refused")) return "connection_refused"; + if (s.includes("no route") || s.includes("network is unreachable")) + return "network_unreachable"; + if ( + s.includes("resolve hostname") || + s.includes("name or service not known") + ) + return "dns_failed"; + if (s.includes("host key verification failed")) return "host_key_failed"; + return "other"; + }; + const fanout = async (items, fn) => { + const r = await codemode.batch(items.map((x) => () => fn(x))), + out = new Array(items.length); + for (const x of r.ok || []) out[x.i] = x.value; + for (const x of r.failed || []) + out[x.i] = { ok: false, error: err(x.error) }; + return out; + }; + + const bashTool = { id: "claude-macpoo::Bash", namespace: "claude-macpoo" }; + const readTool = { id: "claude-macpoo::Read" }; + const identity = text( + await callTool(bashTool.id, { + command: + "hostname; whoami; uname -s; pwd; printenv HOME; command -v ssh || true", + timeout: input.command_timeout_ms, + }), + ) + .split("\n") + .map((x) => x.trim()); + const controller = { + upstream: bashTool.namespace, + bash_tool_id: bashTool.id, + read_tool_id: readTool.id, + hostname: identity[0] || null, + user: identity[1] || null, + platform: identity[2] || null, + cwd: identity[3] || null, + home: identity[4] || null, + ssh_path: identity[5] || null, + }; + if (!controller.home || !controller.ssh_path) + throw new Error("Selected Claude MCP controller is missing HOME or ssh"); + + const root = input.ssh_config || controller.home + "/.ssh/config", + configOpt = input.ssh_config ? "-F " + quote(root) + " " : "", + queue = [root], + seen = new Set(), + aliases = new Map(), + warnings = []; + const resolve = (p, from) => { + if (p.startsWith("~/")) return controller.home + p.slice(1); + if (p.startsWith("/")) return p; + const slash = from.lastIndexOf("/"); + const dir = slash >= 0 ? from.slice(0, slash + 1) : ""; + return dir + p; + }; + while (queue.length && seen.size < 16) { + const path = queue.shift(); + if (!path || seen.has(path)) continue; + seen.add(path); + let body; + try { + body = text(await callTool(readTool.id, { file_path: path })); + } catch (e) { + warnings.push({ type: "config_read_failed", path, error: err(e) }); + continue; + } + for (const raw of body.split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const inc = line.match(/^Include\s+(.+)$/i); + if (inc) { + for (const token of inc[1].trim().split(/\s+/)) { + if (token.includes("*") || token.includes("?") || token.includes("[")) + warnings.push({ + type: "include_glob_skipped", + source: path, + include: token, + }); + else queue.push(resolve(token, path)); + } + continue; + } + const host = line.match(/^Host\s+(.+)$/i); + if (!host) continue; + for (const alias of host[1].trim().split(/\s+/)) + if ( + alias && + !alias.startsWith("!") && + !alias.includes("*") && + !alias.includes("?") && + !alias.includes("[") && + !input.exclude_hosts.includes(alias) + ) { + if (!aliases.has(alias)) aliases.set(alias, { alias, sources: [] }); + aliases.get(alias).sources.push(path); + } + } + } + const config_truncated = queue.length > 0; + if (config_truncated) { + warnings.push({ + type: "config_file_limit_reached", + limit: 16, + remaining: queue.length, + }); + } + for (const raw of input.extra_hosts) { + const alias = String(raw || "").trim(); + if (alias && !input.exclude_hosts.includes(alias) && !aliases.has(alias)) + aliases.set(alias, { alias, sources: ["input.extra_hosts"] }); + } + + const configured = await fanout( + Array.from(aliases.values()), + async (target) => { + try { + const effective = { + hostname: null, + user: null, + port: 22, + identity_files: [], + identity_agent: null, + }; + for (const line of text( + await callTool(bashTool.id, { + command: "ssh " + configOpt + "-G -- " + quote(target.alias), + timeout: input.command_timeout_ms, + }), + ).split("\n")) { + const n = line.indexOf(" "); + if (n < 1) continue; + const k = line.slice(0, n).toLowerCase(), + v = line.slice(n + 1).trim(); + if (k === "hostname") effective.hostname = v; + else if (k === "user") effective.user = v; + else if (k === "port") effective.port = Number(v) || 22; + else if (k === "identityfile") effective.identity_files.push(v); + else if (k === "identityagent") effective.identity_agent = v; + } + return { + ...target, + ok: true, + effective, + key_auth_configured: + effective.identity_files.length > 0 || + Boolean( + effective.identity_agent && effective.identity_agent !== "none", + ), + }; + } catch (e) { + return { + ...target, + ok: false, + key_auth_configured: false, + error: err(e), + }; + } + }, + ); + const opts = [ + "-o BatchMode=yes", + "-o NumberOfPasswordPrompts=0", + "-o PreferredAuthentications=publickey", + "-o PasswordAuthentication=no", + "-o KbdInteractiveAuthentication=no", + "-o ForwardAgent=no", + "-o ClearAllForwardings=yes", + "-o ConnectionAttempts=1", + "-o ConnectTimeout=" + input.connect_timeout_seconds, + ].join(" "); + const targets = await fanout(configured, async (target) => { + if (!target.ok) + return { + ...target, + ssh_reachable: false, + key_auth_working: false, + failure_kind: "config_failed", + }; + try { + const probe = [ + 'printf "hostname=%s\n" "$(hostname)"', + 'printf "user=%s\n" "$(whoami)"', + 'printf "platform=%s\n" "$(uname -s)"', + 'printf "docker_path=%s\n" "$(command -v docker 2>/dev/null || true)"', + 'printf "docker_version=%s\n" "$(docker version --format \'{{.Server.Version}}\' 2>/dev/null || true)"', + 'printf "timeout_path=%s\n" "$(command -v timeout 2>/dev/null || true)"', + ].join("; "); + const fields = {}; + for (const line of text( + await callTool(bashTool.id, { + command: + "ssh " + configOpt + opts + " -- " + quote(target.alias) + " " + quote(probe), + timeout: Math.min(input.command_timeout_ms, 12000), + }), + ).split("\n")) { + const n = line.indexOf("="); + if (n < 1) continue; + fields[line.slice(0, n)] = line.slice(n + 1).trim(); + } + if (!fields.hostname) + return { + ...target, + ssh_reachable: false, + key_auth_working: false, + failure_kind: "empty_probe_response", + error: "empty_probe_response", + }; + return { + ...target, + ssh_reachable: true, + key_auth_working: true, + remote: { + hostname: fields.hostname || null, + user: fields.user || null, + platform: fields.platform || null, + docker_path: fields.docker_path || null, + docker_version: fields.docker_version || null, + timeout_path: fields.timeout_path || null, + }, + }; + } catch (e) { + const message = err(e); + return { + ...target, + ssh_reachable: false, + key_auth_working: false, + failure_kind: kind(message), + error: message, + }; + } + }); + return { + schema_version: "labby.homelab.ssh_targets.v1", + ok: true, + partial: warnings.length > 0, + controller: { ...controller, ssh_config: root }, + discovery: { + parsed_config_files: Array.from(seen), + warnings, + config_truncated, + configured_aliases: targets.length, + key_auth_configured: targets.filter((x) => x.key_auth_configured).length, + key_auth_working: targets.filter((x) => x.key_auth_working).length, + ssh_unreachable: targets.filter((x) => !x.ssh_reachable).length, + }, + targets, + }; +} +``` diff --git a/plugins/labby/skills/using-labby/references/code-mode.md b/plugins/labby/skills/using-labby/references/code-mode.md index 99ad18210..89722eea4 100644 --- a/plugins/labby/skills/using-labby/references/code-mode.md +++ b/plugins/labby/skills/using-labby/references/code-mode.md @@ -302,7 +302,7 @@ enabled = true trace_params = true result_shape_policy = "off" timeout_ms = 30000 -max_source_bytes = 131072 +max_source_bytes = 1048576 max_response_bytes = 24576 max_response_tokens = 6000 token_estimate_divisor = 4 diff --git a/plugins/labby/skills/using-labby/references/config-reference.md b/plugins/labby/skills/using-labby/references/config-reference.md index cf16b1940..4f5f8cf3c 100644 --- a/plugins/labby/skills/using-labby/references/config-reference.md +++ b/plugins/labby/skills/using-labby/references/config-reference.md @@ -38,7 +38,7 @@ enabled = true trace_params = true result_shape_policy = "off" # off | truncate timeout_ms = 30000 -max_source_bytes = 131072 +max_source_bytes = 1048576 max_response_bytes = 24576 max_response_tokens = 6000 token_estimate_divisor = 4