{"record":{"id":"fdfbe75e81d6288c","repo":"zeroclaw-labs/zeroclaw","slug":"url-must-include-a-host","errorCode":null,"errorMessage":"URL must include a host","messagePattern":"URL must include a host","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-tools/src/browser_open.rs","lineNumber":301,"sourceCode":"                    .with_attrs(::serde_json::json!({\"url\": url})),\n                \"browser_open: unsupported URL scheme rejected\"\n            );\n            anyhow::Error::msg(\"Only http:// or https:// URLs are allowed\")\n        })?;\n\n    let authority = rest.split(['/', '?', '#']).next().ok_or_else(|| {\n        ::zeroclaw_log::record!(\n            WARN,\n            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)\n                .with_outcome(::zeroclaw_log::EventOutcome::Failure)\n                .with_attrs(::serde_json::json!({\"url\": url})),\n            \"browser_open: invalid URL\"\n        );\n        anyhow::Error::msg(\"Invalid URL\")\n    })?;\n\n    if authority.is_empty() {\n        anyhow::bail!(\"URL must include a host\");\n    }\n\n    if authority.contains('@') {\n        anyhow::bail!(\"URL userinfo is not allowed\");\n    }\n\n    if authority.starts_with('[') {\n        anyhow::bail!(\"IPv6 hosts are not supported in browser_open\");\n    }\n\n    let host = authority\n        .split(':')\n        .next()\n        .unwrap_or_default()\n        .trim()\n        .trim_end_matches('.')\n        .to_lowercase();\n","sourceCodeStart":283,"sourceCodeEnd":319,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-tools/src/browser_open.rs#L283-L319","documentation":"extract_host strips the http:// or https:// prefix and takes everything before the first '/', '?', or '#' as the authority (browser_open.rs:289-298). If that component is empty, the URL names no host at all and this bail fires (browser_open.rs:300-302). It is the earliest structural host check in browser_open's URL validation.","triggerScenarios":"Passing a URL where nothing follows the scheme before the next delimiter: 'https://' alone, 'https:///path' (empty netloc), 'https://?q=1', or 'http://#fragment'. Typically produced by string templating that interpolates an empty host variable: format!(\"https://{}/x\", host) with host == \"\".","commonSituations":"LLM-generated URLs that truncate after the scheme, config-driven URL builders with a missing hostname key, and copy-paste of scheme-only prefixes during testing.","solutions":["Fix the caller to include a real host: 'https://example.com/path' instead of 'https:///path'.","If the URL is built from parts, assert the host component is non-empty before formatting.","Check the template/variable that produced the URL for an unset or empty hostname."],"exampleFix":"// before\nlet url = format!(\"https://{}/report\", maybe_empty_host);\n\n// after\nanyhow::ensure!(!maybe_empty_host.is_empty(), \"host is required\");\nlet url = format!(\"https://{maybe_empty_host}/report\");","handlingStrategy":"validation","validationCode":"fn has_url_host(url: &str) -> bool {\n    let Some(rest) = url\n        .strip_prefix(\"https://\")\n        .or_else(|| url.strip_prefix(\"http://\"))\n    else {\n        return false;\n    };\n    let authority = rest.split(['/', '?', '#']).next().unwrap_or(\"\");\n    !authority.is_empty()\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Validate URLs have a non-empty host before handing them to browser_open.","Ensure! host variables are non-empty before format!-ing URLs.","Prefer the url::Url crate for construction; it rejects scheme-only URLs at parse time."],"tags":["url-validation","http","browser","rust","zeroclaw"],"backgroundTag":"missing-url-host","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}