1use super::args::{
7 CertifyCommand, CheckpointCommand, Cli, Commands, DaemonCommand, IrCommand, KnowledgeCommand,
8 KnowledgeInvalidationState, McpClient, ProfileCommand, ResultCommand, SnapshotCommand,
9 TaskCommand, WorkflowAuthoringCommand,
10};
11use crate::browser::policy::{BrowserPolicy, PolicyCapability};
12use crate::browser::profile::ProfileManager;
13use crate::browser::session::{
14 ActionKind, BatchStep, BrowserResult, BrowserSession, CheckpointV1, Cookie,
15 KnowledgeConfidence, KnowledgeStore, Locator, PdfOptions, ReconciliationOptions,
16 SemanticIntentExecutionRequest, SemanticIntentRequest, SemanticObservationLevel,
17 SessionOptions, SessionSnapshotStore, StructuredExtractionRequest, VerificationPredicate,
18 VisualCaptureOptions, WaitCondition, WorkflowAuthoringFormat, WorkflowCheckpoint,
19 WorkflowDefinition, WorkflowDiagnosticSeverity, WorkflowRecordingSession, compile_workflow,
20 default_knowledge_store_path, diff_workflows, format_workflow_yaml, preview_workflow,
21 record_semantic_events,
22};
23use crate::capabilities::GlassCapabilityManifest;
24use crate::reliability::{
25 ReliabilityFixtureManifest, ReliabilityReplayBundle, ReliabilityScenario,
26 ReliabilityScenarioObservation, build_reliability_scorecard,
27};
28use crate::reliability_runner::{ReliabilityRunOptions, run_reliability_scenario};
29use crate::results::{ResponseMode, ResultStore, project_and_store};
30use base64::Engine;
31use serde::Serialize;
32use serde_json::Value;
33use std::collections::BTreeMap;
34use std::io::Read;
35use std::path::Path;
36use std::process::Stdio;
37use std::time::Duration;
38use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
39
40pub async fn dispatch(cli: Cli) -> BrowserResult<()> {
43 let policy = policy_from_cli(&cli)?;
44 if cli.experimental_extensions {
45 eprintln!(concat!(
46 "warning: experimental extensions are enabled; extension code is untrusted, ",
47 "sandbox support is required, and behavior may break"
48 ));
49 }
50 if cli.mcp {
51 return crate::mcp::server::run_mcp_server(&cli).await;
52 }
53
54 match &cli.command {
55 Some(Commands::InstallChromium { update }) => {
56 let path = crate::browser::chrome::download_chromium(*update).await?;
57 println!("Chrome for Testing installed at {}", path.display());
58 return Ok(());
59 }
60 Some(Commands::Capabilities) => {
61 print_json(
62 &GlassCapabilityManifest::for_policy_with_experimental_extensions(
63 &policy,
64 cli.experimental_extensions,
65 ),
66 )?;
67 return Ok(());
68 }
69 Some(Commands::Daemon { action }) => {
70 dispatch_daemon(action).await?;
71 return Ok(());
72 }
73 Some(Commands::Doctor { json }) => {
74 dispatch_doctor(&cli, &policy, *json).await?;
75 return Ok(());
76 }
77 Some(Commands::McpConfig { client, print }) => {
78 dispatch_mcp_config(*client, *print)?;
79 return Ok(());
80 }
81 Some(Commands::Certify { action }) if !matches!(action, CertifyCommand::Run { .. }) => {
82 dispatch_certify(action)?;
83 return Ok(());
84 }
85 Some(Commands::Profiles { action }) => {
86 policy.require(PolicyCapability::PersistentProfile)?;
87 dispatch_profiles(action.as_ref())?;
88 return Ok(());
89 }
90 Some(Commands::DeleteProfile { name }) => {
91 policy.require(PolicyCapability::PersistentProfile)?;
92 ProfileManager::new().delete_profile(name)?;
93 println!("deleted profile {name}");
94 return Ok(());
95 }
96 Some(Commands::Knowledge { action }) => {
97 policy.require(PolicyCapability::PersistentProfile)?;
98 dispatch_knowledge(action, cli.knowledge_store.as_deref(), &cli.profile)?;
99 return Ok(());
100 }
101 Some(Commands::Snapshot { action }) if !matches!(action, SnapshotCommand::Create) => {
102 dispatch_snapshot(action, &cli.profile)?;
103 return Ok(());
104 }
105 Some(Commands::Result { action }) => {
106 dispatch_result(action)?;
107 return Ok(());
108 }
109 Some(Commands::Workflow {
110 action: Some(action),
111 input: None,
112 }) => {
113 dispatch_workflow_authoring(action)?;
114 return Ok(());
115 }
116 Some(Commands::Task { action }) => {
117 dispatch_task(action)?;
118 return Ok(());
119 }
120 Some(Commands::Ir { action }) => {
121 dispatch_ir(action)?;
122 return Ok(());
123 }
124 Some(Commands::Tui) | None if cli.prompt.is_none() => {
125 return crate::tui::app::run_tui(&cli).await;
126 }
127 _ => {}
128 }
129
130 let options = SessionOptions {
131 port: cli.port,
132 chrome_path: cli.chrome_path.clone(),
133 profile: cli.profile.clone(),
134 incognito: cli.incognito,
135 attach: cli.attach,
136 target_id: cli.target_id.clone(),
137 frame_id: cli.frame_id.clone(),
138 headed: cli.headed,
139 interaction_mode: cli.interaction,
140 audit: cli.audit,
141 policy: None,
142 };
143 let session = BrowserSession::start_with_policy(&options, policy).await?;
144 let result = if let Some(prompt) = &cli.prompt {
145 run_prompt(&session, prompt, cli.response_mode).await
146 } else if let Some(command) = &cli.command {
147 run_command(&session, command, cli.response_mode).await
148 } else {
149 Ok(())
150 };
151 if let Err(error) = &result
152 && cli.trace_on_error
153 {
154 let trace = session
155 .failure_trace_for(
156 cli_trace_action(cli.command.as_ref(), cli.prompt.as_deref()),
157 error.to_string(),
158 )
159 .await;
160 eprintln!("{}", serde_json::to_string(&trace)?);
161 }
162 let close_result = session.close().await;
163 result?;
164 close_result
165}
166
167async fn dispatch_daemon(action: &DaemonCommand) -> BrowserResult<()> {
168 match action {
169 DaemonCommand::Start { socket, status } => {
170 print_json(&crate::daemon::start(socket.as_deref(), status.as_deref()).await?)?;
171 }
172 DaemonCommand::Status { socket, status } => {
173 print_json(&crate::daemon::status(
174 socket.as_deref(),
175 status.as_deref(),
176 )?)?;
177 }
178 DaemonCommand::Stop { socket, status } => {
179 crate::daemon::stop(socket.as_deref(), status.as_deref())?;
180 print_json(&serde_json::json!({"status": "stopped"}))?;
181 }
182 DaemonCommand::Doctor { socket, status } => {
183 print_json(&crate::daemon::doctor(
184 socket.as_deref(),
185 status.as_deref(),
186 )?)?;
187 }
188 DaemonCommand::Logs { status } => {
189 print_json(&crate::daemon::logs(status.as_deref())?)?;
190 }
191 DaemonCommand::AcknowledgeRecovery {
192 status,
193 request_ids,
194 } => {
195 print_json(&crate::daemon::acknowledge_recovery(
196 status.as_deref(),
197 request_ids,
198 )?)?;
199 }
200 DaemonCommand::Serve { socket, status } => {
201 crate::daemon::serve(socket, status).await?;
202 }
203 }
204 Ok(())
205}
206
207async fn dispatch_doctor(cli: &Cli, policy: &BrowserPolicy, json: bool) -> BrowserResult<()> {
208 let executable = std::env::current_exe()
209 .ok()
210 .map(|path| path.display().to_string());
211 let chrome_path =
212 crate::browser::chrome::resolve_chrome_path(None).map(|path| path.display().to_string());
213 let profile_path = ProfileManager::new().profile_dir(&cli.profile);
214 let runtime_path = default_result_store_path();
215 let profile_writable = path_is_writable(profile_path.parent());
216 let runtime_writable = path_is_writable(runtime_path.parent());
217 let browser_version = chrome_path.as_deref().and_then(|path| {
218 std::process::Command::new(path)
219 .arg("--version")
220 .output()
221 .ok()
222 .filter(|output| output.status.success())
223 .and_then(|output| String::from_utf8(output.stdout).ok())
224 .map(|version| version.trim().to_string())
225 });
226 let mcp_initialized = probe_mcp_initialization(executable.as_deref()).await;
227 let (daemon_socket, daemon_status) = crate::daemon::default_paths();
228 let daemon = crate::daemon::doctor(Some(&daemon_socket), Some(&daemon_status))?;
229 let profiles = ProfileManager::new().list_profiles().unwrap_or_default();
230 let knowledge_path = cli
231 .knowledge_store
232 .clone()
233 .unwrap_or_else(|| default_knowledge_store_path(&cli.profile));
234 let knowledge_exists = knowledge_path.is_file();
235 let manifest = GlassCapabilityManifest::for_policy_with_experimental_extensions(
236 policy,
237 cli.experimental_extensions,
238 );
239 let platform_supported = manifest.constraints.platform != "unsupported";
240 let config_root = dirs::config_dir();
241 let config_writable = path_is_writable(config_root.as_deref());
242 let browser_available = chrome_path.is_some();
243 let status = if platform_supported
244 && browser_available
245 && config_writable
246 && profile_writable
247 && runtime_writable
248 && mcp_initialized
249 {
250 "ready"
251 } else {
252 "degraded"
253 };
254 let findings = serde_json::json!([
255 {
256 "severity": "info",
257 "code": "runtime.executable",
258 "message": executable.as_deref().unwrap_or("unable to resolve executable path"),
259 "remediation": null
260 },
261 {
262 "severity": if browser_available { "info" } else { "warning" },
263 "code": if browser_available { "browser.found" } else { "browser.missing" },
264 "message": if browser_available { "Chrome or Chromium was discovered" } else { "Chrome or Chromium was not discovered" },
265 "remediation": if browser_available { Value::Null } else { Value::String("install Chromium or pass --chrome-path".into()) }
266 },
267 {
268 "severity": if browser_version.is_some() { "info" } else { "warning" },
269 "code": if browser_version.is_some() { "browser.version" } else { "browser.versionUnavailable" },
270 "message": browser_version.as_deref().unwrap_or("browser version could not be queried"),
271 "remediation": if browser_version.is_some() { Value::Null } else { Value::String("verify the browser executable is runnable".into()) }
272 },
273 {
274 "severity": if profile_writable { "info" } else { "warning" },
275 "code": if profile_writable { "runtime.profileWritable" } else { "runtime.profileNotWritable" },
276 "message": profile_path.display().to_string(),
277 "remediation": if profile_writable { Value::Null } else { Value::String("choose a writable profile parent directory".into()) }
278 },
279 {
280 "severity": if runtime_writable { "info" } else { "warning" },
281 "code": if runtime_writable { "runtime.artifactStoreWritable" } else { "runtime.artifactStoreNotWritable" },
282 "message": runtime_path.display().to_string(),
283 "remediation": if runtime_writable { Value::Null } else { Value::String("choose a writable cache directory".into()) }
284 },
285 {
286 "severity": if config_writable { "info" } else { "warning" },
287 "code": if config_writable { "runtime.configWritable" } else { "runtime.configNotWritable" },
288 "message": if config_writable { "Glass configuration directory is writable" } else { "Glass configuration directory is unavailable or not writable" },
289 "remediation": if config_writable { Value::Null } else { Value::String("choose a writable HOME/XDG config directory".into()) }
290 },
291 {
292 "severity": "info",
293 "code": "mcp.stdoutClean",
294 "message": "MCP uses stdout for protocol frames and diagnostics use stderr",
295 "remediation": null
296 },
297 {
298 "severity": if mcp_initialized { "info" } else { "warning" },
299 "code": if mcp_initialized { "mcp.initialized" } else { "mcp.initializationFailed" },
300 "message": if mcp_initialized { "MCP initialize completed with clean stdout" } else { "MCP initialize did not return a valid response" },
301 "remediation": if mcp_initialized { Value::Null } else { Value::String("run the installed executable with --mcp and inspect stderr".into()) }
302 },
303 {
304 "severity": "info",
305 "code": "capability.manifest",
306 "message": "capability statuses are included in the report",
307 "remediation": null
308 }
309 ]);
310 let report = serde_json::json!({
311 "status": status,
312 "version": env!("CARGO_PKG_VERSION"),
313 "platform": manifest.constraints.platform,
314 "executable": executable,
315 "browser": {
316 "family": manifest.constraints.browser_family,
317 "version": browser_version,
318 "chromeAvailable": browser_available,
319 "chromePath": chrome_path,
320 "cdpPort": cli.port,
321 "cdpReachable": crate::browser::chrome::check_chrome_health(cli.port).await,
322 },
323 "profile": {"path": profile_path, "writable": profile_writable},
324 "runtime": {"artifactStore": runtime_path, "writable": runtime_writable},
325 "daemon": daemon,
326 "profiles": {"count": profiles.len(), "names": profiles},
327 "policy": {
328 "preset": manifest.constraints.policy,
329 "capabilities": manifest.capabilities,
330 "capabilityStatuses": manifest.capability_statuses,
331 },
332 "knowledgeStore": {"path": knowledge_path, "exists": knowledge_exists},
333 "extensions": {
334 "enabled": manifest.capabilities.get("extensions").copied().unwrap_or(false),
335 "status": manifest.capability_statuses.get("extensions"),
336 "loader": "disabled",
337 },
338 "findings": findings,
339 });
340 if json {
341 print_json(&report)?;
342 } else {
343 println!("Glass doctor: {status}");
344 for finding in report["findings"].as_array().into_iter().flatten() {
345 println!(
346 "{} {}: {}",
347 finding["severity"].as_str().unwrap_or("info"),
348 finding["code"].as_str().unwrap_or("runtime.unknown"),
349 finding["message"].as_str().unwrap_or("unknown")
350 );
351 }
352 }
353 Ok(())
354}
355
356fn path_is_writable(path: Option<&Path>) -> bool {
357 let Some(path) = path else {
358 return false;
359 };
360 let candidate = if path.exists() {
361 path.to_path_buf()
362 } else {
363 path.ancestors()
364 .find(|ancestor| ancestor.exists())
365 .unwrap_or(path)
366 .to_path_buf()
367 };
368 candidate.is_dir()
369 && std::fs::metadata(&candidate)
370 .map(|metadata| !metadata.permissions().readonly())
371 .unwrap_or(false)
372}
373
374async fn probe_mcp_initialization(executable: Option<&str>) -> bool {
375 let Some(executable) = executable else {
376 return false;
377 };
378 let mut child = match tokio::process::Command::new(executable)
379 .arg("--mcp")
380 .stdin(Stdio::piped())
381 .stdout(Stdio::piped())
382 .stderr(Stdio::null())
383 .spawn()
384 {
385 Ok(child) => child,
386 Err(_) => return false,
387 };
388 let request = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"glass-doctor","version":"0.2.2"}}}"#;
389 let Some(mut stdin) = child.stdin.take() else {
390 let _ = child.kill().await;
391 return false;
392 };
393 if stdin.write_all(request).await.is_err() || stdin.write_all(b"\n").await.is_err() {
394 let _ = child.kill().await;
395 return false;
396 }
397 drop(stdin);
398 let Some(stdout) = child.stdout.take() else {
399 let _ = child.kill().await;
400 return false;
401 };
402 let mut reader = BufReader::new(stdout);
403 let mut line = String::new();
404 let success = tokio::time::timeout(Duration::from_secs(2), reader.read_line(&mut line))
405 .await
406 .ok()
407 .and_then(Result::ok)
408 .is_some_and(|bytes| bytes > 0 && line.contains("\"result\""));
409 let _ = child.kill().await;
410 success
411}
412
413fn dispatch_mcp_config(client: McpClient, _print: bool) -> BrowserResult<()> {
414 let executable = std::env::current_exe()?;
415 let command = executable.display().to_string();
416 let value = match client {
417 McpClient::Generic => serde_json::json!({
418 "command": command,
419 "args": ["--mcp"]
420 }),
421 McpClient::ClaudeCode => serde_json::json!({
422 "mcpServers": {"glass": {"command": command, "args": ["--mcp"]}}
423 }),
424 McpClient::Codex => serde_json::json!({
425 "mcp_servers": {"glass": {"command": command, "args": ["--mcp"]}}
426 }),
427 };
428 print_json(&value)?;
429 Ok(())
430}
431
432fn dispatch_result(action: &ResultCommand) -> BrowserResult<()> {
433 let root = default_result_store_path();
434 let store = ResultStore::new(root);
435 match action {
436 ResultCommand::Show { result_id, section } => {
437 let artifact = store.load(result_id)?;
438 let value = if let Some(section) = section {
439 artifact
440 .details
441 .get(section)
442 .cloned()
443 .ok_or_else(|| format!("result section not found: {section}"))?
444 } else {
445 serde_json::to_value(artifact)?
446 };
447 print_json(&value)?;
448 }
449 ResultCommand::Purge { older_than } => {
450 let age = parse_result_age(older_than)?;
451 print_json(&serde_json::json!({
452 "removed": store.purge_older_than(age)?,
453 "olderThan": older_than,
454 }))?;
455 }
456 }
457 Ok(())
458}
459
460fn default_result_store_path() -> std::path::PathBuf {
461 dirs::cache_dir()
462 .unwrap_or_else(std::env::temp_dir)
463 .join("glass")
464 .join("results")
465}
466
467fn parse_result_age(value: &str) -> BrowserResult<Duration> {
468 let (number, suffix) = value.split_at(
469 value
470 .find(|character: char| !character.is_ascii_digit())
471 .unwrap_or(value.len()),
472 );
473 let amount: u64 = number
474 .parse()
475 .map_err(|_| "older-than must be a positive duration such as 7d or 24h")?;
476 if amount == 0 {
477 return Err("older-than must be positive".into());
478 }
479 let seconds = match suffix {
480 "s" => amount,
481 "m" => amount.saturating_mul(60),
482 "h" => amount.saturating_mul(60 * 60),
483 "d" => amount.saturating_mul(24 * 60 * 60),
484 _ => return Err("older-than must end in s, m, h, or d".into()),
485 };
486 Ok(Duration::from_secs(seconds))
487}
488
489fn cli_trace_action(command: Option<&Commands>, prompt: Option<&str>) -> ActionKind {
490 if let Some(prompt) = prompt {
491 let lower = prompt.trim().to_ascii_lowercase();
492 return if lower.starts_with("double click ") {
493 ActionKind::DoubleClick
494 } else if lower.starts_with("click ") {
495 ActionKind::Click
496 } else if lower.starts_with("type ") {
497 ActionKind::Type
498 } else {
499 ActionKind::Click
500 };
501 }
502 match command {
503 Some(Commands::DoubleClick { .. }) => ActionKind::DoubleClick,
504 Some(Commands::ClickExpectPopup { .. }) => ActionKind::ClickExpectPopup,
505 Some(Commands::Click { .. })
506 | Some(Commands::Preflight { .. })
507 | Some(Commands::ClickAt { .. }) => ActionKind::Click,
508 Some(Commands::Hover { .. }) => ActionKind::Hover,
509 Some(Commands::Drag { .. }) => ActionKind::Drag,
510 Some(Commands::Type { .. }) => ActionKind::Type,
511 Some(Commands::Key { .. }) => ActionKind::KeyPress,
512 Some(Commands::KeyDown { .. }) => ActionKind::KeyDown,
513 Some(Commands::KeyUp { .. }) => ActionKind::KeyUp,
514 Some(Commands::Shortcut { .. }) => ActionKind::Shortcut,
515 Some(Commands::Clear { .. }) => ActionKind::Clear,
516 Some(Commands::Check { .. }) => ActionKind::Check,
517 Some(Commands::Uncheck { .. }) => ActionKind::Uncheck,
518 Some(Commands::Select { .. }) => ActionKind::Select,
519 Some(Commands::Upload { .. }) => ActionKind::Upload,
520 Some(Commands::Scroll { .. }) => ActionKind::Scroll,
521 _ => ActionKind::Click,
522 }
523}
524
525fn dispatch_profiles(action: Option<&ProfileCommand>) -> BrowserResult<()> {
526 let manager = ProfileManager::new();
527 match action {
528 None | Some(ProfileCommand::List) => {
529 let profiles = manager.list_profiles()?;
530 if profiles.is_empty() {
531 println!("no saved profiles");
532 } else {
533 for profile in profiles {
534 println!("{profile}");
535 }
536 }
537 }
538 Some(ProfileCommand::Create { name }) => {
539 manager.create_profile(name)?;
540 println!("created profile {name}");
541 }
542 Some(ProfileCommand::Delete { name }) => {
543 manager.delete_profile(name)?;
544 println!("deleted profile {name}");
545 }
546 }
547 Ok(())
548}
549
550fn dispatch_certify(action: &CertifyCommand) -> BrowserResult<()> {
551 match action {
552 CertifyCommand::Run { .. } => {
553 unreachable!("browser-backed reliability runs are handled after startup")
554 }
555 CertifyCommand::Plan { scenario, fixture } => {
556 let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
557 let fixture =
558 ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture)?)?;
559 let plan = scenario.execution_plan(&fixture)?;
560 print_json(&serde_json::json!({
561 "status": "valid",
562 "plan": plan,
563 }))?;
564 }
565 CertifyCommand::Release {
566 version,
567 scenarios,
568 observations,
569 replays,
570 } => {
571 let scenario_value = read_json_input(Some(scenarios))?;
572 let scenarios: Vec<ReliabilityScenario> = if scenario_value.is_array() {
573 serde_json::from_value(scenario_value)?
574 } else {
575 vec![serde_json::from_value(scenario_value)?]
576 };
577 let observations: Vec<ReliabilityScenarioObservation> =
578 serde_json::from_value(read_json_input(Some(observations))?)?;
579 let replays_validated = if let Some(replays) = replays {
580 let replay_value = read_json_input(Some(replays))?;
581 let replay_values: Vec<Value> = if replay_value.is_array() {
582 serde_json::from_value(replay_value)?
583 } else {
584 vec![replay_value]
585 };
586 let mut replay_by_id = BTreeMap::new();
587 for replay_value in replay_values {
588 let scenario_id = replay_value
589 .get("scenarioId")
590 .and_then(Value::as_str)
591 .ok_or("replay bundle is missing scenarioId")?
592 .to_string();
593 let scenario = scenarios
594 .iter()
595 .find(|scenario| scenario.id == scenario_id)
596 .ok_or_else(|| {
597 format!("replay references unknown scenario {scenario_id}")
598 })?;
599 let bundle = ReliabilityReplayBundle::from_value(replay_value, scenario)?;
600 if replay_by_id.insert(scenario_id.clone(), bundle).is_some() {
601 return Err(format!("duplicate replay for scenario {scenario_id}").into());
602 }
603 }
604 if replay_by_id.len() != scenarios.len() {
605 return Err("replay evidence must cover every scenario".into());
606 }
607 let observations_by_id: BTreeMap<_, _> = observations
608 .iter()
609 .map(|observation| (observation.scenario_id.as_str(), observation))
610 .collect();
611 for (scenario_id, replay) in &replay_by_id {
612 let observation =
613 observations_by_id
614 .get(scenario_id.as_str())
615 .ok_or_else(|| {
616 format!("replay has no matching observation for {scenario_id}")
617 })?;
618 if serde_json::to_value(&replay.observation)?
619 != serde_json::to_value(observation)?
620 {
621 return Err(format!("replay observation differs for {scenario_id}").into());
622 }
623 }
624 true
625 } else {
626 false
627 };
628 let scorecard = build_reliability_scorecard(&scenarios, &observations)?;
629 let certified = scorecard.certified;
630 print_json(&serde_json::json!({
631 "status": if certified { "certified" } else { "blocked" },
632 "version": version,
633 "tool": {"name": "glass", "version": env!("CARGO_PKG_VERSION")},
634 "replaysValidated": replays_validated,
635 "gate": &scorecard.gate,
636 "scorecard": &scorecard,
637 }))?;
638 if !certified {
639 return Err("reliability certification blocked".into());
640 }
641 }
642 CertifyCommand::Replay { scenario, input } => {
643 let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
644 let bundle =
645 ReliabilityReplayBundle::from_value(read_json_input(Some(input))?, &scenario)?;
646 print_json(&serde_json::json!({
647 "status": "valid",
648 "scenarioId": &bundle.scenario_id,
649 "replayHash": bundle.content_hash(&scenario)?,
650 }))?;
651 }
652 CertifyCommand::ReplayDiff {
653 scenario,
654 before,
655 after,
656 } => {
657 let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
658 let before =
659 ReliabilityReplayBundle::from_value(read_json_input(Some(before))?, &scenario)?;
660 let after =
661 ReliabilityReplayBundle::from_value(read_json_input(Some(after))?, &scenario)?;
662 let comparison = before.compare(&after, &scenario)?;
663 print_json(&serde_json::json!({
664 "status": if comparison.equivalent { "equivalent" } else { "changed" },
665 "comparison": comparison,
666 }))?;
667 }
668 }
669 Ok(())
670}
671
672async fn dispatch_certify_run(
673 session: &BrowserSession,
674 scenario_path: &std::path::Path,
675 fixture_path: &std::path::Path,
676 url: &str,
677 workflow_root: &std::path::Path,
678 inputs_path: Option<&std::path::Path>,
679 output: Option<&std::path::Path>,
680) -> BrowserResult<()> {
681 let scenario = ReliabilityScenario::from_json(&std::fs::read_to_string(scenario_path)?)?;
682 let fixture = ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture_path)?)?;
683 let inputs: BTreeMap<String, Value> = match inputs_path {
684 Some(path) => serde_json::from_str(&std::fs::read_to_string(path)?)
685 .map_err(|error| format!("invalid workflow inputs: {error}"))?,
686 None => BTreeMap::new(),
687 };
688 session.navigate(url).await?;
689 let evidence = run_reliability_scenario(
690 session,
691 &scenario,
692 &fixture,
693 &ReliabilityRunOptions {
694 workflow_root: workflow_root.to_path_buf(),
695 inputs,
696 },
697 )
698 .await?;
699 let value = serde_json::json!({
700 "observation": evidence.observation,
701 "replay": evidence.replay,
702 });
703 if let Some(output) = output {
704 tokio::fs::write(output, serde_json::to_vec_pretty(&value)?).await?;
705 }
706 print_json(&value)?;
707 Ok(())
708}
709
710fn dispatch_knowledge(
711 action: &KnowledgeCommand,
712 explicit_path: Option<&std::path::Path>,
713 profile: &str,
714) -> BrowserResult<()> {
715 ProfileManager::validate_name(profile)?;
716 let path = explicit_path
717 .map(std::path::Path::to_path_buf)
718 .unwrap_or_else(|| default_knowledge_store_path(profile));
719 let mut store = KnowledgeStore::open(path)?;
720 match action {
721 KnowledgeCommand::List => print_json(store.snapshot())?,
722 KnowledgeCommand::Show { record_id } => {
723 let record = store
724 .get(record_id)
725 .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
726 print_json(record)?;
727 }
728 KnowledgeCommand::Explain { record_id } => {
729 let record = store
730 .get(record_id)
731 .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
732 print_json(&serde_json::json!({
733 "recordId": &record.record_id,
734 "kind": record.kind,
735 "confidence": record.confidence,
736 "scope": &record.scope,
737 "source": &record.source,
738 "invalidation": &record.invalidation,
739 "history": &record.history,
740 "contentHash": record.content_hash()?,
741 "assessment": "requires a fresh observation; stored knowledge is never an authorization",
742 }))?;
743 }
744 KnowledgeCommand::Stats => print_json(&store.stats()?)?,
745 KnowledgeCommand::Export { output } => {
746 let canonical = store.snapshot().to_canonical_json()?;
747 if let Some(output) = output {
748 std::fs::write(output, canonical)?;
749 println!("exported knowledge to {}", output.display());
750 } else {
751 println!("{canonical}");
752 }
753 }
754 KnowledgeCommand::Import { input } => {
755 let snapshot = serde_json::from_value(read_json_input(Some(input))?)
756 .map_err(|error| format!("invalid knowledge snapshot: {error}"))?;
757 store.replace_snapshot(snapshot)?;
758 print_json(&store.stats()?)?;
759 }
760 KnowledgeCommand::Invalidate {
761 record_id,
762 state,
763 reason,
764 observed_at,
765 } => {
766 let next = match state {
767 KnowledgeInvalidationState::Stale => KnowledgeConfidence::Stale,
768 KnowledgeInvalidationState::Contradicted => KnowledgeConfidence::Contradicted,
769 KnowledgeInvalidationState::Quarantined => KnowledgeConfidence::Quarantined,
770 };
771 let change = store.transition(
772 record_id,
773 next,
774 reason
775 .clone()
776 .unwrap_or_else(|| "caller invalidated record".into()),
777 observed_at
778 .clone()
779 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
780 false,
781 )?;
782 print_json(&change)?;
783 }
784 KnowledgeCommand::Purge { origin } => print_json(&store.purge_origin(origin)?)?,
785 }
786 Ok(())
787}
788
789fn dispatch_snapshot(action: &SnapshotCommand, profile: &str) -> BrowserResult<()> {
790 ProfileManager::validate_name(profile)?;
791 let store = SessionSnapshotStore::new(crate::browser::session::default_session_snapshot_path(
792 profile,
793 ));
794 match action {
795 SnapshotCommand::Create => unreachable!("snapshot creation requires a browser session"),
796 SnapshotCommand::List => print_json(&store.list()?)?,
797 SnapshotCommand::Inspect { snapshot_id } => print_json(&store.load(snapshot_id)?)?,
798 SnapshotCommand::Diff { from, to } => print_json(&store.diff(from, to)?)?,
799 SnapshotCommand::Purge => print_json(&serde_json::json!({"removed": store.purge()?}))?,
800 }
801 Ok(())
802}
803
804fn dispatch_task(action: &TaskCommand) -> BrowserResult<()> {
805 match action {
806 TaskCommand::Validate { input } => {
807 let source = std::fs::read_to_string(input)?;
808 let task = crate::task_protocol::GlassTask::from_json(&source)?;
809 print_json(&serde_json::json!({
810 "valid": true,
811 "schemaVersion": task.schema_version,
812 "task": task.task,
813 }))?;
814 }
815 TaskCommand::Compile {
816 input,
817 output,
818 explain,
819 } => {
820 let source = std::fs::read_to_string(input)?;
821 let task = crate::task_protocol::GlassTask::from_json(&source)?;
822 let plan = crate::task_compiler::compile_task(&task)?;
823 if let Some(output) = output {
824 std::fs::write(output, serde_json::to_vec_pretty(&plan)?)?;
825 println!("compiled task to {}", output.display());
826 } else {
827 print_json(&plan)?;
828 }
829 if *explain {
830 eprintln!("{}", explain_task(&task, &plan)?);
831 }
832 }
833 }
834 Ok(())
835}
836
837fn dispatch_ir(action: &IrCommand) -> BrowserResult<()> {
838 match action {
839 IrCommand::Validate { input } => {
840 let draft = read_web_ir_draft(input)?;
841 print_json(&serde_json::json!({
842 "valid": true,
843 "schemaVersion": draft.schema_version,
844 "revision": draft.revision,
845 }))?;
846 }
847 IrCommand::Inspect { input } => {
848 let draft = read_web_ir_draft(input)?;
849 print_json(&serde_json::json!({
850 "schemaVersion": draft.schema_version,
851 "revision": draft.revision,
852 "entityCount": draft.entities.len(),
853 "relationshipCount": draft.relationships.len(),
854 "coverage": draft.coverage,
855 "truncated": draft.limits.truncated,
856 "opaqueRegions": draft.coverage.opaque_regions,
857 "diagnosticCount": draft.diagnostics.len(),
858 "relationshipHintDiagnosticCount": draft.relationship_hint_diagnostics.len(),
859 }))?;
860 }
861 IrCommand::Diff { before, after } => {
862 let before = read_web_ir_draft(before)?;
863 let after = read_web_ir_draft(after)?;
864 print_json(&before.diff(&after)?)?;
865 }
866 IrCommand::Continuity {
867 before,
868 after,
869 entity_id,
870 } => {
871 let before = read_web_ir_draft(before)?;
872 let after = read_web_ir_draft(after)?;
873 print_json(&before.classify_entity_continuity(&after, entity_id)?)?;
874 }
875 IrCommand::Canonical { input } => {
876 let draft = read_web_ir_draft(input)?;
877 println!("{}", draft.to_canonical_json()?);
878 }
879 }
880 Ok(())
881}
882
883fn read_web_ir_draft(path: &Path) -> BrowserResult<crate::web_ir::GlassWebIrDraft> {
884 let source = std::fs::read_to_string(path)?;
885 let draft: crate::web_ir::GlassWebIrDraft = serde_json::from_str(&source)?;
886 draft.validate()?;
887 Ok(draft)
888}
889
890fn explain_task(
891 task: &crate::task_protocol::GlassTask,
892 plan: &crate::task_compiler::TaskExecutionPlan,
893) -> BrowserResult<String> {
894 let task_name = serde_json::to_string(&task.task)?
895 .trim_matches('"')
896 .to_owned();
897 let risk = serde_json::to_string(&task.risk)?
898 .trim_matches('"')
899 .to_owned();
900 let steps = plan
901 .steps
902 .iter()
903 .map(|step| {
904 let operation = serde_json::to_string(&step.operation)
905 .expect("task plan operations are always serializable")
906 .trim_matches('"')
907 .to_owned();
908 let inputs = if step.input_names.is_empty() {
909 String::new()
910 } else {
911 format!(
912 " inputNames={}",
913 serde_json::to_string(&step.input_names)
914 .expect("task input names are always serializable")
915 )
916 };
917 format!(
918 " {}. {}{} confirmationRequired={}",
919 step.ordinal, operation, inputs, step.requires_confirmation
920 )
921 })
922 .collect::<Vec<_>>()
923 .join("\n");
924 Ok(format!(
925 "task: {task_name}\nscope: {}\nrisk: {risk}\nconfirmationRequired: {}\nsteps:\n{steps}\npostconditions: {}\n",
926 serde_json::to_string(&task.scope)?,
927 plan.confirmation_required,
928 serde_json::to_string(&plan.postconditions)?
929 ))
930}
931
932fn dispatch_workflow_authoring(action: &WorkflowAuthoringCommand) -> BrowserResult<()> {
933 match action {
934 WorkflowAuthoringCommand::Templates { name, output } => {
935 let names = [
936 "account-search",
937 "checkout-submit",
938 "profile-update",
939 "report-download",
940 "support-ticket",
941 ];
942 let Some(name) = name else {
943 print_json(&names)?;
944 return Ok(());
945 };
946 let source = workflow_template(name)
947 .ok_or_else(|| format!("unknown workflow template: {name}"))?;
948 let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml)?;
949 if document
950 .diagnostics
951 .iter()
952 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
953 {
954 return Err(format!("workflow template {name} failed compilation").into());
955 }
956 if let Some(output) = output {
957 std::fs::write(output, source)?;
958 } else {
959 print!("{source}");
960 }
961 }
962 WorkflowAuthoringCommand::Init { name, output } => {
963 let template = match name.as_str() {
964 "search" => "account-search",
965 "form-submit" => "checkout-submit",
966 "paginated-extraction" => "report-download",
967 "authenticated-session" => "profile-update",
968 "dialog-and-download" => "support-ticket",
969 _ => return Err(format!("unknown workflow starter: {name}").into()),
970 };
971 let source = workflow_template(template)
972 .ok_or_else(|| format!("missing workflow starter: {template}"))?;
973 let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml)?;
974 if document
975 .diagnostics
976 .iter()
977 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
978 {
979 return Err(format!("workflow starter {name} failed compilation").into());
980 }
981 if let Some(output) = output {
982 std::fs::write(output, source)?;
983 } else {
984 print!("{source}");
985 }
986 }
987 WorkflowAuthoringCommand::Compile { input, output } => {
988 let source = std::fs::read_to_string(input)?;
989 let document = compile_workflow(&source, authoring_format(input))?;
990 if let Some(output) = output {
991 std::fs::write(output, &document.canonical_json)?;
992 println!("compiled workflow to {}", output.display());
993 } else {
994 println!("{}", document.canonical_json);
995 }
996 if document
997 .diagnostics
998 .iter()
999 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
1000 {
1001 return Err("workflow compilation produced error diagnostics".into());
1002 }
1003 }
1004 WorkflowAuthoringCommand::Format { input, output } => {
1005 let source = std::fs::read_to_string(input)?;
1006 let document = compile_workflow(&source, authoring_format(input))?;
1007 let formatted = format_workflow_yaml(&document.definition)?;
1008 if let Some(output) = output {
1009 std::fs::write(output, formatted)?;
1010 println!("formatted workflow to {}", output.display());
1011 } else {
1012 print!("{formatted}");
1013 }
1014 }
1015 WorkflowAuthoringCommand::Preview { input } => {
1016 let source = std::fs::read_to_string(input)?;
1017 let document = compile_workflow(&source, authoring_format(input))?;
1018 let preview = preview_workflow(&document.definition)?;
1019 print_json(&serde_json::json!({
1020 "preview": preview,
1021 "diagnostics": document.diagnostics,
1022 }))?;
1023 }
1024 WorkflowAuthoringCommand::Diff { before, after } => {
1025 let before_source = std::fs::read_to_string(before)?;
1026 let after_source = std::fs::read_to_string(after)?;
1027 let before_document = compile_workflow(&before_source, authoring_format(before))?;
1028 let after_document = compile_workflow(&after_source, authoring_format(after))?;
1029 let diff = diff_workflows(&before_document.definition, &after_document.definition)?;
1030 print_json(&serde_json::json!({
1031 "diff": diff,
1032 "beforeDiagnostics": before_document.diagnostics,
1033 "afterDiagnostics": after_document.diagnostics,
1034 }))?;
1035 }
1036 WorkflowAuthoringCommand::Record { input, output } => {
1037 let value = read_json_input(input.as_ref())?;
1038 let session: WorkflowRecordingSession = serde_json::from_value(value)?;
1039 let draft = record_semantic_events(session)?;
1040 let serialized = serde_json::to_string_pretty(&draft)?;
1041 if let Some(output) = output {
1042 std::fs::write(output, serialized)?;
1043 println!("recorded workflow draft to {}", output.display());
1044 } else {
1045 println!("{serialized}");
1046 }
1047 }
1048 WorkflowAuthoringCommand::Validate { input } => {
1049 let source = std::fs::read_to_string(input)?;
1050 let document = compile_workflow(&source, authoring_format(input))?;
1051 print_json(&document)?;
1052 }
1053 WorkflowAuthoringCommand::Lint {
1054 input,
1055 warnings_as_errors,
1056 } => {
1057 let source = std::fs::read_to_string(input)?;
1058 let document = compile_workflow(&source, authoring_format(input))?;
1059 let failed = document.diagnostics.iter().any(|diagnostic| {
1060 diagnostic.severity == WorkflowDiagnosticSeverity::Error
1061 || (*warnings_as_errors
1062 && diagnostic.severity == WorkflowDiagnosticSeverity::Warning)
1063 });
1064 print_json(&document.diagnostics)?;
1065 if failed {
1066 return Err("workflow lint failed".into());
1067 }
1068 }
1069 }
1070 Ok(())
1071}
1072
1073fn authoring_format(path: &std::path::Path) -> WorkflowAuthoringFormat {
1074 if path
1075 .extension()
1076 .and_then(|extension| extension.to_str())
1077 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
1078 {
1079 WorkflowAuthoringFormat::Json
1080 } else {
1081 WorkflowAuthoringFormat::Yaml
1082 }
1083}
1084
1085fn workflow_template(name: &str) -> Option<&'static str> {
1086 Some(match name {
1087 "account-search" => include_str!("templates/account-search.yaml"),
1088 "checkout-submit" => include_str!("templates/checkout-submit.yaml"),
1089 "profile-update" => include_str!("templates/profile-update.yaml"),
1090 "report-download" => include_str!("templates/report-download.yaml"),
1091 "support-ticket" => include_str!("templates/support-ticket.yaml"),
1092 _ => return None,
1093 })
1094}
1095async fn run_command(
1096 session: &BrowserSession,
1097 command: &Commands,
1098 response_mode: ResponseMode,
1099) -> BrowserResult<()> {
1100 match command {
1101 Commands::Certify {
1102 action:
1103 CertifyCommand::Run {
1104 scenario,
1105 fixture,
1106 url,
1107 workflow_root,
1108 inputs,
1109 output,
1110 },
1111 } => {
1112 dispatch_certify_run(
1113 session,
1114 scenario,
1115 fixture,
1116 url,
1117 workflow_root,
1118 inputs.as_deref(),
1119 output.as_deref(),
1120 )
1121 .await?;
1122 }
1123 Commands::Snapshot {
1124 action: SnapshotCommand::Create,
1125 } => {
1126 let observation = session
1127 .semantic_observe(SemanticObservationLevel::Structured)
1128 .await?;
1129 let snapshot = crate::browser::session::SessionSnapshot::from_observation(
1130 session.profile.clone(),
1131 observation,
1132 );
1133 let store = SessionSnapshotStore::new(
1134 crate::browser::session::default_session_snapshot_path(&session.profile),
1135 );
1136 store.save(&snapshot)?;
1137 print_json(&snapshot)?;
1138 }
1139 Commands::Capabilities
1140 | Commands::Daemon { .. }
1141 | Commands::Doctor { .. }
1142 | Commands::McpConfig { .. }
1143 | Commands::Result { .. }
1144 | Commands::Certify { .. }
1145 | Commands::Knowledge { .. }
1146 | Commands::Snapshot { .. }
1147 | Commands::Task { .. } => {
1148 unreachable!("offline commands are handled before browser startup")
1149 }
1150 Commands::Navigate {
1151 url,
1152 timeout_ms,
1153 expected_revision,
1154 } => {
1155 if let Some(expected_revision) = expected_revision {
1156 print_json(
1157 &session
1158 .navigate_with_revision(
1159 url,
1160 Duration::from_millis(*timeout_ms),
1161 *expected_revision,
1162 )
1163 .await?,
1164 )?;
1165 } else {
1166 let page = session
1167 .navigate_with_deadline(url, Duration::from_millis(*timeout_ms))
1168 .await?;
1169 print_json(&page)?;
1170 }
1171 }
1172 Commands::Click {
1173 target,
1174 expected_revision,
1175 } => {
1176 if let Some(expected_revision) = expected_revision {
1177 print_json(
1178 &session
1179 .click_with_revision(target, *expected_revision)
1180 .await?,
1181 )?;
1182 } else {
1183 print_json(&session.click(target).await?)?;
1184 }
1185 }
1186 Commands::Preflight { target, action } => {
1187 print_json(&session.preflight_with_action(target, *action).await)?;
1188 }
1189 Commands::ClickAt { x, y } => {
1190 print_json(&session.click_at(*x, *y).await?)?;
1191 }
1192 Commands::ClickExpectPopup {
1193 target,
1194 expected_revision,
1195 } => {
1196 print_json(
1197 &session
1198 .click_expect_popup_with_revision(target, *expected_revision)
1199 .await?,
1200 )?;
1201 }
1202 Commands::DoubleClick {
1203 target,
1204 expected_revision,
1205 } => {
1206 print_json(
1207 &session
1208 .double_click_with_revision(target, *expected_revision)
1209 .await?,
1210 )?;
1211 }
1212 Commands::Hover { target } => print_json(&session.hover(target).await?)?,
1213 Commands::Drag {
1214 source,
1215 destination,
1216 expected_revision,
1217 } => {
1218 print_json(
1219 &session
1220 .drag_with_revision(source, destination, *expected_revision)
1221 .await?,
1222 )?;
1223 }
1224 Commands::Type {
1225 text,
1226 target,
1227 expected_revision,
1228 } => {
1229 print_json(
1230 &session
1231 .type_text_with_expected_revision(text, target.as_deref(), *expected_revision)
1232 .await?,
1233 )?;
1234 }
1235 Commands::Key {
1236 key,
1237 expected_revision,
1238 } => print_json(
1239 &session
1240 .key_press_with_revision(key, *expected_revision)
1241 .await?,
1242 )?,
1243 Commands::KeyDown {
1244 key,
1245 expected_revision,
1246 } => print_json(
1247 &session
1248 .key_down_with_revision(key, *expected_revision)
1249 .await?,
1250 )?,
1251 Commands::KeyUp {
1252 key,
1253 expected_revision,
1254 } => print_json(
1255 &session
1256 .key_up_with_revision(key, *expected_revision)
1257 .await?,
1258 )?,
1259 Commands::Shortcut {
1260 shortcut,
1261 expected_revision,
1262 } => print_json(
1263 &session
1264 .shortcut_with_revision(shortcut, *expected_revision)
1265 .await?,
1266 )?,
1267 Commands::Clear {
1268 target,
1269 expected_revision,
1270 } => print_json(
1271 &session
1272 .clear_with_revision(target, *expected_revision)
1273 .await?,
1274 )?,
1275 Commands::Check {
1276 target,
1277 expected_revision,
1278 } => print_json(
1279 &session
1280 .check_with_revision(target, *expected_revision)
1281 .await?,
1282 )?,
1283 Commands::Uncheck {
1284 target,
1285 expected_revision,
1286 } => print_json(
1287 &session
1288 .uncheck_with_revision(target, *expected_revision)
1289 .await?,
1290 )?,
1291 Commands::Select {
1292 target,
1293 value,
1294 expected_revision,
1295 } => {
1296 print_json(
1297 &session
1298 .select_option_with_revision(target, value, *expected_revision)
1299 .await?,
1300 )?;
1301 }
1302 Commands::Upload {
1303 target,
1304 files,
1305 expected_revision,
1306 } => {
1307 print_json(
1308 &session
1309 .upload_files_with_revision(target, files, *expected_revision)
1310 .await?,
1311 )?;
1312 }
1313 Commands::Screenshot {
1314 output,
1315 format,
1316 quality,
1317 scale,
1318 full_page,
1319 clip,
1320 target,
1321 } => {
1322 let output = session
1323 .policy()
1324 .require_output_path(std::path::Path::new(output))?;
1325 let capture = session
1326 .capture_visual(&VisualCaptureOptions {
1327 format: *format,
1328 quality: *quality,
1329 scale: *scale,
1330 clip: *clip,
1331 full_page: *full_page,
1332 target: target.clone(),
1333 })
1334 .await?;
1335 let mut source = base64::read::DecoderReader::new(
1336 capture.data.as_bytes(),
1337 &base64::engine::general_purpose::STANDARD,
1338 );
1339 let mut file = std::fs::File::create(&output)?;
1340 std::io::copy(&mut source, &mut file)?;
1341 println!("wrote {}", output.display());
1342 print_json(&capture.metadata)?;
1343 }
1344 Commands::Text => println!("{}", session.text().await?),
1345 Commands::Dom => print_json(&session.deep_dom().await?)?,
1346 Commands::Observe {
1347 deep_dom,
1348 screenshot,
1349 form_values,
1350 semantic_level,
1351 region,
1352 } => {
1353 if let Some(level_name) = semantic_level {
1354 if *deep_dom || *screenshot || *form_values {
1355 return Err(
1356 "semantic observation cannot be combined with deep DOM, screenshot, or form values"
1357 .into(),
1358 );
1359 }
1360 let level = parse_semantic_level(level_name)?;
1361 if let Some(region_id) = region {
1362 let page = session.semantic_observe(level).await?;
1363 print_json(
1364 &session
1365 .semantic_expand_region(region_id, page.revision, level)
1366 .await?,
1367 )?;
1368 } else {
1369 print_json(&session.semantic_observe(level).await?)?;
1370 }
1371 return Ok(());
1372 }
1373 let context = match (*deep_dom, *screenshot, *form_values) {
1374 (false, false, false) => session.observe().await?,
1375 (true, false, false) => session.observe_with_dom().await?,
1376 (false, true, false) => session.observe_with_screenshot().await?,
1377 (true, true, false) => session.observe_with_dom_and_screenshot().await?,
1378 (false, false, true) => session.observe_with_form_values().await?,
1379 _ => return Err("form values can only be combined with compact observe".into()),
1380 };
1381 print_json_mode(&context, response_mode)?;
1382 }
1383 Commands::InspectPage => print_json_mode(&session.inspect_page().await?, response_mode)?,
1384 Commands::FindTarget { input } => {
1385 let request = SemanticIntentRequest::from_json(&serde_json::to_string(
1386 &read_json_input(Some(input))?,
1387 )?)?;
1388 print_json_mode(&session.find_target(&request).await?, response_mode)?;
1389 }
1390 Commands::ActAndVerify {
1391 input,
1392 predicate,
1393 timeout_ms,
1394 } => {
1395 let request = SemanticIntentExecutionRequest::from_json(&serde_json::to_string(
1396 &read_json_input(Some(input))?,
1397 )?)?;
1398 let predicate = predicate
1399 .as_deref()
1400 .map(serde_json::from_str::<VerificationPredicate>)
1401 .transpose()?;
1402 print_json_mode(
1403 &session
1404 .act_and_verify(&request, predicate, Duration::from_millis(*timeout_ms))
1405 .await?,
1406 response_mode,
1407 )?;
1408 }
1409 Commands::ExtractStructured { input } => {
1410 let request: StructuredExtractionRequest =
1411 serde_json::from_value(read_json_input(Some(input))?)?;
1412 print_json_mode(&session.extract_structured(&request).await?, response_mode)?;
1413 }
1414 Commands::RecoverRun { execution_id } => {
1415 print_json_mode(&session.recover_run(execution_id)?, response_mode)?;
1416 }
1417 Commands::Scroll {
1418 dx,
1419 dy,
1420 expected_revision,
1421 } => {
1422 print_json(
1423 &session
1424 .scroll_with_revision(*dx, *dy, *expected_revision)
1425 .await?,
1426 )?;
1427 }
1428 Commands::Wait {
1429 condition,
1430 timeout_ms,
1431 } => {
1432 print_json(
1433 &session
1434 .wait(
1435 WaitCondition::parse(condition)?,
1436 Duration::from_millis(*timeout_ms),
1437 )
1438 .await?,
1439 )?;
1440 }
1441 Commands::Diagnostics { duration_ms } => print_json(
1442 &session
1443 .diagnostics(Duration::from_millis(*duration_ms))
1444 .await?,
1445 )?,
1446 Commands::AcceptDialog => {
1447 session.accept_dialog().await?;
1448 print_json(&serde_json::json!({"dialog": "accepted"}))?;
1449 }
1450 Commands::DismissDialog => {
1451 session.dismiss_dialog().await?;
1452 print_json(&serde_json::json!({"dialog": "dismissed"}))?;
1453 }
1454 Commands::DismissConsent => print_json(&session.dismiss_consent().await?)?,
1455 Commands::Download {
1456 destination,
1457 timeout_ms,
1458 } => print_json(
1459 &session
1460 .wait_for_download(destination, Duration::from_millis(*timeout_ms))
1461 .await?,
1462 )?,
1463 Commands::Targets => print_json(&session.list_targets().await?)?,
1464 Commands::NewTarget { url } => print_json(&session.create_target(url).await?)?,
1465 Commands::SelectTarget { id } => print_json(&session.select_target(id).await?)?,
1466 Commands::CloseTarget { id } => {
1467 session.close_target(id).await?;
1468 print_json(&serde_json::json!({"closed": id}))?;
1469 }
1470 Commands::Frames => print_json(&session.list_frames().await?)?,
1471 Commands::SelectFrame { id } => print_json(&session.select_frame(id).await?)?,
1472 Commands::Evaluate { expression } => {
1473 print_json(&session.evaluate(expression).await?)?;
1474 }
1475 Commands::Cookies => print_json(&session.cookies().await?)?,
1476 Commands::ExportCookies { output } => {
1477 let cookies = session.cookies().await?;
1478 let bytes = serde_json::to_vec_pretty(&cookies)?;
1479 tokio::fs::write(output, bytes).await?;
1480 println!("cookies exported to {}", output.display());
1481 }
1482 Commands::ImportCookies { input } => {
1483 const MAX_COOKIE_FILE_BYTES: u64 = 512 * 1024;
1484 let metadata = tokio::fs::metadata(input).await?;
1485 if metadata.len() > MAX_COOKIE_FILE_BYTES {
1486 return Err(format!(
1487 "cookie file exceeds the {}-byte limit",
1488 MAX_COOKIE_FILE_BYTES
1489 )
1490 .into());
1491 }
1492 let bytes = tokio::fs::read(input).await?;
1493 let cookies: Vec<Cookie> = serde_json::from_slice(&bytes)?;
1494 session.set_cookies(&cookies).await?;
1495 println!("{} cookies imported", cookies.len());
1496 }
1497 Commands::Pdf { output, background } => {
1498 let mut opts = PdfOptions::letter();
1499 if *background {
1500 opts.print_background = Some(true);
1501 }
1502 let data = session.print_to_pdf(&opts).await?;
1503 let bytes = base64::engine::general_purpose::STANDARD.decode(&data)?;
1504 tokio::fs::write(&output, &bytes).await?;
1505 println!("PDF saved to {output} ({} bytes)", bytes.len());
1506 }
1507 Commands::FillForm {
1508 fields,
1509 expected_revision,
1510 } => {
1511 let parsed: Vec<serde_json::Value> = serde_json::from_str(fields)?;
1512 let field_refs: Vec<(String, String)> = parsed
1513 .iter()
1514 .map(|v| {
1515 (
1516 v["target"].as_str().unwrap_or("").to_string(),
1517 v["value"].as_str().unwrap_or("").to_string(),
1518 )
1519 })
1520 .collect();
1521 let field_slices: Vec<(&str, &str)> = field_refs
1522 .iter()
1523 .map(|(t, v)| (t.as_str(), v.as_str()))
1524 .collect();
1525 print_json(
1526 &session
1527 .fill_form_with_expected_revision(&field_slices, *expected_revision)
1528 .await?,
1529 )?;
1530 }
1531 Commands::Batch {
1532 input,
1533 atomic,
1534 mode,
1535 expected_revision,
1536 } => {
1537 let payload = read_json_input(input.as_ref())?;
1538 let steps_value = payload.get("steps").cloned().unwrap_or(payload);
1539 let steps: Vec<BatchStep> = serde_json::from_value(steps_value)
1540 .map_err(|error| format!("invalid batch document: {error}"))?;
1541 print_json(
1542 &session
1543 .run_batch_with_mode(&steps, *atomic, *mode, *expected_revision)
1544 .await?,
1545 )?;
1546 }
1547 Commands::Workflow {
1548 action: None,
1549 input,
1550 } => {
1551 let payload = read_json_input(input.as_ref())?;
1552 let workflow_value = payload
1553 .get("workflow")
1554 .cloned()
1555 .unwrap_or_else(|| payload.clone());
1556 let inputs_value = payload
1557 .get("inputs")
1558 .cloned()
1559 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
1560 let workflow = WorkflowDefinition::from_value(workflow_value)
1561 .map_err(|error| format!("invalid workflow: {error}"))?;
1562 let inputs: BTreeMap<String, Value> = serde_json::from_value(inputs_value)
1563 .map_err(|error| format!("invalid workflow inputs: {error}"))?;
1564 print_json(&session.run_workflow(&workflow, &inputs).await?)?;
1565 }
1566 Commands::Workflow {
1567 action: Some(_), ..
1568 } => unreachable!("offline workflow authoring commands are handled before browser startup"),
1569 Commands::WorkflowResume {
1570 workflow,
1571 checkpoint,
1572 inputs,
1573 } => {
1574 let workflow = WorkflowDefinition::from_value(read_json_input(Some(workflow))?)
1575 .map_err(|error| format!("invalid workflow: {error}"))?;
1576 let checkpoint: WorkflowCheckpoint =
1577 serde_json::from_value(read_json_input(Some(checkpoint))?)
1578 .map_err(|error| format!("invalid workflow checkpoint: {error}"))?;
1579 let inputs: BTreeMap<String, Value> = match inputs {
1580 Some(path) => serde_json::from_value(read_json_input(Some(path))?)
1581 .map_err(|error| format!("invalid workflow inputs: {error}"))?,
1582 None => BTreeMap::new(),
1583 };
1584 print_json(
1585 &session
1586 .resume_workflow(&workflow, &inputs, &checkpoint)
1587 .await?,
1588 )?;
1589 }
1590 Commands::ResolveIntent { input } => {
1591 let request = SemanticIntentRequest::from_json(&serde_json::to_string(
1592 &read_json_input(input.as_ref())?,
1593 )?)?;
1594 print_json(&session.resolve_intent(&request).await?)?;
1595 }
1596 Commands::ExecuteIntent { input } => {
1597 let execution = SemanticIntentExecutionRequest::from_json(&serde_json::to_string(
1598 &read_json_input(input.as_ref())?,
1599 )?)?;
1600 print_json(&session.execute_intent(&execution).await?)?;
1601 }
1602 Commands::Verify {
1603 predicate,
1604 timeout_ms,
1605 } => {
1606 let predicate: VerificationPredicate = serde_json::from_str(predicate)
1607 .map_err(|error| format!("invalid verification predicate: {error}"))?;
1608 print_json(
1609 &session
1610 .verify(predicate, Duration::from_millis(*timeout_ms))
1611 .await?,
1612 )?;
1613 }
1614 Commands::ReconcileRefs {
1615 from_revision,
1616 hints,
1617 scope,
1618 refs,
1619 } => {
1620 let options = ReconciliationOptions {
1621 hints: hints
1622 .iter()
1623 .map(|hint| Locator::parse(hint))
1624 .collect::<BrowserResult<Vec<_>>>()?,
1625 scope_ref: scope.clone(),
1626 };
1627 print_json(
1628 &session
1629 .reconcile_references_with_options(*from_revision, refs, &options)
1630 .await?,
1631 )?;
1632 }
1633 Commands::ObserveDelta => {
1634 print_json(&session.observe_delta().await?)?;
1635 }
1636 Commands::Checkpoint { action } => match action {
1637 CheckpointCommand::Export => print_json(&session.export_checkpoint().await?)?,
1638 CheckpointCommand::Import { input } => {
1639 let checkpoint: CheckpointV1 =
1640 serde_json::from_value(read_json_input(input.as_ref())?)
1641 .map_err(|error| format!("invalid checkpoint: {error}"))?;
1642 session.import_checkpoint(&checkpoint).await?;
1643 print_json(&serde_json::json!({"status": "checkpoint_imported"}))?;
1644 }
1645 },
1646 Commands::ClipboardRead => {
1647 let text = session.clipboard_read().await?;
1648 println!("{text}");
1649 }
1650 Commands::ClipboardWrite { text } => {
1651 session.clipboard_write(text).await?;
1652 println!("Text written to clipboard");
1653 }
1654 Commands::Tui
1655 | Commands::Ir { .. }
1656 | Commands::InstallChromium { .. }
1657 | Commands::Profiles { .. }
1658 | Commands::DeleteProfile { .. } => {
1659 unreachable!("handled before starting a browser session")
1660 }
1661 }
1662 Ok(())
1663}
1664
1665fn parse_semantic_level(value: &str) -> BrowserResult<SemanticObservationLevel> {
1666 match value {
1667 "summary" => Ok(SemanticObservationLevel::Summary),
1668 "interactive" => Ok(SemanticObservationLevel::Interactive),
1669 "structured" => Ok(SemanticObservationLevel::Structured),
1670 "detailed" => Ok(SemanticObservationLevel::Detailed),
1671 "raw" => Ok(SemanticObservationLevel::Raw),
1672 _ => Err("expected summary, interactive, structured, detailed, or raw".into()),
1673 }
1674}
1675
1676fn read_json_input(path: Option<&std::path::PathBuf>) -> BrowserResult<serde_json::Value> {
1677 let mut input = String::new();
1678 match path {
1679 Some(path) => std::fs::File::open(path)?.read_to_string(&mut input)?,
1680 None => std::io::stdin().read_to_string(&mut input)?,
1681 };
1682 Ok(serde_json::from_str(&input)?)
1683}
1684
1685pub(crate) fn policy_from_cli(cli: &Cli) -> BrowserResult<BrowserPolicy> {
1686 Ok(BrowserPolicy::new(
1687 cli.policy,
1688 std::env::current_dir()?,
1689 cli.policy_allow.iter().copied(),
1690 cli.policy_confirm.iter().copied(),
1691 )?
1692 .with_host_rules(
1693 cli.policy_allow_host.iter().cloned(),
1694 cli.policy_deny_host.iter().cloned(),
1695 )?
1696 .with_confirmation_tokens(cli.policy_confirm_once.iter().copied())?)
1697}
1698
1699async fn run_prompt(
1700 session: &BrowserSession,
1701 prompt: &str,
1702 response_mode: ResponseMode,
1703) -> BrowserResult<()> {
1704 let trimmed = prompt.trim();
1705 let lower = trimmed.to_lowercase();
1706
1707 for prefix in ["navigate to ", "go to ", "open "] {
1708 if lower.starts_with(prefix) {
1709 let page = session.navigate(trimmed[prefix.len()..].trim()).await?;
1710 print_json_mode(&page, response_mode)?;
1711 return Ok(());
1712 }
1713 }
1714 if let Some(rest) = lower.strip_prefix("click ") {
1715 let target = &trimmed[trimmed.len() - rest.len()..];
1716 print_json_mode(
1717 &session.click(target.trim_matches('"')).await?,
1718 response_mode,
1719 )?;
1720 return Ok(());
1721 }
1722 if let Some(rest) = lower.strip_prefix("double click ") {
1723 let target = &trimmed[trimmed.len() - rest.len()..];
1724 print_json_mode(
1725 &session.double_click(target.trim_matches('"')).await?,
1726 response_mode,
1727 )?;
1728 return Ok(());
1729 }
1730 if let Some(rest) = lower.strip_prefix("type ") {
1731 let text = &trimmed[trimmed.len() - rest.len()..];
1732 print_json_mode(
1733 &session.type_text(text.trim_matches('"'), None).await?,
1734 response_mode,
1735 )?;
1736 return Ok(());
1737 }
1738 if lower.starts_with("screenshot") {
1739 let output = trimmed
1740 .split_once(char::is_whitespace)
1741 .map(|(_, value)| value.trim())
1742 .filter(|value| !value.is_empty())
1743 .unwrap_or("screenshot.png");
1744 let output = session
1745 .policy()
1746 .require_output_path(std::path::Path::new(output))?;
1747 std::fs::write(&output, session.screenshot_png().await?)?;
1748 println!("wrote {}", output.display());
1749 return Ok(());
1750 }
1751 if matches!(
1752 lower.as_str(),
1753 "text" | "get text" | "page text" | "get content"
1754 ) {
1755 println!("{}", session.text().await?);
1756 return Ok(());
1757 }
1758 if matches!(lower.as_str(), "dom" | "snapshot" | "get dom") {
1759 print_json_mode(&session.deep_dom().await?, response_mode)?;
1760 return Ok(());
1761 }
1762 if matches!(lower.as_str(), "observe" | "context") {
1763 print_json_mode(&session.observe().await?, response_mode)?;
1764 return Ok(());
1765 }
1766
1767 print_json_mode(&session.evaluate(trimmed).await?, response_mode)?;
1768 Ok(())
1769}
1770
1771fn print_json_mode<T: Serialize + ?Sized>(value: &T, mode: ResponseMode) -> BrowserResult<()> {
1772 let value = serde_json::to_value(value)?;
1773 let projected = project_and_store(value, mode, "cli", default_result_store_path())?;
1774 println!("{}", compact_json(&projected)?);
1775 Ok(())
1776}
1777
1778fn print_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<()> {
1779 println!("{}", compact_json(value)?);
1780 Ok(())
1781}
1782
1783fn compact_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<String> {
1784 let mut value = serde_json::to_value(value)?;
1785 let payload = serde_json::to_vec(&value)?;
1786 let payload_bytes = payload.len();
1787 if let Some(object) = value.as_object_mut() {
1788 object.insert(
1789 "contextCost".to_string(),
1790 serde_json::json!({
1791 "payloadBytes": payload_bytes,
1792 "estimatedTokens": payload_bytes.div_ceil(4)
1793 }),
1794 );
1795 }
1796 Ok(serde_json::to_string(&value)?)
1797}
1798
1799#[cfg(test)]
1800mod tests {
1801 use super::*;
1802 use serde_json::json;
1803
1804 #[test]
1805 fn structured_cli_output_is_compact_json() {
1806 let output = compact_json(&json!({
1807 "page": {"title": "Glass", "url": "https://example.com"},
1808 "items": [1, 2]
1809 }))
1810 .unwrap();
1811
1812 let parsed = serde_json::from_str::<serde_json::Value>(&output).unwrap();
1813 assert!(!output.contains('\n'));
1814 assert_eq!(parsed["items"], json!([1, 2]));
1815 assert!(parsed["contextCost"]["payloadBytes"].as_u64().unwrap() > 0);
1816 assert!(parsed["contextCost"]["estimatedTokens"].as_u64().unwrap() > 0);
1817 }
1818
1819 #[test]
1820 fn path_writability_accepts_missing_child_under_writable_parent() {
1821 let root = std::env::temp_dir().join(format!("glass-doctor-test-{}", std::process::id()));
1822 std::fs::create_dir_all(&root).unwrap();
1823 let missing_child = root.join("future").join("artifact");
1824
1825 assert!(path_is_writable(Some(&missing_child)));
1826
1827 std::fs::remove_dir_all(root).unwrap();
1828 }
1829
1830 #[test]
1831 fn path_writability_rejects_missing_path_without_parent() {
1832 assert!(!path_is_writable(None));
1833 }
1834
1835 #[test]
1836 fn workflow_starter_templates_compile_without_coordinate_actions() {
1837 for name in [
1838 "account-search",
1839 "checkout-submit",
1840 "profile-update",
1841 "report-download",
1842 "support-ticket",
1843 ] {
1844 let source = workflow_template(name).unwrap();
1845 let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml).unwrap();
1846 assert!(
1847 !document
1848 .diagnostics
1849 .iter()
1850 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error),
1851 "{name} diagnostics: {:?}",
1852 document.diagnostics
1853 );
1854 assert!(!source.contains("clickAt"));
1855 }
1856 }
1857 #[test]
1858 fn task_explanation_contains_metadata_without_input_values() {
1859 let task = crate::task_protocol::GlassTask::from_json(
1860 r#"{
1861 "schemaVersion": 1,
1862 "task": "form.fill",
1863 "scope": {"regionName": "Shipping address"},
1864 "inputs": {"city": "Kuching-secret"},
1865 "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 16},
1866 "risk": "localMutation"
1867 }"#,
1868 )
1869 .unwrap();
1870 let plan = crate::task_compiler::compile_task(&task).unwrap();
1871
1872 let explanation = explain_task(&task, &plan).unwrap();
1873
1874 assert!(explanation.contains("task: form.fill"));
1875 assert!(explanation.contains("scope: {\"regionName\":\"Shipping address\"}"));
1876 assert!(explanation.contains("inputNames=[\"city\"]"));
1877 assert!(!explanation.contains("Kuching-secret"));
1878 }
1879}