From 38890993a681edbfc3f8d70859ec095781159a0d Mon Sep 17 00:00:00 2001 From: Ermag Date: Sat, 7 Nov 2026 15:47:56 +0200 Subject: [PATCH 1/6] rowbit connect finds the setup from any project folder Without --dir and with no Rowbit files in the current folder, commands now look at every setup in ~/.rowbit and the demo (linked as ~/.rowbit/demo), use the only one and say which, list them when there are several, and say to run rowbit setup when there are none. So `cd my-app && rowbit connect claude-code` works. Each client entry gets a token of its own, labelled with the client and project, so connecting a second project no longer signs out the first; rowbit agents lists them. Signed-off-by: Ermag --- README.md | 2 +- crates/rowbit-cli/src/connect.rs | 68 ++++++++++---- crates/rowbit-cli/src/demo.rs | 47 ++++++++++ crates/rowbit-cli/src/main.rs | 33 +++++-- crates/rowbit-cli/src/place.rs | 141 +++++++++++++++++++++++++++-- crates/rowbit-cli/src/setup.rs | 18 +++- crates/rowbit-cli/tests/connect.rs | 78 +++++++++++++++- crates/rowbit-cli/tests/serve.rs | 5 +- crates/rowbit-store/src/lib.rs | 125 ++++++++++++++++++++++++- crates/rowbit-store/src/tests.rs | 47 ++++++++++ docs/connect.md | 17 +++- docs/spec/server.md | 2 +- docs/spec/setup.md | 8 +- site/index.html | 4 +- 14 files changed, 542 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 78c3449..2893b6d 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Adds Rowbit to `~/.cursor/mcp.json`. Restart Cursor. rowbit connect vscode ``` -Or, with `rowbit demo` or `rowbit serve` running, [add it to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=rowbit&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22rowbit_token%22%2C%22description%22%3A%22Rowbit%20agent%20token%20%28from%3A%20rowbit%20connect%20vscode%20--print%20--new-token%29%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22http%3A%2F%2F127.0.0.1%3A8080%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24%7Binput%3Arowbit_token%7D%22%7D%7D) and paste the token from `rowbit connect vscode --print --new-token` when VS Code asks (an agent has one token, so this signs out a client the demo connected). +Or, with `rowbit demo` or `rowbit serve` running, [add it to VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=rowbit&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22rowbit_token%22%2C%22description%22%3A%22Rowbit%20agent%20token%20%28from%3A%20rowbit%20connect%20vscode%20--print%29%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22http%3A%2F%2F127.0.0.1%3A8080%2Fmcp%22%2C%22headers%22%3A%7B%22Authorization%22%3A%22Bearer%20%24%7Binput%3Arowbit_token%7D%22%7D%7D) and paste the token from `rowbit connect vscode --print` when VS Code asks. diff --git a/crates/rowbit-cli/src/connect.rs b/crates/rowbit-cli/src/connect.rs index 060ceed..d414c6d 100644 --- a/crates/rowbit-cli/src/connect.rs +++ b/crates/rowbit-cli/src/connect.rs @@ -765,8 +765,9 @@ pub struct ConnectArgs { /// Have the client start `rowbit mcp` itself, even with mcp.http on #[arg(long)] stdio: bool, - /// Replace the agent's token, signing out the client that has the old one - #[arg(long)] + /// No longer needed: each client entry gets a token of its own, and + /// connecting the same place again replaces only that one + #[arg(long, hide = true)] new_token: bool, /// Replace an entry of the same name in the client #[arg(long)] @@ -924,10 +925,16 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { a.name ); } + let mut label = connection_label(client, a.scope, &env.cwd); + if a.print { + // Added by hand, maybe somewhere else: don't sign out an entry + // `rowbit connect` added here. + label.push_str(" (printed)"); + } let server = if http { Server::Http { url: format!("{}/mcp", address.base()), - token: issue_token(&a, &agent)?, + token: issue_token(&a, &agent, &label)?, } } else { stdio_server(&a, &agent)? @@ -944,8 +951,8 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { let _ = a .store .open() - .and_then(|mut s| Ok(s.revoke_agent_token(&agent)?)); - eprintln!("rowbit: {agent}'s new token is revoked; it has none now"); + .and_then(|mut s| Ok(s.revoke_connection_token(&agent, &label)?)); + eprintln!("rowbit: the new token for {label} is revoked"); } return Err(e); } @@ -993,11 +1000,20 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { match client { // `rowbit demo` says where to open the client itself. _ if a.dir.is_some() => {} - Client::ClaudeCode if a.scope == Scope::Local => println!( - "It applies only in {}. Start Claude Code there: {}", - env.cwd.display(), - client.open_in(&env.cwd, &env) - ), + Client::ClaudeCode if a.scope == Scope::Local => { + let here = std::env::current_dir().ok().as_deref() == Some(env.cwd.as_path()); + if here { + println!( + "It applies in this folder only. Start Claude Code here, or restart it if it's open, and ask it to use Rowbit." + ) + } else { + println!( + "It applies only in {}. Start Claude Code there: {}", + env.cwd.display(), + client.open_in(&env.cwd, &env) + ) + } + } Client::ClaudeCode | Client::Gemini | Client::Codex => {} _ => println!("Restart {} to load it.", client.label()), } @@ -1105,20 +1121,32 @@ fn pick_agent(a: &ConnectArgs) -> anyhow::Result { } } -/// One token per agent: a new one signs out whoever has the old one, so -/// that takes --new-token. -fn issue_token(a: &ConnectArgs, agent: &str) -> anyhow::Result { +/// A token for this client entry: one per client and place, so connecting +/// the agent in another project leaves the others signed in, and +/// connecting the same place again replaces only its own. +fn issue_token(a: &ConnectArgs, agent: &str, label: &str) -> anyhow::Result { let mut store = rowbit_store::Store::open(&a.store.store) .with_context(|| format!("opening {}", a.store.store.display()))?; - anyhow::ensure!( - a.new_token || store.agent_token_times(agent)?.is_none(), - "{agent} already has a token, which another client may be using; pass --new-token to replace it (that client is signed out), or give this client its own agent in rowbit.yml" - ); - Ok(store.issue_agent_token(agent)?) + Ok(store.issue_connection_token(agent, label)?) +} + +/// Which client, and where it uses Rowbit: "Claude Code in ~/app". +pub fn connection_label(client: Client, scope: Scope, cwd: &Path) -> String { + match (client, scope) { + (Client::ClaudeCode, Scope::Local) => { + format!("{} in {}", client.label(), crate::place::tilde(cwd)) + } + (Client::ClaudeCode, _) => format!("{}, every project", client.label()), + _ => client.label().to_owned(), + } } fn stdio_server(a: &ConnectArgs, agent: &str) -> anyhow::Result { - let here = a.dir.clone().unwrap_or_else(|| PathBuf::from(".")); + let here = a + .dir + .clone() + .or_else(|| crate::place::dir().map(Path::to_owned)) + .unwrap_or_else(|| PathBuf::from(".")); let url = match crate::place::pick_url(a.url.clone())? { Some(url) => Some(url), None => crate::demo::database_url(&here), @@ -1158,7 +1186,7 @@ fn print_address(a: &ConnectArgs) -> anyhow::Result<()> { "turn on mcp.http in rowbit-server.yml for an address to give a client, or name a client for its stdio setup" ); let agent = pick_agent(a)?; - let token = issue_token(a, &agent)?; + let token = issue_token(a, &agent, &format!("{} (printed)", a.name))?; println!("URL: {}/mcp", address.base()); println!("Header: Authorization: Bearer {token}"); Ok(()) diff --git a/crates/rowbit-cli/src/demo.rs b/crates/rowbit-cli/src/demo.rs index 76149ec..dc7bcdf 100644 --- a/crates/rowbit-cli/src/demo.rs +++ b/crates/rowbit-cli/src/demo.rs @@ -103,6 +103,46 @@ struct Entry { } /// The agent's workspace, if `dir` is a demo directory that has one. +/// Whether `dir` is a demo's directory. +pub fn is_demo(dir: &Path) -> bool { + dir.join(MARKER).is_file() +} + +/// Points `~/.rowbit/demo` at the demo's directory, so commands run +/// elsewhere find it. Leaves a setup called `demo` alone. +fn link_demo(dir: &Path) { + let Ok(home) = crate::place::home() else { + return; + }; + let link = home.join(crate::place::DEMO_LINK); + match std::fs::symlink_metadata(&link) { + Ok(m) if m.file_type().is_symlink() => { + if std::fs::read_link(&link).ok().as_deref() == Some(dir) { + return; + } + let _ = std::fs::remove_file(&link); + } + Ok(_) => return, + Err(_) => {} + } + if std::fs::create_dir_all(&home).is_ok() { + let _ = std::os::unix::fs::symlink(dir, &link); + } +} + +/// Removes `~/.rowbit/demo` if it points at `dir`. +fn unlink_demo(dir: &Path) { + let Ok(home) = crate::place::home() else { + return; + }; + let link = home.join(crate::place::DEMO_LINK); + let points_here = std::fs::symlink_metadata(&link).is_ok_and(|m| m.file_type().is_symlink()) + && std::fs::read_link(&link).ok().as_deref() == Some(dir); + if points_here { + let _ = std::fs::remove_file(&link); + } +} + pub fn workspace(dir: &Path) -> Option { let w = dir.join(WORKSPACE); (dir.join(MARKER).is_file() && w.is_dir()).then_some(w) @@ -166,6 +206,7 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { .with_context(|| format!("finding {}", dir.display()))?; let workspace = dir.join(WORKSPACE); std::fs::create_dir_all(&workspace).context("creating the agent's workspace")?; + link_demo(&dir); let address = crate::ServerArgs { config: dir.join("rowbit-server.yml"), } @@ -291,6 +332,9 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { "For the CLI, in {shown}: export ROWBIT_DATABASE_URL={}", marker.database_url )); + say( + "To try it from a project of yours instead, cd into it and run: rowbit connect claude-code (or cursor, codex, ...)", + ); say( "Ctrl-C stops the review page; the database keeps running. `rowbit demo clean` removes it all.", ); @@ -511,6 +555,9 @@ fn clean(dir: &Path) -> anyhow::Result<()> { Err(e) => return Err(e).with_context(|| format!("removing {f}")), } } + if let Ok(canonical) = dir.canonicalize() { + unlink_demo(&canonical); + } // The agent's workspace goes only if it's empty: what's in it is yours. let _ = std::fs::remove_dir(&workspace); if std::fs::remove_dir(dir).is_ok() { diff --git a/crates/rowbit-cli/src/main.rs b/crates/rowbit-cli/src/main.rs index f68f165..d17d77e 100644 --- a/crates/rowbit-cli/src/main.rs +++ b/crates/rowbit-cli/src/main.rs @@ -811,15 +811,34 @@ async fn main() -> anyhow::Result<()> { } None => { let store = store.open()?; + let connections = store.connection_tokens()?; + let used = |u: Option| { + u.map(|u| format!("last used {}", review::when(u))) + .unwrap_or_else(|| "never used".into()) + }; for agent in policy.agents.keys() { - match store.agent_token_times(agent)? { - None => println!("{agent} no token"), - Some((created, used)) => println!( - "{agent} token made {}, {}", + let mine: Vec<_> = + connections.iter().filter(|t| &t.agent == agent).collect(); + let manual = store.agent_token_times(agent)?; + if manual.is_none() && mine.is_empty() { + println!("{agent} no token"); + continue; + } + println!("{agent}"); + if let Some((created, last)) = manual { + println!( + " the agent's own token (rowbit agents token, or rowbit connect before 0.2.3): made {}, {}", review::when(created), - used.map(|u| format!("last used {}", review::when(u))) - .unwrap_or_else(|| "never used".into()) - ), + used(last) + ); + } + for t in mine { + println!( + " {}: made {}, {}", + t.label, + review::when(t.created_ms), + used(t.used_ms) + ); } } } diff --git a/crates/rowbit-cli/src/place.rs b/crates/rowbit-cli/src/place.rs index 3342888..7433aca 100644 --- a/crates/rowbit-cli/src/place.rs +++ b/crates/rowbit-cli/src/place.rs @@ -67,16 +67,145 @@ pub fn init(args: Vec) -> anyhow::Result> { anyhow::ensure!(dir.is_dir(), "{} isn't a directory", dir.display()); Some(dir) } - None if Path::new("rowbit.yml").exists() => None, - None => home() - .ok() - .map(|h| h.join("default")) - .filter(|d| is_setup(d)), + // A folder with Rowbit's own files in it is Rowbit's. + None if ["rowbit.yml", "rowbit-server.yml", "rowbit.db"] + .iter() + .any(|f| Path::new(f).exists()) => + { + None + } + None => { + let found = home().map(|h| setups(&h)).unwrap_or_default(); + match pick(&found) { + Picked::One(s) => { + if s.name != "default" { + eprintln!("rowbit: using {}", s.describe()); + } + Some(s.dir.clone()) + } + Picked::Several if needs_files(&args) => anyhow::bail!( + "Rowbit has several setups and none is the default; pick one with --dir:\n{}", + found + .iter() + .map(|s| format!(" {}", s.describe())) + .collect::>() + .join("\n") + ), + Picked::None if needs_files(&args) => anyhow::bail!( + "no Rowbit setup here: no rowbit.yml in this folder and none in ~/.rowbit. \ + Set one up with `rowbit setup` (or try the sample with `rowbit setup --sample`), \ + or point at one with --dir " + ), + _ => None, + } + } }; let _ = DIR.set(dir); Ok(args) } +/// A setup `rowbit setup` or `rowbit demo` made, found without `--dir`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Found { + /// Its name in `~/.rowbit`, or `demo`. + pub name: String, + pub dir: PathBuf, +} + +impl Found { + pub fn describe(&self) -> String { + let shown = tilde(&self.dir); + if self.name == "demo" { + format!("the demo in {shown}") + } else { + format!("{} in {shown}", self.name) + } + } +} + +/// `path` with the home directory written as `~`. +pub fn tilde(path: &Path) -> String { + match std::env::var_os("HOME").map(PathBuf::from) { + Some(home) if !home.as_os_str().is_empty() => match path.strip_prefix(&home) { + Ok(rest) if rest.as_os_str().is_empty() => "~".into(), + Ok(rest) => format!("~/{}", rest.display()), + Err(_) => path.display().to_string(), + }, + _ => path.display().to_string(), + } +} + +/// Every setup in `rowbit_home` (`~/.rowbit`), by name, and the demo: +/// the one `~/.rowbit/demo` links to, or else `~/rowbit-demo`, where +/// `rowbit setup --sample` run from the home directory puts it. +pub fn setups(rowbit_home: &Path) -> Vec { + let mut found = Vec::new(); + let mut demo = None; + if let Ok(entries) = std::fs::read_dir(rowbit_home) { + let mut entries: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + entries.sort(); + for dir in entries { + let Some(name) = dir.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { + continue; + }; + if is_setup(&dir) { + found.push(Found { name, dir }); + } else if name == DEMO_LINK && crate::demo::is_demo(&dir) { + demo = Some(dir.canonicalize().unwrap_or(dir)); + } + } + } + let demo = demo.or_else(|| { + let d = rowbit_home.parent()?.join("rowbit-demo"); + crate::demo::is_demo(&d).then_some(d) + }); + if let Some(dir) = demo { + found.push(Found { + name: "demo".into(), + dir, + }); + } + found +} + +/// The link in `~/.rowbit` to the demo's directory, which `rowbit demo` +/// makes and `rowbit demo clean` removes. +pub const DEMO_LINK: &str = "demo"; + +#[derive(Debug, PartialEq, Eq)] +enum Picked<'a> { + One(&'a Found), + Several, + None, +} + +/// The default setup if there is one, else the only one. +fn pick(found: &[Found]) -> Picked<'_> { + if let Some(d) = found.iter().find(|s| s.name == "default") { + return Picked::One(d); + } + match found { + [one] => Picked::One(one), + [] => Picked::None, + _ => Picked::Several, + } +} + +/// Whether the command reads a setup's files, so finding none is an error +/// worth stopping for. `ping`, `policy`, `server` and `connect` with no +/// client name (it lists them) work without one. +fn needs_files(args: &[OsString]) -> bool { + let (command, _) = command(args); + match command.as_deref() { + None | Some("ping" | "policy" | "server" | "help") => false, + Some("connect") => args + .iter() + .skip(2) + .any(|a| a == "--print" || !a.to_string_lossy().starts_with('-')), + Some(_) => !args.iter().any(|a| a == "--help" || a == "-h"), + } +} + /// The subcommand, and whether a `--dir` came before it. fn command(args: &[OsString]) -> (Option, bool) { let mut dir_first = false; @@ -135,7 +264,7 @@ pub fn file(path: &mut PathBuf, default: &str) { /// the flag nor the environment set it. pub fn database_url() -> anyhow::Result> { let Some(dir) = dir() else { return Ok(None) }; - read_env_file(&dir.join(ENV_FILE)) + Ok(read_env_file(&dir.join(ENV_FILE))?.or_else(|| crate::demo::database_url(dir))) } /// The database URL a command uses. `--url` wins. Otherwise, in a setup's diff --git a/crates/rowbit-cli/src/setup.rs b/crates/rowbit-cli/src/setup.rs index e3fbd1c..4939806 100644 --- a/crates/rowbit-cli/src/setup.rs +++ b/crates/rowbit-cli/src/setup.rs @@ -126,6 +126,11 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> { connect::check_name(&a.name)?; let dir = place::home()?.join(&a.name); + anyhow::ensure!( + !std::fs::symlink_metadata(&dir).is_ok_and(|m| m.file_type().is_symlink()), + "{} is a link (`rowbit demo` keeps one to its directory there); give this setup another name with --name", + dir.display() + ); let resuming = place::has_rowbit_files(&dir); if resuming { say(&format!( @@ -341,10 +346,17 @@ pub async fn run(a: SetupArgs) -> anyhow::Result<()> { "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 )); - if dir != place::home()?.join("default") { + let dir_flag = if dir != place::home()?.join("default") { + format!(" --dir {}", quoted(&dir)) + } else { + String::new() + }; + say(&format!( + "Add Rowbit to another project: cd into it and run rowbit connect claude-code{dir_flag} (or cursor, codex, ...)." + )); + if !dir_flag.is_empty() { say(&format!( - "Other commands find this setup with --dir {}.", - quoted(&dir) + "Other commands find this setup with{dir_flag}, or without it when it's your only one." )); } if !found.own_user { diff --git a/crates/rowbit-cli/tests/connect.rs b/crates/rowbit-cli/tests/connect.rs index ef967dd..e77595a 100644 --- a/crates/rowbit-cli/tests/connect.rs +++ b/crates/rowbit-cli/tests/connect.rs @@ -104,17 +104,17 @@ fn cursor_gets_the_address_and_a_token_of_its_own() { assert!(header.starts_with("Bearer rbt_"), "{file}"); assert!(out.contains(&header[header.len() - 4..]), "{out}"); - // The entry exists, and the agent already has a token. + // The entry exists: replacing it takes --force, and replaces its token. let (ok, out) = c.run(&["connect", "cursor"]); assert!(!ok && out.contains("--force"), "{out}"); - let (ok, out) = c.run(&["connect", "cursor", "--force"]); - assert!(!ok && out.contains("--new-token"), "{out}"); assert_eq!( std::fs::read_to_string(c.home.join(".cursor/mcp.json")).unwrap(), file ); - let (ok, out) = c.run(&["connect", "cursor", "--force", "--new-token"]); + let (ok, out) = c.run(&["connect", "cursor", "--force"]); assert!(ok, "{out}"); + let (_, agents) = c.run(&["agents"]); + assert_eq!(agents.matches(" Cursor: made").count(), 1, "{agents}"); assert_ne!( std::fs::read_to_string(c.home.join(".cursor/mcp.json")).unwrap(), file @@ -153,7 +153,7 @@ fn claude_code_is_set_up_with_its_own_command() { assert!(!ok && out.contains("committed"), "{out}"); let (ok, out) = c.run(&["connect", "claude-code"]); assert!(ok, "{out}"); - assert!(out.contains("Start Claude Code there: cd "), "{out}"); + assert!(out.contains("Start Claude Code here"), "{out}"); assert!(!out.contains("workspace"), "{out}"); let calls = std::fs::read_to_string(&log).unwrap(); assert!(calls.starts_with("mcp get rowbit\n"), "{calls}"); @@ -265,3 +265,71 @@ fn lists_clients_and_prints_an_address() { let token = token_in(&out); assert_eq!(token.len(), 4 + 43, "{out}"); } + +#[test] +fn a_project_folder_finds_the_setup_in_rowbit_home() { + let c = Case::new("anywhere"); + c.http(); + let log = c.fake_claude(); + // The app is a folder of its own; Rowbit's files are elsewhere. + let app = c.dir.parent().unwrap().join("app"); + std::fs::create_dir_all(&app).unwrap(); + let in_app = |args: &[&str]| { + let out = Command::new(env!("CARGO_BIN_EXE_rowbit")) + .args(args) + .current_dir(&app) + .env("HOME", &c.home) + .env("PATH", format!("{}:/usr/bin:/bin", c.bin.display())) + .env_remove("ROWBIT_DATABASE_URL") + .env_remove("ROWBIT_DIR") + .output() + .unwrap(); + ( + out.status.success(), + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ), + ) + }; + let (ok, out) = in_app(&["connect", "claude-code"]); + assert!(!ok && out.contains("rowbit setup"), "{out}"); + // Listing clients needs no setup. + assert!(in_app(&["connect"]).0); + + // One setup, not called default: found, and named. + let shop = c.home.join(".rowbit/shop"); + std::fs::create_dir_all(&shop).unwrap(); + for f in ["rowbit.yml", "rowbit-server.yml"] { + std::fs::copy(c.dir.join(f), shop.join(f)).unwrap(); + } + std::fs::write(shop.join("env"), "ROWBIT_DATABASE_URL=mysql://u:p@h/shop\n").unwrap(); + std::fs::set_permissions( + shop.join("env"), + std::os::unix::fs::PermissionsExt::from_mode(0o600), + ) + .unwrap(); + let (ok, out) = in_app(&["connect", "claude-code"]); + assert!(ok, "{out}"); + assert!(out.contains("using shop in ~/.rowbit/shop"), "{out}"); + assert!(out.contains("Start Claude Code here"), "{out}"); + assert!( + std::fs::read_to_string(&log) + .unwrap() + .contains("mcp add --scope local") + ); + let (_, agents) = in_app(&["agents"]); + assert!(agents.contains("Claude Code in "), "{agents}"); + + // Two, and neither the default: Rowbit asks which. + std::fs::create_dir_all(c.home.join(".rowbit/other")).unwrap(); + for f in ["rowbit.yml", "env"] { + std::fs::copy(shop.join(f), c.home.join(".rowbit/other").join(f)).unwrap(); + } + let (ok, out) = in_app(&["changes"]); + assert!( + !ok && out.contains("--dir") && out.contains("shop in ~/.rowbit/shop"), + "{out}" + ); +} diff --git a/crates/rowbit-cli/tests/serve.rs b/crates/rowbit-cli/tests/serve.rs index 0d28641..a0f90eb 100644 --- a/crates/rowbit-cli/tests/serve.rs +++ b/crates/rowbit-cli/tests/serve.rs @@ -8,6 +8,8 @@ fn rowbit(args: &[&str], dir: &std::path::Path) -> (bool, String) { .args(args) .current_dir(dir) .env("ROWBIT_DATABASE_URL", "mysql://nobody@127.0.0.1:1/none") + // No setups of the person running the tests. + .env("HOME", dir) .env_remove("ROWBIT_SERVER_CONFIG") .output() .unwrap(); @@ -29,6 +31,7 @@ fn dir(name: &str) -> std::path::PathBuf { #[test] fn no_network_listening_without_an_identity_provider() { let d = dir("net"); + std::fs::write(d.join("rowbit.yml"), "version: 0\nagents: {}\n").unwrap(); let (ok, out) = rowbit(&["serve", "--listen", "0.0.0.0:0"], &d); assert!(!ok, "{out}"); assert!(out.contains("identity provider"), "{out}"); @@ -72,7 +75,7 @@ fn agent_tokens_are_shown_once_and_revocable() { assert_eq!(token.len(), 47); let (ok, out) = rowbit(&["agents"], &d); assert!( - ok && out.contains("refund-bot token made") && out.contains("never used"), + ok && out.contains("refund-bot\n the agent's own token") && out.contains("never used"), "{out}" ); assert!(!out.contains(&token)); diff --git a/crates/rowbit-store/src/lib.rs b/crates/rowbit-store/src/lib.rs index cee3a09..8f0846f 100644 --- a/crates/rowbit-store/src/lib.rs +++ b/crates/rowbit-store/src/lib.rs @@ -140,6 +140,55 @@ pub enum DeliveryOutcome { Failed { error: String }, } +/// A token `rowbit connect` made for one client entry: an agent may have +/// one per place it is connected, each labelled with where it is. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConnectionToken { + pub agent: String, + /// Which client, and where: "Claude Code in ~/projects/app". + pub label: String, + pub created_ms: i64, + pub used_ms: Option, +} + +const CONNECTION: &str = "connection."; + +/// Every connection token, by its hash. +fn connection_tokens(conn: &Connection) -> Result, StoreError> { + let mut stmt = conn.prepare( + "SELECT key, value FROM settings WHERE substr(key, 1, length(?1)) = ?1 ORDER BY key", + )?; + let rows = stmt.query_map([CONNECTION], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })?; + let mut out = Vec::new(); + for row in rows { + let (key, value) = row?; + let t: ConnectionToken = + serde_json::from_str(&value).map_err(|e| StoreError::Corrupt(format!("{key}: {e}")))?; + out.push((key[CONNECTION.len()..].to_owned(), t)); + } + Ok(out) +} + +/// Removes the connection tokens `remove` picks, inside a transaction. +fn revoke_connections( + tx: &rusqlite::Transaction<'_>, + remove: impl Fn(&ConnectionToken) -> bool, +) -> Result { + let mut any = false; + for (hash, t) in connection_tokens(tx)? { + if remove(&t) { + tx.execute( + "DELETE FROM settings WHERE key IN (?1, ?2)", + params![format!("token.{hash}"), format!("{CONNECTION}{hash}")], + )?; + any = true; + } + } + Ok(any) +} + /// Removes `agent`'s token and its times, inside a transaction. fn revoke_token(tx: &rusqlite::Transaction<'_>, agent: &str) -> Result { let old: Option = tx @@ -645,16 +694,82 @@ impl Store { Ok(token) } - /// Removes `agent`'s MCP token. Returns whether it had one. + /// Removes every MCP token of `agent`'s, the one `rowbit agents token` + /// made and every connection's. Returns whether it had any. pub fn revoke_agent_token(&mut self, agent: &str) -> Result { let tx = self .conn .transaction_with_behavior(TransactionBehavior::Immediate)?; let had = revoke_token(&tx, agent)?; + let had_connections = revoke_connections(&tx, |t| t.agent == agent)?; + tx.commit()?; + Ok(had || had_connections) + } + + /// Makes a new MCP token for one client entry of `agent`'s, replacing + /// the one with the same label, and returns it. The agent's other + /// tokens keep working. Only its SHA-256 is kept. + pub fn issue_connection_token( + &mut self, + agent: &str, + label: &str, + ) -> Result { + use base64::Engine as _; + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("the system has a random source"); + let token = format!( + "rbt_{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ); + let hash = hex(&Sha256::digest(token.as_bytes())); + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + revoke_connections(&tx, |t| t.agent == agent && t.label == label)?; + let info = ConnectionToken { + agent: agent.to_owned(), + label: label.to_owned(), + created_ms: now_ms(), + used_ms: None, + }; + tx.execute( + "INSERT INTO settings (key, value) VALUES (?1, ?2), (?3, ?4)", + params![ + format!("token.{hash}"), + agent, + format!("{CONNECTION}{hash}"), + serde_json::to_string(&info).expect("serializes"), + ], + )?; + tx.commit()?; + Ok(token) + } + + /// Removes the token of `agent`'s connection with this label. Returns + /// whether there was one. + pub fn revoke_connection_token( + &mut self, + agent: &str, + label: &str, + ) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let had = revoke_connections(&tx, |t| t.agent == agent && t.label == label)?; tx.commit()?; Ok(had) } + /// Every connection's token, by agent and label. + pub fn connection_tokens(&self) -> Result, StoreError> { + let mut out: Vec = connection_tokens(&self.conn)? + .into_iter() + .map(|(_, t)| t) + .collect(); + out.sort_by(|a, b| (&a.agent, &a.label).cmp(&(&b.agent, &b.label))); + Ok(out) + } + /// The agent a token belongs to, and notes that it was used. `None` for /// a token Rowbit didn't issue or has revoked. pub fn agent_for_token(&mut self, token: &str) -> Result, StoreError> { @@ -662,6 +777,14 @@ impl Store { let Some(agent) = self.setting(&format!("token.{hash}"))? else { return Ok(None); }; + let key = format!("{CONNECTION}{hash}"); + if let Some(value) = self.setting(&key)? { + let mut info: ConnectionToken = serde_json::from_str(&value) + .map_err(|e| StoreError::Corrupt(format!("{key}: {e}")))?; + info.used_ms = Some(now_ms()); + self.set_setting(&key, &serde_json::to_string(&info).expect("serializes"))?; + return Ok(Some(agent)); + } self.set_setting( &format!("agent.{agent}.token_used_ms"), &now_ms().to_string(), diff --git a/crates/rowbit-store/src/tests.rs b/crates/rowbit-store/src/tests.rs index ee901e7..86a4163 100644 --- a/crates/rowbit-store/src/tests.rs +++ b/crates/rowbit-store/src/tests.rs @@ -546,6 +546,53 @@ fn agent_tokens_are_kept_as_hashes_and_replaced() { assert!(!store.revoke_agent_token("refund-bot").unwrap()); } +#[test] +fn each_connection_has_a_token_of_its_own() { + let mut store = Store::open_in_memory().unwrap(); + let manual = store.issue_agent_token("bot").unwrap(); + let app = store + .issue_connection_token("bot", "Claude Code in ~/app") + .unwrap(); + let web = store + .issue_connection_token("bot", "Claude Code in ~/web") + .unwrap(); + // Each works, and none signs another out. + for t in [&manual, &app, &web] { + assert_eq!(store.agent_for_token(t).unwrap().as_deref(), Some("bot")); + } + let list = store.connection_tokens().unwrap(); + assert_eq!( + list.iter().map(|t| t.label.as_str()).collect::>(), + ["Claude Code in ~/app", "Claude Code in ~/web"] + ); + assert!(list.iter().all(|t| t.used_ms.is_some())); + + // Connecting the same place again replaces only that one. + let app2 = store + .issue_connection_token("bot", "Claude Code in ~/app") + .unwrap(); + assert_eq!(store.agent_for_token(&app).unwrap(), None); + assert_eq!( + store.agent_for_token(&app2).unwrap().as_deref(), + Some("bot") + ); + assert_eq!(store.agent_for_token(&web).unwrap().as_deref(), Some("bot")); + assert_eq!(store.connection_tokens().unwrap().len(), 2); + + assert!( + store + .revoke_connection_token("bot", "Claude Code in ~/web") + .unwrap() + ); + assert_eq!(store.agent_for_token(&web).unwrap(), None); + + // Revoking the agent signs out every one. + assert!(store.revoke_agent_token("bot").unwrap()); + assert_eq!(store.agent_for_token(&manual).unwrap(), None); + assert_eq!(store.agent_for_token(&app2).unwrap(), None); + assert!(store.connection_tokens().unwrap().is_empty()); +} + #[test] fn settings_are_listed_by_prefix_and_deleted() { let mut store = Store::open_in_memory().unwrap(); diff --git a/docs/connect.md b/docs/connect.md index ce66a53..b226979 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -1,12 +1,25 @@ # Connect your agent -Agents reach Rowbit over MCP. [`rowbit setup`](quickstart.md) connects the clients you pick as it sets Rowbit up, and connects more when run again. `rowbit connect` sets up one client, as one agent from `rowbit.yml`. Run it where `rowbit serve` runs, or pass `--dir` (in the demo, inside `rowbit-demo/`): +Agents reach Rowbit over MCP. [`rowbit setup`](quickstart.md) connects the clients you pick as it sets Rowbit up, and connects more when run again. `rowbit connect` sets up one client, as one agent from `rowbit.yml`: ```sh rowbit connect # lists the clients Rowbit knows, and which are installed rowbit connect claude-code ``` +## Add Rowbit to a project you already have + +Set Rowbit up once with `rowbit setup` (or try it with the demo), then go to your project and connect your agent there: + +```sh +cd ~/projects/my-app +rowbit connect claude-code +``` + +Rowbit finds its setup on its own: the folder's `rowbit.yml` if it has one, else `~/.rowbit/default`, else your only setup in `~/.rowbit` or the demo, and says which it used. With several and no `default`, it lists them and asks for `--dir`. For Claude Code the entry applies to this project only, so other projects don't see Rowbit; `--scope user` adds it everywhere. Each project you connect gets a token of its own, so connecting a second project doesn't sign out the first, and `rowbit agents` lists them all. + +Rowbit doesn't write `.mcp.json` (Claude Code's project scope), since that file is meant to be committed and would carry the token. + | Client | `rowbit connect …` | What it changes | |---|---|---| | Claude Code | `claude-code` | Runs `claude mcp add`, for this directory (`--scope local`, the default) or for you everywhere (`--scope user`) | @@ -22,7 +35,7 @@ With `mcp.http: true` in `rowbit-server.yml` (the demo has it), the client gets `rowbit connect` keeps a backup of any file it changes (`.rowbit-backup`), refuses a file it can't parse, and replaces nothing without being told: - `--force` replaces an entry of the same name; `--name ` adds another. -- `--new-token` replaces the agent's token. An agent has one token, so this signs out the client that had the old one. After `rowbit demo` connected a client, adding another (or using an install button) takes `--print --new-token`. To connect two clients at once, give each its own agent in `rowbit.yml`. +- Each client entry gets a token of its own, named for the client and, for Claude Code, the project: "Claude Code in ~/projects/my-app". Connecting the same place again (with `--force`) replaces only that one. `rowbit agents` lists them, and `rowbit agents revoke ` signs out all of an agent's. - `--agent ` picks the agent when `rowbit.yml` lists more than one. - `--print` changes nothing in the client and prints what to add by hand, with the token. diff --git a/docs/spec/server.md b/docs/spec/server.md index 3ed0f1a..67c9228 100644 --- a/docs/spec/server.md +++ b/docs/spec/server.md @@ -114,7 +114,7 @@ With `mcp.http: true`, `rowbit serve` also serves agents at `/mcp` o Each agent signs in with a token of its own, sent as `Authorization: Bearer `: -- `rowbit agents token ` makes a new token for an agent listed in `rowbit.yml`, prints it once, and keeps only its SHA-256 hash in the state file. Making a new one replaces the old. `rowbit agents revoke ` removes it. `rowbit agents` lists which agents have a token and when it was last used. +- `rowbit agents token ` makes a new token for an agent listed in `rowbit.yml`, prints it once, and keeps only its SHA-256 hash in the state file. Making a new one replaces the old. `rowbit connect` makes one per client entry instead, labelled with where it is (see [rowbit connect](setup.md#rowbit-connect)), so an agent can be connected in several places at once. `rowbit agents revoke ` removes all of the agent's tokens. `rowbit agents` lists each agent's tokens and when each was last used. - Tokens are 32 random bytes, written as `rbt_` and 43 characters of base64url. - The agent's identity is the token's agent, never anything in the request. A missing, unknown or revoked token, or a token for an agent no longer in `rowbit.yml`, gets `401` and nothing else. Failed sign-ins are slowed down as on the review page. - `/mcp` answers only requests addressed to the configured host, and refuses any request with an `Origin` header that isn't `public_url`, so a web page can't reach it from someone's browser. diff --git a/docs/spec/setup.md b/docs/spec/setup.md index 255940e..b094aa3 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -82,7 +82,7 @@ It never runs a statement that changes the database or its users. Where somethin ## Where commands find their files -Every command but `demo` and `setup` takes `--dir ` (or `ROWBIT_DIR`), after the command's name, and then finds `rowbit.yml`, `rowbit-server.yml`, `rowbit.db` and the `env` file in that directory. Without it, a command uses the current directory if it holds a `rowbit.yml`, as in v0.2.1, and otherwise `~/.rowbit/default` if that holds a setup. A flag or variable naming one file (`--policy`, `--store`, `--config` and their variables) still wins for that file. `--dir` before `demo` or `setup` is refused, since they have options of their own. +Every command but `demo` and `setup` takes `--dir ` (or `ROWBIT_DIR`), after the command's name, and then finds `rowbit.yml`, `rowbit-server.yml`, `rowbit.db` and the `env` file in that directory. Without it, a command uses the current directory if it holds a `rowbit.yml`, `rowbit-server.yml` or `rowbit.db`, and otherwise `~/.rowbit/default` if that holds a setup. Without that either, it looks at every setup in `~/.rowbit` and the demo (the one `~/.rowbit/demo` links to, which `rowbit demo` makes and `rowbit demo clean` removes, or else `~/rowbit-demo`): if there is one, it uses it and says which on standard error; if there are several, it lists them and asks for `--dir`; if there are none, it says to run `rowbit setup`. `ping`, `policy`, `server` and `rowbit connect` without a client work without a setup. So `rowbit connect claude-code` works from any project folder. `rowbit setup` refuses a name that is a link, so it never writes into the demo's directory. A flag or variable naming one file (`--policy`, `--store`, `--config` and their variables) still wins for that file. `--dir` before `demo` or `setup` is refused, since they have options of their own. The database URL is `--url` if given. Otherwise, in a setup's directory, it's the `env` file's, even when `ROWBIT_DATABASE_URL` is set: that may be left in the shell from the demo, and pairing a setup's state with another database would go unnoticed. Rowbit says so on standard error when the two differ. Without an `env` file, `ROWBIT_DATABASE_URL` is used, as in v0.2.1. Rowbit refuses an `env` file that others can read, since it holds the database's password. @@ -113,7 +113,7 @@ The database URL is `--url` if given. Otherwise, in a setup's directory, it's th 1. **The directory.** If it doesn't exist, Rowbit creates it and writes the quickstart's `compose.yaml`, `shop.sql` and `rowbit.yml` (built into the binary from `examples/quickstart`, with `--port` in place of `33070`), a `rowbit-server.yml` with `mcp.http: true` and the `--listen` address, `.rowbit-demo`, which marks the directory as the demo's and records the database URL and which client entries the demo added, and an empty `workspace/` for the agent. The agent works there, not beside the files that say how to reach the database around Rowbit (`compose.yaml` holds the database's root password). If it exists with `.rowbit-demo` in it, Rowbit keeps every file as it is, so changes to `rowbit.yml` survive a restart. If it exists, isn't empty and has no marker, Rowbit refuses, so it never writes into a directory of yours. 2. **The database.** `docker compose up -d --wait` in that directory. MariaDB listens on `127.0.0.1:` only, with the quickstart's fixed passwords, which is why the demo is for trying Rowbit and nothing else. 3. **Something to review.** If the state file has no changes yet, Rowbit proposes the README's three writes as `coding-agent`, as `rowbit propose` would: a small fix that is applied (change 1), the risky update that is held (change 2) and a delete that is refused (change 3). The review page has something on it before any agent connects, and the tour carries on from there. -4. **An agent.** On first start, Rowbit lists the [clients](#rowbit-connect) it finds (the client's command on `PATH`, or its configuration directory present) and asks which to connect, the first found by default, or none. Then it does what `rowbit connect ` does, inside the demo directory, with Claude Code's local scope on `workspace/`. If that fails (a client file Rowbit can't parse, an entry by that name already there), it says why and how to connect later, and carries on without a client. One client, because the demo has one agent and an agent has one token; `rowbit connect --new-token` in the directory moves it. `--connect ` picks one without asking, `--no-connect` skips this, and without a terminal Rowbit skips it unless `--connect` is given. On a restart with a client already connected, it skips this. +4. **An agent.** On first start, Rowbit lists the [clients](#rowbit-connect) it finds (the client's command on `PATH`, or its configuration directory present) and asks which to connect, the first found by default, or none. Then it does what `rowbit connect ` does, inside the demo directory, with Claude Code's local scope on `workspace/`. If that fails (a client file Rowbit can't parse, an entry by that name already there), it says why and how to connect later, and carries on without a client. One client here; `rowbit connect ` adds more, each with a token of its own. `--connect ` picks one without asking, `--no-connect` skips this, and without a terminal Rowbit skips it unless `--connect` is given. On a restart with a client already connected, it skips this. 5. **The review page.** Rowbit makes a new page password, then runs `rowbit serve` in that directory in the foreground, with `/mcp` on. A new password each start, since the directory is throwaway and a lost password would otherwise mean `--set-password`. 6. **What to try.** Before serving, it prints the review page's address and password, and what to ask the agent: @@ -153,7 +153,7 @@ Compose names the project after the directory, so two demo directories with the - **The agent** is `--agent `, or the only agent in `rowbit.yml`. With more than one and no `--agent`, Rowbit lists their ids and stops. - **Over HTTP** when `rowbit-server.yml` has `mcp.http: true`. The address is `/mcp`, or `http:///mcp` without a `public_url`. Rowbit makes the agent a token as `rowbit agents token` does and writes it into the client's configuration as `Authorization: Bearer `; the client's machine gets nothing else. If nothing answers at the address, it says to start `rowbit serve`. - **Over stdio** otherwise, with `--stdio`, and always for Claude Desktop. The client starts `rowbit mcp --agent ` with full paths to the binary, `rowbit.yml` and `rowbit.db`, and `ROWBIT_DATABASE_URL` from the environment, or from `.rowbit-demo` in the demo directory. Rowbit prints that the client's configuration now holds the database URL and points at the state file, and that anyone who can read them can act around Rowbit (see [Before you use it for real](../quickstart.md#before-you-use-it-for-real)). -- **One token per agent.** If the agent already has a token, Rowbit refuses unless `--new-token` is given, because a new token signs out the client using the old one. To connect two clients at once, give each its own agent in `rowbit.yml`. +- **One token per client entry.** Over HTTP, each entry gets a token of its own, labelled with the client and, for Claude Code's local scope, the directory: "Claude Code in ~/projects/app", "Claude Code, every project", "Cursor". A new token for the same agent and label replaces the old one; the agent's other tokens keep working. `--print` labels its token "(printed)", so it never signs out an entry `rowbit connect` added. `--new-token`, which v0.2.2 needed to replace an agent's only token, is accepted and does nothing. - **The entry is named `rowbit`,** or `--name `. If the client already has an entry by that name, Rowbit refuses unless `--force` is given, which replaces it. | Client | What Rowbit does | @@ -178,7 +178,7 @@ When Rowbit edits a client's file itself, it: Every run prints what it changed and where, with the token shortened to `rbt_` and its last four characters, and says to restart the client if it only reads its configuration on start. -`rowbit connect --check` changes nothing and makes no token. It says whether the client has the entry (`claude mcp get` from the directory the entry applies to, which in a demo directory is `workspace/`, or the client's file), then, over HTTP without a `public_url`, whether anything answers at the address, and for Claude Code the line that starts it in that directory. It exits non-zero if the entry is missing or nothing answers. It can't be combined with `--print`, `--force`, `--new-token` or `--stdio`. +`rowbit connect --check` changes nothing and makes no token. It says whether the client has the entry (`claude mcp get` from the directory the entry applies to, which in a demo directory is `workspace/`, or the client's file), then, over HTTP without a `public_url`, whether anything answers at the address, and for Claude Code the line that starts it in that directory. It exits non-zero if the entry is missing or nothing answers. It can't be combined with `--print`, `--force` or `--stdio`. `rowbit connect --print` changes no client configuration: it prints the command to run or the entry to add, with the full token, for setting up by hand or on another machine. It still makes the token. `rowbit connect --print` without a client prints the address and the token, for any other MCP client. diff --git a/site/index.html b/site/index.html index 48a9cf6..bc2194a 100644 --- a/site/index.html +++ b/site/index.html @@ -293,7 +293,7 @@

GET STARTED: TWO COMMANDS

Already running Rowbit?
-

Setup connects the clients you pick. To add VS Code from here, use the button and paste the token from rowbit connect vscode --print --new-token when VS Code asks (an agent has one token, so this signs out the other client). The button assumes the default 127.0.0.1:8080.

+

Setup connects the clients you pick. To add VS Code from here, use the button and paste the token from rowbit connect vscode --print when VS Code asks. The button assumes the default 127.0.0.1:8080.

▶ ADD TO VS CODE
@@ -467,7 +467,7 @@

INSERT COIN: YOUR OPINION

})); // A link is the same for everyone, so it can't carry an agent's token: // VS Code asks for it, as a password, when the server is added. -const vsInputs = [{ type: 'promptString', id: 'rowbit_token', description: 'Rowbit agent token (from: rowbit connect vscode --print --new-token)', password: true }]; +const vsInputs = [{ type: 'promptString', id: 'rowbit_token', description: 'Rowbit agent token (from: rowbit connect vscode --print)', password: true }]; const vsConfig = { type: 'http', url: 'http://127.0.0.1:8080/mcp', headers: { Authorization: 'Bearer ${input:rowbit_token}' } }; $('#vscode').href = 'https://insiders.vscode.dev/redirect/mcp/install?name=rowbit&inputs=' + encodeURIComponent(JSON.stringify(vsInputs)) + '&config=' + encodeURIComponent(JSON.stringify(vsConfig)); From 5451076c30daef5ded3912252f28ed5c73391948 Mon Sep 17 00:00:00 2001 From: Ermag Date: Sat, 7 Nov 2026 15:52:21 +0200 Subject: [PATCH 2/6] Clickable links in the terminal, and the demo opens the review page Review page addresses and docs links are OSC 8 links in terminals known to show them (Ghostty, iTerm2, WezTerm, kitty, VS Code, ...), plain elsewhere; ROWBIT_HYPERLINKS overrides. rowbit demo opens the review page in the browser once it answers (--no-open skips). The MCP instructions and the skill ask agents to give the review link as a Markdown link. Signed-off-by: Ermag --- crates/rowbit-cli/src/demo.rs | 11 +- crates/rowbit-cli/src/main.rs | 6 +- crates/rowbit-cli/src/setup.rs | 12 ++- crates/rowbit-cli/src/term.rs | 141 ++++++++++++++++++++++++++ crates/rowbit-mcp/src/lib.rs | 4 +- docs/spec/setup.md | 8 +- plugins/rowbit/skills/rowbit/SKILL.md | 2 +- 7 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 crates/rowbit-cli/src/term.rs diff --git a/crates/rowbit-cli/src/demo.rs b/crates/rowbit-cli/src/demo.rs index dc7bcdf..a199537 100644 --- a/crates/rowbit-cli/src/demo.rs +++ b/crates/rowbit-cli/src/demo.rs @@ -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)] @@ -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) { @@ -347,6 +351,11 @@ pub async fn run(a: DemoArgs) -> anyhow::Result<()> { println!(); std::io::stdout().flush()?; + if !a.no_open && std::io::stdin().is_terminal() && std::io::stdout().is_terminal() { + say("Opening the review page in your browser (--no-open skips this)."); + println!(); + crate::term::open_when_up(address.clone()); + } std::env::set_current_dir(&dir)?; crate::serve( None, diff --git a/crates/rowbit-cli/src/main.rs b/crates/rowbit-cli/src/main.rs index d17d77e..ae7ebd0 100644 --- a/crates/rowbit-cli/src/main.rs +++ b/crates/rowbit-cli/src/main.rs @@ -4,6 +4,7 @@ mod grants; mod place; mod review; mod setup; +mod term; use std::path::PathBuf; @@ -399,7 +400,10 @@ 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"); } diff --git a/crates/rowbit-cli/src/setup.rs b/crates/rowbit-cli/src/setup.rs index 4939806..042118e 100644 --- a/crates/rowbit-cli/src/setup.rs +++ b/crates/rowbit-cli/src/setup.rs @@ -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] = [ @@ -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 { @@ -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)) diff --git a/crates/rowbit-cli/src/term.rs b/crates/rowbit-cli/src/term.rs new file mode 100644 index 0000000..692df25 --- /dev/null +++ b/crates/rowbit-cli/src/term.rs @@ -0,0 +1,141 @@ +//! 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 { + 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) -> 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::().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() +} + +/// Opens `url` in the browser once something answers there, from a +/// thread of its own, giving up after ten seconds. +pub fn open_when_up(url: String) { + std::thread::spawn(move || wait_and_open(&url, 50)); +} + +/// Opens `url` in the browser once something answers there, trying +/// `tries` times, 200ms apart. Returns whether it opened. +pub fn wait_and_open(url: &str, tries: u32) -> bool { + let Some(addr) = url + .strip_prefix("http://") + .and_then(|rest| rest.split('/').next()) + .and_then(|host| std::net::ToSocketAddrs::to_socket_addrs(host).ok()) + .and_then(|mut a| a.next()) + else { + return false; + }; + for _ in 0..tries { + if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)) + .is_ok() + { + return open_browser(url); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + '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 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\\" + ); + } +} diff --git a/crates/rowbit-mcp/src/lib.rs b/crates/rowbit-mcp/src/lib.rs index 6d71542..7172880 100644 --- a/crates/rowbit-mcp/src/lib.rs +++ b/crates/rowbit-mcp/src/lib.rs @@ -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 Markdown link, such as \ +[Review change 4](http://127.0.0.1:8080/changes/4), so they can click it, \ +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`."; diff --git a/docs/spec/setup.md b/docs/spec/setup.md index b094aa3..ff543b4 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -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: `/changes/`, or `http:///changes/` 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: `/changes/`, or `http:///changes/` 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 Markdown link so clients that render Markdown make it clickable, 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 once it answers, when run in a terminal; `--no-open` skips that. ## The skill diff --git a/plugins/rowbit/skills/rowbit/SKILL.md b/plugins/rowbit/skills/rowbit/SKILL.md index 00c2e0f..3a9c637 100644 --- a/plugins/rowbit/skills/rowbit/SKILL.md +++ b/plugins/rowbit/skills/rowbit/SKILL.md @@ -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 Markdown link** (`[Review change 4]()`), so they can click it, 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. From 7ba0df640500615c4c87cb6a685685b8ec795da2 Mon Sep 17 00:00:00 2001 From: Ermag Date: Sat, 7 Nov 2026 15:59:07 +0200 Subject: [PATCH 3/6] Files named on the command line or in the environment need no setup Signed-off-by: Ermag --- crates/rowbit-cli/src/place.rs | 14 +++++++++++++- docs/spec/setup.md | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/rowbit-cli/src/place.rs b/crates/rowbit-cli/src/place.rs index 7433aca..482a72c 100644 --- a/crates/rowbit-cli/src/place.rs +++ b/crates/rowbit-cli/src/place.rs @@ -193,8 +193,20 @@ fn pick(found: &[Found]) -> Picked<'_> { /// Whether the command reads a setup's files, so finding none is an error /// worth stopping for. `ping`, `policy`, `server` and `connect` with no -/// client name (it lists them) work without one. +/// client name (it lists them) work without one, and so does a command +/// told where its files are (`--policy`, `ROWBIT_STORE`, ...). fn needs_files(args: &[OsString]) -> bool { + let named = args.iter().any(|a| { + let a = a.to_string_lossy(); + ["--policy", "--store", "--config"] + .iter() + .any(|f| a == *f || a.starts_with(&format!("{f}="))) + }) || ["ROWBIT_POLICY", "ROWBIT_STORE", "ROWBIT_SERVER_CONFIG"] + .iter() + .any(|v| std::env::var_os(v).is_some()); + if named { + return false; + } let (command, _) = command(args); match command.as_deref() { None | Some("ping" | "policy" | "server" | "help") => false, diff --git a/docs/spec/setup.md b/docs/spec/setup.md index b094aa3..74ee9c1 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -82,7 +82,7 @@ It never runs a statement that changes the database or its users. Where somethin ## Where commands find their files -Every command but `demo` and `setup` takes `--dir ` (or `ROWBIT_DIR`), after the command's name, and then finds `rowbit.yml`, `rowbit-server.yml`, `rowbit.db` and the `env` file in that directory. Without it, a command uses the current directory if it holds a `rowbit.yml`, `rowbit-server.yml` or `rowbit.db`, and otherwise `~/.rowbit/default` if that holds a setup. Without that either, it looks at every setup in `~/.rowbit` and the demo (the one `~/.rowbit/demo` links to, which `rowbit demo` makes and `rowbit demo clean` removes, or else `~/rowbit-demo`): if there is one, it uses it and says which on standard error; if there are several, it lists them and asks for `--dir`; if there are none, it says to run `rowbit setup`. `ping`, `policy`, `server` and `rowbit connect` without a client work without a setup. So `rowbit connect claude-code` works from any project folder. `rowbit setup` refuses a name that is a link, so it never writes into the demo's directory. A flag or variable naming one file (`--policy`, `--store`, `--config` and their variables) still wins for that file. `--dir` before `demo` or `setup` is refused, since they have options of their own. +Every command but `demo` and `setup` takes `--dir ` (or `ROWBIT_DIR`), after the command's name, and then finds `rowbit.yml`, `rowbit-server.yml`, `rowbit.db` and the `env` file in that directory. Without it, a command uses the current directory if it holds a `rowbit.yml`, `rowbit-server.yml` or `rowbit.db`, and otherwise `~/.rowbit/default` if that holds a setup. Without that either, it looks at every setup in `~/.rowbit` and the demo (the one `~/.rowbit/demo` links to, which `rowbit demo` makes and `rowbit demo clean` removes, or else `~/rowbit-demo`): if there is one, it uses it and says which on standard error; if there are several, it lists them and asks for `--dir`; if there are none, it says to run `rowbit setup`. `ping`, `policy`, `server`, `rowbit connect` without a client, and a command given its files (`--policy`, `--store`, `--config` or their variables) work without one. So `rowbit connect claude-code` works from any project folder. `rowbit setup` refuses a name that is a link, so it never writes into the demo's directory. A flag or variable naming one file (`--policy`, `--store`, `--config` and their variables) still wins for that file. `--dir` before `demo` or `setup` is refused, since they have options of their own. The database URL is `--url` if given. Otherwise, in a setup's directory, it's the `env` file's, even when `ROWBIT_DATABASE_URL` is set: that may be left in the shell from the demo, and pairing a setup's state with another database would go unnoticed. Rowbit says so on standard error when the two differ. Without an `env` file, `ROWBIT_DATABASE_URL` is used, as in v0.2.1. Rowbit refuses an `env` file that others can read, since it holds the database's password. From 80f209a9c715648ad635cc8644102892ee2f540c Mon Sep 17 00:00:00 2001 From: Ermag Date: Sat, 7 Nov 2026 23:03:11 +0200 Subject: [PATCH 4/6] Review fixes: sign out one connection, keep the old entry until the new is written - rowbit agents lists each token with an id; rowbit agents revoke --connection signs out one, --single only the token from before 0.2.3. - A reconnect's old token is revoked only once the client entry holding the new one is written, so a failed connect leaves the old entry working. - Printed tokens are labelled with their time, so they don't replace one another. - --new-token revokes the single token from before 0.2.3; without it, connect says the agent still has one. - Marking a token used can't bring back a revoked connection's row. - needs_files finds connect's arguments wherever it sits; "here" compares canonical paths; a setup named demo isn't called the demo. Signed-off-by: Ermag --- crates/rowbit-cli/src/connect.rs | 62 ++++++++++++++---- crates/rowbit-cli/src/main.rs | 47 ++++++++++++-- crates/rowbit-cli/src/place.rs | 27 ++++++-- crates/rowbit-cli/tests/connect.rs | 18 +++++ crates/rowbit-cli/tests/serve.rs | 8 ++- crates/rowbit-store/src/lib.rs | 101 ++++++++++++++++++++++++----- crates/rowbit-store/src/tests.rs | 40 ++++++++++-- docs/connect.md | 3 +- docs/spec/server.md | 2 +- docs/spec/setup.md | 2 +- 10 files changed, 255 insertions(+), 55 deletions(-) diff --git a/crates/rowbit-cli/src/connect.rs b/crates/rowbit-cli/src/connect.rs index d414c6d..13166f8 100644 --- a/crates/rowbit-cli/src/connect.rs +++ b/crates/rowbit-cli/src/connect.rs @@ -765,9 +765,9 @@ pub struct ConnectArgs { /// Have the client start `rowbit mcp` itself, even with mcp.http on #[arg(long)] stdio: bool, - /// No longer needed: each client entry gets a token of its own, and - /// connecting the same place again replaces only that one - #[arg(long, hide = true)] + /// Also revoke the agent's single token from before 0.2.3 (each client + /// entry now gets a token of its own) + #[arg(long)] new_token: bool, /// Replace an entry of the same name in the client #[arg(long)] @@ -927,14 +927,17 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { } let mut label = connection_label(client, a.scope, &env.cwd); if a.print { - // Added by hand, maybe somewhere else: don't sign out an entry - // `rowbit connect` added here. - label.push_str(" (printed)"); + // Added by hand, maybe somewhere else: each printed token is a + // connection of its own, which replaces none. + label.push_str(&format!(" (printed {})", printed_at())); } + let mut issued = None; let server = if http { + let (token, id) = issue_token(&a, &agent, &label)?; + issued = Some(id); Server::Http { url: format!("{}/mcp", address.base()), - token: issue_token(&a, &agent, &label)?, + token, } } else { stdio_server(&a, &agent)? @@ -946,17 +949,36 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { let applied = match apply(client, &env, &a.name, &server, a.scope, a.force) { Ok(applied) => applied, Err(e) => { - if http { - // Nothing holds the new token; don't leave it valid. + if let Some(id) = &issued { + // Nothing holds the new token; don't leave it valid. An + // entry from before keeps its own. let _ = a .store .open() - .and_then(|mut s| Ok(s.revoke_connection_token(&agent, &label)?)); - eprintln!("rowbit: the new token for {label} is revoked"); + .and_then(|mut s| Ok(s.revoke_connection(&agent, id)?)); + eprintln!( + "rowbit: the new token is revoked; an entry that was there before still works" + ); } return Err(e); } }; + if let Some(id) = &issued { + let mut store = a.store.open()?; + // The entry holding the new token is written: the one it replaced + // goes. + store.retire_older_connections(&agent, &label, id)?; + if store.agent_token_times(&agent)?.is_some() { + if a.new_token { + store.revoke_single_token(&agent)?; + println!("{agent}'s single token from before is revoked."); + } else { + println!( + "{agent} also has a single token from before 0.2.3, which still works. If no client uses it any more: rowbit agents revoke {agent} --single" + ); + } + } + } if applied.tightened { println!( "{} was readable by other users; it is now yours only, since it holds the agent's credentials.", @@ -1001,7 +1023,10 @@ pub fn run(a: ConnectArgs) -> anyhow::Result> { // `rowbit demo` says where to open the client itself. _ if a.dir.is_some() => {} Client::ClaudeCode if a.scope == Scope::Local => { - let here = std::env::current_dir().ok().as_deref() == Some(env.cwd.as_path()); + let canonical = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_owned()); + let here = std::env::current_dir() + .ok() + .is_some_and(|d| canonical(&d) == canonical(&env.cwd)); if here { println!( "It applies in this folder only. Start Claude Code here, or restart it if it's open, and ask it to use Rowbit." @@ -1124,12 +1149,21 @@ fn pick_agent(a: &ConnectArgs) -> anyhow::Result { /// A token for this client entry: one per client and place, so connecting /// the agent in another project leaves the others signed in, and /// connecting the same place again replaces only its own. -fn issue_token(a: &ConnectArgs, agent: &str, label: &str) -> anyhow::Result { +fn issue_token(a: &ConnectArgs, agent: &str, label: &str) -> anyhow::Result<(String, String)> { let mut store = rowbit_store::Store::open(&a.store.store) .with_context(|| format!("opening {}", a.store.store.display()))?; Ok(store.issue_connection_token(agent, label)?) } +/// When a token was printed, to tell printed tokens apart. +fn printed_at() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + rowbit_engine::rfc3339(secs) +} + /// Which client, and where it uses Rowbit: "Claude Code in ~/app". pub fn connection_label(client: Client, scope: Scope, cwd: &Path) -> String { match (client, scope) { @@ -1186,7 +1220,7 @@ fn print_address(a: &ConnectArgs) -> anyhow::Result<()> { "turn on mcp.http in rowbit-server.yml for an address to give a client, or name a client for its stdio setup" ); let agent = pick_agent(a)?; - let token = issue_token(a, &agent, &format!("{} (printed)", a.name))?; + let (token, _) = issue_token(a, &agent, &format!("{} (printed {})", a.name, printed_at()))?; println!("URL: {}/mcp", address.base()); println!("Header: Authorization: Bearer {token}"); Ok(()) diff --git a/crates/rowbit-cli/src/main.rs b/crates/rowbit-cli/src/main.rs index d17d77e..4978764 100644 --- a/crates/rowbit-cli/src/main.rs +++ b/crates/rowbit-cli/src/main.rs @@ -506,8 +506,19 @@ fn mysql_database(url: &str) -> anyhow::Result { enum AgentsCommand { /// Make a new token for an agent, replacing any it had, and print it once Token { agent: String }, - /// Remove an agent's token - Revoke { agent: String }, + /// Sign an agent out: every token it has, or one + /// + /// `rowbit agents` lists each token with its id. + Revoke { + agent: String, + /// Only this connection's token, by the id `rowbit agents` shows + #[arg(long, conflicts_with = "single")] + connection: Option, + /// Only the single token from `rowbit agents token` (or + /// `rowbit connect` before 0.2.3) + #[arg(long)] + single: bool, + }, } #[derive(Subcommand)] @@ -801,10 +812,27 @@ async fn main() -> anyhow::Result<()> { ); println!("{token}"); } - Some(AgentsCommand::Revoke { agent }) => { + Some(AgentsCommand::Revoke { + agent, + connection, + single, + }) => { let mut store = store.open()?; - if store.revoke_agent_token(&agent)? { - println!("{agent}'s token is revoked"); + if let Some(id) = connection { + match store.revoke_connection(&agent, &id)? { + Some(label) => println!("{agent}'s token for {label} is revoked"), + None => anyhow::bail!( + "{agent} has no connection {id}; `rowbit agents` lists them with their ids" + ), + } + } else if single { + if store.revoke_single_token(&agent)? { + println!("{agent}'s single token is revoked"); + } else { + println!("{agent} had no single token"); + } + } else if store.revoke_agent_token(&agent)? { + println!("every token of {agent}'s is revoked"); } else { println!("{agent} had no token"); } @@ -827,20 +855,25 @@ async fn main() -> anyhow::Result<()> { println!("{agent}"); if let Some((created, last)) = manual { println!( - " the agent's own token (rowbit agents token, or rowbit connect before 0.2.3): made {}, {}", + " {:<10} single token (rowbit agents token, or rowbit connect before 0.2.3): made {}, {}", + "--single", review::when(created), used(last) ); } for t in mine { println!( - " {}: made {}, {}", + " {:<10} {}: made {}, {}", + t.id, t.label, review::when(t.created_ms), used(t.used_ms) ); } } + println!( + "\nSign one out: rowbit agents revoke --connection (or --single); all of an agent's: rowbit agents revoke " + ); } } } diff --git a/crates/rowbit-cli/src/place.rs b/crates/rowbit-cli/src/place.rs index 482a72c..bf30d52 100644 --- a/crates/rowbit-cli/src/place.rs +++ b/crates/rowbit-cli/src/place.rs @@ -115,7 +115,7 @@ pub struct Found { impl Found { pub fn describe(&self) -> String { let shown = tilde(&self.dir); - if self.name == "demo" { + if self.name == "demo" && crate::demo::is_demo(&self.dir) { format!("the demo in {shown}") } else { format!("{} in {shown}", self.name) @@ -210,10 +210,27 @@ fn needs_files(args: &[OsString]) -> bool { let (command, _) = command(args); match command.as_deref() { None | Some("ping" | "policy" | "server" | "help") => false, - Some("connect") => args - .iter() - .skip(2) - .any(|a| a == "--print" || !a.to_string_lossy().starts_with('-')), + Some("connect") => { + // What follows the command's name: a client, or --print. + let after = args + .iter() + .skip(1) + .skip_while(|a| a.to_string_lossy() != "connect") + .skip(1); + let mut after = after.peekable(); + let mut needs = false; + while let Some(a) = after.next() { + let a = a.to_string_lossy(); + if a == "--print" { + needs = true; + } else if a == "--agent" || a == "--name" || a == "--scope" || a == "--url" { + after.next(); + } else if !a.starts_with('-') { + needs = true; + } + } + needs + } Some(_) => !args.iter().any(|a| a == "--help" || a == "-h"), } } diff --git a/crates/rowbit-cli/tests/connect.rs b/crates/rowbit-cli/tests/connect.rs index e77595a..feab8a5 100644 --- a/crates/rowbit-cli/tests/connect.rs +++ b/crates/rowbit-cli/tests/connect.rs @@ -321,6 +321,24 @@ fn a_project_folder_finds_the_setup_in_rowbit_home() { ); let (_, agents) = in_app(&["agents"]); assert!(agents.contains("Claude Code in "), "{agents}"); + // Reconnecting the same place leaves one token for it. + let (ok, out) = in_app(&["connect", "claude-code", "--force"]); + assert!(ok, "{out}"); + let (_, agents) = in_app(&["agents"]); + assert_eq!(agents.matches(": made").count(), 1, "{agents}"); + // And that one can be signed out on its own, by its id. + let id = agents + .lines() + .find(|l| l.contains("Claude Code in ")) + .and_then(|l| l.split_whitespace().next()) + .unwrap() + .to_owned(); + let (ok, out) = in_app(&["agents", "revoke", "coding-agent", "--connection", &id]); + assert!(ok && out.contains("Claude Code in "), "{out}"); + let (_, agents) = in_app(&["agents"]); + assert!(agents.contains("coding-agent no token"), "{agents}"); + let (ok, out) = in_app(&["connect", "claude-code", "--force"]); + assert!(ok, "{out}"); // Two, and neither the default: Rowbit asks which. std::fs::create_dir_all(c.home.join(".rowbit/other")).unwrap(); diff --git a/crates/rowbit-cli/tests/serve.rs b/crates/rowbit-cli/tests/serve.rs index a0f90eb..5460859 100644 --- a/crates/rowbit-cli/tests/serve.rs +++ b/crates/rowbit-cli/tests/serve.rs @@ -75,12 +75,16 @@ fn agent_tokens_are_shown_once_and_revocable() { assert_eq!(token.len(), 47); let (ok, out) = rowbit(&["agents"], &d); assert!( - ok && out.contains("refund-bot\n the agent's own token") && out.contains("never used"), + ok && out.contains("refund-bot\n --single single token") && out.contains("never used"), "{out}" ); assert!(!out.contains(&token)); + let (ok, out) = rowbit(&["agents", "revoke", "refund-bot", "--connection", "abcdef12"], &d); + assert!(!ok && out.contains("no connection"), "{out}"); + let (ok, out) = rowbit(&["agents", "revoke", "refund-bot", "--single"], &d); + assert!(ok && out.contains("single token is revoked"), "{out}"); let (ok, out) = rowbit(&["agents", "revoke", "refund-bot"], &d); - assert!(ok && out.contains("revoked"), "{out}"); + assert!(ok && out.contains("had no token"), "{out}"); let (_, out) = rowbit(&["agents"], &d); assert!(out.contains("refund-bot no token"), "{out}"); } diff --git a/crates/rowbit-store/src/lib.rs b/crates/rowbit-store/src/lib.rs index 8f0846f..f8b06c5 100644 --- a/crates/rowbit-store/src/lib.rs +++ b/crates/rowbit-store/src/lib.rs @@ -144,6 +144,10 @@ pub enum DeliveryOutcome { /// one per place it is connected, each labelled with where it is. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectionToken { + /// The first characters of the token's hash, which name it in + /// `rowbit agents revoke --connection`. Not stored; filled in on reading. + #[serde(skip)] + pub id: String, pub agent: String, /// Which client, and where: "Claude Code in ~/projects/app". pub label: String, @@ -152,6 +156,8 @@ pub struct ConnectionToken { } const CONNECTION: &str = "connection."; +/// How many characters of a connection token's hash name it. +pub const CONNECTION_ID_LEN: usize = 8; /// Every connection token, by its hash. fn connection_tokens(conn: &Connection) -> Result, StoreError> { @@ -164,9 +170,11 @@ fn connection_tokens(conn: &Connection) -> Result let mut out = Vec::new(); for row in rows { let (key, value) = row?; - let t: ConnectionToken = + let mut t: ConnectionToken = serde_json::from_str(&value).map_err(|e| StoreError::Corrupt(format!("{key}: {e}")))?; - out.push((key[CONNECTION.len()..].to_owned(), t)); + let hash = key[CONNECTION.len()..].to_owned(); + t.id = hash.chars().take(CONNECTION_ID_LEN).collect(); + out.push((hash, t)); } Ok(out) } @@ -706,14 +714,16 @@ impl Store { Ok(had || had_connections) } - /// Makes a new MCP token for one client entry of `agent`'s, replacing - /// the one with the same label, and returns it. The agent's other - /// tokens keep working. Only its SHA-256 is kept. + /// Makes a new MCP token for one client entry of `agent`'s and returns + /// it with its id. Older tokens with the same label keep working until + /// [`Store::retire_older_connections`], so a client entry that fails + /// to be written leaves the one before it signed in. Only the token's + /// SHA-256 is kept. pub fn issue_connection_token( &mut self, agent: &str, label: &str, - ) -> Result { + ) -> Result<(String, String), StoreError> { use base64::Engine as _; let mut bytes = [0u8; 32]; getrandom::fill(&mut bytes).expect("the system has a random source"); @@ -725,8 +735,8 @@ impl Store { let tx = self .conn .transaction_with_behavior(TransactionBehavior::Immediate)?; - revoke_connections(&tx, |t| t.agent == agent && t.label == label)?; let info = ConnectionToken { + id: String::new(), agent: agent.to_owned(), label: label.to_owned(), created_ms: now_ms(), @@ -742,20 +752,70 @@ impl Store { ], )?; tx.commit()?; - Ok(token) + Ok((token, hash.chars().take(CONNECTION_ID_LEN).collect())) } - /// Removes the token of `agent`'s connection with this label. Returns - /// whether there was one. - pub fn revoke_connection_token( + /// Removes `agent`'s tokens with this label other than the one whose id + /// is `keep`: once the client entry holding `keep` is written. + pub fn retire_older_connections( &mut self, agent: &str, label: &str, + keep: &str, ) -> Result { let tx = self .conn .transaction_with_behavior(TransactionBehavior::Immediate)?; - let had = revoke_connections(&tx, |t| t.agent == agent && t.label == label)?; + let mut any = false; + for (hash, t) in connection_tokens(&tx)? { + if t.agent == agent && t.label == label && !hash.starts_with(keep) { + tx.execute( + "DELETE FROM settings WHERE key IN (?1, ?2)", + params![format!("token.{hash}"), format!("{CONNECTION}{hash}")], + )?; + any = true; + } + } + tx.commit()?; + Ok(any) + } + + /// Removes the one connection token of `agent`'s whose id is `id` (at + /// least its first [`CONNECTION_ID_LEN`] characters). Returns its label + /// if there was one. + pub fn revoke_connection( + &mut self, + agent: &str, + id: &str, + ) -> Result, StoreError> { + if id.len() < CONNECTION_ID_LEN { + return Ok(None); + } + let id = id.to_ascii_lowercase(); + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let found = connection_tokens(&tx)? + .into_iter() + .find(|(hash, t)| t.agent == agent && hash.starts_with(&id)); + if let Some((hash, _)) = &found { + tx.execute( + "DELETE FROM settings WHERE key IN (?1, ?2)", + params![format!("token.{hash}"), format!("{CONNECTION}{hash}")], + )?; + } + tx.commit()?; + Ok(found.map(|(_, t)| t.label)) + } + + /// Removes only the token `rowbit agents token` (or `rowbit connect` + /// before 0.2.3) made for `agent`, leaving its connections. Returns + /// whether it had one. + pub fn revoke_single_token(&mut self, agent: &str) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let had = revoke_token(&tx, agent)?; tx.commit()?; Ok(had) } @@ -766,7 +826,9 @@ impl Store { .into_iter() .map(|(_, t)| t) .collect(); - out.sort_by(|a, b| (&a.agent, &a.label).cmp(&(&b.agent, &b.label))); + out.sort_by(|a, b| { + (&a.agent, &a.label, a.created_ms).cmp(&(&b.agent, &b.label, b.created_ms)) + }); Ok(out) } @@ -778,11 +840,14 @@ impl Store { return Ok(None); }; let key = format!("{CONNECTION}{hash}"); - if let Some(value) = self.setting(&key)? { - let mut info: ConnectionToken = serde_json::from_str(&value) - .map_err(|e| StoreError::Corrupt(format!("{key}: {e}")))?; - info.used_ms = Some(now_ms()); - self.set_setting(&key, &serde_json::to_string(&info).expect("serializes"))?; + if self.setting(&key)?.is_some() { + // One statement, and only while the token still exists, so a + // revoke in another process never sees its row come back. + self.conn.execute( + "UPDATE settings SET value = json_set(value, '$.used_ms', ?1) + WHERE key = ?2 AND EXISTS (SELECT 1 FROM settings WHERE key = ?3)", + params![now_ms(), key, format!("token.{hash}")], + )?; return Ok(Some(agent)); } self.set_setting( diff --git a/crates/rowbit-store/src/tests.rs b/crates/rowbit-store/src/tests.rs index 86a4163..a6e74cf 100644 --- a/crates/rowbit-store/src/tests.rs +++ b/crates/rowbit-store/src/tests.rs @@ -550,12 +550,13 @@ fn agent_tokens_are_kept_as_hashes_and_replaced() { fn each_connection_has_a_token_of_its_own() { let mut store = Store::open_in_memory().unwrap(); let manual = store.issue_agent_token("bot").unwrap(); - let app = store + let (app, app_id) = store .issue_connection_token("bot", "Claude Code in ~/app") .unwrap(); - let web = store + let (web, web_id) = store .issue_connection_token("bot", "Claude Code in ~/web") .unwrap(); + assert_eq!(app_id.len(), CONNECTION_ID_LEN); // Each works, and none signs another out. for t in [&manual, &app, &web] { assert_eq!(store.agent_for_token(t).unwrap().as_deref(), Some("bot")); @@ -565,12 +566,20 @@ fn each_connection_has_a_token_of_its_own() { list.iter().map(|t| t.label.as_str()).collect::>(), ["Claude Code in ~/app", "Claude Code in ~/web"] ); + assert_eq!(list[0].id, app_id); assert!(list.iter().all(|t| t.used_ms.is_some())); - // Connecting the same place again replaces only that one. - let app2 = store + // Connecting the same place again: both work until the new entry is + // written, then only the new one. + let (app2, app2_id) = store .issue_connection_token("bot", "Claude Code in ~/app") .unwrap(); + assert_eq!(store.agent_for_token(&app).unwrap().as_deref(), Some("bot")); + assert!( + store + .retire_older_connections("bot", "Claude Code in ~/app", &app2_id) + .unwrap() + ); assert_eq!(store.agent_for_token(&app).unwrap(), None); assert_eq!( store.agent_for_token(&app2).unwrap().as_deref(), @@ -579,14 +588,33 @@ fn each_connection_has_a_token_of_its_own() { assert_eq!(store.agent_for_token(&web).unwrap().as_deref(), Some("bot")); assert_eq!(store.connection_tokens().unwrap().len(), 2); + // One connection, by its id; too short an id names none. + assert_eq!(store.revoke_connection("bot", &web_id[..4]).unwrap(), None); + assert_eq!(store.revoke_connection("other", &web_id).unwrap(), None); + assert_eq!( + store.revoke_connection("bot", &web_id).unwrap().as_deref(), + Some("Claude Code in ~/web") + ); + assert_eq!(store.agent_for_token(&web).unwrap(), None); + // A revoked token's row doesn't come back when it is tried. assert!( store - .revoke_connection_token("bot", "Claude Code in ~/web") + .connection_tokens() .unwrap() + .iter() + .all(|t| t.id != web_id) + ); + + // The single token alone, leaving the connections. + assert!(store.revoke_single_token("bot").unwrap()); + assert_eq!(store.agent_for_token(&manual).unwrap(), None); + assert_eq!( + store.agent_for_token(&app2).unwrap().as_deref(), + Some("bot") ); - assert_eq!(store.agent_for_token(&web).unwrap(), None); // Revoking the agent signs out every one. + let manual = store.issue_agent_token("bot").unwrap(); assert!(store.revoke_agent_token("bot").unwrap()); assert_eq!(store.agent_for_token(&manual).unwrap(), None); assert_eq!(store.agent_for_token(&app2).unwrap(), None); diff --git a/docs/connect.md b/docs/connect.md index b226979..e8f4bbd 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -35,7 +35,8 @@ With `mcp.http: true` in `rowbit-server.yml` (the demo has it), the client gets `rowbit connect` keeps a backup of any file it changes (`.rowbit-backup`), refuses a file it can't parse, and replaces nothing without being told: - `--force` replaces an entry of the same name; `--name ` adds another. -- Each client entry gets a token of its own, named for the client and, for Claude Code, the project: "Claude Code in ~/projects/my-app". Connecting the same place again (with `--force`) replaces only that one. `rowbit agents` lists them, and `rowbit agents revoke ` signs out all of an agent's. +- Each client entry gets a token of its own, named for the client and, for Claude Code, the project: "Claude Code in ~/projects/my-app". Connecting the same place again (with `--force`) replaces only that one, once the new entry is written. `rowbit agents` lists them with an id each; `rowbit agents revoke --connection ` signs out one, and `rowbit agents revoke ` all of them. Tokens don't expire, so revoke the ones for projects you've moved or deleted. +- `--new-token` also revokes the agent's single token from before 0.2.3, once no client uses it; without it, `rowbit connect` says when the agent still has one. - `--agent ` picks the agent when `rowbit.yml` lists more than one. - `--print` changes nothing in the client and prints what to add by hand, with the token. diff --git a/docs/spec/server.md b/docs/spec/server.md index 67c9228..0a41585 100644 --- a/docs/spec/server.md +++ b/docs/spec/server.md @@ -114,7 +114,7 @@ With `mcp.http: true`, `rowbit serve` also serves agents at `/mcp` o Each agent signs in with a token of its own, sent as `Authorization: Bearer `: -- `rowbit agents token ` makes a new token for an agent listed in `rowbit.yml`, prints it once, and keeps only its SHA-256 hash in the state file. Making a new one replaces the old. `rowbit connect` makes one per client entry instead, labelled with where it is (see [rowbit connect](setup.md#rowbit-connect)), so an agent can be connected in several places at once. `rowbit agents revoke ` removes all of the agent's tokens. `rowbit agents` lists each agent's tokens and when each was last used. +- `rowbit agents token ` makes a new token for an agent listed in `rowbit.yml`, prints it once, and keeps only its SHA-256 hash in the state file. Making a new one replaces the old. `rowbit connect` makes one per client entry instead, labelled with where it is (see [rowbit connect](setup.md#rowbit-connect)), so an agent can be connected in several places at once. `rowbit agents` lists each agent's tokens, each with an id and when it was last used. `rowbit agents revoke ` removes all of the agent's tokens, `--connection ` one of them, and `--single` only the one `rowbit agents token` made. - Tokens are 32 random bytes, written as `rbt_` and 43 characters of base64url. - The agent's identity is the token's agent, never anything in the request. A missing, unknown or revoked token, or a token for an agent no longer in `rowbit.yml`, gets `401` and nothing else. Failed sign-ins are slowed down as on the review page. - `/mcp` answers only requests addressed to the configured host, and refuses any request with an `Origin` header that isn't `public_url`, so a web page can't reach it from someone's browser. diff --git a/docs/spec/setup.md b/docs/spec/setup.md index 74ee9c1..4e38102 100644 --- a/docs/spec/setup.md +++ b/docs/spec/setup.md @@ -153,7 +153,7 @@ Compose names the project after the directory, so two demo directories with the - **The agent** is `--agent `, or the only agent in `rowbit.yml`. With more than one and no `--agent`, Rowbit lists their ids and stops. - **Over HTTP** when `rowbit-server.yml` has `mcp.http: true`. The address is `/mcp`, or `http:///mcp` without a `public_url`. Rowbit makes the agent a token as `rowbit agents token` does and writes it into the client's configuration as `Authorization: Bearer `; the client's machine gets nothing else. If nothing answers at the address, it says to start `rowbit serve`. - **Over stdio** otherwise, with `--stdio`, and always for Claude Desktop. The client starts `rowbit mcp --agent ` with full paths to the binary, `rowbit.yml` and `rowbit.db`, and `ROWBIT_DATABASE_URL` from the environment, or from `.rowbit-demo` in the demo directory. Rowbit prints that the client's configuration now holds the database URL and points at the state file, and that anyone who can read them can act around Rowbit (see [Before you use it for real](../quickstart.md#before-you-use-it-for-real)). -- **One token per client entry.** Over HTTP, each entry gets a token of its own, labelled with the client and, for Claude Code's local scope, the directory: "Claude Code in ~/projects/app", "Claude Code, every project", "Cursor". A new token for the same agent and label replaces the old one; the agent's other tokens keep working. `--print` labels its token "(printed)", so it never signs out an entry `rowbit connect` added. `--new-token`, which v0.2.2 needed to replace an agent's only token, is accepted and does nothing. +- **One token per client entry.** Over HTTP, each entry gets a token of its own, labelled with the client and, for Claude Code's local scope, the directory: "Claude Code in ~/projects/app", "Claude Code, every project", "Cursor". A new token for the same agent and label replaces the old one, which is revoked only once the client's entry is written, so a failed `connect` leaves the entry that was there working; the agent's other tokens keep working. `--print` labels its token "(printed