Skip to content
15 changes: 14 additions & 1 deletion crates/rowbit-cli/src/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ pub struct DemoArgs {
/// Don't connect an agent client
#[arg(long)]
no_connect: bool,
/// Don't open the review page in the browser
#[arg(long)]
no_open: bool,
}

#[derive(clap::Subcommand)]
Expand Down Expand Up @@ -273,7 +276,8 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> {
println!();
say("Rowbit is guarding the demo shop database.");
say(&format!(
"Review page: {address} password: {password} name: anything"
"Review page: {} password: {password} name: anything",
crate::term::link(&address)
));
println!();
match (&connected, &open) {
Expand Down Expand Up @@ -347,6 +351,14 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> {
println!();
std::io::stdout().flush()?;

let open = !a.no_open
&& std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& crate::term::can_open_browser();
if open {
say("Opening the review page in your browser (--no-open skips this).");
println!();
}
std::env::set_current_dir(&dir)?;
crate::serve(
None,
Expand All @@ -355,6 +367,7 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> {
config: dir.join("rowbit-server.yml"),
},
&setup,
open,
)
.await
}
Expand Down
18 changes: 15 additions & 3 deletions crates/rowbit-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod grants;
mod place;
mod review;
mod setup;
mod term;

use std::path::PathBuf;

Expand Down Expand Up @@ -349,6 +350,7 @@ async fn serve(
set_password: bool,
server: &ServerArgs,
setup: &Setup,
open_browser: bool,
) -> anyhow::Result<()> {
let config = server.load()?;
let listen = config.listen(listen);
Expand Down Expand Up @@ -399,14 +401,24 @@ async fn serve(
mcp: config.mcp_http.then_some(database),
};
let address = address.trim_end_matches('/');
eprintln!("rowbit: review page on {address}/");
eprintln!(
"rowbit: review page on {}",
term::link_err(&format!("{address}/"))
);
if config.mcp_http {
eprintln!("rowbit: agents over MCP at {address}/mcp, each with its own token");
}
for n in &config.notify {
eprintln!("rowbit: held changes are announced to {}", n.name);
}
rowbit_web::serve(engine, web, listen).await?;
let page = format!("{address}/");
rowbit_web::serve_then(engine, web, listen, move |_| {
// Bound: whatever answers is this server, not another left running.
if open_browser {
term::open_browser(&page);
}
})
.await?;
Ok(())
}

Expand Down Expand Up @@ -788,7 +800,7 @@ async fn main() -> anyhow::Result<()> {
set_password,
server,
setup,
} => serve(listen, set_password, &server, &setup).await?,
} => serve(listen, set_password, &server, &setup, false).await?,
Command::Agents {
command,
policy,
Expand Down
12 changes: 8 additions & 4 deletions crates/rowbit-cli/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use rowbit_mysql::grants::{self, Account, Grant, Object};

use crate::connect::{self, Client, Env, Scope};
use crate::place;
use crate::term;

/// What Rowbit's own user needs on each database, as in docs/privileges.md.
const NEEDED: [&str; 6] = [
Expand Down Expand Up @@ -313,10 +314,12 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> {
}
match &password {
Some(p) => say(&format!(
"Review page: {address} password: {p} (shown this once)"
"Review page: {} password: {p} (shown this once)",
term::link(&address)
)),
None => say(&format!(
"Review page: {address} (password as set before)"
"Review page: {} (password as set before)",
term::link(&address)
)),
}
for (client, scope) in &connected {
Expand All @@ -343,8 +346,9 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> {
}
say("Writes wait for you on the review page until you add rules to rowbit.yml.");
say(&format!(
"Agents can read every table {} can. Limit what each one sees with scopes: https://ermag.github.io/rowbit/docs/spec/policy.html#scopes",
found.account
"Agents can read every table {} can. Limit what each one sees with scopes: {}",
found.account,
term::link("https://ermag.github.io/rowbit/docs/spec/policy.html#scopes")
));
let dir_flag = if dir != place::home()?.join("default") {
format!(" --dir {}", quoted(&dir))
Expand Down
146 changes: 146 additions & 0 deletions crates/rowbit-cli/src/term.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Links people can click in a terminal. Plain URLs depend on the
//! terminal spotting them, and some only open them on ⌘-click or not at
//! all; OSC 8 marks the text as a link outright. See "Links in the
//! terminal" in docs/spec/setup.md.

use std::io::IsTerminal;

/// `url` as a link where the terminal is known to show OSC 8 links, and
/// as it is otherwise (a pipe, a log, an unknown terminal).
pub fn link(url: &str) -> String {
link_if(url, std::io::stdout().is_terminal())
}

/// [`link`], for standard error.
pub fn link_err(url: &str) -> String {
link_if(url, std::io::stderr().is_terminal())
}

fn link_if(url: &str, terminal: bool) -> String {
if terminal && supports(|k| std::env::var(k).ok()) {
osc8(url, url)
} else {
url.to_owned()
}
}

fn osc8(url: &str, text: &str) -> String {
// Control characters would end the sequence early.
let clean = |s: &str| s.chars().filter(|c| !c.is_control()).collect::<String>();
let (url, text) = (clean(url), clean(text));
format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
}

/// Whether the environment names a terminal that shows OSC 8 links.
/// `ROWBIT_HYPERLINKS=0` or `1` decides; otherwise a terminal on the list,
/// and never inside tmux or screen, which drop the sequence unless set up
/// to pass it on.
fn supports(var: impl Fn(&str) -> Option<String>) -> bool {
match var("ROWBIT_HYPERLINKS").as_deref() {
Some("0") => return false,
Some("1") => return true,
_ => {}
}
if var("TMUX").is_some() || var("STY").is_some() {
return false;
}
if var("TERM").as_deref() == Some("dumb") {
return false;
}
let program = var("TERM_PROGRAM").unwrap_or_default();
matches!(
program.as_str(),
"ghostty" | "iTerm.app" | "WezTerm" | "vscode" | "Hyper" | "rio" | "WarpTerminal"
) || var("TERM").is_some_and(|t| t == "xterm-kitty" || t == "xterm-ghostty")
|| var("KITTY_WINDOW_ID").is_some()
|| var("WT_SESSION").is_some()
|| var("VTE_VERSION")
.and_then(|v| v.parse::<u32>().ok())
.is_some_and(|v| v >= 5000)
}

/// Opens `url` in the default browser. Returns whether that started.
pub fn open_browser(url: &str) -> bool {
let opener = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
std::process::Command::new(opener)
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok()
}

/// Whether a browser can be opened here: not over SSH, and on Linux only
/// with a display.
pub fn can_open_browser() -> bool {
can_open(|k| std::env::var_os(k).is_some(), cfg!(target_os = "macos"))
}

fn can_open(set: impl Fn(&str) -> bool, macos: bool) -> bool {
if set("SSH_CONNECTION") || set("SSH_TTY") {
return false;
}
macos || set("DISPLAY") || set("WAYLAND_DISPLAY")
}

#[cfg(test)]
mod tests {
use super::*;

fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |k| {
vars.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| (*v).to_owned())
}
}

#[test]
fn known_terminals_get_links() {
assert!(supports(env(&[("TERM_PROGRAM", "ghostty")])));
assert!(supports(env(&[("TERM_PROGRAM", "iTerm.app")])));
assert!(supports(env(&[("TERM", "xterm-kitty")])));
assert!(supports(env(&[("VTE_VERSION", "7600")])));
assert!(!supports(env(&[("TERM_PROGRAM", "Apple_Terminal")])));
assert!(!supports(env(&[])));
assert!(!supports(env(&[
("TERM_PROGRAM", "ghostty"),
("TMUX", "/tmp/x")
])));
assert!(!supports(env(&[
("TERM_PROGRAM", "ghostty"),
("ROWBIT_HYPERLINKS", "0")
])));
assert!(supports(env(&[("ROWBIT_HYPERLINKS", "1")])));
}

#[test]
fn browsers_open_only_where_someone_can_see_them() {
let set = |vars: &'static [&'static str]| move |k: &str| vars.contains(&k);
assert!(can_open(set(&[]), true));
assert!(!can_open(set(&["SSH_CONNECTION"]), true));
assert!(!can_open(set(&[]), false));
assert!(can_open(set(&["WAYLAND_DISPLAY"]), false));
}

#[test]
fn control_characters_stay_out_of_links() {
assert_eq!(
osc8("http://x/\x1b]8;;evil\x07", "a\x1bb"),
"\x1b]8;;http://x/]8;;evil\x1b\\ab\x1b]8;;\x1b\\"
);
}

#[test]
fn a_link_shows_its_address() {
assert_eq!(
osc8("http://127.0.0.1:8080/", "http://127.0.0.1:8080/"),
"\x1b]8;;http://127.0.0.1:8080/\x1b\\http://127.0.0.1:8080/\x1b]8;;\x1b\\"
);
}
}
4 changes: 3 additions & 1 deletion crates/rowbit-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ send one INSERT, UPDATE or DELETE (or a schema change) to `propose_write`. \
Rowbit previews it without running it and checks it against the team's \
rules: it may be applied at once, held for a person, or rejected with a \
reason. You cannot approve changes; a person does. When a change is held, \
give the person its `review_url`, say what it changes, and stop: don't \
give the person its `review_url` as a link whose text is the URL \
itself, as in <http://127.0.0.1:8080/changes/4>, so they can click it and \
see where it goes; say what it changes, and stop: don't \
retry the write in another form or split it to get under a rule. Follow \
it with `change_status`.";

Expand Down
14 changes: 13 additions & 1 deletion crates/rowbit-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,25 @@ pub fn router(engine: Arc<Engine>, web: Web) -> Router {

/// Serves the page on `addr` until the process stops. `web.hosts` gains
/// the address it listens on.
pub async fn serve(engine: Arc<Engine>, mut web: Web, addr: SocketAddr) -> std::io::Result<()> {
pub async fn serve(engine: Arc<Engine>, web: Web, addr: SocketAddr) -> std::io::Result<()> {
serve_then(engine, web, addr, |_| {}).await
}

/// [`serve`], calling `listening` once the address is bound, before the
/// first request: for opening a browser on this server and no other.
pub async fn serve_then(
engine: Arc<Engine>,
mut web: Web,
addr: SocketAddr,
listening: impl FnOnce(SocketAddr),
) -> std::io::Result<()> {
let listener = tokio::net::TcpListener::bind(addr).await?;
let local = listener.local_addr()?;
web.hosts.push(local.to_string());
if local.ip().is_loopback() {
web.hosts.push(format!("localhost:{}", local.port()));
}
listening(local);
axum::serve(listener, router(engine, web)).await
}

Expand Down
8 changes: 7 additions & 1 deletion docs/spec/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,13 @@ The home page and the README also offer install links for VS Code (`https://insi

## Review links

So that a held write ends with the person knowing where to look, `propose_write` and `change_status` return `review_url` for a held change: `<public_url>/changes/<id>`, or `http://<listen>/changes/<id>` without a `public_url`, the same link notifications use. `rowbit serve` records that address in the state file when it starts, so `rowbit mcp` over stdio, which shares the file, links to the same place; before `rowbit serve` has ever run, the link uses `http://127.0.0.1:8080`. The server's instructions tell the agent to give the person that link and not to retry the write in another form. The link reveals nothing: the page needs sign-in, and the agent could reach its address already. Opened signed out, it goes through sign-in (carrying only the change's number) and then to that change. See [Agent tools](change-lifecycle.md#agent-tools-mcp).
So that a held write ends with the person knowing where to look, `propose_write` and `change_status` return `review_url` for a held change: `<public_url>/changes/<id>`, or `http://<listen>/changes/<id>` without a `public_url`, the same link notifications use. `rowbit serve` records that address in the state file when it starts, so `rowbit mcp` over stdio, which shares the file, links to the same place; before `rowbit serve` has ever run, the link uses `http://127.0.0.1:8080`. The server's instructions tell the agent to give the person that link, as a link whose text is the URL itself (`<http://…/changes/4>`), so clients that render Markdown make it clickable while the person still sees where it goes (a link labelled "Review change 4" could hide a look-alike page asking for the review password), and not to retry the write in another form. The link reveals nothing: the page needs sign-in, and the agent could reach its address already. Opened signed out, it goes through sign-in (carrying only the change's number) and then to that change. See [Agent tools](change-lifecycle.md#agent-tools-mcp).

## Links in the terminal

A plain URL in a terminal is only clickable if the terminal spots it, and some open it only on ⌘-click or Ctrl-click. So where Rowbit prints an address for a person (the review page in `setup`, `demo` and `serve`, the docs link in `setup`), it marks it as a link with OSC 8, in terminals known to show those: Ghostty, iTerm2, WezTerm, kitty, VS Code's terminal, Windows Terminal, Hyper, Rio, Warp and VTE-based ones (GNOME Terminal and others). It prints the address as it is when the output isn't a terminal, in Terminal.app, which doesn't support them, and inside tmux or screen, which drop them unless set up to pass them on. `ROWBIT_HYPERLINKS=1` or `0` overrides the choice. The link's text is the address itself, so copying still works.

`rowbit demo` also opens the review page in the browser, when run in a terminal: once its own server has bound the address, so never another server left on that port. It doesn't over SSH or, on Linux, without a display; `--no-open` skips it. Link targets have control characters removed, so a `public_url` can't end the sequence early.

## The skill

Expand Down
2 changes: 1 addition & 1 deletion plugins/rowbit/skills/rowbit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Send one `INSERT`, `UPDATE` or `DELETE` (or one schema change) to `propose_write
The result has a `state`:

- `applied`: done. Tell the person what changed (table, row count).
- `held`: waiting for a person. **Give them the `review_url` from the result**, say in a sentence what the write changes and why, and stop. Don't wait in a loop; they may take a while. If they ask, check it later with `change_status`.
- `held`: waiting for a person. **Give them the `review_url` from the result as a link that shows the URL itself** (`<http://127.0.0.1:8080/changes/4>`, never a link with other text), so they can click it and see where it goes, say in a sentence what the write changes and why, and stop. Don't wait in a loop; they may take a while. If they ask, check it later with `change_status`.
- `rejected`: refused by a rule or by a person, with a `reason`. Tell the person the reason. Don't try again in another form.
- `failed`: Rowbit couldn't preview or apply it; the reason says why. Fix a genuine mistake (a typo, a wrong column) once; otherwise report it.
- `approved` means a person approved it and Rowbit is applying it; `undone` means a person took an applied change back.
Expand Down
Loading