1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
mod base64;
mod browser;
mod cdp;
mod chrome_args;
mod cli;
mod cli_actions;
mod commands;
mod connect_cli;
#[cfg(unix)]
mod daemon;
mod element;
mod element_ref;
mod element_selector;
mod element_controls;
mod emulation;
mod geometry;
mod hit_test;
mod hit_test_report;
mod hints;
mod kill;
mod landing;
mod macros;
mod macros_record;
mod macros_cmd;
mod macros_run;
mod orphans;
mod pipe;
mod pipe_dispatch;
mod pipe_dispatch_actions;
mod pipe_emulation;
mod pipe_report;
mod profiles;
mod read_back;
mod render;
mod run;
mod run_helpers;
mod serving;
mod session;
mod session_load;
mod setup;
mod snapshot;
mod snapshot_render;
mod snapshot_secret;
mod truncate;
mod verdict;
mod verdict_evidence;
mod verdict_words;
/// Shared error type alias used across the crate.
pub(crate) type BoxError = Box<dyn std::error::Error>;
use clap::Parser;
use serde_json::json;
use crate::cli::Cli;
use crate::run_helpers::error_hint;
#[tokio::main]
async fn main() {
// Not `Cli::parse()`: clap exits 2 on a usage error, and 2 now means "the assertion did
// not hold" (`commands::assert`). A wrong flag is the caller's mistake, not a fact about
// the page, so it joins every other operational failure at 1 and leaves 2 to mean one
// thing. `--help`/`--version` still print to stdout and exit 0.
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
let usage = !matches!(
e.kind(),
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayVersion
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
);
if usage {
// One usage error is really about flag position, and clap's own tip for it sends
// the reader off to escape an argument they never meant as a string. `hints`
// rewrites that one and returns every other error unchanged. Help and version
// still go through clap, on stdout.
let argv: Vec<String> = std::env::args().collect();
eprint!("{}", hints::usage_error(&e.to_string(), &argv));
} else {
let _ = e.print();
}
std::process::exit(i32::from(usage));
}
};
let json_mode = cli.json;
// Captured before `cli` is consumed by `run`: an error hint names a command to run, and
// that command has to reach the browser THIS invocation drove, not the default one.
let browser = cli.browser.clone();
// Parse and stop. The embedded guide (`llm-guide.txt`, printed by `--help`) is what
// an agent copies its invocations from, and it once documented a flag that did not
// exist; checking it against the parser needs a way to reach clap's verdict —
// including missing required arguments, which `--help` short-circuits past — without
// launching a browser. Env var rather than a flag: this is a test affordance, not
// part of the command surface.
if std::env::var_os("CHROME_AGENT_PARSE_ONLY").is_some() {
return;
}
// Clean up this invocation's managed Chrome on Ctrl+C — and only this one. The
// handler used to walk every entry in the shared sessions.json and kill each pid
// raw, so interrupting one agent killed every other agent's browser mid-task and
// bypassed the PID-reuse guard every other kill path goes through. Installed after
// parsing because it needs to know which browser is ours — and whether we own one
// at all: `--browser` is global, so `daemon start` carries the default name for a
// browser it never launched.
let interrupted_browser = run_helpers::interrupt_owns_browser(&cli.command).then(|| cli.browser.clone());
tokio::spawn(async move {
if matches!(tokio::signal::ctrl_c().await, Ok(())) {
if let Some(name) = interrupted_browser
&& let Ok(store) = session::load_session()
&& let Some(pid) = run_helpers::interrupt_kill_target(&store, &name) {
run_helpers::kill_pid(pid);
}
// The store answers only for browsers that reached it. Interrupting a cold
// start before its first save — reproducible inside ~0.3 s — left a Chrome
// running that no later command could name, which is how a headless browser
// survives for 19 days. This reaps that one, and is a no-op once saved.
kill::reap_unpersisted();
std::process::exit(130);
}
});
if let Err(e) = run::run(cli).await {
// Same window, reached by returning rather than by signal: the launch succeeds and
// then `CdpClient::connect` or `resolve_page_target` fails, so the browser is up and
// the store never learned its pid. A browser that DID reach the store is left alone
// — a failed command leaving a usable browser behind is the existing contract.
kill::reap_unpersisted();
// An assertion that did not hold is not a broken tool: it gets its own exit code so
// a caller can tell "the page is not in that state" (2) from "the browser never
// started" (1). Checked before the generic handler below, which would print it as a
// failure and exit 1 — the very conflation the code exists to remove.
if let Some(not_held) = e.downcast_ref::<commands::assert::NotHeld>() {
std::process::exit(not_held.report());
}
// A macro that stopped has a report of its own — the step, the guard, what was observed
// and the action's own `next`. It prints once, here, rather than being flattened to its
// first sentence by the handler below.
if let Some(stopped) = e.downcast_ref::<macros_run::Stopped>() {
std::process::exit(stopped.report());
}
// A refusal is not a bare sentence: `--on-intercept refuse` measured who was in the
// way before deciding not to act, and the mode that refuses is the one whose caller has
// the most re-planning to do. Same `ok:false` and same exit 1 — nothing was dispatched,
// so the command did not do what it was asked.
if let Some(refused) = hit_test::refusal_in(&e) {
if json_mode {
println!("{}", refused.to_json(&browser));
} else {
eprintln!("error: {refused}");
for line in refused.text_lines(&browser) {
eprintln!("{line}");
}
}
std::process::exit(1);
}
let msg = e.to_string();
if json_mode {
let hint = error_hint(&msg, &browser);
let mut obj = json!({"ok": false, "error": msg});
if let Some(h) = hint {
obj["hint"] = json!(h);
}
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
eprintln!("error: {msg}");
if let Some(hint) = error_hint(&msg, &browser) {
eprintln!("hint: {hint}");
}
}
std::process::exit(1);
}
}