use anyhow::Context;
use cephas::browser::{Browser, BrowserConfig};
use cephas::storage::ProfileStore;
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, BufRead, Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command as ProcessCommand, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use uuid::Uuid;
const AGENT_SCREENSHOT_SCHEMA: &str = "cephas.agent-screenshot.v1";
const AGENT_SMOKE_SCHEMA: &str = "cephas.agent-smoke.v1";
const INTERNAL_ROUTE_TRUST_SMOKE_SCHEMA: &str = "cephas.internal-route-trust-smoke.v1";
const LAUNCH_SAFETY_SMOKE_SCHEMA: &str = "cephas.launch-safety-smoke.v1";
const AGENT_TOOLING_SCHEMA: &str = "cephas.agent-tooling.v1";
const DIAGNOSTICS_SCHEMA: &str = "cephas.diagnostics.v1";
const SELF_TEST_SCHEMA: &str = "cephas.self-test.v1";
const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
const INTERNAL_ROUTE_TRUST_ATTEMPT: &str = "cephas://settings/theme?id=glass";
const UNKNOWN_INTERNAL_LAUNCH_TARGET: &str = "cephas://unknown/internal-route";
const UNSAFE_HOME_LAUNCH_VALUE: &str = "cephas://agent";
const INTERNAL_ROUTE_TRUST_TIMEOUT: Duration = Duration::from_secs(6);
#[derive(Debug, Parser)]
#[command(name = "cephas", version, about = "A privacy-first Rust browser shell")]
struct Cli {
#[arg(long, global = true, help = "Use a custom profile JSON path")]
profile: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
#[command(about = "Launch the desktop browser window")]
Launch {
#[arg(help = "URL or search terms to open first")]
target: Option<String>,
},
#[command(about = "Open an interactive browser session")]
Browse {
#[arg(help = "URL or search terms to open first")]
target: Option<String>,
},
#[command(about = "Open one page, print reader-mode text, then exit")]
Open {
#[arg(help = "URL or search terms")]
target: String,
},
#[command(about = "Open one page, print an AI-ready reader packet, then exit")]
AiReader {
#[arg(help = "URL or search terms")]
target: String,
},
#[command(about = "Open one page and print an agent-reader extraction artifact")]
AgentReader {
#[arg(
help = "Tool: agent-brief, full-text, outline, link-map, data-signals, json-artifact"
)]
tool: String,
#[arg(help = "URL or search terms")]
target: String,
},
#[command(about = "Print the agent browser-command manifest")]
AgentCommands {
#[arg(long, help = "Print one JSON command per line")]
jsonl: bool,
},
#[command(about = "Print a portable manifest for agentic code tools")]
AgentTooling,
#[command(about = "Run a basic stdio MCP server for code tools")]
Mcp,
#[command(about = "Print the UUIDv7 plugin registry")]
Plugins {
#[arg(long, help = "Print one plugin manifest per line")]
jsonl: bool,
#[command(subcommand)]
action: Option<PluginCommand>,
},
#[command(about = "Render a URL or internal page and write an agent screenshot PNG")]
AgentScreenshot {
#[arg(help = "URL, internal page, or search terms to capture")]
target: String,
#[arg(short, long, default_value = "cephas-agent-screenshot.png")]
output: PathBuf,
#[arg(long, default_value_t = 1280)]
width: i32,
#[arg(long, default_value_t = 900)]
height: i32,
#[arg(long, help = "Print a machine-readable JSON result")]
json: bool,
},
#[command(
about = "Run visual smoke screenshots for internal Agent surfaces",
visible_alias = "visual-smoke"
)]
AgentSmoke {
#[arg(short, long, default_value = "cephas-smoke")]
output_dir: PathBuf,
#[arg(long, default_value_t = 1280)]
width: i32,
#[arg(long, default_value_t = 900)]
height: i32,
#[arg(long, value_name = "CASE", help = "Run only a named smoke case")]
case: Vec<String>,
#[arg(long, default_value_t = 1024, help = "Minimum expected PNG size")]
min_bytes: u64,
#[arg(long, help = "Print a machine-readable JSON report")]
json: bool,
},
#[command(about = "Print profile location and basic stats")]
Profile,
#[command(about = "Print bounded resource diagnostics")]
Diagnostics {
#[arg(long, help = "Print a machine-readable JSON report")]
json: bool,
#[arg(long, help = "Exit non-zero when diagnostics are not ok")]
strict: bool,
},
#[command(about = "Run fast built-in policy and resource self checks")]
SelfTest {
#[arg(long, help = "Print a machine-readable JSON report")]
json: bool,
},
}
#[derive(Debug, Subcommand)]
enum PluginCommand {
#[command(about = "Print bundled project plugins available for local install")]
Available {
#[arg(long, help = "Print one bundled plugin module per line")]
jsonl: bool,
},
#[command(about = "Validate profile plugin registry and bundled module metadata")]
Check {
#[arg(long, help = "Fail when warnings are present, not only errors")]
strict: bool,
#[arg(long, help = "Alias for --strict")]
fail_on_warning: bool,
},
#[command(about = "Install a bundled project plugin into this profile")]
Install {
#[arg(help = "UUID plugin ID or bundled module ID")]
selector: String,
},
#[command(about = "Install every bundled project plugin not yet installed")]
InstallAll,
#[command(about = "Refresh one installed bundled plugin from its project manifest")]
Sync {
#[arg(help = "UUID plugin ID or bundled module ID")]
selector: String,
},
#[command(about = "Refresh every installed bundled plugin from project manifests")]
SyncAll,
#[command(about = "Enable an installed profile plugin")]
Enable {
#[arg(help = "UUID plugin ID or bundled module ID")]
selector: String,
},
#[command(about = "Disable an installed profile plugin")]
Disable {
#[arg(help = "UUID plugin ID or bundled module ID")]
selector: String,
},
#[command(about = "Remove an installed profile plugin")]
Remove {
#[arg(help = "UUID plugin ID or bundled module ID")]
selector: String,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let profile_path = cli.profile.clone();
let store = ProfileStore::new(cli.profile)?;
let profile = store.load()?;
match cli.command.unwrap_or(Command::Launch { target: None }) {
Command::Launch { target } => {
let fallback_profile = profile.clone();
if let Err(err) = cephas::gui::launch(profile, store, target.clone()) {
eprintln!("native window failed: {err:#}");
if std::env::var_os("CEPHAS_DISABLE_EXTERNAL_FALLBACK").is_none()
&& can_launch_external_fallback(&fallback_profile, target.as_deref())
{
eprintln!("opening the system browser instead");
cephas::gui::launch_external(&fallback_profile, target.as_deref())
} else {
Err(err).context("not opening internal or non-web target externally")
}
} else {
Ok(())
}
}
Command::Browse { target } => {
let browser = Browser::new(profile, BrowserConfig::default())?;
cephas::ui::run_interactive(browser, store, target)
}
Command::Open { target } => {
let browser = Browser::new(profile, BrowserConfig::default())?;
open_once(browser, store, target)
}
Command::AiReader { target } => {
let browser = Browser::new(profile, BrowserConfig::default())?;
ai_reader_once(browser, store, target)
}
Command::AgentReader { tool, target } => {
let browser = Browser::new(profile, BrowserConfig::default())?;
agent_reader_once(browser, store, tool, target)
}
Command::AgentCommands { jsonl } => {
let manifest = cephas::agents::agent_command_manifest();
if jsonl {
for entry in manifest {
println!("{}", serde_json::to_string(&entry)?);
}
} else {
println!("{}", serde_json::to_string_pretty(&manifest)?);
}
Ok(())
}
Command::AgentTooling => {
println!(
"{}",
serde_json::to_string_pretty(&agent_tooling_manifest())?
);
Ok(())
}
Command::Mcp => run_mcp_server(&profile, &store),
Command::Plugins { jsonl, action } => plugin_command(profile, store, jsonl, action),
Command::AgentScreenshot {
target,
output,
width,
height,
json,
} => agent_screenshot(&profile, target, output, width, height, json),
Command::AgentSmoke {
output_dir,
width,
height,
case,
min_bytes,
json,
} => agent_smoke(
output_dir,
width,
height,
case,
min_bytes,
json,
profile_path,
),
Command::Profile => {
println!("profile: {}", store.path().display());
println!("history entries: {}", profile.history.len());
println!("bookmarks: {}", profile.bookmarks.len());
println!(
"plugins: {} / {}",
profile.plugins.installed_count(),
profile.plugins.max_plugins
);
Ok(())
}
Command::Diagnostics { json, strict } => {
print_diagnostics_report(&profile, &store, json, strict)
}
Command::SelfTest { json } => print_self_test(json),
}
}
fn can_launch_external_fallback(profile: &cephas::Profile, target: Option<&str>) -> bool {
let candidate = target
.map(str::trim)
.filter(|target| !target.is_empty())
.unwrap_or_else(|| profile.home.as_str());
let Ok(url) = url::Url::parse(candidate) else {
return true;
};
matches!(url.scheme(), "http" | "https")
&& !(url.scheme() == "https" && url.host_str() == Some("cephas.local"))
}
fn print_self_test(json: bool) -> anyhow::Result<()> {
let report = self_test_report();
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
for check in &report.checks {
let status = if check.ok { "ok" } else { "FAILED" };
println!("{status} {} - {}", check.id, check.detail);
}
if report.ok {
println!("self-test ok");
}
}
if report.ok {
Ok(())
} else {
anyhow::bail!("self-test failed")
}
}
fn self_test_report() -> SelfTestReport {
let checks = vec![
self_test_launch_route_policy(),
self_test_profile_resource_clamps(),
self_test_profile_home_migration(),
self_test_plugin_resource_clamps(),
];
SelfTestReport {
schema: SELF_TEST_SCHEMA,
ok: checks.iter().all(|check| check.ok),
checks,
}
}
fn self_test_check(id: &'static str, ok: bool, detail: impl Into<String>) -> SelfTestCheck {
SelfTestCheck {
id,
ok,
detail: detail.into(),
}
}
fn self_test_launch_route_policy() -> SelfTestCheck {
let profile = cephas::Profile::default();
let ok = can_launch_external_fallback(&profile, Some("https://example.com"))
&& can_launch_external_fallback(&profile, Some("search terms"))
&& !can_launch_external_fallback(&profile, Some("cephas://agent"))
&& !can_launch_external_fallback(&profile, Some("https://cephas.local/agent"))
&& !can_launch_external_fallback(&profile, Some("file:///tmp/cephas"));
self_test_check(
"launch-route-policy",
ok,
"web/search targets can fall back externally; internal and file targets cannot",
)
}
fn self_test_profile_resource_clamps() -> SelfTestCheck {
let mut high = cephas::Profile {
max_history: cephas::storage::DEFAULT_MAX_HISTORY + 10_000,
max_bookmarks: cephas::storage::DEFAULT_MAX_BOOKMARKS + 10_000,
..cephas::Profile::default()
};
high.enforce_limits();
let mut low = cephas::Profile {
max_history: 0,
max_bookmarks: 0,
..cephas::Profile::default()
};
low.enforce_limits();
let ok = high.max_history == cephas::storage::DEFAULT_MAX_HISTORY
&& high.max_bookmarks == cephas::storage::DEFAULT_MAX_BOOKMARKS
&& low.max_history == 1
&& low.max_bookmarks == 1;
self_test_check(
"profile-resource-clamps",
ok,
format!(
"history/bookmark maxima clamp to {}/{} and minimum 1",
cephas::storage::DEFAULT_MAX_HISTORY,
cephas::storage::DEFAULT_MAX_BOOKMARKS
),
)
}
fn self_test_profile_home_migration() -> SelfTestCheck {
let mut profile = cephas::Profile {
home: url::Url::parse("cephas://agent").expect("valid test URL"),
..cephas::Profile::default()
};
profile.enforce_limits();
let expected = cephas::Profile::default().home;
let ok = profile.home == expected;
self_test_check(
"profile-home-migration",
ok,
format!("unsafe persisted home migrates to {expected}"),
)
}
fn self_test_plugin_resource_clamps() -> SelfTestCheck {
let builtins = cephas::plugins::PluginRegistry::default().installed.len();
let mut high = cephas::plugins::PluginRegistry {
max_plugins: cephas::plugins::DEFAULT_MAX_PLUGINS + 10_000,
..cephas::plugins::PluginRegistry::default()
};
high.enforce_limits();
let mut low = cephas::plugins::PluginRegistry {
max_plugins: 0,
..cephas::plugins::PluginRegistry::default()
};
low.enforce_limits();
let ok = high.max_plugins == cephas::plugins::DEFAULT_MAX_PLUGINS
&& low.max_plugins == builtins
&& low.installed.len() >= builtins;
self_test_check(
"plugin-resource-clamps",
ok,
format!(
"plugin registry max clamps to {} and at least {} built-ins",
cephas::plugins::DEFAULT_MAX_PLUGINS,
builtins
),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AgentSmokeCase {
name: &'static str,
target: &'static str,
output_file: &'static str,
kind: AgentSmokeCaseKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentSmokeCaseKind {
Screenshot,
InternalRouteTrust,
LaunchSafety(LaunchSafetyScenario),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LaunchSafetyScenario {
UnsafeHome,
UnknownInternalTarget,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PngDimensions {
width: u32,
height: u32,
}
#[derive(Debug, Deserialize, Serialize)]
struct AgentScreenshotReport {
schema: String,
ok: bool,
target: String,
output: String,
width: i32,
height: i32,
bytes: Option<u64>,
duration_ms: u64,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct AgentSmokeReport {
schema: &'static str,
ok: bool,
output_dir: String,
width: i32,
height: i32,
min_bytes: u64,
duration_ms: u64,
cases: Vec<AgentSmokeCaseReport>,
}
#[derive(Debug, Serialize)]
struct AgentSmokeCaseReport {
name: String,
target: String,
output: String,
ok: bool,
bytes: Option<u64>,
duration_ms: u64,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct InternalRouteTrustSmokeReport {
schema: &'static str,
ok: bool,
source_url: String,
attempted_route: &'static str,
profile_path: String,
before_theme: &'static str,
after_theme: &'static str,
server_saw_request: bool,
child_status: String,
duration_ms: u64,
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct LaunchSafetySmokeReport {
schema: &'static str,
ok: bool,
case: &'static str,
profile_path: String,
launch_target: Option<&'static str>,
tampered_home: Option<&'static str>,
loaded_home: Option<String>,
child_status: String,
duration_ms: u64,
error: Option<String>,
}
#[derive(Debug)]
struct BrowserChildOutcome {
status: String,
stderr: String,
}
#[derive(Debug, Serialize)]
struct AgentToolingManifest {
schema: &'static str,
package: &'static str,
version: &'static str,
binary: &'static str,
commands: Vec<AgentToolCommand>,
browser_commands: Vec<cephas::agents::AgentCommandManifestEntry>,
internal_urls: Vec<AgentToolUrl>,
smoke_cases: Vec<AgentToolSmokeCase>,
human_control_routes: Vec<AgentToolRoute>,
reader_tools: Vec<AgentToolReader>,
resource_caps: Vec<AgentToolResourceCap>,
diagnostics_surfaces: Vec<AgentToolDiagnosticsSurface>,
contracts: Vec<AgentToolContract>,
schemas: Vec<AgentToolSchema>,
notes: Vec<&'static str>,
}
#[derive(Debug, Serialize)]
struct AgentToolCommand {
id: &'static str,
argv: Vec<&'static str>,
output: &'static str,
description: &'static str,
}
#[derive(Debug, Serialize)]
struct AgentToolUrl {
id: &'static str,
url: &'static str,
description: &'static str,
}
#[derive(Debug, Serialize)]
struct AgentToolSmokeCase {
name: &'static str,
target: &'static str,
output_file: &'static str,
}
#[derive(Debug, Serialize)]
struct AgentToolRoute {
id: &'static str,
route: &'static str,
description: &'static str,
}
#[derive(Debug, Serialize)]
struct AgentToolReader {
id: &'static str,
label: &'static str,
description: &'static str,
}
#[derive(Debug, Clone, Copy, Serialize)]
struct AgentToolResourceCap {
id: &'static str,
value: u64,
unit: &'static str,
description: &'static str,
}
#[derive(Debug, Serialize)]
struct AgentToolDiagnosticsSurface {
id: &'static str,
access: Vec<&'static str>,
output: &'static str,
scope: &'static str,
description: &'static str,
}
#[derive(Debug, Serialize)]
struct DiagnosticsReport {
schema: &'static str,
ok: bool,
profile_path: String,
profile: DiagnosticsProfile,
session: Option<DiagnosticsSession>,
downloads: Option<DiagnosticsDownloads>,
plugins: DiagnosticsPlugins,
armour: DiagnosticsArmour,
agent_log: Option<DiagnosticsAgentLog>,
recordings: Option<DiagnosticsRecordings>,
resource_caps: Vec<AgentToolResourceCap>,
resource_checks: BTreeMap<&'static str, DiagnosticsResourceCheck>,
unobserved_resource_caps: Vec<DiagnosticsUnobservedResourceCap>,
errors: Vec<DiagnosticsError>,
}
#[derive(Debug, Clone, Copy, Serialize)]
struct DiagnosticsUsage {
current: u64,
max: u64,
}
#[derive(Debug, Serialize)]
struct DiagnosticsProfile {
home: String,
theme: &'static str,
ai_browsing_enabled: bool,
media_policy: &'static str,
media_capture_enabled: bool,
web_acceleration: &'static str,
history_entries: DiagnosticsUsage,
bookmarks: DiagnosticsUsage,
}
#[derive(Debug, Serialize)]
struct DiagnosticsSession {
windows: DiagnosticsUsage,
tabs: DiagnosticsUsage,
tabs_per_window: DiagnosticsUsage,
}
#[derive(Debug, Serialize)]
struct DiagnosticsDownloads {
records: DiagnosticsUsage,
in_progress: u64,
paused: u64,
completed: u64,
cancelled: u64,
failed: u64,
}
#[derive(Debug, Serialize)]
struct DiagnosticsPlugins {
installed: DiagnosticsUsage,
enabled: u64,
planned: u64,
}
#[derive(Debug, Serialize)]
struct DiagnosticsArmour {
site_allowlist_hosts: DiagnosticsUsage,
blocked_hosts: DiagnosticsUsage,
}
#[derive(Debug, Serialize)]
struct DiagnosticsAgentLog {
bytes: DiagnosticsUsage,
archives: DiagnosticsUsage,
total_archive_bytes: DiagnosticsUsage,
largest_archive_bytes: DiagnosticsUsage,
}
#[derive(Debug, Serialize)]
struct DiagnosticsRecordings {
directories: DiagnosticsUsage,
total_bytes: u64,
}
#[derive(Debug, Serialize)]
struct DiagnosticsError {
component: &'static str,
error: String,
}
#[derive(Debug, Serialize)]
struct DiagnosticsResourceCheck {
id: &'static str,
component: &'static str,
current: u64,
max: u64,
unit: &'static str,
ok: bool,
description: &'static str,
}
#[derive(Debug, Serialize)]
struct DiagnosticsUnobservedResourceCap {
id: &'static str,
component: &'static str,
max: u64,
unit: &'static str,
reason: &'static str,
surface: &'static str,
description: &'static str,
}
#[derive(Debug, Serialize)]
struct SelfTestReport {
schema: &'static str,
ok: bool,
checks: Vec<SelfTestCheck>,
}
#[derive(Debug, Serialize)]
struct SelfTestCheck {
id: &'static str,
ok: bool,
detail: String,
}
#[derive(Debug, Serialize)]
struct AgentToolContract {
id: &'static str,
path: &'static str,
description: &'static str,
}
#[derive(Debug, Serialize)]
struct AgentToolSchema {
id: &'static str,
schema: &'static str,
}
#[derive(Debug, Serialize)]
struct PluginRegistryReport<'a> {
schema: &'static str,
enabled: bool,
max_plugins: usize,
installed_count: usize,
enabled_count: usize,
planned_count: usize,
bundled_count: usize,
available_count: usize,
installed: &'a [cephas::plugins::PluginManifest],
bundled: &'a [cephas::plugins::PluginModule],
available: &'a [cephas::plugins::PluginModule],
}
#[derive(Debug, Serialize)]
struct PluginActionReport {
schema: &'static str,
action: &'static str,
module_id: Option<String>,
source_path: Option<String>,
plugin: cephas::plugins::PluginManifest,
installed_count: usize,
enabled_count: usize,
}
#[derive(Debug, Serialize)]
struct PluginBulkActionReport {
schema: &'static str,
action: &'static str,
changed_count: usize,
plugins: Vec<cephas::plugins::PluginManifest>,
installed_count: usize,
enabled_count: usize,
available_count: usize,
}
fn agent_smoke_cases() -> [AgentSmokeCase; 10] {
[
AgentSmokeCase {
name: "agent",
target: "cephas://agent",
output_file: "agent.png",
kind: AgentSmokeCaseKind::Screenshot,
},
AgentSmokeCase {
name: "agent-docs",
target: "cephas://agent/docs",
output_file: "agent-docs.png",
kind: AgentSmokeCaseKind::Screenshot,
},
AgentSmokeCase {
name: "agent-document",
target: "https://cephas.local/agent",
output_file: "agent-document.png",
kind: AgentSmokeCaseKind::Screenshot,
},
AgentSmokeCase {
name: "start",
target: "cephas://start",
output_file: "start.png",
kind: AgentSmokeCaseKind::Screenshot,
},
AgentSmokeCase {
name: "settings",
target: "cephas://settings",
output_file: "settings.png",
kind: AgentSmokeCaseKind::Screenshot,
},
AgentSmokeCase {
name: "diagnostics",
target: "cephas://diagnostics",
output_file: "diagnostics.png",
kind: AgentSmokeCaseKind::Screenshot,
},
AgentSmokeCase {
name: "plugins",
target: "cephas://plugins",
output_file: "plugins.png",
kind: AgentSmokeCaseKind::Screenshot,
},
AgentSmokeCase {
name: "internal-route-trust",
target: "untrusted local page -> cephas://settings/theme?id=glass",
output_file: "internal-route-trust.json",
kind: AgentSmokeCaseKind::InternalRouteTrust,
},
AgentSmokeCase {
name: "launch-unsafe-home",
target: "isolated profile with unsafe home -> safe launch",
output_file: "launch-unsafe-home.json",
kind: AgentSmokeCaseKind::LaunchSafety(LaunchSafetyScenario::UnsafeHome),
},
AgentSmokeCase {
name: "launch-unknown-internal",
target: "launch cephas://unknown/internal-route -> blocked start page",
output_file: "launch-unknown-internal.json",
kind: AgentSmokeCaseKind::LaunchSafety(LaunchSafetyScenario::UnknownInternalTarget),
},
]
}
fn select_agent_smoke_cases(names: &[String]) -> anyhow::Result<Vec<AgentSmokeCase>> {
let cases = agent_smoke_cases();
if names.is_empty() {
return Ok(cases.to_vec());
}
let mut selected = Vec::new();
for name in names {
let Some(case) = cases.iter().copied().find(|case| case.name == name) else {
let valid = cases
.iter()
.map(|case| case.name)
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!("unknown smoke case '{name}'. Expected one of: {valid}");
};
selected.push(case);
}
Ok(selected)
}
fn agent_screenshot(
profile: &cephas::Profile,
target: String,
output: PathBuf,
width: i32,
height: i32,
json: bool,
) -> anyhow::Result<()> {
let started = Instant::now();
let result = cephas::gui::capture_webpage_screenshot(profile, &target, &output, width, height);
let bytes = fs::metadata(&output).ok().map(|metadata| metadata.len());
let duration_ms = elapsed_ms(started);
match result {
Ok(()) => {
if json {
let report = AgentScreenshotReport {
schema: AGENT_SCREENSHOT_SCHEMA.to_string(),
ok: true,
target,
output: output.display().to_string(),
width,
height,
bytes,
duration_ms,
error: None,
};
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("screenshot: {}", output.display());
}
Ok(())
}
Err(err) => {
let error = format!("{err:#}");
if json {
let report = AgentScreenshotReport {
schema: AGENT_SCREENSHOT_SCHEMA.to_string(),
ok: false,
target,
output: output.display().to_string(),
width,
height,
bytes,
duration_ms,
error: Some(error.clone()),
};
println!("{}", serde_json::to_string_pretty(&report)?);
}
anyhow::bail!(error)
}
}
}
fn agent_smoke(
output_dir: PathBuf,
width: i32,
height: i32,
case_names: Vec<String>,
min_bytes: u64,
json: bool,
profile_path: Option<PathBuf>,
) -> anyhow::Result<()> {
let started = Instant::now();
fs::create_dir_all(&output_dir).with_context(|| {
format!(
"failed to create visual smoke directory {}",
output_dir.display()
)
})?;
let cases = select_agent_smoke_cases(&case_names)?;
let mut reports = Vec::new();
for case in cases {
let output = output_dir.join(case.output_file);
let mut report = run_agent_smoke_case(case, &output, width, height, profile_path.as_ref());
let error = report.error.take().or_else(|| {
validate_agent_smoke_artifact(
case.kind,
&output,
report.bytes,
min_bytes,
width,
height,
)
});
let ok = error.is_none();
if !json {
if ok {
println!(
"smoke artifact: {} ({} bytes)",
report.output,
report.bytes.unwrap_or(0)
);
} else if let Some(error) = &error {
println!("smoke failed: {}: {error}", case.name);
}
}
report.ok = ok;
report.error = error;
reports.push(report);
}
let ok = reports.iter().all(|case| case.ok);
let report = AgentSmokeReport {
schema: AGENT_SMOKE_SCHEMA,
ok,
output_dir: output_dir.display().to_string(),
width,
height,
min_bytes,
duration_ms: elapsed_ms(started),
cases: reports,
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else if ok {
println!("visual smoke ok: {}", output_dir.display());
}
if ok {
Ok(())
} else {
let failed = report.cases.iter().filter(|case| !case.ok).count();
anyhow::bail!("visual smoke failed: {failed} case(s) failed")
}
}
fn run_agent_smoke_case(
case: AgentSmokeCase,
output: &PathBuf,
width: i32,
height: i32,
profile_path: Option<&PathBuf>,
) -> AgentSmokeCaseReport {
match case.kind {
AgentSmokeCaseKind::InternalRouteTrust => {
return run_internal_route_trust_smoke_case(case, output);
}
AgentSmokeCaseKind::LaunchSafety(scenario) => {
return run_launch_safety_smoke_case(case, scenario, output);
}
AgentSmokeCaseKind::Screenshot => {}
}
let started = Instant::now();
let mut command = match std::env::current_exe() {
Ok(path) => ProcessCommand::new(path),
Err(err) => {
return AgentSmokeCaseReport {
name: case.name.to_string(),
target: case.target.to_string(),
output: output.display().to_string(),
ok: false,
bytes: None,
duration_ms: elapsed_ms(started),
error: Some(format!("failed to resolve current executable: {err}")),
};
}
};
if let Some(profile_path) = profile_path {
command.arg("--profile").arg(profile_path);
}
command
.arg("agent-screenshot")
.arg(case.target)
.arg("--output")
.arg(output)
.arg("--width")
.arg(width.to_string())
.arg("--height")
.arg(height.to_string())
.arg("--json");
let child = match command.output() {
Ok(child) => child,
Err(err) => {
return AgentSmokeCaseReport {
name: case.name.to_string(),
target: case.target.to_string(),
output: output.display().to_string(),
ok: false,
bytes: None,
duration_ms: elapsed_ms(started),
error: Some(format!("failed to run screenshot subprocess: {err}")),
};
}
};
let stdout = String::from_utf8_lossy(&child.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&child.stderr).trim().to_string();
let parsed = serde_json::from_str::<AgentScreenshotReport>(&stdout);
match parsed {
Ok(report) => {
let error = if child.status.success() && report.ok {
None
} else {
Some(report.error.unwrap_or_else(|| {
format!("screenshot subprocess exited with {}", child.status)
}))
};
AgentSmokeCaseReport {
name: case.name.to_string(),
target: case.target.to_string(),
output: report.output,
ok: error.is_none(),
bytes: report.bytes,
duration_ms: elapsed_ms(started),
error,
}
}
Err(err) => {
let mut detail = format!("failed to parse screenshot JSON: {err}");
if !stdout.is_empty() {
detail.push_str(&format!("; stdout: {stdout}"));
}
if !stderr.is_empty() {
detail.push_str(&format!("; stderr: {stderr}"));
}
AgentSmokeCaseReport {
name: case.name.to_string(),
target: case.target.to_string(),
output: output.display().to_string(),
ok: false,
bytes: fs::metadata(output).ok().map(|metadata| metadata.len()),
duration_ms: elapsed_ms(started),
error: Some(detail),
}
}
}
}
fn validate_agent_smoke_artifact(
kind: AgentSmokeCaseKind,
output: &Path,
bytes: Option<u64>,
min_bytes: u64,
width: i32,
height: i32,
) -> Option<String> {
if kind == AgentSmokeCaseKind::Screenshot {
return validate_smoke_screenshot_artifact(output, bytes, min_bytes, width, height)
.err()
.map(|err| format!("{err:#}"));
}
bytes
.is_none()
.then(|| format!("smoke artifact missing {}", output.display()))
}
fn validate_smoke_screenshot_artifact(
output: &Path,
bytes: Option<u64>,
min_bytes: u64,
width: i32,
height: i32,
) -> anyhow::Result<()> {
let Some(size) = bytes else {
anyhow::bail!("smoke artifact missing {}", output.display());
};
if size < min_bytes {
anyhow::bail!(
"suspiciously small screenshot {} ({} bytes)",
output.display(),
size
);
}
let dimensions = read_png_dimensions(output)
.with_context(|| format!("failed to validate screenshot PNG {}", output.display()))?;
let expected = PngDimensions {
width: width.clamp(360, 4096) as u32,
height: height.clamp(360, 4096) as u32,
};
if png_device_scale(dimensions, expected).is_none() {
anyhow::bail!(
"screenshot dimensions {}x{} did not match expected {}x{} at a bounded integer device scale for {}",
dimensions.width,
dimensions.height,
expected.width,
expected.height,
output.display()
);
}
Ok(())
}
fn png_device_scale(dimensions: PngDimensions, expected: PngDimensions) -> Option<u32> {
if expected.width == 0 || expected.height == 0 {
return None;
}
if !dimensions.width.is_multiple_of(expected.width)
|| !dimensions.height.is_multiple_of(expected.height)
{
return None;
}
let width_scale = dimensions.width / expected.width;
let height_scale = dimensions.height / expected.height;
(width_scale == height_scale && (1..=4).contains(&width_scale)).then_some(width_scale)
}
fn read_png_dimensions(path: &Path) -> anyhow::Result<PngDimensions> {
const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
let mut file = fs::File::open(path)
.with_context(|| format!("failed to open screenshot PNG {}", path.display()))?;
let mut header = [0_u8; 33];
file.read_exact(&mut header)
.with_context(|| format!("failed to read screenshot PNG header {}", path.display()))?;
if &header[0..8] != PNG_SIGNATURE {
anyhow::bail!("artifact is not a PNG");
}
let ihdr_len = u32::from_be_bytes(header[8..12].try_into()?);
if ihdr_len != 13 || &header[12..16] != b"IHDR" {
anyhow::bail!("PNG is missing the leading IHDR chunk");
}
let width = u32::from_be_bytes(header[16..20].try_into()?);
let height = u32::from_be_bytes(header[20..24].try_into()?);
if width == 0 || height == 0 {
anyhow::bail!("PNG has invalid zero dimensions");
}
Ok(PngDimensions { width, height })
}
fn run_internal_route_trust_smoke_case(
case: AgentSmokeCase,
output: &Path,
) -> AgentSmokeCaseReport {
let started = Instant::now();
let mut report = match run_internal_route_trust_smoke(output, started) {
Ok(report) => report,
Err(err) => InternalRouteTrustSmokeReport {
schema: INTERNAL_ROUTE_TRUST_SMOKE_SCHEMA,
ok: false,
source_url: String::new(),
attempted_route: INTERNAL_ROUTE_TRUST_ATTEMPT,
profile_path: String::new(),
before_theme: cephas::BrowserTheme::Paper.id(),
after_theme: cephas::BrowserTheme::Paper.id(),
server_saw_request: false,
child_status: "not-started".to_string(),
duration_ms: elapsed_ms(started),
error: Some(format!("{err:#}")),
},
};
let write_error = write_internal_route_trust_report(output, &report).err();
if let Some(err) = write_error {
report.ok = false;
report.error = Some(format!("failed to write trust smoke report: {err:#}"));
}
let bytes = fs::metadata(output).ok().map(|metadata| metadata.len());
AgentSmokeCaseReport {
name: case.name.to_string(),
target: case.target.to_string(),
output: output.display().to_string(),
ok: report.ok,
bytes,
duration_ms: elapsed_ms(started),
error: report.error,
}
}
fn run_launch_safety_smoke_case(
case: AgentSmokeCase,
scenario: LaunchSafetyScenario,
output: &Path,
) -> AgentSmokeCaseReport {
let started = Instant::now();
let mut report = match run_launch_safety_smoke(output, started, case.name, scenario) {
Ok(report) => report,
Err(err) => LaunchSafetySmokeReport {
schema: LAUNCH_SAFETY_SMOKE_SCHEMA,
ok: false,
case: case.name,
profile_path: String::new(),
launch_target: scenario.launch_target(),
tampered_home: scenario.tampered_home(),
loaded_home: None,
child_status: "not-started".to_string(),
duration_ms: elapsed_ms(started),
error: Some(format!("{err:#}")),
},
};
let write_error = write_launch_safety_report(output, &report).err();
if let Some(err) = write_error {
report.ok = false;
report.error = Some(format!("failed to write launch safety report: {err:#}"));
}
let bytes = fs::metadata(output).ok().map(|metadata| metadata.len());
AgentSmokeCaseReport {
name: case.name.to_string(),
target: case.target.to_string(),
output: output.display().to_string(),
ok: report.ok,
bytes,
duration_ms: elapsed_ms(started),
error: report.error,
}
}
fn run_launch_safety_smoke(
output: &Path,
started: Instant,
case_name: &'static str,
scenario: LaunchSafetyScenario,
) -> anyhow::Result<LaunchSafetySmokeReport> {
if let Some(parent) = output.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create smoke output directory {}",
parent.display()
)
})?;
}
let smoke_id = Uuid::now_v7();
let profile_dir = std::env::temp_dir().join(format!("cephas-launch-safety-smoke-{smoke_id}"));
let profile_path = profile_dir.join("profile.json");
let profile_store = ProfileStore::new(Some(profile_path.clone()))?;
match scenario {
LaunchSafetyScenario::UnsafeHome => {
write_tampered_profile_home(&profile_path, UNSAFE_HOME_LAUNCH_VALUE)?;
}
LaunchSafetyScenario::UnknownInternalTarget => {
profile_store.save(&cephas::Profile::default())?;
}
}
let loaded_home_result = profile_store.load().map(|profile| profile.home.to_string());
let child_result = run_browser_child(
&profile_path,
scenario.launch_target(),
INTERNAL_ROUTE_TRUST_TIMEOUT,
);
let _ = fs::remove_dir_all(&profile_dir);
let (loaded_home, profile_error) = match loaded_home_result {
Ok(home) => (Some(home), None),
Err(err) => (
None,
Some(format!("failed to load isolated profile: {err:#}")),
),
};
let (child_status, child_error) = match child_result {
Ok(outcome) => {
let error = launch_safety_child_error(&outcome);
(outcome.status, error)
}
Err(err) => ("failed".to_string(), Some(format!("{err:#}"))),
};
let scenario_error = launch_safety_scenario_error(scenario, loaded_home.as_deref());
let error = profile_error.or(child_error).or(scenario_error);
let ok = error.is_none();
Ok(LaunchSafetySmokeReport {
schema: LAUNCH_SAFETY_SMOKE_SCHEMA,
ok,
case: case_name,
profile_path: profile_path.display().to_string(),
launch_target: scenario.launch_target(),
tampered_home: scenario.tampered_home(),
loaded_home,
child_status,
duration_ms: elapsed_ms(started),
error,
})
}
impl LaunchSafetyScenario {
fn launch_target(self) -> Option<&'static str> {
match self {
Self::UnsafeHome => None,
Self::UnknownInternalTarget => Some(UNKNOWN_INTERNAL_LAUNCH_TARGET),
}
}
fn tampered_home(self) -> Option<&'static str> {
match self {
Self::UnsafeHome => Some(UNSAFE_HOME_LAUNCH_VALUE),
Self::UnknownInternalTarget => None,
}
}
}
fn launch_safety_scenario_error(
scenario: LaunchSafetyScenario,
loaded_home: Option<&str>,
) -> Option<String> {
match scenario {
LaunchSafetyScenario::UnsafeHome => {
let expected = cephas::Profile::default().home.to_string();
(loaded_home != Some(expected.as_str())).then(|| {
format!(
"unsafe persisted home was not sanitized: expected {expected}, got {}",
loaded_home.unwrap_or("unreadable")
)
})
}
LaunchSafetyScenario::UnknownInternalTarget => None,
}
}
fn launch_safety_child_error(outcome: &BrowserChildOutcome) -> Option<String> {
let stderr = outcome.stderr.to_ascii_lowercase();
if stderr.contains("opening the system browser instead") {
return Some("browser child attempted external fallback".to_string());
}
if stderr.contains("native window failed") {
return Some(format!(
"browser child reported native window failure: {}",
bounded_child_output(&outcome.stderr)
));
}
if stderr.contains("not opening internal or non-web target externally") {
return Some(format!(
"browser child rejected launch target before GTK safety handling: {}",
bounded_child_output(&outcome.stderr)
));
}
None
}
fn run_internal_route_trust_smoke(
output: &Path,
started: Instant,
) -> anyhow::Result<InternalRouteTrustSmokeReport> {
if let Some(parent) = output.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create smoke output directory {}",
parent.display()
)
})?;
}
let smoke_id = Uuid::now_v7();
let profile_dir = std::env::temp_dir().join(format!("cephas-route-trust-smoke-{smoke_id}"));
let profile_path = profile_dir.join("profile.json");
let profile_store = ProfileStore::new(Some(profile_path.clone()))?;
let initial_profile = cephas::Profile::default();
profile_store.save(&initial_profile)?;
let before_theme = initial_profile.theme.id();
let listener = TcpListener::bind(("127.0.0.1", 0)).context("failed to bind smoke server")?;
listener
.set_nonblocking(true)
.context("failed to configure smoke server")?;
let address = listener
.local_addr()
.context("failed to read smoke server address")?;
let source_url = format!("http://{address}/");
let server = thread::spawn(move || serve_internal_route_trust_page(listener));
let child_status_result = run_browser_child_for_route_trust(&profile_path, &source_url);
let server_result = server
.join()
.unwrap_or_else(|_| Err(anyhow::anyhow!("smoke server thread panicked")));
let profile_result = profile_store.load();
let _ = fs::remove_dir_all(&profile_dir);
let (child_status, child_error) = match child_status_result {
Ok(status) => (status, None),
Err(err) => ("failed".to_string(), Some(format!("{err:#}"))),
};
let (server_saw_request, server_error) = match server_result {
Ok(value) => (value, None),
Err(err) => (false, Some(format!("{err:#}"))),
};
let (after_theme, profile_error) = match profile_result {
Ok(profile) => (profile.theme.id(), None),
Err(err) => ("unreadable", Some(format!("{err:#}"))),
};
let route_blocked = after_theme == before_theme;
let error = child_error
.or(server_error)
.or(profile_error)
.or_else(|| {
(!server_saw_request)
.then(|| "untrusted test page was not requested by the browser".to_string())
})
.or_else(|| {
(!route_blocked).then(|| {
format!("untrusted page changed profile theme from {before_theme} to {after_theme}")
})
});
let ok = error.is_none();
Ok(InternalRouteTrustSmokeReport {
schema: INTERNAL_ROUTE_TRUST_SMOKE_SCHEMA,
ok,
source_url,
attempted_route: INTERNAL_ROUTE_TRUST_ATTEMPT,
profile_path: profile_path.display().to_string(),
before_theme,
after_theme,
server_saw_request,
child_status,
duration_ms: elapsed_ms(started),
error,
})
}
fn serve_internal_route_trust_page(listener: TcpListener) -> anyhow::Result<bool> {
let started = Instant::now();
loop {
match listener.accept() {
Ok((mut stream, _)) => {
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let body = internal_route_trust_html();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)?;
return Ok(true);
}
Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
if started.elapsed() >= INTERNAL_ROUTE_TRUST_TIMEOUT {
return Ok(false);
}
thread::sleep(Duration::from_millis(25));
}
Err(err) => return Err(err).context("failed to accept smoke browser request"),
}
}
}
fn internal_route_trust_html() -> String {
format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Cephas route trust smoke</title><meta http-equiv="refresh" content="0; url={route}"></head><body><p>Attempting blocked Cephas route.</p><script>location.href={route_json};</script></body></html>"#,
route = INTERNAL_ROUTE_TRUST_ATTEMPT,
route_json =
serde_json::to_string(INTERNAL_ROUTE_TRUST_ATTEMPT).expect("serialize route smoke URL")
)
}
fn run_browser_child_for_route_trust(
profile_path: &Path,
source_url: &str,
) -> anyhow::Result<String> {
let outcome = run_browser_child(profile_path, Some(source_url), INTERNAL_ROUTE_TRUST_TIMEOUT)?;
if outcome.stderr.is_empty() {
Ok(outcome.status)
} else {
Ok(format!(
"{}; stderr: {}",
outcome.status,
bounded_child_output(&outcome.stderr)
))
}
}
fn run_browser_child(
profile_path: &Path,
target: Option<&str>,
timeout: Duration,
) -> anyhow::Result<BrowserChildOutcome> {
let executable = std::env::current_exe().context("failed to resolve current executable")?;
let mut command = ProcessCommand::new(executable);
command
.arg("--profile")
.arg(profile_path)
.arg("launch")
.env("CEPHAS_DISABLE_EXTERNAL_FALLBACK", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(target) = target {
command.arg(target);
}
let mut child = command
.spawn()
.context("failed to launch browser child for launch safety smoke")?;
let started = Instant::now();
loop {
if let Some(status) = child.try_wait().context("failed to poll browser child")? {
let output = child
.wait_with_output()
.context("failed to collect browser child output")?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if status.success() {
return Ok(BrowserChildOutcome {
status: format!("exited:{status}"),
stderr,
});
}
anyhow::bail!(
"browser child exited with {status}; stderr: {}",
bounded_child_output(&stderr)
);
}
if started.elapsed() >= timeout {
let _ = child.kill();
let output = child
.wait_with_output()
.context("failed to collect killed browser child output")?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Ok(BrowserChildOutcome {
status: "killed-after-timeout".to_string(),
stderr,
});
}
thread::sleep(Duration::from_millis(50));
}
}
fn bounded_child_output(output: &str) -> String {
const MAX_CHILD_OUTPUT_CHARS: usize = 1_000;
let output = output.trim();
if output.chars().count() <= MAX_CHILD_OUTPUT_CHARS {
return output.to_string();
}
let mut truncated = output
.chars()
.take(MAX_CHILD_OUTPUT_CHARS)
.collect::<String>();
truncated.push_str("...[truncated]");
truncated
}
fn write_tampered_profile_home(profile_path: &Path, home: &str) -> anyhow::Result<()> {
if let Some(parent) = profile_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("failed to create profile directory {}", parent.display()))?;
}
let mut value = serde_json::to_value(cephas::Profile::default())?;
let object = value
.as_object_mut()
.context("default profile did not serialize as an object")?;
object.insert("home".to_string(), Value::String(home.to_string()));
fs::write(profile_path, serde_json::to_vec_pretty(&value)?).with_context(|| {
format!(
"failed to write tampered profile {}",
profile_path.display()
)
})
}
fn write_internal_route_trust_report(
output: &Path,
report: &InternalRouteTrustSmokeReport,
) -> anyhow::Result<()> {
if let Some(parent) = output.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create smoke output directory {}",
parent.display()
)
})?;
}
fs::write(output, serde_json::to_vec_pretty(report)?)
.with_context(|| format!("failed to write smoke report {}", output.display()))
}
fn write_launch_safety_report(
output: &Path,
report: &LaunchSafetySmokeReport,
) -> anyhow::Result<()> {
if let Some(parent) = output.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create smoke output directory {}",
parent.display()
)
})?;
}
fs::write(output, serde_json::to_vec_pretty(report)?)
.with_context(|| format!("failed to write smoke report {}", output.display()))
}
fn elapsed_ms(started: Instant) -> u64 {
started.elapsed().as_millis().min(u64::MAX as u128) as u64
}
fn agent_tooling_manifest() -> AgentToolingManifest {
AgentToolingManifest {
schema: AGENT_TOOLING_SCHEMA,
package: env!("CARGO_PKG_NAME"),
version: env!("CARGO_PKG_VERSION"),
binary: "cephas",
commands: vec![
AgentToolCommand {
id: "agent-commands",
argv: vec!["cephas", "agent-commands"],
output: "json",
description: "Stable browser-command manifest.",
},
AgentToolCommand {
id: "agent-commands-jsonl",
argv: vec!["cephas", "agent-commands", "--jsonl"],
output: "jsonl",
description: "One browser-command manifest entry per line.",
},
AgentToolCommand {
id: "agent-smoke-json",
argv: vec!["cephas", "agent-smoke", "--output-dir", "<dir>", "--json"],
output: AGENT_SMOKE_SCHEMA,
description: "Machine-readable visual smoke report for internal pages.",
},
AgentToolCommand {
id: "agent-screenshot-json",
argv: vec![
"cephas",
"agent-screenshot",
"<target>",
"--output",
"<png>",
"--json",
],
output: AGENT_SCREENSHOT_SCHEMA,
description: "Machine-readable screenshot capture result.",
},
AgentToolCommand {
id: "agent-reader-json-artifact",
argv: vec!["cephas", "agent-reader", "json-artifact", "<url>"],
output: cephas::reader::AGENT_READER_SCHEMA,
description: "Deterministic reader artifact for ingestion, including source_body_truncated and content_truncated flags.",
},
AgentToolCommand {
id: "agent-tooling",
argv: vec!["cephas", "agent-tooling"],
output: AGENT_TOOLING_SCHEMA,
description: "Portable manifest for code tools integrating with Cephas.",
},
AgentToolCommand {
id: "mcp-stdio",
argv: vec!["cephas", "mcp"],
output: "mcp-json-rpc-stdio",
description: "Basic read-only MCP stdio server exposing Cephas discovery, diagnostics, command manifest, and plugin registry tools.",
},
AgentToolCommand {
id: "diagnostics-json",
argv: vec!["cephas", "diagnostics", "--json"],
output: DIAGNOSTICS_SCHEMA,
description: "Machine-readable bounded resource diagnostics with resource_checks and unobserved_resource_caps for profile, session, downloads, Agent logs, recordings, plugins, and Armour state.",
},
AgentToolCommand {
id: "diagnostics-strict",
argv: vec!["cephas", "diagnostics", "--json", "--strict"],
output: DIAGNOSTICS_SCHEMA,
description: "Machine-readable diagnostics that exits non-zero when any observed resource check exceeds its cap or a diagnostics component cannot be read; unobserved_resource_caps are informational.",
},
AgentToolCommand {
id: "self-test-json",
argv: vec!["cephas", "self-test", "--json"],
output: SELF_TEST_SCHEMA,
description: "Fast pure self checks for launch route policy, resource cap clamps, and unsafe home migration.",
},
AgentToolCommand {
id: "plugins",
argv: vec!["cephas", "plugins"],
output: cephas::plugins::PLUGIN_REGISTRY_SCHEMA,
description: "UUIDv7 plugin registry with capabilities, permissions, and bounded limits.",
},
AgentToolCommand {
id: "plugins-jsonl",
argv: vec!["cephas", "plugins", "--jsonl"],
output: "jsonl",
description: "One UUIDv7 plugin manifest per line.",
},
AgentToolCommand {
id: "plugins-available",
argv: vec!["cephas", "plugins", "available"],
output: cephas::plugins::PLUGIN_MODULE_SCHEMA,
description: "Bundled project plugin modules available for local installation.",
},
AgentToolCommand {
id: "plugins-check",
argv: vec!["cephas", "plugins", "check"],
output: cephas::plugins::PLUGIN_CHECK_SCHEMA,
description: "Validate profile plugin registry state and bundled module metadata.",
},
AgentToolCommand {
id: "plugins-check-strict",
argv: vec!["cephas", "plugins", "check", "--strict"],
output: cephas::plugins::PLUGIN_CHECK_SCHEMA,
description: "Validate plugin state and fail when warnings or errors are present.",
},
AgentToolCommand {
id: "plugins-install",
argv: vec!["cephas", "plugins", "install", "<plugin-id-or-module-id>"],
output: "cephas.plugins.action.v1",
description: "Install one bundled project plugin into the active profile.",
},
AgentToolCommand {
id: "plugins-install-all",
argv: vec!["cephas", "plugins", "install-all"],
output: "cephas.plugins.action.v1",
description: "Install every bundled project plugin not yet installed in the active profile.",
},
AgentToolCommand {
id: "plugins-sync",
argv: vec!["cephas", "plugins", "sync", "<plugin-id-or-module-id>"],
output: "cephas.plugins.action.v1",
description: "Refresh one installed bundled plugin from the project module manifest.",
},
AgentToolCommand {
id: "plugins-sync-all",
argv: vec!["cephas", "plugins", "sync-all"],
output: "cephas.plugins.action.v1",
description: "Refresh every installed bundled plugin from project module manifests.",
},
AgentToolCommand {
id: "plugins-enable",
argv: vec!["cephas", "plugins", "enable", "<plugin-id-or-module-id>"],
output: "cephas.plugins.action.v1",
description: "Enable one installed profile plugin.",
},
AgentToolCommand {
id: "plugins-disable",
argv: vec!["cephas", "plugins", "disable", "<plugin-id-or-module-id>"],
output: "cephas.plugins.action.v1",
description: "Disable one installed profile plugin.",
},
AgentToolCommand {
id: "plugins-remove",
argv: vec!["cephas", "plugins", "remove", "<plugin-id-or-module-id>"],
output: "cephas.plugins.action.v1",
description: "Remove one installed profile plugin.",
},
],
browser_commands: cephas::agents::agent_command_manifest(),
internal_urls: vec![
AgentToolUrl {
id: "start",
url: "cephas://start",
description: "Local Start page.",
},
AgentToolUrl {
id: "start-document",
url: "https://cephas.local/start",
description: "Document base URL for the local Start page HTML.",
},
AgentToolUrl {
id: "start-theme-toggle",
url: "cephas://start/theme",
description: "Cycle the saved Start/browser theme; mutates the active profile.",
},
AgentToolUrl {
id: "settings",
url: "cephas://settings",
description: "Local Settings page.",
},
AgentToolUrl {
id: "settings-document",
url: "https://cephas.local/settings",
description: "Document base URL for the local Settings page HTML.",
},
AgentToolUrl {
id: "settings-theme-preset",
url: "cephas://settings/theme-preset?id=<preset-id>",
description: "Apply one bounded built-in custom theme preset to the active profile.",
},
AgentToolUrl {
id: "diagnostics-page",
url: "cephas://diagnostics",
description: "Local live runtime diagnostics page with bounded counts and JSON command guidance.",
},
AgentToolUrl {
id: "diagnostics-document",
url: "https://cephas.local/diagnostics",
description: "Document base URL for the local Diagnostics page.",
},
AgentToolUrl {
id: "plugins-page",
url: "cephas://plugins",
description: "Local Settings-style UUIDv7 plugin registry and bundled module install page.",
},
AgentToolUrl {
id: "agent",
url: "cephas://agent",
description: "Agent control page.",
},
AgentToolUrl {
id: "agent-docs",
url: "cephas://agent/docs",
description: "Local agentic integration guide for code tools and agentic processes.",
},
AgentToolUrl {
id: "agent-emulation",
url: "cephas://agent#emulation",
description: "Visual dry-run lab for every registered agent command.",
},
AgentToolUrl {
id: "agent-emulate-command",
url: "cephas://agent/emulate?command=<command-id>",
description: "Internal route that selects one dry-run command in the Agent page lab.",
},
AgentToolUrl {
id: "agent-document",
url: "https://cephas.local/agent",
description: "Document base URL for the Agent page HTML.",
},
AgentToolUrl {
id: "agent-docs-document",
url: "https://cephas.local/agent/docs",
description: "Document base URL for the local Agent integration docs.",
},
AgentToolUrl {
id: "plugins-document",
url: "https://cephas.local/plugins",
description: "Document base URL for the local Plugins page.",
},
],
smoke_cases: agent_smoke_cases()
.into_iter()
.map(|case| AgentToolSmokeCase {
name: case.name,
target: case.target,
output_file: case.output_file,
})
.collect(),
human_control_routes: vec![
AgentToolRoute {
id: "enable-human-control",
route: "cephas://agent/human/enable",
description: "Enable session-only browser-window human control.",
},
AgentToolRoute {
id: "disable-human-control",
route: "cephas://agent/human/disable",
description: "Disable browser-window human control.",
},
AgentToolRoute {
id: "human-move-pointer",
route: "cephas://agent/human/move?x=<x>&y=<y>",
description: "Move the in-page pointer at viewport coordinates.",
},
AgentToolRoute {
id: "human-click",
route: "cephas://agent/human/click?x=<x>&y=<y>",
description: "Click the active normal page at viewport coordinates.",
},
AgentToolRoute {
id: "human-type-text",
route: "cephas://agent/human/type?text=<urlencoded>",
description: "Type URL-encoded text into the focused element.",
},
AgentToolRoute {
id: "human-press-key",
route: "cephas://agent/human/key?key=<key>",
description: "Dispatch a key press to the active page.",
},
AgentToolRoute {
id: "human-scroll",
route: "cephas://agent/human/scroll?dx=<dx>&dy=<dy>",
description: "Scroll the active page by viewport deltas.",
},
AgentToolRoute {
id: "human-wait",
route: "cephas://agent/human/wait?ms=<milliseconds>",
description: "Wait without changing the page.",
},
],
reader_tools: cephas::reader::AgentReaderTool::ALL
.into_iter()
.map(|tool| AgentToolReader {
id: tool.id(),
label: tool.label(),
description: tool.description(),
})
.collect(),
resource_caps: agent_tooling_resource_caps(),
diagnostics_surfaces: vec![
AgentToolDiagnosticsSurface {
id: "diagnostics-json",
access: vec![
"cephas diagnostics --json",
"cephas diagnostics --json --strict",
"mcp tool cephas_diagnostics",
],
output: DIAGNOSTICS_SCHEMA,
scope: "persisted/offline profile, session, downloads, plugin, Agent log, recording, Armour, and resource-cap checks",
description: "Machine-readable diagnostics for resources that can be read without attaching to a running GTK browser window; caps that cannot be checked here are represented in unobserved_resource_caps.",
},
AgentToolDiagnosticsSurface {
id: "diagnostics-live-page",
access: vec![
"cephas://diagnostics",
"cephas agent-screenshot cephas://diagnostics --output <png> --json",
"cephas agent-smoke --output-dir <dir> --case diagnostics --json",
],
output: "local-html; screenshot capture returns cephas.agent-screenshot.v1",
scope: "running GTK/WebKit browser process counters including open tabs, closed-tab snapshots, active downloads, Agent buffers, save queue state, active recording, pending human-control waits, and RSS",
description: "Stable local route for live runtime diagnostics; capture it with screenshot or smoke tools instead of scraping popovers. No standalone CLI JSON is exposed because these counters are process-local to the active browser UI.",
},
],
contracts: vec![
AgentToolContract {
id: "elixir-backend",
path: "elixir_backend.MD",
description: "Optional authenticated Elixir/Phoenix backend contract for profile/session sync, Agent event upload, visual recording upload, auth, retention, and UUIDv7 identity.",
},
AgentToolContract {
id: "plugins-backend",
path: "plugins_backend.MD",
description: "Backend-facing plugin supply contract for UUIDv7 manifests, permissions, capabilities, registry limits, and package ingestion boundaries.",
},
AgentToolContract {
id: "scrin-optimization",
path: "SCRIN_OPTIMIZATION.md",
description: "Capture/render handoff for screenshots, smoke output, visual recordings, and Scrin-style tooling.",
},
],
schemas: vec![
AgentToolSchema {
id: "agent-reader",
schema: cephas::reader::AGENT_READER_SCHEMA,
},
AgentToolSchema {
id: "agent-screenshot",
schema: AGENT_SCREENSHOT_SCHEMA,
},
AgentToolSchema {
id: "agent-smoke",
schema: AGENT_SMOKE_SCHEMA,
},
AgentToolSchema {
id: "internal-route-trust-smoke",
schema: INTERNAL_ROUTE_TRUST_SMOKE_SCHEMA,
},
AgentToolSchema {
id: "launch-safety-smoke",
schema: LAUNCH_SAFETY_SMOKE_SCHEMA,
},
AgentToolSchema {
id: "agent-tooling",
schema: AGENT_TOOLING_SCHEMA,
},
AgentToolSchema {
id: "diagnostics",
schema: DIAGNOSTICS_SCHEMA,
},
AgentToolSchema {
id: "self-test",
schema: SELF_TEST_SCHEMA,
},
AgentToolSchema {
id: "plugins",
schema: cephas::plugins::PLUGIN_REGISTRY_SCHEMA,
},
AgentToolSchema {
id: "plugin-module",
schema: cephas::plugins::PLUGIN_MODULE_SCHEMA,
},
AgentToolSchema {
id: "plugins-check",
schema: cephas::plugins::PLUGIN_CHECK_SCHEMA,
},
AgentToolSchema {
id: "plugins-action",
schema: "cephas.plugins.action.v1",
},
AgentToolSchema {
id: "plugins-backend-registry",
schema: cephas::plugins::PLUGIN_BACKEND_REGISTRY_SCHEMA,
},
AgentToolSchema {
id: "plugins-backend-ingest-report",
schema: cephas::plugins::PLUGIN_BACKEND_INGEST_REPORT_SCHEMA,
},
],
notes: vec![
"Prefer JSON or JSONL command output for agentic processes.",
"Use --profile <path> for isolated smoke runs that mutate settings or browsing state.",
"Human-control routes require opt-in and are rejected on private or internal pages.",
"Use the AI Inspect theme for high-contrast screenshot segmentation; use Glass for translucent human-facing chrome; use bounded custom theme presets as seeds for profile-local custom themes.",
"PDF targets stay in the WebKit browser surface when the runtime supports internal PDF viewing; zoom uses WebKit layout zoom.",
"Internal pages are rendered locally and should not require network access.",
"Long-running integrations must honor bounded resource caps and use diagnostics to verify stabilization.",
"CLI diagnostics JSON covers persisted/offline resources; live GTK runtime counters are exposed through cephas://diagnostics and can be captured with agent-screenshot or agent-smoke.",
"Plugins are persisted with UUIDv7 IDs, explicit capabilities, explicit permissions, a bounded registry, and local bundled module install actions.",
],
}
}
fn agent_tooling_resource_caps() -> Vec<AgentToolResourceCap> {
vec![
AgentToolResourceCap {
id: "open-tabs",
value: cephas::gui::MAX_OPEN_TABS as u64,
unit: "count",
description: "Maximum tabs retained by the desktop browser window.",
},
AgentToolResourceCap {
id: "closed-tab-snapshots",
value: cephas::gui::MAX_CLOSED_TABS as u64,
unit: "count",
description: "Maximum closed-tab snapshots retained for restore controls.",
},
AgentToolResourceCap {
id: "history-entries",
value: cephas::storage::DEFAULT_MAX_HISTORY as u64,
unit: "count",
description: "Maximum profile history entries retained locally.",
},
AgentToolResourceCap {
id: "bookmark-entries",
value: cephas::storage::DEFAULT_MAX_BOOKMARKS as u64,
unit: "count",
description: "Maximum profile bookmarks retained locally.",
},
AgentToolResourceCap {
id: "plugin-records",
value: cephas::plugins::DEFAULT_MAX_PLUGINS as u64,
unit: "count",
description: "Maximum UUIDv7 plugin records in the profile registry.",
},
AgentToolResourceCap {
id: "download-records",
value: cephas::downloads::DEFAULT_MAX_DOWNLOAD_RECORDS as u64,
unit: "count",
description: "Maximum completed download records retained locally.",
},
AgentToolResourceCap {
id: "active-downloads",
value: cephas::downloads::MAX_ACTIVE_DOWNLOADS as u64,
unit: "count",
description: "Maximum concurrent active downloads.",
},
AgentToolResourceCap {
id: "agent-delegations",
value: cephas::agents::DEFAULT_MAX_AGENT_DELEGATIONS as u64,
unit: "count",
description: "Maximum active Agent delegations retained in memory.",
},
AgentToolResourceCap {
id: "agent-events",
value: cephas::agents::DEFAULT_MAX_AGENT_EVENTS as u64,
unit: "count",
description: "Maximum in-memory Agent events before older events are pruned.",
},
AgentToolResourceCap {
id: "agent-audit-log-bytes",
value: cephas::agents::DEFAULT_MAX_AGENT_LOG_BYTES,
unit: "bytes",
description: "Maximum active Agent JSONL audit log size before rotation.",
},
AgentToolResourceCap {
id: "agent-audit-log-archives",
value: cephas::agents::DEFAULT_MAX_AGENT_LOG_ARCHIVES as u64,
unit: "count",
description: "Maximum rotated Agent audit log archives retained.",
},
AgentToolResourceCap {
id: "agent-audit-log-archive-bytes",
value: cephas::agents::DEFAULT_MAX_AGENT_LOG_BYTES
* cephas::agents::DEFAULT_MAX_AGENT_LOG_ARCHIVES as u64,
unit: "bytes",
description: "Maximum total bytes across retained Agent audit log archives.",
},
AgentToolResourceCap {
id: "agent-audit-log-largest-archive-bytes",
value: cephas::agents::DEFAULT_MAX_AGENT_LOG_BYTES,
unit: "bytes",
description: "Maximum expected size of one rotated Agent audit log archive.",
},
AgentToolResourceCap {
id: "recording-events",
value: cephas::recording::DEFAULT_MAX_RECORDING_EVENTS as u64,
unit: "count",
description: "Maximum visual recording events per recording.",
},
AgentToolResourceCap {
id: "recording-frames",
value: cephas::recording::DEFAULT_MAX_RECORDING_FRAMES as u64,
unit: "count",
description: "Maximum PNG frames retained per visual recording.",
},
AgentToolResourceCap {
id: "recording-directories",
value: cephas::recording::DEFAULT_MAX_RECORDING_DIRECTORIES as u64,
unit: "count",
description: "Maximum visual recording directories retained before pruning.",
},
AgentToolResourceCap {
id: "armour-site-allowlist-hosts",
value: cephas::privacy::DEFAULT_MAX_SITE_ALLOWLIST as u64,
unit: "count",
description: "Maximum Armour per-site allowlist hosts.",
},
AgentToolResourceCap {
id: "armour-blocked-hosts",
value: cephas::privacy::DEFAULT_MAX_BLOCKED_HOSTS as u64,
unit: "count",
description: "Maximum Armour blocked host rules, including built-ins.",
},
AgentToolResourceCap {
id: "discovered-subresources",
value: cephas::privacy::MAX_DISCOVERED_SUBRESOURCES as u64,
unit: "count",
description: "Maximum discovered subresources retained in a privacy report.",
},
AgentToolResourceCap {
id: "navigation-stack-entries",
value: cephas::browser::DEFAULT_MAX_NAVIGATION_STACK as u64,
unit: "count",
description: "Maximum terminal-browser back or forward stack entries.",
},
AgentToolResourceCap {
id: "page-body-bytes",
value: cephas::browser::DEFAULT_MAX_PAGE_BODY_BYTES as u64,
unit: "bytes",
description: "Maximum page body bytes read by terminal and GUI reader fetches.",
},
AgentToolResourceCap {
id: "session-windows",
value: cephas::session::MAX_SESSION_WINDOWS as u64,
unit: "count",
description: "Maximum session windows persisted in session snapshots.",
},
AgentToolResourceCap {
id: "session-tabs-per-window",
value: cephas::session::MAX_SESSION_TABS_PER_WINDOW as u64,
unit: "count",
description: "Maximum tabs persisted per session window.",
},
AgentToolResourceCap {
id: "save-queue-jobs",
value: cephas::gui::SAVE_QUEUE_CAPACITY as u64,
unit: "count",
description: "Maximum queued async save jobs before callers fall back to synchronous saves.",
},
AgentToolResourceCap {
id: "pending-human-waits",
value: cephas::gui::MAX_PENDING_HUMAN_WAITS as u64,
unit: "count",
description: "Maximum queued browser-window human-control wait timers.",
},
AgentToolResourceCap {
id: "ai-reader-content-bytes",
value: cephas::reader::DEFAULT_AI_READER_MAX_CONTENT_BYTES as u64,
unit: "bytes",
description: "Maximum extracted AI Reader content bytes.",
},
AgentToolResourceCap {
id: "ai-reader-headings",
value: cephas::reader::DEFAULT_AI_READER_MAX_HEADINGS as u64,
unit: "count",
description: "Maximum headings retained in AI Reader documents.",
},
AgentToolResourceCap {
id: "agent-reader-links",
value: cephas::reader::DEFAULT_AGENT_READER_MAX_LINKS as u64,
unit: "count",
description: "Maximum links retained in Agent reader link maps.",
},
AgentToolResourceCap {
id: "agent-reader-signal-values",
value: cephas::reader::DEFAULT_AGENT_READER_MAX_SIGNAL_VALUES as u64,
unit: "count",
description: "Maximum metadata signal values retained per Agent reader artifact.",
},
AgentToolResourceCap {
id: "agent-reader-tables",
value: cephas::reader::DEFAULT_AGENT_READER_MAX_TABLES as u64,
unit: "count",
description: "Maximum tables retained in Agent reader data signals.",
},
AgentToolResourceCap {
id: "agent-reader-table-rows",
value: cephas::reader::DEFAULT_AGENT_READER_MAX_TABLE_ROWS as u64,
unit: "count",
description: "Maximum rows retained per Agent reader table.",
},
AgentToolResourceCap {
id: "agent-reader-table-cells",
value: cephas::reader::DEFAULT_AGENT_READER_MAX_TABLE_CELLS as u64,
unit: "count",
description: "Maximum cells retained per Agent reader table row.",
},
]
}
fn diagnostics_report(profile: &cephas::Profile, store: &ProfileStore) -> DiagnosticsReport {
let mut errors = Vec::new();
let session = match store.session_store().load() {
Ok(session) => {
let windows = session.windows.len() as u64;
let tabs_per_window = session
.windows
.iter()
.map(|window| window.tabs.len() as u64)
.max()
.unwrap_or(0);
let tabs = session
.windows
.iter()
.map(|window| window.tabs.len() as u64)
.sum::<u64>();
Some(DiagnosticsSession {
windows: DiagnosticsUsage {
current: windows,
max: cephas::session::MAX_SESSION_WINDOWS as u64,
},
tabs: DiagnosticsUsage {
current: tabs,
max: (cephas::session::MAX_SESSION_WINDOWS
* cephas::session::MAX_SESSION_TABS_PER_WINDOW)
as u64,
},
tabs_per_window: DiagnosticsUsage {
current: tabs_per_window,
max: cephas::session::MAX_SESSION_TABS_PER_WINDOW as u64,
},
})
}
Err(err) => {
errors.push(DiagnosticsError {
component: "session",
error: err.to_string(),
});
None
}
};
let downloads = match store.download_store().load() {
Ok(downloads) => {
let mut in_progress = 0_u64;
let mut paused = 0_u64;
let mut completed = 0_u64;
let mut cancelled = 0_u64;
let mut failed = 0_u64;
for record in downloads.records() {
match &record.status {
cephas::downloads::DownloadStatus::InProgress => in_progress += 1,
cephas::downloads::DownloadStatus::Paused => paused += 1,
cephas::downloads::DownloadStatus::Completed => completed += 1,
cephas::downloads::DownloadStatus::Cancelled => cancelled += 1,
cephas::downloads::DownloadStatus::Failed(_) => failed += 1,
}
}
Some(DiagnosticsDownloads {
records: DiagnosticsUsage {
current: downloads.records().count() as u64,
max: downloads.max_records() as u64,
},
in_progress,
paused,
completed,
cancelled,
failed,
})
}
Err(err) => {
errors.push(DiagnosticsError {
component: "downloads",
error: err.to_string(),
});
None
}
};
let agent_log =
match cephas::agents::AgentLogStore::new(None).and_then(|store| store.diagnostics()) {
Ok(log) => Some(DiagnosticsAgentLog {
bytes: DiagnosticsUsage {
current: log.bytes,
max: log.max_bytes,
},
archives: DiagnosticsUsage {
current: log.archives as u64,
max: log.max_archives as u64,
},
total_archive_bytes: DiagnosticsUsage {
current: log.total_archive_bytes,
max: log.max_total_archive_bytes,
},
largest_archive_bytes: DiagnosticsUsage {
current: log.largest_archive_bytes,
max: log.max_archive_bytes,
},
}),
Err(err) => {
errors.push(DiagnosticsError {
component: "agent-log",
error: err.to_string(),
});
None
}
};
let recordings = match cephas::recording::BrowserRecordingStore::new(None)
.and_then(|store| store.diagnostics())
{
Ok(recordings) => Some(DiagnosticsRecordings {
directories: DiagnosticsUsage {
current: recordings.directories as u64,
max: recordings.max_directories as u64,
},
total_bytes: recordings.total_bytes,
}),
Err(err) => {
errors.push(DiagnosticsError {
component: "recordings",
error: err.to_string(),
});
None
}
};
let resource_caps = agent_tooling_resource_caps();
let resource_checks = diagnostics_resource_checks(
&resource_caps,
profile,
session.as_ref(),
downloads.as_ref(),
agent_log.as_ref(),
recordings.as_ref(),
);
let unobserved_resource_caps =
diagnostics_unobserved_resource_caps(&resource_caps, &resource_checks);
let ok = errors.is_empty() && resource_checks.values().all(|check| check.ok);
DiagnosticsReport {
schema: DIAGNOSTICS_SCHEMA,
ok,
profile_path: store.path().display().to_string(),
profile: DiagnosticsProfile {
home: profile.home.to_string(),
theme: profile.theme.id(),
ai_browsing_enabled: profile.ai_browsing_enabled,
media_policy: profile.media_playback.id(),
media_capture_enabled: profile.media_playback.allows_capture(),
web_acceleration: profile.web_acceleration.id(),
history_entries: DiagnosticsUsage {
current: profile.history.len() as u64,
max: profile.max_history.max(1) as u64,
},
bookmarks: DiagnosticsUsage {
current: profile.bookmarks.len() as u64,
max: profile.max_bookmarks.max(1) as u64,
},
},
session,
downloads,
plugins: DiagnosticsPlugins {
installed: DiagnosticsUsage {
current: profile.plugins.installed_count() as u64,
max: profile.plugins.max_plugins.max(1) as u64,
},
enabled: profile.plugins.enabled_count() as u64,
planned: profile.plugins.planned_count() as u64,
},
armour: DiagnosticsArmour {
site_allowlist_hosts: DiagnosticsUsage {
current: profile.shields.site_allowlist.len() as u64,
max: cephas::privacy::DEFAULT_MAX_SITE_ALLOWLIST as u64,
},
blocked_hosts: DiagnosticsUsage {
current: profile.shields.blocked_hosts.len() as u64,
max: cephas::privacy::DEFAULT_MAX_BLOCKED_HOSTS as u64,
},
},
agent_log,
recordings,
resource_caps,
resource_checks,
unobserved_resource_caps,
errors,
}
}
fn diagnostics_unobserved_resource_caps(
caps: &[AgentToolResourceCap],
checks: &BTreeMap<&'static str, DiagnosticsResourceCheck>,
) -> Vec<DiagnosticsUnobservedResourceCap> {
caps.iter()
.filter(|cap| !checks.contains_key(cap.id))
.map(|cap| {
let (component, reason, surface) = diagnostics_unobserved_cap_metadata(cap.id);
DiagnosticsUnobservedResourceCap {
id: cap.id,
component,
max: cap.value,
unit: cap.unit,
reason,
surface,
description: cap.description,
}
})
.collect()
}
fn diagnostics_unobserved_cap_metadata(id: &str) -> (&'static str, &'static str, &'static str) {
match id {
"open-tabs" | "closed-tab-snapshots" | "save-queue-jobs" | "pending-human-waits" => (
"browser-ui",
"live-runtime-only",
"cephas://diagnostics or agent-smoke --output-dir <dir> --case diagnostics --json",
),
"agent-delegations" | "agent-events" => (
"agent-runtime",
"live-runtime-only",
"cephas://diagnostics or agent-smoke --output-dir <dir> --case diagnostics --json",
),
"recording-events" | "recording-frames" => (
"recording-runtime",
"live-runtime-only",
"cephas://diagnostics or agent-smoke --output-dir <dir> --case diagnostics --json",
),
"discovered-subresources" => (
"privacy-report",
"per-operation-limit",
"privacy report generation",
),
"navigation-stack-entries" => (
"terminal-browser",
"live-runtime-only",
"interactive terminal browser diagnostics",
),
"page-body-bytes" => ("fetch", "per-operation-limit", "page and reader fetches"),
"ai-reader-content-bytes" | "ai-reader-headings" => (
"ai-reader",
"per-artifact-limit",
"ai-reader artifact output",
),
"agent-reader-links"
| "agent-reader-signal-values"
| "agent-reader-tables"
| "agent-reader-table-rows"
| "agent-reader-table-cells" => (
"agent-reader",
"per-artifact-limit",
"agent-reader artifact output",
),
_ => (
"resource-cap",
"not-observed-by-persisted-diagnostics",
"agent-tooling.resource_caps",
),
}
}
fn diagnostics_resource_checks(
caps: &[AgentToolResourceCap],
profile: &cephas::Profile,
session: Option<&DiagnosticsSession>,
downloads: Option<&DiagnosticsDownloads>,
agent_log: Option<&DiagnosticsAgentLog>,
recordings: Option<&DiagnosticsRecordings>,
) -> BTreeMap<&'static str, DiagnosticsResourceCheck> {
let mut checks = BTreeMap::new();
push_diagnostics_usage_check(
&mut checks,
caps,
"profile",
"history-entries",
DiagnosticsUsage {
current: profile.history.len() as u64,
max: profile.max_history.max(1) as u64,
},
);
push_diagnostics_usage_check(
&mut checks,
caps,
"profile",
"bookmark-entries",
DiagnosticsUsage {
current: profile.bookmarks.len() as u64,
max: profile.max_bookmarks.max(1) as u64,
},
);
push_diagnostics_usage_check(
&mut checks,
caps,
"plugins",
"plugin-records",
DiagnosticsUsage {
current: profile.plugins.installed_count() as u64,
max: profile.plugins.max_plugins.max(1) as u64,
},
);
push_diagnostics_usage_check(
&mut checks,
caps,
"armour",
"armour-site-allowlist-hosts",
DiagnosticsUsage {
current: profile.shields.site_allowlist.len() as u64,
max: cephas::privacy::DEFAULT_MAX_SITE_ALLOWLIST as u64,
},
);
push_diagnostics_usage_check(
&mut checks,
caps,
"armour",
"armour-blocked-hosts",
DiagnosticsUsage {
current: profile.shields.blocked_hosts.len() as u64,
max: cephas::privacy::DEFAULT_MAX_BLOCKED_HOSTS as u64,
},
);
if let Some(session) = session {
push_diagnostics_usage_check(
&mut checks,
caps,
"session",
"session-windows",
session.windows,
);
push_diagnostics_usage_check(
&mut checks,
caps,
"session",
"session-tabs-per-window",
session.tabs_per_window,
);
}
if let Some(downloads) = downloads {
push_diagnostics_usage_check(
&mut checks,
caps,
"downloads",
"download-records",
downloads.records,
);
push_diagnostics_usage_check(
&mut checks,
caps,
"downloads",
"active-downloads",
DiagnosticsUsage {
current: downloads.in_progress,
max: cephas::downloads::MAX_ACTIVE_DOWNLOADS as u64,
},
);
}
if let Some(agent_log) = agent_log {
push_diagnostics_usage_check(
&mut checks,
caps,
"agent-log",
"agent-audit-log-bytes",
agent_log.bytes,
);
push_diagnostics_usage_check(
&mut checks,
caps,
"agent-log",
"agent-audit-log-archives",
agent_log.archives,
);
push_diagnostics_usage_check(
&mut checks,
caps,
"agent-log",
"agent-audit-log-archive-bytes",
agent_log.total_archive_bytes,
);
push_diagnostics_usage_check(
&mut checks,
caps,
"agent-log",
"agent-audit-log-largest-archive-bytes",
agent_log.largest_archive_bytes,
);
}
if let Some(recordings) = recordings {
push_diagnostics_usage_check(
&mut checks,
caps,
"recordings",
"recording-directories",
recordings.directories,
);
}
checks
}
fn push_diagnostics_usage_check(
checks: &mut BTreeMap<&'static str, DiagnosticsResourceCheck>,
caps: &[AgentToolResourceCap],
component: &'static str,
id: &'static str,
usage: DiagnosticsUsage,
) {
let Some(cap) = caps.iter().find(|cap| cap.id == id) else {
return;
};
checks.insert(
id,
DiagnosticsResourceCheck {
id: cap.id,
component,
current: usage.current,
max: usage.max,
unit: cap.unit,
ok: usage.current <= usage.max,
description: cap.description,
},
);
}
fn print_diagnostics_report(
profile: &cephas::Profile,
store: &ProfileStore,
json: bool,
strict: bool,
) -> anyhow::Result<()> {
let report = diagnostics_report(profile, store);
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
if diagnostics_should_fail(&report, strict) {
return Err(diagnostics_failure(&report));
}
return Ok(());
}
println!("diagnostics: {}", report.profile_path);
println!(" status: {}", if report.ok { "ok" } else { "attention" });
let ok_checks = report
.resource_checks
.values()
.filter(|check| check.ok)
.count();
println!(
" resource checks: {ok_checks} / {} ok",
report.resource_checks.len()
);
if !report.unobserved_resource_caps.is_empty() {
println!(
" unobserved resource caps: {} (see JSON unobserved_resource_caps)",
report.unobserved_resource_caps.len()
);
}
println!(
" history: {} / {}",
report.profile.history_entries.current, report.profile.history_entries.max
);
println!(
" bookmarks: {} / {}",
report.profile.bookmarks.current, report.profile.bookmarks.max
);
println!(
" plugins: {} / {}, {} enabled, {} planned",
report.plugins.installed.current,
report.plugins.installed.max,
report.plugins.enabled,
report.plugins.planned
);
if let Some(session) = &report.session {
println!(
" session windows: {} / {}",
session.windows.current, session.windows.max
);
println!(
" session tabs: {} / {}",
session.tabs.current, session.tabs.max
);
}
if let Some(downloads) = &report.downloads {
println!(
" download records: {} / {}",
downloads.records.current, downloads.records.max
);
println!(
" download states: {} in progress, {} paused, {} complete, {} cancelled, {} failed",
downloads.in_progress,
downloads.paused,
downloads.completed,
downloads.cancelled,
downloads.failed
);
}
println!(
" Armour allowlist: {} / {}",
report.armour.site_allowlist_hosts.current, report.armour.site_allowlist_hosts.max
);
println!(
" Armour blocked hosts: {} / {}",
report.armour.blocked_hosts.current, report.armour.blocked_hosts.max
);
if let Some(agent_log) = &report.agent_log {
println!(
" Agent audit log: {} / {} bytes, {} / {} archives, {} / {} archive bytes, largest {} / {} bytes",
agent_log.bytes.current,
agent_log.bytes.max,
agent_log.archives.current,
agent_log.archives.max,
agent_log.total_archive_bytes.current,
agent_log.total_archive_bytes.max,
agent_log.largest_archive_bytes.current,
agent_log.largest_archive_bytes.max
);
}
if let Some(recordings) = &report.recordings {
println!(
" recordings: {} / {} directories, {} bytes",
recordings.directories.current, recordings.directories.max, recordings.total_bytes
);
}
for check in report.resource_checks.values().filter(|check| !check.ok) {
println!(
" {} over cap: {} / {} {}",
check.id, check.current, check.max, check.unit
);
}
for error in &report.errors {
println!(
" {} diagnostics unavailable: {}",
error.component, error.error
);
}
if diagnostics_should_fail(&report, strict) {
return Err(diagnostics_failure(&report));
}
Ok(())
}
fn diagnostics_should_fail(report: &DiagnosticsReport, strict: bool) -> bool {
strict && !report.ok
}
fn diagnostics_failure(report: &DiagnosticsReport) -> anyhow::Error {
let failed_checks = report
.resource_checks
.values()
.filter(|check| !check.ok)
.count();
anyhow::anyhow!(
"diagnostics failed: {failed_checks} resource checks over cap, {} collection errors",
report.errors.len()
)
}
fn run_mcp_server(profile: &cephas::Profile, store: &ProfileStore) -> anyhow::Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = line?;
let line = line.trim();
if line.is_empty() {
continue;
}
let response = match serde_json::from_str::<Value>(line) {
Ok(message) => mcp_handle_message(profile, store, message),
Err(err) => Some(mcp_error_response(
None,
-32700,
format!("parse error: {err}"),
)),
};
if let Some(response) = response {
serde_json::to_writer(&mut stdout, &response)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
}
}
Ok(())
}
fn mcp_handle_message(
profile: &cephas::Profile,
store: &ProfileStore,
message: Value,
) -> Option<Value> {
match message {
Value::Array(messages) => {
let responses = messages
.into_iter()
.filter_map(|message| mcp_handle_request(profile, store, message))
.collect::<Vec<_>>();
if responses.is_empty() {
None
} else {
Some(Value::Array(responses))
}
}
message => mcp_handle_request(profile, store, message),
}
}
fn mcp_handle_request(
profile: &cephas::Profile,
store: &ProfileStore,
request: Value,
) -> Option<Value> {
let Some(object) = request.as_object() else {
return Some(mcp_error_response(
None,
-32600,
"invalid request".to_string(),
));
};
let id = object.get("id").cloned();
let Some(method) = object.get("method").and_then(Value::as_str) else {
return id.map(|id| mcp_error_response(Some(id), -32600, "missing method".to_string()));
};
let id = id?;
if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
return Some(mcp_error_response(
Some(id),
-32600,
"expected jsonrpc 2.0".to_string(),
));
}
match method {
"initialize" => Some(mcp_success_response(
id,
json!({
"protocolVersion": MCP_PROTOCOL_VERSION,
"serverInfo": {
"name": "cephas",
"version": env!("CARGO_PKG_VERSION")
},
"capabilities": {
"tools": {
"listChanged": false
}
}
}),
)),
"ping" => Some(mcp_success_response(id, json!({}))),
"tools/list" => Some(mcp_success_response(
id,
json!({ "tools": mcp_tool_definitions() }),
)),
"tools/call" => Some(
match mcp_tool_call_result(profile, store, object.get("params")) {
Ok(result) => mcp_success_response(id, result),
Err(err) => mcp_error_response(Some(id), -32602, err.to_string()),
},
),
"resources/list" => Some(mcp_success_response(id, json!({ "resources": [] }))),
"prompts/list" => Some(mcp_success_response(id, json!({ "prompts": [] }))),
_ => Some(mcp_error_response(
Some(id),
-32601,
format!("unknown method '{method}'"),
)),
}
}
fn mcp_success_response(id: Value, result: Value) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"result": result
})
}
fn mcp_error_response(id: Option<Value>, code: i64, message: String) -> Value {
json!({
"jsonrpc": "2.0",
"id": id.unwrap_or(Value::Null),
"error": {
"code": code,
"message": message
}
})
}
fn mcp_tool_definitions() -> Vec<Value> {
[
(
"cephas_agent_tooling",
"Return Cephas' portable agent-tooling manifest as JSON.",
),
(
"cephas_agent_commands",
"Return the stable browser-command manifest as JSON.",
),
(
"cephas_diagnostics",
"Return bounded resource diagnostics as JSON.",
),
(
"cephas_plugins",
"Return the UUIDv7 profile plugin registry and bundled module catalog as JSON.",
),
]
.into_iter()
.map(|(name, description)| {
json!({
"name": name,
"description": description,
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
})
})
.collect()
}
fn mcp_tool_call_result(
profile: &cephas::Profile,
store: &ProfileStore,
params: Option<&Value>,
) -> anyhow::Result<Value> {
let params = params
.and_then(Value::as_object)
.context("tools/call params must be an object")?;
let name = params
.get("name")
.and_then(Value::as_str)
.context("tools/call params.name must be a string")?;
let text = match name {
"cephas_agent_tooling" => serde_json::to_string_pretty(&agent_tooling_manifest())?,
"cephas_agent_commands" => {
serde_json::to_string_pretty(&cephas::agents::agent_command_manifest())?
}
"cephas_diagnostics" => serde_json::to_string_pretty(&diagnostics_report(profile, store))?,
"cephas_plugins" => plugin_registry_report_json(profile)?,
other => anyhow::bail!("unknown MCP tool '{other}'"),
};
Ok(json!({
"content": [
{
"type": "text",
"text": text
}
],
"isError": false
}))
}
fn plugin_registry_report_json(profile: &cephas::Profile) -> anyhow::Result<String> {
let bundled = cephas::plugins::bundled_plugin_modules();
let available = cephas::plugins::available_bundled_plugin_modules(&profile.plugins);
let report = PluginRegistryReport {
schema: cephas::plugins::PLUGIN_REGISTRY_SCHEMA,
enabled: profile.plugins.enabled,
max_plugins: profile.plugins.max_plugins,
installed_count: profile.plugins.installed_count(),
enabled_count: profile.plugins.enabled_count(),
planned_count: profile.plugins.planned_count(),
bundled_count: bundled.len(),
available_count: available.len(),
installed: &profile.plugins.installed,
bundled: &bundled,
available: &available,
};
Ok(serde_json::to_string_pretty(&report)?)
}
fn plugin_command(
mut profile: cephas::Profile,
store: ProfileStore,
jsonl: bool,
action: Option<PluginCommand>,
) -> anyhow::Result<()> {
let Some(action) = action else {
return print_plugins(&profile, jsonl);
};
match action {
PluginCommand::Available { jsonl } => print_bundled_plugins(&profile, jsonl),
PluginCommand::Check {
strict,
fail_on_warning,
} => print_plugin_check(&profile, strict, fail_on_warning),
PluginCommand::Install { selector } => {
let id = resolve_plugin_selector(&selector)?;
let plugin = profile.plugins.install_bundled(id)?;
store.save(&profile)?;
print_plugin_action("install", plugin, &profile)
}
PluginCommand::InstallAll => {
let plugins = profile.plugins.install_all_available_bundled()?;
store.save(&profile)?;
print_plugin_bulk_action("install-all", plugins, &profile)
}
PluginCommand::Sync { selector } => {
let id = resolve_plugin_selector(&selector)?;
let plugin = profile.plugins.sync_bundled(id)?;
store.save(&profile)?;
print_plugin_action("sync", plugin, &profile)
}
PluginCommand::SyncAll => {
let plugins = profile.plugins.sync_all_installed_bundled()?;
store.save(&profile)?;
print_plugin_bulk_action("sync-all", plugins, &profile)
}
PluginCommand::Enable { selector } => {
let id = resolve_plugin_selector(&selector)?;
let plugin = profile
.plugins
.set_status(id, cephas::plugins::PluginStatus::Enabled)?;
store.save(&profile)?;
print_plugin_action("enable", plugin, &profile)
}
PluginCommand::Disable { selector } => {
let id = resolve_plugin_selector(&selector)?;
let plugin = profile
.plugins
.set_status(id, cephas::plugins::PluginStatus::Disabled)?;
store.save(&profile)?;
print_plugin_action("disable", plugin, &profile)
}
PluginCommand::Remove { selector } => {
let id = resolve_plugin_selector(&selector)?;
let plugin = profile.plugins.remove(id)?;
store.save(&profile)?;
print_plugin_action("remove", plugin, &profile)
}
}
}
fn print_plugins(profile: &cephas::Profile, jsonl: bool) -> anyhow::Result<()> {
if jsonl {
for plugin in &profile.plugins.installed {
println!("{}", serde_json::to_string(plugin)?);
}
return Ok(());
}
let bundled = cephas::plugins::bundled_plugin_modules();
let available = cephas::plugins::available_bundled_plugin_modules(&profile.plugins);
let report = PluginRegistryReport {
schema: cephas::plugins::PLUGIN_REGISTRY_SCHEMA,
enabled: profile.plugins.enabled,
max_plugins: profile.plugins.max_plugins,
installed_count: profile.plugins.installed_count(),
enabled_count: profile.plugins.enabled_count(),
planned_count: profile.plugins.planned_count(),
bundled_count: bundled.len(),
available_count: available.len(),
installed: &profile.plugins.installed,
bundled: &bundled,
available: &available,
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn print_bundled_plugins(profile: &cephas::Profile, jsonl: bool) -> anyhow::Result<()> {
let bundled = cephas::plugins::available_bundled_plugin_modules(&profile.plugins);
if jsonl {
for module in &bundled {
println!("{}", serde_json::to_string(module)?);
}
} else {
println!("{}", serde_json::to_string_pretty(&bundled)?);
}
Ok(())
}
fn print_plugin_check(
profile: &cephas::Profile,
strict: bool,
fail_on_warning: bool,
) -> anyhow::Result<()> {
let report = cephas::plugins::check_plugin_registry(&profile.plugins);
println!("{}", serde_json::to_string_pretty(&report)?);
if plugin_check_should_fail(&report, strict, fail_on_warning) {
anyhow::bail!(
"plugin check failed: {} errors, {} warnings",
report.error_count,
report.warning_count
);
}
Ok(())
}
fn plugin_check_should_fail(
report: &cephas::plugins::PluginCheckReport,
strict: bool,
fail_on_warning: bool,
) -> bool {
report.error_count > 0 || ((strict || fail_on_warning) && report.warning_count > 0)
}
fn resolve_plugin_selector(selector: &str) -> anyhow::Result<cephas::plugins::PluginId> {
cephas::plugins::resolve_plugin_selector(selector)
.with_context(|| format!("unknown plugin selector '{selector}'"))
}
fn print_plugin_action(
action: &'static str,
plugin: cephas::plugins::PluginManifest,
profile: &cephas::Profile,
) -> anyhow::Result<()> {
let module = cephas::plugins::bundled_plugin_module_for_manifest(plugin.id);
let report = PluginActionReport {
schema: "cephas.plugins.action.v1",
action,
module_id: module.as_ref().map(|module| module.module_id.clone()),
source_path: module.as_ref().map(|module| module.source_path.clone()),
plugin,
installed_count: profile.plugins.installed_count(),
enabled_count: profile.plugins.enabled_count(),
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn print_plugin_bulk_action(
action: &'static str,
plugins: Vec<cephas::plugins::PluginManifest>,
profile: &cephas::Profile,
) -> anyhow::Result<()> {
let report = PluginBulkActionReport {
schema: "cephas.plugins.action.v1",
action,
changed_count: plugins.len(),
plugins,
installed_count: profile.plugins.installed_count(),
enabled_count: profile.plugins.enabled_count(),
available_count: cephas::plugins::available_bundled_plugin_modules(&profile.plugins).len(),
};
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn open_once(mut browser: Browser, store: ProfileStore, target: String) -> anyhow::Result<()> {
{
let page = browser
.open(&target)
.with_context(|| format!("failed to open {target}"))?;
cephas::ui::print_page(page);
}
store.save(&browser.profile)?;
Ok(())
}
fn ai_reader_once(mut browser: Browser, store: ProfileStore, target: String) -> anyhow::Result<()> {
{
let page = browser
.open(&target)
.with_context(|| format!("failed to open {target}"))?;
let document = cephas::reader::ai_reader_document(&page.article, &page.url);
println!("{}", cephas::reader::format_ai_reader_markdown(&document));
}
store.save(&browser.profile)?;
Ok(())
}
fn agent_reader_once(
mut browser: Browser,
store: ProfileStore,
tool: String,
target: String,
) -> anyhow::Result<()> {
let tool = cephas::reader::AgentReaderTool::from_id(&tool).with_context(|| {
let tools = cephas::reader::AgentReaderTool::ALL
.into_iter()
.map(|tool| tool.id())
.collect::<Vec<_>>()
.join(", ");
format!("unknown agent reader tool '{tool}'. Expected one of: {tools}")
})?;
{
let page = browser
.open(&target)
.with_context(|| format!("failed to open {target}"))?;
let artifact = cephas::reader::agent_reader_artifact(
&page.article,
&page.body,
&page.url,
tool,
page.body_truncated,
);
println!("{}", cephas::reader::format_agent_reader_output(&artifact));
}
store.save(&browser.profile)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smoke_case_selection_defaults_to_all_cases() {
let cases = select_agent_smoke_cases(&[]).unwrap();
assert_eq!(cases.len(), 10);
assert_eq!(cases[0].name, "agent");
assert_eq!(cases[1].name, "agent-docs");
assert_eq!(cases[2].name, "agent-document");
assert_eq!(cases[3].name, "start");
assert_eq!(cases[4].name, "settings");
assert_eq!(cases[5].name, "diagnostics");
assert_eq!(cases[6].name, "plugins");
assert_eq!(cases[7].name, "internal-route-trust");
assert_eq!(cases[7].kind, AgentSmokeCaseKind::InternalRouteTrust);
assert_eq!(cases[8].name, "launch-unsafe-home");
assert_eq!(
cases[8].kind,
AgentSmokeCaseKind::LaunchSafety(LaunchSafetyScenario::UnsafeHome)
);
assert_eq!(cases[9].name, "launch-unknown-internal");
assert_eq!(
cases[9].kind,
AgentSmokeCaseKind::LaunchSafety(LaunchSafetyScenario::UnknownInternalTarget)
);
}
#[test]
fn smoke_case_selection_filters_by_name() {
let cases = select_agent_smoke_cases(&["start".to_string()]).unwrap();
assert_eq!(
cases,
vec![AgentSmokeCase {
name: "start",
target: "cephas://start",
output_file: "start.png",
kind: AgentSmokeCaseKind::Screenshot,
}]
);
}
#[test]
fn smoke_case_selection_rejects_unknown_names() {
let err = select_agent_smoke_cases(&["missing".to_string()]).unwrap_err();
assert!(err.to_string().contains("unknown smoke case 'missing'"));
assert!(err.to_string().contains("agent-document"));
assert!(err.to_string().contains("launch-unknown-internal"));
}
#[test]
fn smoke_report_serializes_suite_and_case_duration() {
let case = AgentSmokeCaseReport {
name: "settings".to_string(),
target: "cephas://settings".to_string(),
output: "/tmp/cephas-smoke/settings.png".to_string(),
ok: true,
bytes: Some(1234),
duration_ms: 42,
error: None,
};
let report = AgentSmokeReport {
schema: AGENT_SMOKE_SCHEMA,
ok: true,
output_dir: "/tmp/cephas-smoke".to_string(),
width: 1280,
height: 900,
min_bytes: 1024,
duration_ms: 84,
cases: vec![case],
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"duration_ms\":84"));
assert!(json.contains("\"duration_ms\":42"));
}
#[test]
fn internal_route_trust_smoke_page_targets_mutating_route() {
let html = internal_route_trust_html();
assert!(html.contains("Cephas route trust smoke"));
assert!(html.contains(INTERNAL_ROUTE_TRUST_ATTEMPT));
assert!(html.contains("location.href="));
}
#[test]
fn internal_route_trust_report_serializes_schema() {
let report = InternalRouteTrustSmokeReport {
schema: INTERNAL_ROUTE_TRUST_SMOKE_SCHEMA,
ok: true,
source_url: "http://127.0.0.1:1/".to_string(),
attempted_route: INTERNAL_ROUTE_TRUST_ATTEMPT,
profile_path: "/tmp/profile.json".to_string(),
before_theme: "paper",
after_theme: "paper",
server_saw_request: true,
child_status: "killed-after-timeout".to_string(),
duration_ms: 77,
error: None,
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains(INTERNAL_ROUTE_TRUST_SMOKE_SCHEMA));
assert!(json.contains(INTERNAL_ROUTE_TRUST_ATTEMPT));
assert!(json.contains("\"server_saw_request\":true"));
}
#[test]
fn launch_safety_report_serializes_schema() {
let report = LaunchSafetySmokeReport {
schema: LAUNCH_SAFETY_SMOKE_SCHEMA,
ok: true,
case: "launch-unknown-internal",
profile_path: "/tmp/profile.json".to_string(),
launch_target: Some(UNKNOWN_INTERNAL_LAUNCH_TARGET),
tampered_home: None,
loaded_home: Some("https://www.google.com/".to_string()),
child_status: "killed-after-timeout".to_string(),
duration_ms: 77,
error: None,
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains(LAUNCH_SAFETY_SMOKE_SCHEMA));
assert!(json.contains(UNKNOWN_INTERNAL_LAUNCH_TARGET));
assert!(json.contains("\"case\":\"launch-unknown-internal\""));
}
#[test]
fn launch_safety_tampered_home_loads_as_safe_home() {
let dir = tempfile::tempdir().unwrap();
let profile_path = dir.path().join("profile.json");
write_tampered_profile_home(&profile_path, UNSAFE_HOME_LAUNCH_VALUE).unwrap();
let store = ProfileStore::new(Some(profile_path)).unwrap();
let loaded = store.load().unwrap();
assert_eq!(loaded.home, cephas::Profile::default().home);
assert!(
launch_safety_scenario_error(
LaunchSafetyScenario::UnsafeHome,
Some(loaded.home.as_str())
)
.is_none()
);
}
#[test]
fn screenshot_report_serializes_duration() {
let report = AgentScreenshotReport {
schema: AGENT_SCREENSHOT_SCHEMA.to_string(),
ok: true,
target: "cephas://settings".to_string(),
output: "/tmp/cephas-settings.png".to_string(),
width: 1280,
height: 900,
bytes: Some(1234),
duration_ms: 77,
error: None,
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"duration_ms\":77"));
}
#[test]
fn smoke_png_validation_checks_header_and_dimensions() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("valid.png");
fs::write(&png, png_header(2560, 1800)).unwrap();
assert!(validate_smoke_screenshot_artifact(&png, Some(2048), 1024, 1280, 900).is_ok());
assert_eq!(
read_png_dimensions(&png).unwrap(),
PngDimensions {
width: 2560,
height: 1800,
}
);
assert_eq!(
png_device_scale(
PngDimensions {
width: 2560,
height: 1800,
},
PngDimensions {
width: 1280,
height: 900,
},
),
Some(2)
);
}
#[test]
fn smoke_png_validation_rejects_bad_artifacts() {
let dir = tempfile::tempdir().unwrap();
let invalid = dir.path().join("invalid.png");
let wrong_size = dir.path().join("wrong-size.png");
fs::write(&invalid, b"not a png with enough bytes to read").unwrap();
fs::write(&wrong_size, png_header(2560, 900)).unwrap();
assert!(
validate_smoke_screenshot_artifact(&invalid, Some(2048), 1024, 1280, 900)
.unwrap_err()
.to_string()
.contains("failed to validate screenshot PNG")
);
assert!(
validate_smoke_screenshot_artifact(&wrong_size, Some(2048), 1024, 1280, 900)
.unwrap_err()
.to_string()
.contains("bounded integer device scale")
);
assert!(
validate_smoke_screenshot_artifact(&wrong_size, Some(10), 1024, 1280, 900)
.unwrap_err()
.to_string()
.contains("suspiciously small screenshot")
);
}
fn png_header(width: u32, height: u32) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"\x89PNG\r\n\x1a\n");
bytes.extend_from_slice(&13_u32.to_be_bytes());
bytes.extend_from_slice(b"IHDR");
bytes.extend_from_slice(&width.to_be_bytes());
bytes.extend_from_slice(&height.to_be_bytes());
bytes.extend_from_slice(&[8, 6, 0, 0, 0]);
bytes.extend_from_slice(&0_u32.to_be_bytes());
bytes
}
#[test]
fn external_fallback_rejects_internal_and_non_web_targets() {
let mut profile = cephas::Profile::default();
assert!(can_launch_external_fallback(
&profile,
Some("https://example.com")
));
assert!(can_launch_external_fallback(&profile, Some("search terms")));
assert!(!can_launch_external_fallback(
&profile,
Some("cephas://agent")
));
assert!(!can_launch_external_fallback(
&profile,
Some("https://cephas.local/agent")
));
assert!(!can_launch_external_fallback(
&profile,
Some("file:///tmp/x")
));
profile.home = url::Url::parse("cephas://start").unwrap();
assert!(!can_launch_external_fallback(&profile, None));
}
#[test]
fn self_test_report_passes_and_serializes_schema() {
let report = self_test_report();
assert_eq!(report.schema, SELF_TEST_SCHEMA);
assert!(report.ok);
assert!(report.checks.len() >= 4);
assert!(report.checks.iter().any(|check| {
check.id == "launch-route-policy" && check.detail.contains("internal")
}));
assert!(report.checks.iter().any(|check| {
check.id == "profile-resource-clamps" && check.detail.contains("history")
}));
assert!(report.checks.iter().any(|check| {
check.id == "profile-home-migration" && check.detail.contains("https://")
}));
assert!(report.checks.iter().any(|check| {
check.id == "plugin-resource-clamps" && check.detail.contains("plugin")
}));
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains(SELF_TEST_SCHEMA));
assert!(json.contains("\"ok\":true"));
}
#[test]
fn plugin_registry_report_includes_available_module_sources() {
let profile = cephas::Profile::default();
let bundled = cephas::plugins::bundled_plugin_modules();
let available = cephas::plugins::available_bundled_plugin_modules(&profile.plugins);
let report = PluginRegistryReport {
schema: cephas::plugins::PLUGIN_REGISTRY_SCHEMA,
enabled: profile.plugins.enabled,
max_plugins: profile.plugins.max_plugins,
installed_count: profile.plugins.installed_count(),
enabled_count: profile.plugins.enabled_count(),
planned_count: profile.plugins.planned_count(),
bundled_count: bundled.len(),
available_count: available.len(),
installed: &profile.plugins.installed,
bundled: &bundled,
available: &available,
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"available\""));
assert!(json.contains("\"source_path\""));
assert!(json.contains("plugins/reader-outline/plugin.json"));
}
#[test]
fn plugin_check_exit_policy_fails_errors_and_strict_warnings() {
let default_registry = cephas::plugins::PluginRegistry::default();
let clean = cephas::plugins::check_plugin_registry(&default_registry);
assert!(!plugin_check_should_fail(&clean, false, false));
assert!(!plugin_check_should_fail(&clean, true, false));
let module = cephas::plugins::bundled_plugin_modules()
.first()
.unwrap()
.clone();
let mut warning_registry = cephas::plugins::PluginRegistry::default();
warning_registry
.install_bundled(module.manifest.id)
.unwrap();
warning_registry
.installed
.iter_mut()
.find(|plugin| plugin.id == module.manifest.id)
.unwrap()
.name = "Drifted module name".to_string();
let warning = cephas::plugins::check_plugin_registry(&warning_registry);
assert!(warning.ok);
assert_eq!(warning.error_count, 0);
assert!(warning.warning_count > 0);
assert!(!plugin_check_should_fail(&warning, false, false));
assert!(plugin_check_should_fail(&warning, true, false));
assert!(plugin_check_should_fail(&warning, false, true));
let broken_registry = cephas::plugins::PluginRegistry {
max_plugins: 0,
..Default::default()
};
let broken = cephas::plugins::check_plugin_registry(&broken_registry);
assert!(!broken.ok);
assert!(plugin_check_should_fail(&broken, false, false));
}
#[test]
fn plugin_check_strict_flags_parse() {
let strict = Cli::try_parse_from(["cephas", "plugins", "check", "--strict"]).unwrap();
let fail_on_warning =
Cli::try_parse_from(["cephas", "plugins", "check", "--fail-on-warning"]).unwrap();
match strict.command {
Some(Command::Plugins {
action:
Some(PluginCommand::Check {
strict,
fail_on_warning,
}),
..
}) => {
assert!(strict);
assert!(!fail_on_warning);
}
other => panic!("unexpected strict command: {other:?}"),
}
match fail_on_warning.command {
Some(Command::Plugins {
action:
Some(PluginCommand::Check {
strict,
fail_on_warning,
}),
..
}) => {
assert!(!strict);
assert!(fail_on_warning);
}
other => panic!("unexpected fail-on-warning command: {other:?}"),
}
}
#[test]
fn diagnostics_json_flag_parses() {
let cli = Cli::try_parse_from(["cephas", "diagnostics", "--json", "--strict"]).unwrap();
match cli.command {
Some(Command::Diagnostics { json, strict }) => {
assert!(json);
assert!(strict);
}
other => panic!("unexpected diagnostics command: {other:?}"),
}
}
#[test]
fn diagnostics_report_includes_bounded_counts() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(Some(dir.path().join("profile.json"))).unwrap();
let mut profile = cephas::Profile::default();
let example = url::Url::parse("https://example.com/").unwrap();
profile.record_visit("Example", example.clone());
assert!(profile.add_bookmark("Example", example.clone()));
store.save(&profile).unwrap();
let mut downloads = cephas::downloads::DownloadManager::default();
let download_id =
downloads.start(example.clone(), PathBuf::from("example.bin"), None, None);
assert!(downloads.finish(download_id));
store.download_store().save(&downloads).unwrap();
let session = cephas::session::BrowserSession {
windows: vec![cephas::session::SessionWindow {
id: cephas::WindowId(1),
active_tab: Some(cephas::TabId(7)),
tabs: vec![cephas::session::SessionTab {
id: cephas::TabId(7),
url: Some(example),
title: "Example".to_string(),
pinned: false,
muted: false,
private: false,
zoom: 1.0,
}],
}],
};
store.session_store().save(&session).unwrap();
let report = diagnostics_report(&profile, &store);
assert_eq!(report.schema, DIAGNOSTICS_SCHEMA);
assert!(report.ok);
assert_eq!(report.profile.history_entries.current, 1);
assert_eq!(report.profile.bookmarks.current, 1);
assert_eq!(
report.plugins.installed.max,
cephas::plugins::DEFAULT_MAX_PLUGINS as u64
);
let session = report.session.as_ref().unwrap();
assert_eq!(session.windows.current, 1);
assert_eq!(session.tabs.current, 1);
assert_eq!(session.tabs_per_window.current, 1);
let downloads = report.downloads.as_ref().unwrap();
assert_eq!(downloads.records.current, 1);
assert_eq!(downloads.completed, 1);
assert!(
report
.resource_caps
.iter()
.any(|cap| cap.id == "plugin-records")
);
let history_check = report.resource_checks.get("history-entries").unwrap();
assert_eq!(history_check.current, 1);
assert!(history_check.ok);
let tabs_check = report
.resource_checks
.get("session-tabs-per-window")
.unwrap();
assert_eq!(tabs_check.current, 1);
assert_eq!(tabs_check.max, 100);
let downloads_check = report.resource_checks.get("download-records").unwrap();
assert_eq!(downloads_check.current, 1);
assert!(downloads_check.ok);
assert!(
report
.unobserved_resource_caps
.iter()
.any(|cap| cap.id == "open-tabs" && cap.reason == "live-runtime-only")
);
assert!(
report
.unobserved_resource_caps
.iter()
.any(|cap| cap.id == "page-body-bytes" && cap.reason == "per-operation-limit")
);
for cap in &report.resource_caps {
assert!(
report.resource_checks.contains_key(cap.id)
|| report
.unobserved_resource_caps
.iter()
.any(|unobserved| unobserved.id == cap.id),
"cap {} is neither checked nor marked unobserved",
cap.id
);
}
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains(DIAGNOSTICS_SCHEMA));
assert!(json.contains("\"resource_caps\""));
assert!(json.contains("\"resource_checks\""));
assert!(json.contains("\"unobserved_resource_caps\""));
assert!(json.contains("\"history-entries\""));
}
#[test]
fn diagnostics_report_flags_resource_pressure() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(Some(dir.path().join("profile.json"))).unwrap();
let url = url::Url::parse("https://example.com/").unwrap();
let mut profile = cephas::Profile {
max_history: 1,
..cephas::Profile::default()
};
profile.history = vec![
cephas::HistoryEntry {
title: "One".to_string(),
url: url.clone(),
visited_at: chrono::Utc::now(),
},
cephas::HistoryEntry {
title: "Two".to_string(),
url,
visited_at: chrono::Utc::now(),
},
];
let report = diagnostics_report(&profile, &store);
let history_check = report.resource_checks.get("history-entries").unwrap();
assert!(!report.ok);
assert_eq!(history_check.current, 2);
assert_eq!(history_check.max, 1);
assert!(!history_check.ok);
assert!(diagnostics_should_fail(&report, true));
assert!(!diagnostics_should_fail(&report, false));
}
#[test]
fn tooling_manifest_exposes_portable_agent_surfaces() {
let manifest = agent_tooling_manifest();
assert_eq!(manifest.schema, AGENT_TOOLING_SCHEMA);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "agent-smoke-json")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "agent-commands-jsonl")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "mcp-stdio")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "diagnostics-json")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "diagnostics-strict")
);
assert!(manifest.commands.iter().any(|command| {
command.id == "self-test-json" && command.output == SELF_TEST_SCHEMA
}));
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "plugins-jsonl")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "plugins-available")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "plugins-check")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "plugins-check-strict")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "plugins-install")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "plugins-install-all")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "plugins-sync-all")
);
assert!(
manifest
.commands
.iter()
.any(|command| command.id == "plugins-sync")
);
assert!(
manifest
.browser_commands
.iter()
.any(|command| command.id == "human-click")
);
assert!(
manifest
.internal_urls
.iter()
.any(|url| url.url == "cephas://agent")
);
assert!(
manifest
.internal_urls
.iter()
.any(|url| url.url == "cephas://agent/docs")
);
assert!(
manifest
.internal_urls
.iter()
.any(|url| url.url == "cephas://start/theme")
);
assert!(
manifest
.internal_urls
.iter()
.any(|url| url.url == "https://cephas.local/start")
);
assert!(
manifest
.internal_urls
.iter()
.any(|url| url.url == "cephas://plugins")
);
assert!(
manifest
.internal_urls
.iter()
.any(|url| url.url == "cephas://diagnostics")
);
assert!(
manifest
.internal_urls
.iter()
.any(|url| url.url == "https://cephas.local/settings")
);
assert!(manifest.internal_urls.iter().any(|url| {
url.url == "cephas://settings/theme-preset?id=<preset-id>"
&& url.description.contains("bounded")
}));
assert!(
manifest
.notes
.iter()
.any(|note| note.contains("bounded custom theme presets"))
);
assert!(
manifest
.smoke_cases
.iter()
.any(|case| case.name == "agent-docs")
);
assert!(
manifest
.smoke_cases
.iter()
.any(|case| case.name == "plugins" && case.output_file == "plugins.png")
);
assert!(
manifest
.smoke_cases
.iter()
.any(|case| case.name == "diagnostics" && case.output_file == "diagnostics.png")
);
assert!(
manifest
.smoke_cases
.iter()
.any(|case| case.name == "settings" && case.output_file == "settings.png")
);
assert!(manifest.smoke_cases.iter().any(|case| {
case.name == "internal-route-trust"
&& case.output_file == "internal-route-trust.json"
&& case.target.contains("cephas://settings/theme")
}));
assert!(manifest.smoke_cases.iter().any(|case| {
case.name == "launch-unsafe-home" && case.output_file == "launch-unsafe-home.json"
}));
assert!(manifest.smoke_cases.iter().any(|case| {
case.name == "launch-unknown-internal"
&& case.output_file == "launch-unknown-internal.json"
&& case.target.contains("cephas://unknown")
}));
assert!(
manifest
.internal_urls
.iter()
.any(|url| url.url.starts_with("cephas://agent/emulate"))
);
assert!(
manifest
.human_control_routes
.iter()
.any(|route| route.route.starts_with("cephas://agent/human/click"))
);
assert_eq!(
manifest.reader_tools.len(),
cephas::reader::AgentReaderTool::ALL.len()
);
assert!(manifest.resource_caps.len() >= 20);
assert!(manifest.resource_caps.iter().any(|cap| {
cap.id == "open-tabs" && cap.value == cephas::gui::MAX_OPEN_TABS as u64
}));
assert!(manifest.resource_caps.iter().any(|cap| {
cap.id == "agent-audit-log-bytes"
&& cap.value == cephas::agents::DEFAULT_MAX_AGENT_LOG_BYTES
&& cap.unit == "bytes"
}));
assert!(manifest.resource_caps.iter().any(|cap| {
cap.id == "agent-audit-log-archive-bytes"
&& cap.value
== cephas::agents::DEFAULT_MAX_AGENT_LOG_BYTES
* cephas::agents::DEFAULT_MAX_AGENT_LOG_ARCHIVES as u64
&& cap.unit == "bytes"
}));
assert!(manifest.resource_caps.iter().any(|cap| {
cap.id == "agent-audit-log-largest-archive-bytes"
&& cap.value == cephas::agents::DEFAULT_MAX_AGENT_LOG_BYTES
&& cap.unit == "bytes"
}));
assert!(manifest.resource_caps.iter().any(|cap| {
cap.id == "save-queue-jobs" && cap.value == cephas::gui::SAVE_QUEUE_CAPACITY as u64
}));
assert!(manifest.resource_caps.iter().any(|cap| {
cap.id == "pending-human-waits"
&& cap.value == cephas::gui::MAX_PENDING_HUMAN_WAITS as u64
}));
assert!(manifest.resource_caps.iter().any(|cap| {
cap.id == "page-body-bytes"
&& cap.value == cephas::browser::DEFAULT_MAX_PAGE_BODY_BYTES as u64
}));
assert!(manifest.diagnostics_surfaces.iter().any(|surface| {
surface.id == "diagnostics-json"
&& surface.output == DIAGNOSTICS_SCHEMA
&& surface.scope.contains("persisted/offline")
}));
assert!(manifest.diagnostics_surfaces.iter().any(|surface| {
surface.id == "diagnostics-live-page"
&& surface
.access
.iter()
.any(|access| access.contains("agent-screenshot"))
&& surface.description.contains("process-local")
}));
assert!(
manifest
.contracts
.iter()
.any(|contract| contract.path == "elixir_backend.MD")
);
assert!(
manifest
.contracts
.iter()
.any(|contract| contract.path == "plugins_backend.MD")
);
assert!(
manifest
.contracts
.iter()
.any(|contract| contract.path == "SCRIN_OPTIMIZATION.md")
);
assert!(
manifest
.notes
.iter()
.any(|note| note.contains("bounded resource caps"))
);
assert!(
manifest
.notes
.iter()
.any(|note| note.contains("live GTK runtime counters"))
);
assert!(
manifest
.notes
.iter()
.any(|note| note.contains("AI Inspect"))
);
assert!(
manifest
.notes
.iter()
.any(|note| note.contains("PDF targets"))
);
assert!(
manifest
.schemas
.iter()
.any(|schema| schema.schema == DIAGNOSTICS_SCHEMA)
);
assert!(
manifest
.schemas
.iter()
.any(|schema| schema.schema == SELF_TEST_SCHEMA)
);
assert!(
manifest
.schemas
.iter()
.any(|schema| schema.schema == INTERNAL_ROUTE_TRUST_SMOKE_SCHEMA)
);
assert!(
manifest
.schemas
.iter()
.any(|schema| schema.schema == LAUNCH_SAFETY_SMOKE_SCHEMA)
);
assert!(
manifest
.schemas
.iter()
.any(|schema| schema.schema == cephas::plugins::PLUGIN_REGISTRY_SCHEMA)
);
assert!(
manifest
.schemas
.iter()
.any(|schema| schema.schema == cephas::plugins::PLUGIN_MODULE_SCHEMA)
);
assert!(
manifest
.schemas
.iter()
.any(|schema| schema.schema == cephas::plugins::PLUGIN_CHECK_SCHEMA)
);
assert!(
manifest
.schemas
.iter()
.any(|schema| schema.schema == cephas::plugins::PLUGIN_BACKEND_REGISTRY_SCHEMA)
);
}
#[test]
fn mcp_initialize_returns_server_capabilities() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(Some(dir.path().join("profile.json"))).unwrap();
let response = mcp_handle_message(
&cephas::Profile::default(),
&store,
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
}),
)
.unwrap();
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 1);
assert_eq!(response["result"]["protocolVersion"], MCP_PROTOCOL_VERSION);
assert_eq!(response["result"]["serverInfo"]["name"], "cephas");
assert!(response["result"]["capabilities"]["tools"].is_object());
}
#[test]
fn mcp_tools_list_exposes_read_only_tools() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(Some(dir.path().join("profile.json"))).unwrap();
let response = mcp_handle_message(
&cephas::Profile::default(),
&store,
json!({
"jsonrpc": "2.0",
"id": "tools",
"method": "tools/list",
"params": {}
}),
)
.unwrap();
let tools = response["result"]["tools"].as_array().unwrap();
for name in [
"cephas_agent_tooling",
"cephas_agent_commands",
"cephas_diagnostics",
"cephas_plugins",
] {
assert!(tools.iter().any(|tool| tool["name"] == name));
}
assert!(tools.iter().all(|tool| {
tool["inputSchema"]["additionalProperties"] == false
&& tool["inputSchema"]["properties"]
.as_object()
.unwrap()
.is_empty()
}));
}
#[test]
fn mcp_tools_call_returns_agent_tooling_text() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(Some(dir.path().join("profile.json"))).unwrap();
let response = mcp_handle_message(
&cephas::Profile::default(),
&store,
json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "cephas_agent_tooling",
"arguments": {}
}
}),
)
.unwrap();
assert_eq!(response["id"], 2);
assert_eq!(response["result"]["isError"], false);
let text = response["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains(AGENT_TOOLING_SCHEMA));
assert!(text.contains("mcp-stdio"));
}
#[test]
fn mcp_unknown_method_and_tool_return_json_rpc_errors() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(Some(dir.path().join("profile.json"))).unwrap();
let profile = cephas::Profile::default();
let method_response = mcp_handle_message(
&profile,
&store,
json!({
"jsonrpc": "2.0",
"id": 3,
"method": "missing/method"
}),
)
.unwrap();
let tool_response = mcp_handle_message(
&profile,
&store,
json!({
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": { "name": "missing_tool" }
}),
)
.unwrap();
assert_eq!(method_response["error"]["code"], -32601);
assert!(
method_response["error"]["message"]
.as_str()
.unwrap()
.contains("unknown method")
);
assert_eq!(tool_response["error"]["code"], -32602);
assert!(
tool_response["error"]["message"]
.as_str()
.unwrap()
.contains("unknown MCP tool")
);
}
}