pub(crate) mod actions;
mod analytics;
pub mod chat;
#[cfg(feature = "debug")]
mod debug;
pub(crate) mod export;
mod http;
mod metrics;
pub(crate) mod notify;
mod quota;
pub(crate) mod report;
mod search;
mod tabs;
mod term;
mod tokens;
pub mod tunnel;
use crate::cli;
use crate::fleet;
use crate::loader::Loader;
use crate::pricing::Plan;
use crate::session::Session;
use crate::watch::Watch;
use http::{EventStream, Request};
use std::collections::HashMap;
use std::io::Write;
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
const DEFAULT_PORT: u16 = 7777;
const PORT_SEARCH: u16 = 16;
const MAX_CONNECTIONS: usize = 32;
const FULL_WALK: Duration = Duration::from_secs(30);
const SSE_KEEPALIVE: Duration = Duration::from_secs(15);
const NO_SUCH_SESSION: &str = "no session with that id, or the prefix matches more than one";
const APP_HTML: &str = include_str!("assets/app/index.html");
const FAVICON: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\">\
<rect width=\"64\" height=\"64\" rx=\"14\" fill=\"#16151a\"/>\
<circle cx=\"32\" cy=\"32\" r=\"10\" fill=\"#e0a52c\"/></svg>";
static FAVICON_P: std::sync::LazyLock<http::Packed> =
std::sync::LazyLock::new(|| http::Packed::new(FAVICON));
static MANIFEST_P: std::sync::LazyLock<http::Packed> =
std::sync::LazyLock::new(|| http::Packed::new(MANIFEST));
const MANIFEST: &str = concat!(
r#"{"name":"cctop","short_name":"cctop","display":"standalone","#,
r#""start_url":"/","icons":[{"src":"/favicon.svg","sizes":"any","#,
r##""type":"image/svg+xml"}],"theme_color":"#16151a","background_color":"#16151a"}"##
);
const QUOTA_INTERVAL_SECS: u64 = 60;
const QUOTA_TICK: Duration = Duration::from_secs(10);
struct Shared {
token: String,
readonly: String,
actions: bool,
port: u16,
plan: Plan,
latest: Mutex<Arc<Snapshot>>,
updated: Condvar,
store: crate::cache::Store,
quota: Mutex<String>,
topics: Mutex<search::Topics>,
notify: Option<notify::Webhook>,
hosts: HashMap<String, fleet::Host>,
pages: [std::sync::OnceLock<http::Packed>; 2],
}
struct Snapshot {
version: u64,
json: String,
rows: Vec<(String, String)>,
sessions: Vec<Session>,
host_errors: Vec<(String, String)>,
}
#[derive(Default)]
struct Remotes {
rows: HashMap<String, Vec<Session>>,
errors: HashMap<String, String>,
}
pub const HELP: &str = "\
cctop serve — the session table, and the sessions themselves, in a browser
USAGE:
cctop serve [OPTIONS]
OPTIONS:
--bind <ADDR> Address to listen on [default: 127.0.0.1]. Anything other
than a loopback address puts the page on your network, which
is announced on stderr when it happens
--port <PORT> Port to listen on [default: 7777]. Without this flag a busy
port is stepped past; with it, a busy port is an error
--no-token Serve without an access token. Every process and user on the
machine can then read your sessions — and since the token is
what authorises an action, this also turns actions off
--no-actions Serve the pages without the buttons: no prompts, no resuming,
no handing a session to another agent
--tunnel Also reach the page from anywhere, over a trycloudflare quick
tunnel. Needs nothing installed, lasts as long as this
process, and puts the link on the public internet — so the
token is what stands between it and your agents
--plan <PLAN> Billing plan for cost figures: retail, max, or included
[default: retail]
--delay <SECS> Seconds between refreshes [default: 2]
--host <HOST> Also serve the sessions on another machine, over ssh.
Repeatable; same syntax as `cctop --host`
--notify <URL> POST a JSON event to this URL when a session crosses into
waiting or asking — once per crossing, on a short deadline.
[env: CCTOP_NOTIFY_URL]
--token-file <PATH>
Keep the tokens across restarts: read them from PATH, or
mint them and write PATH (mode 600) when it does not exist.
A file other users can read, or do not own, is refused
--rotate-token With --token-file: replace the tokens in it, revoking every
link built on the old ones
-h, --help Print this help
The page shows each session's conversation, what it edited, and what it can
reach, and it can send a prompt to a live session, resume a dead one, or hand
one to a different agent. Whoever holds the link can do all of that, which is
why the link carries a token and the default is loopback only.
";
pub struct Options {
pub bind: String,
pub port: u16,
pub port_given: bool,
pub no_token: bool,
pub no_actions: bool,
pub tunnel: bool,
pub plan: Plan,
pub delay: Duration,
pub hosts: Vec<fleet::Host>,
pub notify: Option<String>,
pub tokens: Option<tokens::Tokens>,
pub scan: bool,
}
impl Default for Options {
fn default() -> Self {
Options {
bind: "127.0.0.1".to_string(),
port: DEFAULT_PORT,
port_given: false,
no_token: false,
no_actions: false,
tunnel: false,
plan: Plan::Retail,
delay: Duration::from_secs(2),
hosts: Vec::new(),
notify: None,
tokens: None,
scan: true,
}
}
}
pub struct Serving {
pub local: String,
pub public: Option<String>,
pub readonly: String,
pub actions: bool,
shared: Arc<Shared>,
remotes: Arc<Mutex<Remotes>>,
plan: Plan,
version: Mutex<u64>,
_tunnel: Option<tunnel::Tunnel>,
running: Arc<AtomicBool>,
port: u16,
}
impl Serving {
pub fn best(&self) -> &str {
self.public.as_deref().unwrap_or(&self.local)
}
pub fn publish_with_quota(&self, sessions: &[Session], quota: &crate::quota::Quota) {
let Ok(mut version) = self.version.lock() else {
return;
};
publish(
&self.shared,
&self.remotes,
sessions,
self.plan,
&self.shared.store,
&mut version,
Some(quota),
);
}
}
pub fn open_in_browser(url: &str) -> bool {
std::process::Command::new("xdg-open")
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok()
}
impl Drop for Serving {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
let _ = std::net::TcpStream::connect(("127.0.0.1", self.port));
}
}
pub fn start(options: Options) -> anyhow::Result<Serving> {
let listener = listen(&options.bind, options.port, options.port_given)?;
let addr = listener.local_addr()?;
if let Some(why) = tunnel_objection(options.tunnel, addr.ip().is_loopback(), options.no_token) {
anyhow::bail!("{why}");
}
let tokens = match (options.no_token, options.tokens) {
(true, Some(_)) => anyhow::bail!(
"--token-file with --no-token names tokens and then serves without them. \
Drop one of the two."
),
(true, None) => tokens::Tokens::none(),
(false, Some(given)) => given,
(false, None) => tokens::Tokens::fresh(),
};
let token = tokens.full;
let actions = !options.no_actions && !options.no_token;
let tunnel = match options.tunnel {
true => Some(tunnel::start(addr.port())?),
false => None,
};
let origin = tunnel
.as_ref()
.map(|t| t.url.clone())
.unwrap_or_else(|| format!("http://127.0.0.1:{}", addr.port()));
let shared = Arc::new(Shared {
token: token.clone(),
readonly: tokens.readonly,
actions,
port: addr.port(),
plan: options.plan,
latest: Mutex::new(Arc::new(Snapshot {
version: 0,
json: "[]".to_string(),
rows: Vec::new(),
sessions: Vec::new(),
host_errors: Vec::new(),
})),
updated: Condvar::new(),
store: crate::cache::Store::new(),
quota: Mutex::new(quota::EMPTY.to_string()),
topics: Mutex::new(search::Topics::default()),
notify: options
.notify
.or_else(|| {
std::env::var("CCTOP_NOTIFY_URL")
.ok()
.filter(|url| !url.is_empty())
})
.map(|target| notify::Webhook::new(target, origin.clone(), token.clone())),
pages: Default::default(),
hosts: options
.hosts
.iter()
.map(|host| (host.target.clone(), host.clone()))
.collect(),
});
let remotes = Arc::new(Mutex::new(Remotes::default()));
if options.scan {
for host in &options.hosts {
spawn_host_poller(host.clone(), Arc::clone(&remotes));
}
}
if options.scan {
spawn_refresher(
Arc::clone(&shared),
Arc::clone(&remotes),
options.plan,
options.delay,
);
spawn_quota_poller(Arc::clone(&shared));
}
let query = match token.is_empty() {
true => String::new(),
false => format!("?t={token}"),
};
crate::elog::event(
"serve",
"listen",
serde_json::json!({
"addr": addr.to_string(),
"token": !token.is_empty(),
"actions": actions,
"tunnel": options.tunnel,
"notify": shared.notify.is_some(),
}),
);
let running = Arc::new(AtomicBool::new(true));
{
let (shared, running) = (Arc::clone(&shared), Arc::clone(&running));
std::thread::Builder::new()
.name("cctop-serve-accept".into())
.spawn(move || accept_loop(listener, shared, running))?;
}
let readonly_link = match shared.readonly.is_empty() {
true => String::new(),
false => format!("{origin}/?t={}", shared.readonly),
};
Ok(Serving {
local: format!("http://127.0.0.1:{}/{query}", addr.port()),
public: tunnel.as_ref().map(|t| format!("{}/{query}", t.url)),
readonly: readonly_link,
actions,
shared,
remotes,
plan: options.plan,
version: Mutex::new(0),
_tunnel: tunnel,
running,
port: addr.port(),
})
}
fn accept_loop(listener: TcpListener, shared: Arc<Shared>, running: Arc<AtomicBool>) {
let live = Arc::new(AtomicUsize::new(0));
for stream in listener.incoming() {
if !running.load(Ordering::Relaxed) {
return;
}
let Ok(mut stream) = stream else { continue };
if live.load(Ordering::Relaxed) >= MAX_CONNECTIONS {
http::respond_error(&mut stream, None, 503, "too many open connections");
continue;
}
let slot = Connection::take(&live);
let shared = Arc::clone(&shared);
let _ = std::thread::Builder::new()
.name("cctop-serve".into())
.spawn(move || {
let _slot = slot;
serve_connection(&shared, &mut stream);
});
}
}
pub fn run(argv: &[String]) -> anyhow::Result<i32> {
let mut bind = "127.0.0.1".to_string();
let mut port = DEFAULT_PORT;
let mut port_given = false;
let mut no_token = false;
let mut no_actions = false;
let mut want_tunnel = false;
let mut plan = Plan::Retail;
let mut delay = Duration::from_secs(2);
let mut hosts: Vec<String> = Vec::new();
let mut notify = None;
let mut token_file: Option<std::path::PathBuf> = None;
let mut rotate_token = false;
let mut it = argv.iter();
while let Some(flag) = it.next() {
let mut value = || {
it.next()
.cloned()
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
};
match flag.as_str() {
"-h" | "--help" => {
print!("{HELP}");
return Ok(0);
}
"--bind" => bind = value()?,
"--port" => {
port = value()?
.parse()
.map_err(|_| anyhow::anyhow!("--port takes a number from 1 to 65535"))?;
port_given = true;
}
"--no-token" => no_token = true,
"--no-actions" => no_actions = true,
"--tunnel" => want_tunnel = true,
"--plan" => {
let given = value()?;
plan = Plan::parse(&given).ok_or_else(|| {
anyhow::anyhow!("unsupported plan '{given}'; use retail, max or included")
})?;
}
"--delay" => {
let secs: f64 = value()?
.parse()
.map_err(|_| anyhow::anyhow!("--delay takes a number of seconds"))?;
if !secs.is_finite() || !(0.5..3600.0).contains(&secs) {
anyhow::bail!("--delay must be between 0.5 and 3600 seconds");
}
delay = Duration::from_secs_f64(secs);
}
"--host" => hosts.push(value()?),
"--notify" => notify = Some(value()?),
"--token-file" => token_file = Some(value()?.into()),
"--rotate-token" => rotate_token = true,
other => anyhow::bail!("unknown option '{other}'\n\n{HELP}"),
}
}
let tokens = match &token_file {
Some(_) if no_token => anyhow::bail!(
"--token-file with --no-token names tokens and then serves without them. \
Drop one of the two."
),
Some(path) => {
let (tokens, how) = tokens::load_or_create(path, rotate_token)?;
match how {
tokens::Loaded::Reused => eprintln!(
"cctop: tokens from {} — links from earlier runs still work",
path.display()
),
tokens::Loaded::Created => eprintln!(
"cctop: new tokens written to {} — they outlive this run; \
--rotate-token replaces them",
path.display()
),
}
Some(tokens)
}
None if rotate_token => anyhow::bail!(
"--rotate-token rotates the tokens in a --token-file; without one, \
every run already mints new tokens"
),
None => None,
};
if want_tunnel {
eprintln!("cctop: opening a trycloudflare tunnel…");
let _ = std::io::stderr().flush();
}
let serving = start(Options {
bind: bind.clone(),
port,
port_given,
no_token,
no_actions,
tunnel: want_tunnel,
plan,
delay,
hosts: fleet::Host::collect(&hosts),
notify,
tokens,
scan: true,
})?;
announce(&serving, &bind, no_token);
loop {
std::thread::park();
}
}
struct Connection(Arc<AtomicUsize>);
impl Connection {
fn take(live: &Arc<AtomicUsize>) -> Connection {
live.fetch_add(1, Ordering::Relaxed);
Connection(Arc::clone(live))
}
}
impl Drop for Connection {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
fn listen(bind: &str, port: u16, port_given: bool) -> anyhow::Result<TcpListener> {
let last = match port_given {
true => port,
false => port.saturating_add(PORT_SEARCH),
};
let mut last_error = None;
for candidate in port..=last {
let addr = (bind, candidate)
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("could not resolve --bind {bind}: {e}"))?
.next()
.ok_or_else(|| anyhow::anyhow!("--bind {bind} resolved to no address"))?;
match TcpListener::bind(addr) {
Ok(listener) => return Ok(listener),
Err(e) => last_error = Some(e),
}
}
let why = last_error.map_or_else(|| "no port to try".to_string(), |e| e.to_string());
match port_given {
true => anyhow::bail!("could not listen on {bind}:{port}: {why}"),
false => anyhow::bail!(
"could not listen on {bind}, ports {port} to {last}: {why}\n\
Use --port to name a free one."
),
}
}
fn tunnel_objection(want_tunnel: bool, loopback: bool, no_token: bool) -> Option<&'static str> {
if !want_tunnel {
return None;
}
if no_token {
return Some(
"--tunnel with --no-token would publish your sessions, and a prompt \
box for your agents, to anyone who finds the URL.\n\
Drop one of the two: the token is what makes the link a credential.",
);
}
if !loopback {
return Some(
"--tunnel does not need --bind: the tunnel connects out from this \
machine, so it reaches a loopback listener.\n\
Binding wider would put the page on your local network as well, \
which --tunnel is not asking for.",
);
}
None
}
fn announce(serving: &Serving, bind: &str, no_token: bool) {
if let Some(public) = &serving.public {
eprintln!("cctop: serving on {public}");
eprintln!("cctop: also on {}", serving.local);
if !serving.readonly.is_empty() {
eprintln!("cctop: read-only link — no actions: {}", serving.readonly);
}
eprintln!(
"cctop: that first link is on the public internet. Anyone who has it \
can read every session on this machine — and, unless --no-actions, \
type at your agents, which runs commands as you. Cloudflare carries \
the traffic and can read it. The tunnel ends when this process does."
);
let _ = std::io::stderr().flush();
} else {
eprintln!("cctop: serving on {}", serving.local);
if !serving.readonly.is_empty() {
eprintln!("cctop: read-only link — no actions: {}", serving.readonly);
}
}
if bind != "127.0.0.1" && serving.public.is_none() {
eprintln!(
"cctop: bound to {bind}, so this is on your network and not just this \
machine"
);
}
if no_token {
eprintln!(
"cctop: no token — every process and user on this machine can read \
your sessions"
);
}
let _ = std::io::stderr().flush();
}
fn new_token() -> String {
crate::util::random_hex(TOKEN_BYTES)
}
const TOKEN_BYTES: usize = 16;
fn token_matches(expected: &str, given: &str) -> bool {
if expected.len() != given.len() {
return false;
}
let mut diff = 0u8;
for (a, b) in expected.bytes().zip(given.bytes()) {
diff |= a ^ b;
}
diff == 0
}
fn spawn_host_poller(host: fleet::Host, remotes: Arc<Mutex<Remotes>>) {
std::thread::spawn(move || {
loop {
let snapshot = host.poll();
if let Ok(mut remotes) = remotes.lock() {
match snapshot {
fleet::Snapshot::Rows(rows) => {
remotes.errors.remove(&host.target);
remotes.rows.insert(host.target.clone(), rows);
}
fleet::Snapshot::Failed(why) => {
remotes.errors.insert(host.target.clone(), why);
}
}
}
std::thread::sleep(fleet::POLL);
}
});
}
fn spawn_quota_poller(shared: Arc<Shared>) {
std::thread::spawn(move || {
let mut claude: Vec<crate::quota::ProfileQuota> = Vec::new();
let mut codex: Vec<crate::quota::ProfileQuota> = Vec::new();
let (mut claude_due, mut codex_due) = (Instant::now(), Instant::now());
loop {
let now = Instant::now();
let mut changed = false;
if now >= claude_due {
claude = crate::config::accounts_for(crate::pricing::Provider::Claude)
.iter()
.map(|profile| crate::quota::ProfileQuota {
profile: profile.name.clone(),
status: crate::quota::fetch_claude(profile),
source: profile.source,
})
.collect();
let delay = claude
.iter()
.map(|q| q.status.retry_delay_secs(QUOTA_INTERVAL_SECS))
.max()
.unwrap_or(QUOTA_INTERVAL_SECS);
claude_due = now + Duration::from_secs(delay);
changed = true;
}
if now >= codex_due {
codex = crate::config::accounts_for(crate::pricing::Provider::Codex)
.iter()
.map(|profile| crate::quota::ProfileQuota {
profile: profile.name.clone(),
status: crate::quota::fetch_codex(profile),
source: profile.source,
})
.collect();
let delay = codex
.iter()
.map(|q| q.status.retry_delay_secs(QUOTA_INTERVAL_SECS))
.max()
.unwrap_or(QUOTA_INTERVAL_SECS);
codex_due = now + Duration::from_secs(delay);
changed = true;
}
if changed && let Ok(mut slot) = shared.quota.lock() {
*slot = serde_json::to_string("a::document(&crate::quota::Quota {
fetched: true,
claude: claude.clone(),
codex: codex.clone(),
}))
.unwrap_or_else(|_| quota::EMPTY.to_string());
}
std::thread::sleep(QUOTA_TICK);
}
});
}
fn spawn_refresher(shared: Arc<Shared>, remotes: Arc<Mutex<Remotes>>, plan: Plan, delay: Duration) {
std::thread::spawn(move || {
crate::pricing::refresh_pricing_blocking();
let mut loader = Loader::new();
let watch = Watch::start();
let listener = crate::hook::Listener::start();
let mut reports = crate::hook::Reports::new();
let mut peek = crate::peek::Peek::new();
let read_screen = crate::settings::Settings::load().read_screen == Some(true);
loader.set_hook_claims(reports.claims.clone());
let mut yolo = crate::yolo::Auto::new(shared.actions);
let mut rows = loader.load(plan);
stamp(&mut rows, &reports, &mut peek, read_screen);
yolo.tick(&mut rows);
let mut walked = Instant::now();
let mut version = 0u64;
publish(
&shared,
&remotes,
&rows,
plan,
loader.store(),
&mut version,
None,
);
loop {
std::thread::sleep(delay);
let mut appeared = false;
for event in listener.as_ref().map(|l| l.drain()).unwrap_or_default() {
let (lifecycle, moved) = reports.observe(&event);
appeared |= lifecycle;
if moved {
crate::hook::save_claims(&reports.claims);
loader.set_hook_claims(reports.claims.clone());
}
}
reports.promote_matured();
appeared |= watch.as_ref().is_some_and(|w| {
w.took_structural_change()
|| w.awaiting_discovery(|path| {
rows.iter().any(|s| s.data_file.as_deref() == Some(path))
})
});
if appeared || walked.elapsed() >= FULL_WALK {
rows = loader.load(plan);
walked = Instant::now();
} else {
loader.refresh_live(plan, &mut rows);
}
stamp(&mut rows, &reports, &mut peek, read_screen);
yolo.tick(&mut rows);
publish(
&shared,
&remotes,
&rows,
plan,
loader.store(),
&mut version,
None,
);
}
});
}
fn stamp(
rows: &mut [Session],
reports: &crate::hook::Reports,
peek: &mut crate::peek::Peek,
read_screen: bool,
) {
for session in rows.iter_mut() {
let screened = match (read_screen, session.is_running()) {
(true, true) => session
.root_pid()
.and_then(|pid| peek.read(session.provider.as_str(), pid)),
_ => None,
};
session.apply_reports(reports.report(&session.session_id), screened.as_ref());
reports.stamp_sandbox(session);
}
}
fn publish(
shared: &Shared,
remotes: &Mutex<Remotes>,
local: &[Session],
plan: Plan,
store: &crate::cache::Store,
version: &mut u64,
usage: Option<&crate::quota::Quota>,
) {
let (mut sessions, host_errors) = match remotes.lock() {
Ok(remotes) => {
let mut merged = local.to_vec();
for rows in remotes.rows.values() {
merged.extend(rows.iter().cloned());
}
let mut errors: Vec<(String, String)> = remotes
.errors
.iter()
.map(|(h, why)| (h.clone(), why.clone()))
.collect();
errors.sort();
(merged, errors)
}
Err(_) => (local.to_vec(), Vec::new()),
};
sessions.sort_by(|a, b| b.last_active.cmp(&a.last_active));
let document = cli::json_sessions(&sessions, plan, store);
let rows: Vec<(String, String)> = document
.iter()
.filter_map(|row| {
let value = serde_json::to_value(row).ok()?;
let id = value.get("session_id")?.as_str()?.to_string();
Some((id, value.to_string()))
})
.collect();
let json = format!(
"[{}]",
rows.iter()
.map(|(_, r)| r.as_str())
.collect::<Vec<_>>()
.join(",")
);
if let Some(usage) = usage
&& let Ok(mut slot) = shared.quota.lock()
{
*slot = serde_json::to_string("a::document(usage))
.unwrap_or_else(|_| quota::EMPTY.to_string());
}
*version += 1;
let snapshot = Arc::new(Snapshot {
version: *version,
json,
rows,
sessions,
host_errors,
});
crate::elog::event(
"scan",
"snapshot",
serde_json::json!({
"version": snapshot.version,
"sessions": snapshot.sessions.len(),
"running": snapshot.sessions.iter().filter(|s| s.is_running()).count(),
"remote_hosts": snapshot.host_errors.len(),
}),
);
let mut crossed = Vec::new();
if let Ok(mut latest) = shared.latest.lock() {
if let Some(hook) = &shared.notify {
crossed = hook
.crossed(&latest.sessions, &snapshot.sessions)
.into_iter()
.map(|(event, s)| (event, s.clone()))
.collect();
}
*latest = snapshot;
}
shared.updated.notify_all();
if let Some(hook) = &shared.notify {
for (event, session) in crossed {
hook.post(event, &session);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Access {
Full,
ReadOnly,
}
fn cookie_name(port: u16) -> String {
format!("cctop_access_{port}")
}
fn access_for(shared: &Shared, presented: &str) -> Option<Access> {
if shared.token.is_empty() || token_matches(&shared.token, presented) {
return Some(Access::Full);
}
if !shared.readonly.is_empty() && token_matches(&shared.readonly, presented) {
return Some(Access::ReadOnly);
}
None
}
fn serve_connection(shared: &Shared, stream: &mut TcpStream) {
let request = match Request::parse(stream) {
Ok(request) => request,
Err((status, why)) => return http::respond_error(stream, None, status, why),
};
if request.path == "/metrics" {
if request.method == "POST" {
return http::respond_error(stream, Some(&request), 405, "/metrics answers GET");
}
let snapshot = current(shared);
let body = metrics::render(
&snapshot.sessions,
shared.plan,
&shared.store,
snapshot.host_errors.len(),
);
return http::respond(
stream,
Some(&request),
200,
metrics::CONTENT_TYPE,
body.as_bytes(),
);
}
let Some(access) = access_for(shared, request.token())
.or_else(|| access_for(shared, request.bearer()))
.or_else(|| {
access_for(
shared,
request.cookie(&cookie_name(shared.port)).unwrap_or(""),
)
})
else {
crate::elog::event(
"http",
"request",
serde_json::json!({"method": request.method, "path": request.path, "access": "denied"}),
);
return http::respond_error(
stream,
Some(&request),
403,
"missing or wrong access token — open the link cctop printed",
);
};
crate::elog::event(
"http",
"request",
serde_json::json!({
"method": request.method,
"path": request.path,
"access": match access {
Access::Full => "full",
Access::ReadOnly => "readonly",
},
}),
);
#[cfg(feature = "debug")]
if debug::intercept(stream, &request) {
return;
}
let path = request.path.clone();
#[cfg(feature = "debug")]
if let Some(rest) = path.strip_prefix("/api/debug/")
&& debug::route(shared, stream, &request, rest)
{
return;
}
if let Some(rest) = path.strip_prefix("/rmux-ws/") {
return relay_terminal(shared, stream, &request, rest, access);
}
if let Some(file) = term::file(&path) {
return http::respond_packed(
stream,
&request,
file.content_type,
file.body,
file.cache,
"",
Some(http::TERM_POLICY),
);
}
match path.as_str() {
"/" => app_page(shared, stream, &request, access),
"/favicon.svg" => http::respond_packed(
stream,
&request,
"image/svg+xml",
&FAVICON_P,
http::REVALIDATE,
"",
None,
),
"/manifest.webmanifest" => http::respond_packed(
stream,
&request,
"application/manifest+json",
&MANIFEST_P,
http::REVALIDATE,
"",
None,
),
"/api/sessions" => {
let snapshot = current(shared);
http::respond(
stream,
Some(&request),
200,
"application/json; charset=utf-8",
snapshot.json.as_bytes(),
);
}
"/api/hosts" => {
let snapshot = current(shared);
let body = serde_json::to_string(&snapshot.host_errors).unwrap_or_default();
http::respond(
stream,
Some(&request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
);
}
"/api/quota" => {
let body = shared
.quota
.lock()
.map(|q| q.clone())
.unwrap_or_else(|_| quota::EMPTY.to_string());
http::respond(
stream,
Some(&request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
);
}
"/api/search" => api_search(shared, stream, &request),
"/api/events" => events(shared, stream, &request),
"/insight/optimize" => api_insight(shared, stream, &request, "optimize"),
"/insight/compare" => api_insight(shared, stream, &request, "compare"),
"/analytics" => app_page(shared, stream, &request, access),
"/workspace" => app_page(shared, stream, &request, access),
"/api/analytics" => {
let snapshot = current(shared);
let built = analytics::build(&snapshot.sessions, shared.plan, &shared.store);
json(stream, &request, &built);
}
"/api/agents" => {
let body = serde_json::json!({
"actions": shared.actions && access == Access::Full,
"agents": actions::agents(),
});
http::respond(
stream,
Some(&request),
200,
"application/json; charset=utf-8",
body.to_string().as_bytes(),
);
}
"/api/launch" => {
let Some(body) = may_act(shared, stream, &request, access) else {
return;
};
let agent = body
.get("agent")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let cwd = body.get("cwd").and_then(serde_json::Value::as_str);
match actions::launch_agent(agent, cwd) {
Ok(done) => json(stream, &request, &done),
Err((status, why)) => http::respond_error(stream, Some(&request), status, &why),
}
}
_ if path.starts_with("/session/") => app_page(shared, stream, &request, access),
_ if path.starts_with("/window/") => app_page(shared, stream, &request, access),
_ if path.starts_with("/api/report/") => {
api_report(shared, stream, &request, &path["/api/report/".len()..]);
}
_ if path.starts_with("/api/chat/") && path.ends_with("/markdown") => {
let id = &path["/api/chat/".len()..path.len() - "/markdown".len()];
api_chat_markdown(shared, stream, &request, id);
}
_ if path.starts_with("/api/chat/") => {
api_chat(shared, stream, &request, &path["/api/chat/".len()..]);
}
_ if path.starts_with("/api/access/") => {
api_access(shared, stream, &request, &path["/api/access/".len()..]);
}
_ if path.starts_with("/api/handoff/") => {
let snapshot = current(shared);
let id = &path["/api/handoff/".len()..];
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(&request), 404, NO_SUCH_SESSION);
};
let targets = match shared.actions && access == Access::Full && session.remote.is_none()
{
true => crate::handoff::targets(session),
false => Vec::new(),
};
let targets: Vec<serde_json::Value> = targets
.iter()
.map(|t| {
serde_json::json!({
"agent": t.agent,
"account": t.account,
"label": t.label(),
})
})
.collect();
json(stream, &request, &serde_json::json!({ "targets": targets }))
}
_ if path.starts_with("/api/window/") => {
let name = &path["/api/window/".len()..];
if !tabs::is_tab(&crate::rmux::running(), name) {
return http::respond_error(
stream,
Some(&request),
404,
"no open tab by that name",
);
}
match crate::rmux::window_size(name) {
Some((cols, rows)) => {
let web: Vec<[u16; 2]> = crate::rmux::clients(name)
.into_iter()
.filter(|(browser, _, _)| *browser)
.map(|(_, c, r)| [c, r])
.collect();
json(
stream,
&request,
&serde_json::json!({ "cols": cols, "rows": rows, "web": web }),
)
}
None => http::respond_error(stream, Some(&request), 404, "that tab has no window"),
}
}
"/api/tabs" => {
let snapshot = current(shared);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let list = tabs::build(crate::rmux::running_in_tab_order(), &snapshot.sessions, now);
let body = serde_json::json!({ "tabs": list }).to_string();
http::respond_revalidated(
stream,
&request,
"application/json; charset=utf-8",
body.as_bytes(),
);
}
_ if path.starts_with("/api/tab/") => {
api_tab(shared, stream, &request, &path["/api/tab/".len()..], access);
}
_ if path.starts_with("/api/act/") => {
api_act(shared, stream, &request, &path["/api/act/".len()..], access);
}
_ => http::respond_error(stream, Some(&request), 404, "no such page"),
}
}
fn api_search(shared: &Shared, stream: &mut TcpStream, request: &Request) {
let needle = request
.query
.get("q")
.map(|q| q.trim().to_lowercase())
.unwrap_or_default();
if needle.chars().count() < search::MIN_CHARS {
return json(stream, request, &serde_json::json!({ "hits": [] }));
}
let snapshot = current(shared);
let hits = search::run(&shared.topics, &snapshot.sessions, &needle);
json(stream, request, &serde_json::json!({ "hits": hits }))
}
fn api_insight(shared: &Shared, stream: &mut TcpStream, request: &Request, which: &str) {
let analyses = crate::insight::scan(shared.plan);
let selected = crate::insight::only(&analyses, None);
let text = match which {
"optimize" => crate::insight::optimize::report(&selected),
_ => crate::insight::compare::report(&selected),
};
http::respond(
stream,
Some(request),
200,
"text/plain; charset=utf-8",
text.as_bytes(),
);
}
fn api_chat(shared: &Shared, stream: &mut TcpStream, request: &Request, id: &str) {
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(request), 404, NO_SUCH_SESSION);
};
let before = request
.query
.get("before")
.and_then(|v| v.parse::<usize>().ok());
let since = chat::Since {
stamp: request
.query
.get("since")
.cloned()
.filter(|s| !s.is_empty()),
after: request.query.get("after").and_then(|v| v.parse().ok()),
};
if session.remote.is_some() {
let mut args = vec!["--chat".to_string(), session.session_id.clone()];
if let Some(before) = before {
args.extend(["--before".to_string(), before.to_string()]);
}
return remote_chat(shared, stream, request, session, &args, &since);
}
if let Some(path) = session.data_file.as_deref()
&& since.stamp.is_some()
&& since.stamp == chat::stamp_of(path)
{
return json(
stream,
request,
&chat::Conversation {
supported: true,
stamp: since.stamp,
unchanged: true,
..Default::default()
},
);
}
json(
stream,
request,
&chat::build(session, before).narrowed(&since),
);
}
fn remote_chat(
shared: &Shared,
stream: &mut TcpStream,
request: &Request,
session: &Session,
args: &[String],
since: &chat::Since,
) {
if since.stamp.is_none() && since.after.is_none() {
return remote_json(shared, stream, request, session, args);
}
let Some(host) = session
.remote
.as_ref()
.and_then(|r| shared.hosts.get(&r.host))
else {
return remote_json(shared, stream, request, session, args);
};
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
let body = match host.run(&argv) {
Ok(body) => body,
Err(_) => return remote_json(shared, stream, request, session, args),
};
let Ok(mut conversation) = serde_json::from_str::<chat::Conversation>(&body) else {
return http::respond(
stream,
Some(request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
);
};
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
body.hash(&mut hasher);
conversation.stamp = Some(format!("r{:016x}", hasher.finish()));
json(stream, request, &conversation.narrowed(since));
}
fn api_chat_markdown(shared: &Shared, stream: &mut TcpStream, request: &Request, id: &str) {
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(request), 404, NO_SUCH_SESSION);
};
let variant = request.query.get("variant").map(String::as_str);
let options = export::Options {
tool_output: variant == Some("tools"),
};
let markdown = match (variant, session.remote.is_some()) {
(Some("brief"), false) => {
let data = shared.store.session_data_fresh(session);
crate::handoff::build(session, Some(&data)).to_markdown()
}
(Some("brief"), true) => {
match remote_text(shared, session, &["--handoff", &session.session_id]) {
Ok(text) => text,
Err(why) => return http::respond_error(stream, Some(request), 502, &why),
}
}
(_, false) => export::render(session, &chat::whole(session), options),
(_, true) => match remote_markdown(shared, session, options) {
Ok(text) => text,
Err(why) => return http::respond_error(stream, Some(request), 502, &why),
},
};
http::respond(
stream,
Some(request),
200,
"text/markdown; charset=utf-8",
markdown.as_bytes(),
);
}
fn remote_markdown(
shared: &Shared,
session: &Session,
options: export::Options,
) -> Result<String, String> {
let id = session.session_id.as_str();
let mut args = vec!["--export", id];
if options.tool_output {
args.push("--tool-output");
}
let first = match remote_text(shared, session, &args) {
Ok(text) => return Ok(text),
Err(why) => why,
};
let body = remote_text(shared, session, &["--chat", id]).map_err(|_| first.clone())?;
let conversation = serde_json::from_str::<chat::Conversation>(&body).map_err(|_| first)?;
Ok(export::render(session, &conversation, options))
}
fn remote_text(shared: &Shared, session: &Session, args: &[&str]) -> Result<String, String> {
let remote = session
.remote
.as_ref()
.expect("only a remote row reaches remote_text");
let Some(host) = shared.hosts.get(&remote.host) else {
return Err(format!(
"this session is on {}, which this run is not connected to",
remote.host
));
};
host.run(args)
.map_err(|why| format!("{} could not answer: {why}", remote.host))
}
fn api_access(shared: &Shared, stream: &mut TcpStream, request: &Request, id: &str) {
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(request), 404, NO_SUCH_SESSION);
};
if session.remote.is_some() {
return remote_json(
shared,
stream,
request,
session,
&["--access".to_string(), session.session_id.clone()],
);
}
let data = shared.store.session_data_fresh(session);
json(stream, request, &crate::access::build(session, Some(&data)));
}
fn may_act(
shared: &Shared,
stream: &mut TcpStream,
request: &Request,
access: Access,
) -> Option<serde_json::Value> {
if access == Access::ReadOnly {
http::respond_error(stream, Some(request), 403, "this link is read-only");
return None;
}
if !shared.actions {
http::respond_error(
stream,
Some(request),
403,
"this cctop serve is read-only — restart it without --no-actions, \
and with a token, to act on a session",
);
return None;
}
if !request.wants_json() {
http::respond_error(
stream,
Some(request),
405,
"an action is a POST with a JSON body",
);
return None;
}
match request.json() {
Ok(body) => Some(body),
Err((status, why)) => {
http::respond_error(stream, Some(request), status, why);
None
}
}
}
fn api_tab(shared: &Shared, stream: &mut TcpStream, request: &Request, rest: &str, access: Access) {
let Some(body) = may_act(shared, stream, request, access) else {
return;
};
let Some(name) = rest.strip_suffix("/terminal") else {
return http::respond_error(stream, Some(request), 404, "no such action");
};
let frontend = body
.get("origin")
.and_then(serde_json::Value::as_str)
.and_then(actions::frontend_for);
let fresh = body.get("fresh").and_then(serde_json::Value::as_bool) == Some(true);
match actions::tab_terminal(name, frontend.as_deref(), fresh) {
Ok(terminal) => json(stream, request, &terminal),
Err((status, why)) => http::respond_error(stream, Some(request), status, &why),
}
}
fn api_act(shared: &Shared, stream: &mut TcpStream, request: &Request, rest: &str, access: Access) {
let Some(body) = may_act(shared, stream, request, access) else {
return;
};
let Some((verb, id)) = rest.split_once('/') else {
return http::respond_error(stream, Some(request), 404, "no such action");
};
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(request), 404, NO_SUCH_SESSION);
};
let field = |name: &str| {
body.get(name)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
};
if verb == "image" {
return match actions::image(&field("data")) {
Ok(filed) => json(stream, request, &filed),
Err((status, why)) => http::respond_error(stream, Some(request), status, &why),
};
}
if verb == "terminal" {
let frontend = actions::frontend_for(&field("origin"));
let fresh = body.get("fresh").and_then(serde_json::Value::as_bool) == Some(true);
return match actions::terminal(session, frontend.as_deref(), fresh) {
Ok(terminal) => json(stream, request, &terminal),
Err((status, why)) => http::respond_error(stream, Some(request), status, &why),
};
}
let outcome = match verb {
"send" => actions::send(session, &field("text")),
"answer" => actions::answer(session, &field("choice")),
"yolo" => match body.get("on").and_then(serde_json::Value::as_bool) {
Some(on) => actions::yolo(session, on),
None => Err((400, "YOLO is `{\"on\": true}` or `{\"on\": false}`".into())),
},
"resume" => actions::resume(session),
"handoff" => {
let data = shared.store.session_data_fresh(session);
actions::handoff(
session,
Some(&data),
&field("agent"),
Some(&field("account")),
)
}
_ => return http::respond_error(stream, Some(request), 404, "no such action"),
};
match outcome {
Ok(done) => json(stream, request, &done),
Err((status, why)) => http::respond_error(stream, Some(request), status, &why),
}
}
fn json<T: serde::Serialize>(stream: &mut TcpStream, request: &Request, value: &T) {
match serde_json::to_string(value) {
Ok(body) => http::respond(
stream,
Some(request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
),
Err(e) => http::respond_error(
stream,
Some(request),
503,
&format!("could not render that: {e}"),
),
}
}
fn current(shared: &Shared) -> Arc<Snapshot> {
match shared.latest.lock() {
Ok(latest) => Arc::clone(&latest),
Err(poisoned) => Arc::clone(&poisoned.into_inner()),
}
}
fn app_page(shared: &Shared, stream: &mut TcpStream, request: &Request, access: Access) {
let (credential, actions) = match access {
Access::Full => (shared.token.as_str(), shared.actions),
Access::ReadOnly => (shared.readonly.as_str(), false),
};
let page = shared.pages[access as usize].get_or_init(|| {
http::Packed::new(APP_HTML.replace("__CCTOP_CONFIG__", &app_config(credential, actions)))
});
let mut headers = String::new();
if !request.token().is_empty() && !credential.is_empty() {
let held = request
.cookie(&cookie_name(shared.port))
.and_then(|c| access_for(shared, c));
if !(access == Access::ReadOnly && held == Some(Access::Full)) {
headers = format!(
"Set-Cookie: {}={credential}; Path=/; HttpOnly; SameSite=Strict\r\n",
cookie_name(shared.port)
);
}
}
http::respond_packed(
stream,
request,
"text/html; charset=utf-8",
page,
http::REVALIDATE,
&headers,
None,
);
}
fn relay_terminal(
shared: &Shared,
stream: &mut TcpStream,
request: &Request,
rest: &str,
access: Access,
) {
use std::io::{Read, Write};
if access != Access::Full || !shared.actions {
return http::respond_error(
stream,
Some(request),
403,
"this link cannot open terminals",
);
}
if !request.is_websocket() {
return http::respond_error(
stream,
Some(request),
400,
"a terminal socket is a WebSocket",
);
}
let Some((port, tail)) = rest.split_once('/') else {
return http::respond_error(stream, Some(request), 404, "no such terminal");
};
let Some(port) = port
.parse::<u16>()
.ok()
.filter(|p| crate::rmux::relayable(*p))
else {
return http::respond_error(stream, Some(request), 404, "no such terminal");
};
let Ok(mut upstream) = TcpStream::connect(("127.0.0.1", port)) else {
return http::respond_error(
stream,
Some(request),
502,
"the terminal's share has closed",
);
};
let query = match request.raw_query() {
"" => String::new(),
q => format!("?{q}"),
};
let mut head = format!("GET /{tail}{query} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n");
for (name, value) in request.headers() {
if !name.eq_ignore_ascii_case("host") {
head.push_str(&format!("{name}: {value}\r\n"));
}
}
head.push_str("\r\n");
if upstream.write_all(head.as_bytes()).is_err() {
return http::respond_error(
stream,
Some(request),
502,
"the terminal's share has closed",
);
}
let _ = stream.set_read_timeout(None);
let _ = stream.set_write_timeout(None);
let (Ok(mut down_read), Ok(mut up_write)) = (stream.try_clone(), upstream.try_clone()) else {
return;
};
let pump = std::thread::spawn(move || {
let mut buf = [0u8; 16 * 1024];
while let Ok(n) = upstream.read(&mut buf) {
if n == 0 || down_read.write_all(&buf[..n]).is_err() {
break;
}
}
let _ = down_read.shutdown(std::net::Shutdown::Both);
});
let mut buf = [0u8; 16 * 1024];
while let Ok(n) = stream.read(&mut buf) {
if n == 0 || up_write.write_all(&buf[..n]).is_err() {
break;
}
}
let _ = up_write.shutdown(std::net::Shutdown::Both);
let _ = pump.join();
}
fn app_config(credential: &str, actions: bool) -> String {
let home = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
serde_json::json!({
"token": credential,
"actions": actions,
"home": home,
"version": env!("CARGO_PKG_VERSION"),
})
.to_string()
.replace('<', "\\u003c")
}
fn events(shared: &Shared, stream: &mut TcpStream, request: &Request) {
events_every(shared, stream, request, SSE_KEEPALIVE);
}
fn events_every(shared: &Shared, stream: &mut TcpStream, request: &Request, keepalive: Duration) {
let only = request
.query
.get("session")
.cloned()
.filter(|s| !s.is_empty());
let fine = request.query.get("rows").is_some_and(|v| v == "patch");
let mut last_row: Option<String> = None;
let mut last_table: Option<Arc<Snapshot>> = None;
let Ok(mut sse) = EventStream::open(stream, request) else {
return;
};
crate::elog::event("sse", "open", serde_json::json!({}));
let mut sent = 0u64;
let mut wrote = Instant::now();
let by = loop {
let snapshot = {
let Ok(latest) = shared.latest.lock() else {
break "lock";
};
let due = keepalive.saturating_sub(wrote.elapsed());
let (latest, _) = match shared
.updated
.wait_timeout_while(latest, due, |s| s.version <= sent)
{
Ok(pair) => pair,
Err(_) => break "wait",
};
Arc::clone(&latest)
};
if snapshot.version > sent {
let frame = match &only {
None => match &last_table {
None => Some(("sessions", snapshot.json.clone())),
Some(before) => match fine {
true => table_patch(&before.rows, &snapshot.rows),
false => table_delta(&before.rows, &snapshot.rows),
}
.map(|delta| ("rows", delta)),
},
Some(id) => {
let row = snapshot
.rows
.iter()
.find(|(sid, _)| sid == id)
.or_else(|| {
snapshot
.rows
.iter()
.find(|(sid, _)| sid.starts_with(id.as_str()))
})
.map(|(_, r)| r.clone());
match row {
Some(row) if last_row.as_ref() != Some(&row) => {
last_row = Some(row.clone());
Some(("sessions", format!("[{row}]")))
}
_ => None,
}
}
};
if let Some((event, body)) = frame {
if sse.send(event, &body).is_err() {
break "send";
}
wrote = Instant::now();
}
if only.is_none() {
last_table = Some(Arc::clone(&snapshot));
}
sent = snapshot.version;
}
if wrote.elapsed() >= keepalive {
if sse.ping().is_err() {
break "ping";
}
wrote = Instant::now();
}
};
crate::elog::event(
"sse",
"close",
serde_json::json!({ "by": by, "sent": sent }),
);
}
fn table_delta(before: &[(String, String)], after: &[(String, String)]) -> Option<String> {
let had: HashMap<&str, &str> = before
.iter()
.map(|(id, row)| (id.as_str(), row.as_str()))
.collect();
let set: Vec<&str> = after
.iter()
.filter(|(id, row)| had.get(id.as_str()) != Some(&row.as_str()))
.map(|(_, row)| row.as_str())
.collect();
let reordered =
before.len() != after.len() || before.iter().zip(after).any(|((a, _), (b, _))| a != b);
if set.is_empty() && !reordered {
return None;
}
let mut body = format!("{{\"set\":[{}]", set.join(","));
if reordered {
let ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect();
body.push_str(",\"order\":");
body.push_str(&serde_json::to_string(&ids).unwrap_or_else(|_| "[]".into()));
}
body.push('}');
Some(body)
}
fn table_patch(before: &[(String, String)], after: &[(String, String)]) -> Option<String> {
use serde_json::{Map, Value};
let had: HashMap<&str, &str> = before
.iter()
.map(|(id, row)| (id.as_str(), row.as_str()))
.collect();
let mut set: Vec<&str> = Vec::new();
let mut patch: Vec<Value> = Vec::new();
for (id, row) in after {
let Some(old) = had.get(id.as_str()) else {
set.push(row);
continue;
};
if old == row {
continue;
}
let parsed = (
serde_json::from_str::<Map<String, Value>>(old),
serde_json::from_str::<Map<String, Value>>(row),
);
let (Ok(old), Ok(new)) = parsed else {
set.push(row);
continue;
};
let to: Map<String, Value> = new
.iter()
.filter(|(k, v)| old.get(*k) != Some(*v))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let drop: Vec<&String> = old.keys().filter(|k| !new.contains_key(*k)).collect();
let mut one = serde_json::json!({ "id": id, "to": to });
if !drop.is_empty() {
one["drop"] = serde_json::json!(drop);
}
match one.to_string().len() < row.len() {
true => patch.push(one),
false => set.push(row),
}
}
let reordered =
before.len() != after.len() || before.iter().zip(after).any(|((a, _), (b, _))| a != b);
if set.is_empty() && patch.is_empty() && !reordered {
return None;
}
let mut body = format!("{{\"set\":[{}]", set.join(","));
if !patch.is_empty() {
body.push_str(",\"patch\":");
body.push_str(&Value::from(patch).to_string());
}
if reordered {
let ids: Vec<&str> = after.iter().map(|(id, _)| id.as_str()).collect();
let list = |ids: &[&str]| serde_json::to_string(ids).unwrap_or_else(|_| "[]".into());
match order_edit(before, &ids) {
Some((head, gone)) => {
body.push_str(&format!(
",\"head\":{},\"gone\":{}",
list(&head),
list(&gone)
));
}
None => body.push_str(&format!(",\"order\":{}", list(&ids))),
}
}
body.push('}');
Some(body)
}
fn order_edit<'a>(
before: &'a [(String, String)],
after: &[&'a str],
) -> Option<(Vec<&'a str>, Vec<&'a str>)> {
use std::collections::HashSet;
let now: HashSet<&str> = after.iter().copied().collect();
let was: HashSet<&str> = before.iter().map(|(id, _)| id.as_str()).collect();
if now.len() != after.len() || was.len() != before.len() {
return None;
}
let gone: Vec<&str> = before
.iter()
.map(|(id, _)| id.as_str())
.filter(|id| !now.contains(id))
.collect();
let kept: Vec<&str> = before
.iter()
.map(|(id, _)| id.as_str())
.filter(|id| now.contains(id))
.collect();
let mut j = kept.len();
let mut tail = 0;
for id in after.iter().rev() {
match kept[..j].iter().rposition(|k| k == id) {
Some(at) => {
j = at;
tail += 1;
}
None => break,
}
}
let head: Vec<&str> = after[..after.len() - tail].to_vec();
(head.len() + gone.len() < after.len()).then_some((head, gone))
}
fn api_report(shared: &Shared, stream: &mut TcpStream, request: &Request, id: &str) {
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(request), 404, NO_SUCH_SESSION);
};
if session.remote.is_some() {
return remote_json(
shared,
stream,
request,
session,
&["--report".to_string(), session.session_id.clone()],
);
}
let data = shared.store.session_data_fresh(session);
let built = report::build(session, &data, shared.plan);
match serde_json::to_string(&built) {
Ok(body) => http::respond(
stream,
Some(request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
),
Err(e) => http::respond_error(
stream,
Some(request),
503,
&format!("could not render the report: {e}"),
),
}
}
fn remote_json(
shared: &Shared,
stream: &mut TcpStream,
request: &Request,
session: &Session,
args: &[String],
) {
let remote = session
.remote
.as_ref()
.expect("only a remote row reaches remote_json");
let Some(host) = shared.hosts.get(&remote.host) else {
return http::respond_error(
stream,
Some(request),
502,
&format!(
"this session is on {}, which this run is not connected to",
remote.host
),
);
};
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
match host.run(&argv) {
Ok(body) => http::respond(
stream,
Some(request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
),
Err(why) => http::respond_error(
stream,
Some(request),
502,
&format!("{} could not answer: {why}", remote.host),
),
}
}
fn find<'a>(sessions: &'a [Session], id: &str) -> Option<&'a Session> {
if id.is_empty() {
return None;
}
if let Some(exact) = sessions.iter().find(|s| s.session_id == id) {
return Some(exact);
}
let mut matches = sessions.iter().filter(|s| s.session_id.starts_with(id));
let first = matches.next()?;
matches.next().is_none().then_some(first)
}
#[cfg(test)]
mod tests {
use super::*;
fn session(id: &str) -> Session {
Session::new(crate::pricing::Provider::Claude, id.into())
}
#[test]
fn a_token_matches_only_itself() {
let token = new_token();
assert!(token_matches(&token, &token));
assert!(!token_matches(&token, ""));
assert!(!token_matches(&token, &token[..token.len() - 1]));
let mut wrong = token.clone();
wrong.pop();
wrong.push(if token.ends_with('a') { 'b' } else { 'a' });
assert!(!token_matches(&token, &wrong));
}
#[test]
fn tokens_differ_between_runs() {
assert_ne!(new_token(), new_token());
assert_eq!(new_token().len(), TOKEN_BYTES * 2);
}
#[test]
fn sessions_resolve_by_id_and_by_unambiguous_prefix() {
let rows = vec![session("abc123"), session("abd999"), session("zz")];
assert_eq!(find(&rows, "abc123").unwrap().session_id, "abc123");
assert_eq!(find(&rows, "abc").unwrap().session_id, "abc123");
assert_eq!(find(&rows, "zz").unwrap().session_id, "zz");
}
#[test]
fn an_ambiguous_prefix_resolves_to_nothing() {
let rows = vec![session("abc123"), session("abd999")];
assert!(find(&rows, "ab").is_none());
assert!(find(&rows, "").is_none());
assert!(find(&rows, "nope").is_none());
}
#[test]
fn a_tunnel_refuses_the_two_combinations_that_undo_it() {
assert!(tunnel_objection(true, true, true).is_some());
assert!(tunnel_objection(true, false, false).is_some());
assert!(tunnel_objection(true, true, false).is_none());
assert!(tunnel_objection(false, false, true).is_none());
}
#[test]
fn the_app_page_is_built_and_takes_its_config() {
assert!(APP_HTML.contains(
r#"<script id="cctop-config" type="application/json">__CCTOP_CONFIG__</script>"#
));
assert!(APP_HTML.contains("<div id=\"root\"></div>"));
assert!(!APP_HTML.contains(" src=\"/assets/"));
assert!(!APP_HTML.contains("<link rel=\"stylesheet\""));
}
#[test]
fn the_app_config_cannot_end_its_script_element() {
let json = app_config("abc", true);
assert!(!json.contains('<'));
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["token"], "abc");
assert_eq!(v["actions"], true);
}
#[test]
fn a_token_is_safe_to_paste_into_a_url_unescaped() {
let token = new_token();
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token {token} would need escaping in a URL"
);
}
#[test]
fn a_named_port_is_not_stepped_past() {
let held = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let taken = held.local_addr().unwrap().port();
assert!(listen("127.0.0.1", taken, true).is_err());
let stepped = listen("127.0.0.1", taken, false).expect("the search finds a free port");
assert_ne!(stepped.local_addr().unwrap().port(), taken);
}
fn shared(token: &str, readonly: &str) -> Shared {
Shared {
token: token.to_string(),
readonly: readonly.to_string(),
actions: true,
port: 7777,
plan: Plan::Retail,
latest: Mutex::new(Arc::new(Snapshot {
version: 0,
json: "[]".to_string(),
rows: Vec::new(),
sessions: Vec::new(),
host_errors: Vec::new(),
})),
updated: Condvar::new(),
store: crate::cache::Store::new(),
quota: Mutex::new(quota::EMPTY.to_string()),
topics: Mutex::new(search::Topics::default()),
notify: None,
hosts: HashMap::new(),
pages: Default::default(),
}
}
#[test]
fn the_readonly_token_opens_the_page_but_not_the_actions() {
let guarded = shared("full", "view");
assert_eq!(access_for(&guarded, "full"), Some(Access::Full));
assert_eq!(access_for(&guarded, "view"), Some(Access::ReadOnly));
assert_eq!(access_for(&guarded, "wrong"), None);
assert_eq!(access_for(&guarded, ""), None);
let open = shared("", "");
assert_eq!(access_for(&open, ""), Some(Access::Full));
assert_eq!(access_for(&open, "anything"), Some(Access::Full));
}
fn api_search_body(shared: &Shared, target: &str) -> String {
use std::io::Read;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (mut server, _) = listener.accept().unwrap();
client
.write_all(format!("GET {target} HTTP/1.1\r\n\r\n").as_bytes())
.unwrap();
let request = Request::parse(&server).unwrap();
api_search(shared, &mut server, &request);
drop(server);
let mut raw = String::new();
client.read_to_string(&mut raw).unwrap();
raw
}
#[test]
fn a_search_without_a_real_query_finds_nothing() {
let shared = shared("", "");
for target in ["/api/search", "/api/search?q=", "/api/search?q=ab"] {
let body = api_search_body(&shared, target);
assert!(body.contains("\"hits\":[]"), "{target}: {body}");
}
}
#[test]
fn a_search_names_the_session_whose_transcript_matched() {
let path =
std::env::temp_dir().join(format!("cctop-serve-search-{}.jsonl", std::process::id()));
std::fs::write(&path, "{\"text\":\"please fix the flywheel\"}\n").unwrap();
let mut s = Session::new(crate::pricing::Provider::Claude, "sess-1".into());
s.data_file = Some(path.clone());
let shared = shared("", "");
*shared.latest.lock().unwrap() = Arc::new(Snapshot {
version: 1,
json: "[]".to_string(),
rows: Vec::new(),
sessions: vec![s],
host_errors: Vec::new(),
});
let body = api_search_body(&shared, "/api/search?q=flywheel");
let _ = std::fs::remove_file(path);
assert!(body.contains("\"session_id\":\"sess-1\""), "{body}");
assert!(body.contains("flywheel"), "{body}");
}
fn status_of(shared: &Shared, method: &str, target: &str, headers: &str) -> String {
let raw = response_of(shared, method, target, headers);
raw.lines().next().unwrap_or_default().to_string()
}
fn response_of(shared: &Shared, method: &str, target: &str, headers: &str) -> String {
use std::io::Read;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (mut server, _) = listener.accept().unwrap();
client
.write_all(format!("{method} {target} HTTP/1.1\r\n{headers}\r\n").as_bytes())
.unwrap();
serve_connection(shared, &mut server);
drop(server);
let mut raw = String::new();
client.read_to_string(&mut raw).unwrap();
raw
}
#[test]
fn the_markdown_route_serves_the_conversation_behind_the_token() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sess-md.jsonl");
let lines = [
r###"{"type":"user","timestamp":"2026-10-07T10:00:00Z","message":{"content":"## run the tests"}}"###,
r#"{"type":"assistant","timestamp":"2026-10-07T10:00:05Z","message":{"content":[{"type":"text","text":"Running them."},{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"cargo test"}}]}}"#,
r#"{"type":"user","timestamp":"2026-10-07T10:00:09Z","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"test result: ok"}]}}"#,
];
std::fs::write(&path, lines.join("\n")).unwrap();
let mut s = Session::new(crate::pricing::Provider::Claude, "sess-md".into());
s.data_file = Some(path);
let guarded = shared("full", "view");
*guarded.latest.lock().unwrap() = Arc::new(Snapshot {
version: 1,
json: "[]".to_string(),
rows: Vec::new(),
sessions: vec![s],
host_errors: Vec::new(),
});
let bearer = "Authorization: Bearer view\r\n";
let plain = response_of(&guarded, "GET", "/api/chat/sess-md/markdown", bearer);
assert!(plain.starts_with("HTTP/1.1 200 "), "{plain}");
assert!(plain.contains("text/markdown"), "{plain}");
assert!(plain.contains("> ## run the tests"), "{plain}");
assert!(plain.contains("- **Bash** `cargo test`"), "{plain}");
assert!(!plain.contains("test result: ok"), "{plain}");
let tools = response_of(
&guarded,
"GET",
"/api/chat/sess-md/markdown?variant=tools",
bearer,
);
assert!(tools.contains("test result: ok"), "{tools}");
let missing = status_of(&guarded, "GET", "/api/chat/nope/markdown", bearer);
assert!(missing.contains(" 404 "), "{missing}");
let anonymous = status_of(&guarded, "GET", "/api/chat/sess-md/markdown", "");
assert!(anonymous.contains(" 403 "), "{anonymous}");
}
#[test]
fn a_bearer_header_is_the_same_check_as_the_query() {
let guarded = shared("full", "view");
let bearer = |token: &str| format!("Authorization: Bearer {token}\r\n");
assert!(status_of(&guarded, "GET", "/api/hosts", &bearer("full")).contains(" 200 "));
assert!(status_of(&guarded, "GET", "/api/hosts", &bearer("view")).contains(" 200 "));
assert!(status_of(&guarded, "GET", "/api/hosts", &bearer("wrong")).contains(" 403 "));
assert!(status_of(&guarded, "GET", "/api/hosts", "").contains(" 403 "));
let act = status_of(
&guarded,
"POST",
"/api/launch",
&format!(
"{}Content-Type: application/json\r\nContent-Length: 2\r\n\r\n{{}}",
bearer("view")
),
);
assert!(act.contains(" 403 "), "{act}");
}
#[test]
fn metrics_answers_without_a_token_and_nothing_else_does() {
let guarded = shared("full", "view");
for target in ["/metrics", "/metrics?t=wrong"] {
let status = status_of(&guarded, "GET", target, "");
assert!(status.contains(" 200 "), "{target}: {status}");
}
let wrong = status_of(
&guarded,
"GET",
"/metrics",
"Authorization: Bearer wrong\r\n",
);
assert!(wrong.contains(" 200 "), "{wrong}");
for target in ["/api/sessions", "/metrics/", "/metricsx", "/"] {
let status = status_of(&guarded, "GET", target, "");
assert!(status.contains(" 403 "), "{target}: {status}");
}
}
fn rows(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(id, row)| (id.to_string(), row.to_string()))
.collect()
}
#[test]
fn a_delta_carries_only_the_rows_that_moved() {
let before = rows(&[("a", r#"{"id":"a","n":1}"#), ("b", r#"{"id":"b","n":1}"#)]);
assert_eq!(table_delta(&before, &before.clone()), None);
let changed = rows(&[("a", r#"{"id":"a","n":2}"#), ("b", r#"{"id":"b","n":1}"#)]);
assert_eq!(
table_delta(&before, &changed).as_deref(),
Some(r#"{"set":[{"id":"a","n":2}]}"#)
);
let grown = rows(&[
("c", r#"{"id":"c"}"#),
("a", r#"{"id":"a","n":1}"#),
("b", r#"{"id":"b","n":1}"#),
]);
assert_eq!(
table_delta(&before, &grown).as_deref(),
Some(r#"{"set":[{"id":"c"}],"order":["c","a","b"]}"#)
);
let shrunk = rows(&[("b", r#"{"id":"b","n":1}"#)]);
assert_eq!(
table_delta(&before, &shrunk).as_deref(),
Some(r#"{"set":[],"order":["b"]}"#)
);
let swapped = rows(&[("b", r#"{"id":"b","n":1}"#), ("a", r#"{"id":"a","n":1}"#)]);
assert_eq!(
table_delta(&before, &swapped).as_deref(),
Some(r#"{"set":[],"order":["b","a"]}"#)
);
}
fn put(shared: &Shared, version: u64, table: &[(&str, &str)]) {
let rows = rows(table);
let json = format!(
"[{}]",
rows.iter()
.map(|(_, r)| r.as_str())
.collect::<Vec<_>>()
.join(",")
);
*shared.latest.lock().unwrap() = Arc::new(Snapshot {
version,
json,
rows,
sessions: Vec::new(),
host_errors: Vec::new(),
});
shared.updated.notify_all();
}
fn open_stream(
shared: &Arc<Shared>,
query: &str,
keepalive: Duration,
) -> impl FnMut() -> (String, String) {
open_stream_with(shared, query, "", keepalive).1
}
fn open_stream_with(
shared: &Arc<Shared>,
query: &str,
headers: &str,
keepalive: Duration,
) -> (String, impl FnMut() -> (String, String)) {
use std::io::{BufRead, BufReader};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (mut server, _) = listener.accept().unwrap();
client
.write_all(format!("GET /api/events{query} HTTP/1.1\r\n{headers}\r\n").as_bytes())
.unwrap();
let request = Request::parse(&server).unwrap();
let shared = Arc::clone(shared);
std::thread::spawn(move || events_every(&shared, &mut server, &request, keepalive));
client
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let mut reader = BufReader::new(client);
let mut head = String::new();
loop {
let mut line = String::new();
reader.read_line(&mut line).expect("the response head");
if line == "\r\n" {
break;
}
head.push_str(&line);
}
let mut reader: Box<dyn BufRead> = match head.contains("Content-Encoding: gzip") {
true => Box::new(BufReader::new(flate2::read::GzDecoder::new(reader))),
false => Box::new(reader),
};
let next = move || {
let (mut event, mut data) = (String::new(), String::new());
loop {
let mut line = String::new();
reader
.read_line(&mut line)
.expect("a frame before the timeout");
let line = line.trim_end_matches('\n');
if line.is_empty() {
return (event, data);
}
if let Some(e) = line.strip_prefix("event: ") {
event = e.to_string();
} else if let Some(d) = line.strip_prefix("data: ") {
data = d.to_string();
}
}
};
(head, next)
}
#[test]
fn the_table_stream_sends_the_table_once_and_then_what_changed() {
let shared = Arc::new(shared("", ""));
put(&shared, 1, &[("a", r#"{"a":1}"#), ("b", r#"{"b":1}"#)]);
let mut next = open_stream(&shared, "", Duration::from_secs(30));
assert_eq!(next(), ("sessions".into(), r#"[{"a":1},{"b":1}]"#.into()));
put(&shared, 2, &[("a", r#"{"a":1}"#), ("b", r#"{"b":1}"#)]);
put(&shared, 3, &[("a", r#"{"a":1}"#), ("b", r#"{"b":2}"#)]);
assert_eq!(next(), ("rows".into(), r#"{"set":[{"b":2}]}"#.into()));
}
#[test]
fn a_quiet_stream_pings_even_while_refreshes_keep_landing() {
let shared = Arc::new(shared("", ""));
put(&shared, 1, &[("abc", r#"{"s":"asking"}"#)]);
let mut next = open_stream(&shared, "?session=abc", Duration::from_millis(300));
assert_eq!(next(), ("sessions".into(), r#"[{"s":"asking"}]"#.into()));
let churn = {
let shared = Arc::clone(&shared);
std::thread::spawn(move || {
for version in 2..60 {
put(&shared, version, &[("abc", r#"{"s":"asking"}"#)]);
std::thread::sleep(Duration::from_millis(50));
}
})
};
assert_eq!(next(), ("ping".into(), "1".into()));
churn.join().unwrap();
}
#[test]
fn a_patch_carries_only_the_fields_that_moved() {
let row = |id: &str, cpu: u32, extra: &str| {
format!(
r#"{{"session_id":"{id}","title":"a long title that is the bulk of the row","cpu":{cpu}{extra}}}"#
)
};
let before = vec![
("a".to_string(), row("a", 1, r#","permission":"edits""#)),
("b".to_string(), row("b", 1, "")),
];
assert_eq!(table_patch(&before, &before.clone()), None);
let after = vec![
("a".to_string(), row("a", 2, "")),
("b".to_string(), row("b", 1, "")),
];
assert_eq!(
table_patch(&before, &after).as_deref(),
Some(r#"{"set":[],"patch":[{"id":"a","to":{"cpu":2},"drop":["permission"]}]}"#)
);
let grown = vec![
("c".to_string(), row("c", 1, "")),
("a".to_string(), before[0].1.clone()),
("b".to_string(), before[1].1.clone()),
];
let delta = table_patch(&before, &grown).unwrap();
assert!(
delta.starts_with(&format!(r#"{{"set":[{}]"#, row("c", 1, ""))),
"{delta}"
);
assert!(delta.ends_with(r#""head":["c"],"gone":[]}"#), "{delta}");
}
#[test]
fn an_order_edit_rebuilds_the_order_it_describes() {
let ids = |s: &str| -> Vec<(String, String)> {
s.chars().map(|c| (c.to_string(), String::new())).collect()
};
let apply = |before: &str, head: &[&str], gone: &[&str]| -> String {
let mut out: String = head.concat();
out.extend(
before
.chars()
.filter(|c| !head.contains(&c.to_string().as_str()))
.filter(|c| !gone.contains(&c.to_string().as_str())),
);
out
};
for (before, after) in [
("abcdefgh", "dabcefgh"), ("abcdefgh", "xabcdefgh"), ("abcdefgh", "abcdefg"), ("abcdefgh", "abdefgh"), ("abcdefgh", "hgabcdef"), ("abcdefgh", "fabcdegh"), ] {
let after_ids: Vec<String> = after.chars().map(String::from).collect();
let after_refs: Vec<&str> = after_ids.iter().map(String::as_str).collect();
let before_ids = ids(before);
let (head, gone) = order_edit(&before_ids, &after_refs)
.unwrap_or_else(|| panic!("{before} -> {after} should be an edit"));
assert_eq!(apply(before, &head, &gone), after, "{before} -> {after}");
assert!(head.len() <= 2, "{before} -> {after}: head {head:?}");
}
let after_ids: Vec<String> = "hgf".chars().map(String::from).collect();
let after_refs: Vec<&str> = after_ids.iter().map(String::as_str).collect();
assert_eq!(order_edit(&ids("abcdefgh"), &after_refs), None);
assert_eq!(order_edit(&ids("aab"), &["b", "a", "a"]), None);
}
#[test]
fn only_a_stream_that_asks_is_sent_patches() {
let shared = Arc::new(shared("", ""));
let row = |n: u32| {
format!(
r#"{{"session_id":"a","title":"{}","n":{n}}}"#,
"x".repeat(200)
)
};
let first = row(1);
put(&shared, 1, &[("a", &first)]);
let mut old = open_stream(&shared, "", Duration::from_secs(30));
let mut new = open_stream(&shared, "?rows=patch", Duration::from_secs(30));
assert_eq!(old().0, "sessions");
assert_eq!(new().0, "sessions");
let second = row(2);
put(&shared, 2, &[("a", &second)]);
assert_eq!(old(), ("rows".into(), format!(r#"{{"set":[{second}]}}"#)));
assert_eq!(
new(),
(
"rows".into(),
r#"{"set":[],"patch":[{"id":"a","to":{"n":2}}]}"#.into()
)
);
}
#[test]
fn the_event_stream_is_gzip_and_flushed_per_event() {
let shared = Arc::new(shared("", ""));
put(&shared, 1, &[("a", r#"{"a":1}"#)]);
let (head, mut next) = open_stream_with(
&shared,
"",
"Accept-Encoding: gzip, deflate, br\r\n",
Duration::from_millis(300),
);
assert!(head.contains("Content-Encoding: gzip"), "{head}");
assert_eq!(next(), ("sessions".into(), r#"[{"a":1}]"#.into()));
put(&shared, 2, &[("a", r#"{"a":2}"#)]);
assert_eq!(next(), ("rows".into(), r#"{"set":[{"a":2}]}"#.into()));
assert_eq!(next(), ("ping".into(), "1".into()));
let (plain, _) = open_stream_with(&shared, "", "", Duration::from_secs(30));
assert!(!plain.contains("Content-Encoding"), "{plain}");
}
fn exchange(shared: &Shared, target: &str, headers: &str) -> (String, Vec<u8>) {
use std::io::Read;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (mut server, _) = listener.accept().unwrap();
client
.write_all(format!("GET {target} HTTP/1.1\r\n{headers}\r\n").as_bytes())
.unwrap();
let mut raw = Vec::new();
std::thread::scope(|scope| {
scope.spawn(move || serve_connection(shared, &mut server));
client.read_to_end(&mut raw).unwrap();
});
let split = raw
.windows(4)
.position(|w| w == b"\r\n\r\n")
.expect("a response head");
let head = String::from_utf8(raw[..split].to_vec()).unwrap();
(head, raw[split + 4..].to_vec())
}
fn header<'a>(head: &'a str, name: &str) -> Option<&'a str> {
head.lines().find_map(|line| {
let (k, v) = line.split_once(':')?;
k.eq_ignore_ascii_case(name).then_some(v.trim())
})
}
const JS_PATH: &str = "/_astro/index.astro_astro_type_script_index_0_lang.CdYCpIGm.js";
#[test]
fn hashed_files_are_kept_for_a_year_and_pages_are_revalidated() {
let guarded = shared("full", "view");
let auth = "Authorization: Bearer full\r\n";
for (path, cache) in [
(JS_PATH, http::IMMUTABLE),
("/_astro/index.D2bSaP4U.css", http::IMMUTABLE),
(
"/_astro/rmux_web_crypto_wasm_bg.C8R0tHIf.wasm",
http::IMMUTABLE,
),
("/term/crabs/lime-dark.svg", http::IMMUTABLE),
("/term/", http::REVALIDATE),
("/", http::REVALIDATE),
("/favicon.svg", http::REVALIDATE),
] {
let (head, _) = exchange(&guarded, path, auth);
assert!(head.starts_with("HTTP/1.1 200 "), "{path}: {head}");
assert_eq!(header(&head, "Cache-Control"), Some(cache), "{path}");
if cache == http::REVALIDATE {
assert!(header(&head, "ETag").is_some(), "{path} has no ETag");
}
}
let (head, _) = exchange(&guarded, JS_PATH, "");
assert!(head.starts_with("HTTP/1.1 403 "), "{head}");
let (head, _) = exchange(&guarded, "/api/sessions", auth);
assert_eq!(header(&head, "Cache-Control"), Some("no-store"));
}
#[test]
fn a_page_the_browser_holds_is_answered_304_and_no_body() {
let guarded = shared("full", "view");
for path in ["/?t=full", "/term/", JS_PATH] {
let (head, body) = exchange(&guarded, path, "Authorization: Bearer full\r\n");
let etag = header(&head, "ETag").expect("an ETag").to_string();
assert!(!body.is_empty());
let (head, body) = exchange(
&guarded,
path,
&format!("Authorization: Bearer full\r\nIf-None-Match: {etag}\r\n"),
);
assert!(head.starts_with("HTTP/1.1 304 "), "{path}: {head}");
assert!(body.is_empty(), "{path}: a 304 carries no body");
assert_eq!(header(&head, "ETag"), Some(etag.as_str()));
let (head, _) = exchange(
&guarded,
path,
"Authorization: Bearer full\r\nIf-None-Match: W/\"stale\"\r\n",
);
assert!(head.starts_with("HTTP/1.1 200 "), "{path}: {head}");
}
let tag = |token: &str| {
let (head, _) = exchange(&guarded, &format!("/?t={token}"), "");
header(&head, "ETag").unwrap().to_string()
};
assert_ne!(tag("full"), tag("view"));
let full = tag("full");
let (head, _) = exchange(&guarded, "/?t=full", &format!("If-None-Match: {full}\r\n"));
assert!(head.starts_with("HTTP/1.1 304 "), "{head}");
assert!(header(&head, "Set-Cookie").is_some(), "{head}");
}
#[test]
fn bodies_are_compressed_for_a_client_that_offers_it() {
use std::io::Read;
let guarded = shared("full", "view");
let auth = "Authorization: Bearer full\r\n";
let js = term::file(JS_PATH).unwrap();
let raw = exchange(&guarded, JS_PATH, auth);
assert_eq!(header(&raw.0, "Content-Encoding"), None);
assert_eq!(header(&raw.0, "Vary"), Some("Accept-Encoding"));
let (head, body) = exchange(
&guarded,
JS_PATH,
&format!("{auth}Accept-Encoding: gzip\r\n"),
);
assert_eq!(header(&head, "Content-Encoding"), Some("gzip"));
assert_eq!(
header(&head, "Content-Length"),
Some(body.len().to_string().as_str())
);
let mut plain = Vec::new();
flate2::read::GzDecoder::new(&body[..])
.read_to_end(&mut plain)
.unwrap();
assert_eq!(plain, raw.1);
assert!(
body.len() < raw.1.len() / 3,
"{} of {}",
body.len(),
raw.1.len()
);
let (head, body) = exchange(
&guarded,
JS_PATH,
&format!("{auth}Accept-Encoding: gzip, deflate, br\r\n"),
);
assert_eq!(header(&head, "Content-Encoding"), Some("br"));
let mut plain = Vec::new();
brotli::Decompressor::new(&body[..], 4096)
.read_to_end(&mut plain)
.unwrap();
assert_eq!(plain, raw.1);
assert_eq!(js.content_type, "application/javascript; charset=utf-8");
let table: Vec<(String, String)> = (0..40)
.map(|i| {
(
format!("s{i}"),
format!(r#"{{"session_id":"s{i}","title":"a session"}}"#),
)
})
.collect();
let pairs: Vec<(&str, &str)> = table
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
put(&guarded, 1, &pairs);
let (head, _) = exchange(
&guarded,
"/api/sessions",
&format!("{auth}Accept-Encoding: gzip\r\n"),
);
assert_eq!(header(&head, "Content-Encoding"), Some("gzip"), "{head}");
}
}