chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A private repository's owner can cancel its runs
.changeset/space-cancel.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: patch | ||
--- | ||
| ||
# Owners of private repositories can cancel their runs | ||
| ||
`knotless run cancel` (and `run_cancel`) on a private repository was refused, `Scope does not permit create in at://…/space/dev.knotless.space/…`, because the private repository's space type did not list `dev.knotless.cancel`, and a PDS grants a login only the collections it lists. It lists it now: once knotless publishes its lexicons with it, log in again to allow it. A refusal like that, from a PDS that did not grant what your login asks for, no longer says your login is older than what knotless asks for: it says the PDS did not grant it, and why. | ||
crates/knotless/src/cli.rs+64−23
| @@ -48,39 +48,48 @@ fn runtime() -> tokio::runtime::Runtime { | ||
.expect("a current-thread runtime always builds") | ||
} | ||
| ||
fn report(name: &str, res: Result<()>) -> i32 { | ||
fn report(ctx: &Ctx, name: &str, res: Result<()>) -> i32 { | ||
match res { | ||
Ok(()) => 0, | ||
Err(err) => { | ||
eprintln!("{}", failed(name, &err)); | ||
eprintln!("{}", failed(ctx, name, &err)); | ||
1 | ||
} | ||
} | ||
} | ||
| ||
/// What `name` says of `err`: with, for a PDS refusing a call for want of | ||
/// scope, how to grant it, as a login from before knotless asked for it | ||
/// is refused. | ||
fn failed(name: &str, err: &anyhow::Error) -> String { | ||
/// scope, how to grant it. A login from before knotless asked for it logs | ||
/// in again; one that asked, but was not granted it, is told so. | ||
fn failed(ctx: &Ctx, name: &str, err: &anyhow::Error) -> String { | ||
let said = format!("{name}: {err:#}"); | ||
match oauth::out_of_scope(err) { | ||
true => format!("{said}\n{name}: {BEHIND}: run `knotless login` again to allow this"), | ||
false => said, | ||
if !oauth::out_of_scope(err) { | ||
return said; | ||
} | ||
let why = match (oauth::login_behind(ctx).is_some(), oauth::in_space(err)) { | ||
(true, _) => format!("{BEHIND}: run `knotless login` again to allow this"), | ||
(false, true) => format!("{NOT_GRANTED}: {NOT_LISTED}"), | ||
(false, false) => NOT_GRANTED.to_string(), | ||
}; | ||
format!("{said}\n{name}: {why}") | ||
} | ||
| ||
/// What is said of a login asked for less than knotless asks for now. | ||
const BEHIND: &str = "your login is older than what knotless asks for now"; | ||
/// What is said of a refusal for want of scope a login asked for. | ||
const NOT_GRANTED: &str = "your login asks for this, but your PDS did not grant it"; | ||
/// Why a PDS did not grant a write in a space a login asked for. | ||
const NOT_LISTED: &str = "a PDS grants writes in a space as the lexicon knotless publishes for its type listed them when you logged in, and that left this one out; once knotless's lexicon lists it, run `knotless login` again"; | ||
| ||
/// `git-remote-knotless <remote> <url>`, run by git. | ||
pub fn helper_main(args: Vec<String>) -> i32 { | ||
let ctx = Ctx::new(Env::from_process()); | ||
let res = runtime().block_on(async { | ||
let Some(url) = args.get(2).or(args.get(1)) else { | ||
bail!("usage: git-remote-knotless <remote> <url>"); | ||
}; | ||
let url = RepoUrl::parse(url)?; | ||
let ctx = Ctx::new(Env::from_process()); | ||
let mut backend = Knotless::new(ctx, &url, Git::from_env()).await?; | ||
let mut backend = Knotless::new(ctx.clone(), &url, Git::from_env()).await?; | ||
// git passes the URL twice for a URL given on the command line. | ||
if let [_, remote, given] = &args[..] | ||
&& remote != given | ||
| @@ -92,9 +101,9 @@ pub fn helper_main(args: Vec<String>) -> i32 { | ||
helper::run(&mut backend, stdin, tokio::io::stdout()).await | ||
}); | ||
if res.is_err() { | ||
crate::identity::forget_gone(&Env::from_process().cache_dir); | ||
crate::identity::forget_gone(&ctx.env.cache_dir); | ||
} | ||
report("git-remote-knotless", res) | ||
report(&ctx, "git-remote-knotless", res) | ||
} | ||
| ||
const USAGE: &str = "usage: knotless <command>, or knotless <group> <action> ... | ||
| @@ -552,7 +561,7 @@ pub fn main_with( | ||
if let Some(newer) = known.notice().filter(|_| !told) { | ||
eprintln!("knotless: {newer}"); | ||
} | ||
report("knotless", res) | ||
report(&ctx, "knotless", res) | ||
} | ||
| ||
/// What `args`, a command but logging in or out, says on its last line | ||
| @@ -561,15 +570,14 @@ pub fn main_with( | ||
/// logs in again. | ||
fn login_behind(ctx: &Ctx, args: &[String]) -> Option<String> { | ||
let logging_in = matches!(args.get(1).map(String::as_str), Some("login" | "logout")); | ||
let named = ctx.env.identifier.clone().filter(|i| i.starts_with("did:")); | ||
let me = named.or_else(|| std::fs::read_to_string(ctx.env.config_dir.join("me")).ok())?; | ||
let session = session::load(ctx, me.trim())?; | ||
let session = oauth::login_behind(ctx).filter(|_| !logging_in)?; | ||
let who = [&session.handle, &session.did] | ||
.into_iter() | ||
.find(|w| !w.is_empty()); | ||
let who = who.cloned().unwrap_or_default(); | ||
let said = format!("knotless: {BEHIND}: run `knotless login {who}` again to allow all of it\n"); | ||
(!logging_in && oauth::behind(&session, &oauth::SCOPE)).then_some(said) | ||
Some(format!( | ||
"knotless: {BEHIND}: run `knotless login {who}` again to allow all of it\n" | ||
)) | ||
} | ||
| ||
/// `knotless org`: invitations, joining and leaving, and who is in. | ||
| @@ -2933,14 +2941,47 @@ mod tests { | ||
} | ||
| ||
#[test] | ||
fn refusals_for_want_of_scope_say_to_log_in_again() { | ||
let refused = anyhow::anyhow!("InvalidToken (403): Scope does not permit create"); | ||
let said = failed("knotless", &refused); | ||
fn refusals_for_want_of_scope_say_how_to_grant_it() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let config = dir.path().to_string_lossy().into_owned(); | ||
let ctx = Ctx::new(Env::from_vars(|k| match k { | ||
"KNOTLESS_CONFIG_DIR" => Some(config.clone()), | ||
"KNOTLESS_IDENTIFIER" => Some("did:plc:a".into()), | ||
_ => None, | ||
})); | ||
let mut s: session::Session = serde_json::from_value(json!({ | ||
"did": "did:plc:a", "handle": "a.test", "accessJwt": "", "refreshJwt": "", | ||
"oauth": {"key": "", "issuer": "", "tokenEndpoint": "", "expiresAt": 0, "scope": "atproto"} | ||
})) | ||
.unwrap(); | ||
session::save(&ctx, &s).unwrap(); | ||
let space = "InvalidToken (403): Scope does not permit create in at://did:plc:a/space/dev.knotless.space/r"; | ||
let refused = anyhow::anyhow!("{space}"); | ||
// A login from before knotless asked for it logs in again. | ||
let said = failed(&ctx, "knotless", &refused); | ||
assert!( | ||
said.ends_with(&format!( | ||
"knotless: {BEHIND}: run `knotless login` again to allow this" | ||
)), | ||
"{said}" | ||
); | ||
// One that asked for it is not told it is older: its PDS did not | ||
// grant the write, as the space type's lexicon left it out. | ||
s.oauth.as_mut().unwrap().scope = Some(oauth::SCOPE.clone()); | ||
session::save(&ctx, &s).unwrap(); | ||
let said = failed(&ctx, "knotless", &refused); | ||
assert!(!said.contains(BEHIND), "{said}"); | ||
assert!( | ||
said.ends_with(&format!("knotless: {NOT_GRANTED}: {NOT_LISTED}")), | ||
"{said}" | ||
); | ||
let rpc = anyhow::anyhow!("ScopeMissingError (403): no"); | ||
let said = failed(&ctx, "knotless", &rpc); | ||
assert!( | ||
said.ends_with("run `knotless login` again to allow this"), | ||
said.ends_with(&format!("knotless: {NOT_GRANTED}")), | ||
"{said}" | ||
); | ||
let said = failed("knotless", &anyhow::anyhow!("no such repository")); | ||
let said = failed(&ctx, "knotless", &anyhow::anyhow!("no such repository")); | ||
assert_eq!(said, "knotless: no such repository"); | ||
} | ||
| ||
crates/knotless/src/oauth.rs+60−1
| @@ -197,6 +197,12 @@ pub fn behind(session: &Session, asked: &str) -> bool { | ||
!kept.as_deref().is_some_and(same) | ||
} | ||
| ||
/// The session of the account `ctx` acts as, as it was kept, when it is | ||
/// [`behind`] what knotless asks for now: logging in again grants the rest. | ||
pub fn login_behind(ctx: &Ctx) -> Option<Session> { | ||
session::kept_me(ctx).filter(|s| behind(s, &SCOPE)) | ||
} | ||
| ||
/// The scope the loopback client ID `oauth` was granted to names. | ||
fn loopback_scope(oauth: &OAuth) -> Option<String> { | ||
let id = Url::parse(oauth.loopback_id.as_deref()?).ok()?; | ||
| @@ -217,6 +223,15 @@ pub fn out_of_scope(err: &impl std::fmt::Display) -> bool { | ||
said.contains("Scope does not permit") || said.contains("ScopeMissingError") | ||
} | ||
| ||
/// Whether `err`, a refusal for want of scope ([`out_of_scope`]), was of a | ||
/// write in a space. A PDS grants a login that asks for a space type by | ||
/// name the collections the type's lexicon lists as it logs in, and no | ||
/// other: a collection that lexicon leaves out is refused, whatever the | ||
/// login asked, until it lists it and the account logs in again. | ||
pub fn in_space(err: &impl std::fmt::Display) -> bool { | ||
out_of_scope(err) && format!("{err:#}").contains("/space/") | ||
} | ||
| ||
/// The loopback client, which names its scope and redirect in its ID. The | ||
/// redirect leaves out the port, so any port the listener gets matches. | ||
pub fn client_id() -> String { | ||
| @@ -945,6 +960,13 @@ mod tests { | ||
assert!(!out_of_scope(&anyhow::anyhow!( | ||
"Forbidden (403): not yours" | ||
))); | ||
// A write in a space, refused as the space type's lexicon leaves | ||
// its collection out; not a call that names no space. | ||
assert!(in_space(&err)); | ||
assert!(!in_space(&"ScopeMissingError (403): no")); | ||
assert!(!in_space( | ||
&"Forbidden (403): not in at://did:plc:a/space/x/self" | ||
)); | ||
} | ||
| ||
#[test] | ||
| @@ -1004,7 +1026,7 @@ mod tests { | ||
// The PDS grants each type's collections, so they go unnamed. | ||
assert!(!SCOPE.contains("collection=")); | ||
assert!(SCOPE.ends_with(" space:dev.knotless.space?authority=*&manage=create&manage=update&manage=delete space:dev.knotless.orgSpace?authority=*&manage=create&manage=update&manage=delete")); | ||
assert!(SPELLED_OUT.contains("&collection=dev.knotless.rerun&collection=dev.knotless.orgMember&collection=dev.knotless.site&manage=create&manage=update&manage=delete")); | ||
assert!(SPELLED_OUT.contains("&collection=dev.knotless.rerun&collection=dev.knotless.cancel&collection=dev.knotless.orgMember&collection=dev.knotless.site&manage=create&manage=update&manage=delete")); | ||
assert!(SPELLED_OUT.ends_with(" space:dev.knotless.orgSpace?authority=*&collection=dev.knotless.orgMember&collection=dev.knotless.orgAcceptance&manage=create&manage=update&manage=delete")); | ||
assert!(SCOPE.contains(" repo:dev.knotless.pull ")); | ||
assert!(SCOPE.contains(" repo:dev.knotless.check ")); | ||
| @@ -1020,4 +1042,41 @@ mod tests { | ||
assert_eq!(SCOPE.matches(" blob:image/png ").count(), 1); | ||
assert!(!SCOPE.contains("transition:")); | ||
} | ||
| ||
/// A PDS grants a space scope that names its type alone the | ||
/// collections the type's lexicon lists, and refuses a write in any | ||
/// other, whatever the login asked: each space type's lexicon lists | ||
/// every collection knotless writes there, the gateway's included. | ||
#[test] | ||
fn space_lexicons_list_every_collection_written() { | ||
let gateway: Vec<&str> = (gateway::SCOPE.split(' ')) | ||
.filter_map(|s| s.strip_prefix("space:")) | ||
.collect(); | ||
for lexicon in [SPACE_LEXICON, ORG_SPACE_LEXICON] { | ||
let kind = space_type(lexicon); | ||
let doc: Value = serde_json::from_str(lexicon).unwrap(); | ||
let listed: Vec<&str> = doc["defs"]["main"]["collections"] | ||
.as_array() | ||
.unwrap() | ||
.iter() | ||
.filter_map(Value::as_str) | ||
.collect(); | ||
let written = crate::space::collections(&kind).unwrap(); | ||
let missing: Vec<_> = written.iter().filter(|c| !listed.contains(c)).collect(); | ||
assert!( | ||
missing.is_empty(), | ||
"{kind}'s lexicon leaves out {missing:?}" | ||
); | ||
let prefix = format!("{kind}?"); | ||
let granted = gateway | ||
.iter() | ||
.filter_map(|s| s.strip_prefix(prefix.as_str())); | ||
let named = | ||
granted.flat_map(|s| s.split('&').filter_map(|p| p.strip_prefix("collection="))); | ||
for collection in named { | ||
assert!(written.contains(&collection), "{collection} in {kind}"); | ||
} | ||
} | ||
assert_eq!(crate::space::collections("dev.other.space"), None); | ||
} | ||
} | ||
crates/knotless/src/records.rs+13−0
| @@ -85,6 +85,19 @@ pub mod nsid { | ||
/// A repository's website, in its owner's account, keyed by the | ||
/// repository's key and replaced on each deploy. | ||
pub const SITE: &str = "dev.knotless.site"; | ||
/// Every collection knotless writes in a private repository's space: | ||
/// its owner's records of it and their requests of its runners, what | ||
/// its members write about it, its runners' checks, its organization's | ||
/// record of each member, and its site. `dev.knotless.space`'s lexicon | ||
/// lists each, as a PDS grants a login only the collections it lists. | ||
pub const SPACE: [&str; 21] = [ | ||
REPO, REF, CLAIM, PACK, REDIRECT, PULL, ISSUE, COMMENT, STATE, HIDE, BLOCK, QUEUE, LABEL, | ||
ASSIGNMENT, AUTO_MERGE, REACTION, CHECK, RERUN, CANCEL, ORG_MEMBER, SITE, | ||
]; | ||
/// Every collection knotless writes in an organization's space: its | ||
/// invitations and hidden members, and its members' joining. | ||
/// `dev.knotless.orgSpace`'s lexicon lists each. | ||
pub const ORG_SPACE: [&str; 2] = [ORG_MEMBER, ORG_ACCEPTANCE]; | ||
} | ||
| ||
pub const PACK_MIME: &str = "application/x-git-packed-objects"; | ||
crates/knotless/src/remote.rs+10−0
| @@ -639,7 +639,17 @@ impl Remote { | ||
pub async fn apply(&self, mut writes: Vec<Value>, auth: &Auth) -> Result<()> { | ||
let (nsid, body) = match &self.space { | ||
Some(space) => { | ||
// A PDS grants a login only the collections its space type's | ||
// lexicon lists, and that lists each of `space::collections`. | ||
let parsed = SpaceRef::parse(&space.uri).ok(); | ||
let declared = parsed.and_then(|s| space::collections(&s.space_type)); | ||
let listed = |c: &str| declared.is_none_or(|d| d.contains(&c)); | ||
for write in &mut writes { | ||
let collection = write["collection"].as_str().unwrap_or_default(); | ||
debug_assert!( | ||
listed(collection), | ||
"{collection} is not in its space's list" | ||
); | ||
let kind = write["$type"].as_str().unwrap_or_default(); | ||
write["$type"] = kind | ||
.replace("com.atproto.repo.", "com.atproto.space.") | ||
crates/knotless/src/session.rs+9−0
| @@ -98,6 +98,15 @@ pub fn load(ctx: &Ctx, did: &str) -> Option<Session> { | ||
store::read(&path(ctx, did)) | ||
} | ||
| ||
/// The session of the account this machine acts as, as it was kept, with | ||
/// nothing asked of the network: `KNOTLESS_IDENTIFIER` when it is a DID, | ||
/// else the one `knotless login` last logged in. | ||
pub fn kept_me(ctx: &Ctx) -> Option<Session> { | ||
let named = ctx.env.identifier.clone().filter(|i| i.starts_with("did:")); | ||
let me = named.or_else(|| std::fs::read_to_string(ctx.env.config_dir.join("me")).ok())?; | ||
load(ctx, me.trim()) | ||
} | ||
| ||
/// Forgets `did`'s session without a word to its PDS: for an account gone | ||
/// from it. | ||
pub fn remove(ctx: &Ctx, did: &str) { | ||
crates/knotless/src/space.rs+11−0
| @@ -25,6 +25,17 @@ use std::sync::Arc; | ||
| ||
pub const SPACE_TYPE: &str = "dev.knotless.space"; | ||
| ||
/// The collections knotless writes in a space of `space_type`, when it is | ||
/// one of knotless's own: those its lexicon has to list, since a PDS | ||
/// grants a login no write in any other. | ||
pub fn collections(space_type: &str) -> Option<&'static [&'static str]> { | ||
match space_type { | ||
SPACE_TYPE => Some(&crate::records::nsid::SPACE), | ||
crate::org::SPACE_TYPE => Some(&crate::records::nsid::ORG_SPACE), | ||
_ => None, | ||
} | ||
} | ||
| ||
/// The entry in an appview's DID document that queries are proxied to, and | ||
/// that a space's authority tells of each write in a space it was asked to | ||
/// ([`Space::register_notify`]). | ||
crates/knotless-mcp/src/lib.rs+82−18
| @@ -279,22 +279,32 @@ fn answer(res: anyhow::Result<Value>) -> Result<CallToolResult, McpError> { | ||
Ok(match res { | ||
Ok(Value::String(text)) => CallToolResult::success(vec![ContentBlock::text(text)]), | ||
Ok(value) => CallToolResult::success(vec![ContentBlock::text(value.to_string())]), | ||
Err(err) => CallToolResult::error(vec![ContentBlock::text(failed(&err))]), | ||
Err(err) => CallToolResult::error(vec![ContentBlock::text(fix::said(&err, Words::Tools))]), | ||
}) | ||
} | ||
| ||
/// What a tool says of `err`: with, for a PDS refusing a call for want of | ||
/// scope, how the user grants it, whichever way this server was reached. | ||
fn failed(err: &anyhow::Error) -> String { | ||
let said = fix::said(err, Words::Tools); | ||
match knotless::oauth::out_of_scope(err) { | ||
true => format!("{said}\n\n{LOG_IN_AGAIN}"), | ||
false => said, | ||
} | ||
/// How the user grants what the refusal for want of scope in `said` lacks, | ||
/// said to the model: signing in again, when the login knotless acts with | ||
/// is `behind` what knotless asks for now; else that the PDS did not grant | ||
/// what it asked for. | ||
fn grant(said: &str, behind: &dyn Fn() -> bool) -> Option<&'static str> { | ||
if !knotless::oauth::out_of_scope(&said) { | ||
return None; | ||
} | ||
Some(match (behind(), knotless::oauth::in_space(&said)) { | ||
(true, _) => LOG_IN_AGAIN, | ||
(false, true) => NOT_LISTED, | ||
(false, false) => NOT_GRANTED, | ||
}) | ||
} | ||
| ||
/// How the user grants what their login lacks, said to the model. | ||
const LOG_IN_AGAIN: &str = "The PDS refused this because the login knotless acts with is older than what knotless asks for now. Ask the user to sign in again to allow it: to reconnect knotless in their assistant's connector settings, or, for a knotless run on their machine, to run `knotless login <handle>` in a terminal."; | ||
/// What is said of a refusal for want of scope the login asked for. | ||
const NOT_GRANTED: &str = "The PDS did not grant this, though the login knotless acts with asks for it: signing in again asks for nothing more."; | ||
/// What is said of a write in a space the login asked for, but its PDS | ||
/// left out of the grant. | ||
const NOT_LISTED: &str = "The PDS did not grant this write in the space, though the login knotless acts with asks for it: a PDS grants writes in a space as the lexicon knotless publishes for the space's type listed them when the user signed in, and that left this one out. Once knotless's lexicon lists it, the user signs in again to allow it: to reconnect knotless in their assistant's connector settings, or, for a knotless run on their machine, to run `knotless login <handle>` in a terminal."; | ||
| ||
/// A comment's `line`, as its record has it, with what the record leaves | ||
/// to be read said too: the side, `new` unless it is `old`, and the first | ||
| @@ -1451,6 +1461,35 @@ impl Server { | ||
} | ||
} | ||
| ||
/// What a tool or resource says of `err`: with, for a PDS refusing a | ||
/// call for want of scope, how the user grants it, whichever way this | ||
/// server was reached. | ||
fn failed(&self, err: &anyhow::Error) -> String { | ||
let said = fix::said(err, Words::Tools); | ||
match self.grant(&said) { | ||
Some(how) => format!("{said}\n\n{how}"), | ||
None => said, | ||
} | ||
} | ||
| ||
/// [`grant`], as the login this server acts with stands. | ||
fn grant(&self, said: &str) -> Option<&'static str> { | ||
grant(said, &|| knotless::oauth::login_behind(&self.ctx).is_some()) | ||
} | ||
| ||
/// `done`, a tool's answer, saying as [`Server::failed`] does how the | ||
/// user grants what a failure for want of scope lacks. | ||
fn advise(&self, done: &mut CallToolResult) { | ||
if done.is_error != Some(true) { | ||
return; | ||
} | ||
let said = done.content.iter().filter_map(|c| c.as_text()); | ||
let said = said.map(|t| t.text.as_str()).collect::<Vec<_>>().join("\n"); | ||
if let Some(how) = self.grant(&said) { | ||
done.content = vec![ContentBlock::text(format!("{said}\n\n{how}"))]; | ||
} | ||
} | ||
| ||
/// The tools it offers. | ||
fn router(&self) -> rmcp::handler::server::router::tool::ToolRouter<Server> { | ||
let mut router = Self::tool_router(); | ||
| @@ -4252,6 +4291,9 @@ impl ServerHandler for Server { | ||
} | ||
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); | ||
let mut res = self.router().call(tcc).await?; | ||
if let CallToolResponse::Complete(done) = &mut res { | ||
self.advise(done); | ||
} | ||
if let CallToolResponse::Complete(done) = &mut res | ||
&& let Some(newer) = self.notice.lock().unwrap().take() | ||
{ | ||
| @@ -4311,15 +4353,37 @@ mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn refusals_for_want_of_scope_say_how_to_sign_in_again() { | ||
let said = failed(&anyhow::anyhow!( | ||
"InvalidToken (403): Scope does not permit create" | ||
)); | ||
assert!(said.ends_with(LOG_IN_AGAIN), "{said}"); | ||
assert_eq!( | ||
failed(&anyhow::anyhow!("no such repository")), | ||
"no such repository" | ||
); | ||
fn refusals_for_want_of_scope_say_how_to_grant_it() { | ||
let space = "InvalidToken (403): Scope does not permit create in at://did:plc:a/space/dev.knotless.space/r"; | ||
let (behind, asked) = (|| true, || false); | ||
assert_eq!(grant(space, &behind), Some(LOG_IN_AGAIN)); | ||
// A login that asked for it is not told it is older. | ||
assert_eq!(grant(space, &asked), Some(NOT_LISTED)); | ||
let rpc = "ScopeMissingError (403): no"; | ||
assert_eq!(grant(rpc, &asked), Some(NOT_GRANTED)); | ||
assert_eq!(grant("no such repository", &asked), None); | ||
let dir = tempfile::tempdir().unwrap(); | ||
let config = dir.path().to_string_lossy().into_owned(); | ||
let env = knotless::env::Env::from_vars(|k| match k { | ||
"KNOTLESS_CONFIG_DIR" => Some(config.clone()), | ||
_ => None, | ||
}); | ||
let server = Server::new(Ctx::new(env)); | ||
let said = server.failed(&anyhow::anyhow!("{space}")); | ||
assert_eq!(said, format!("{space}\n\n{NOT_LISTED}")); | ||
let said = server.failed(&anyhow::anyhow!("no such repository")); | ||
assert_eq!(said, "no such repository"); | ||
// A tool's failure says it as a resource's does. | ||
let mut done = answer(Err(anyhow::anyhow!("{space}"))).unwrap(); | ||
server.advise(&mut done); | ||
let text: Vec<_> = done.content.iter().filter_map(|c| c.as_text()).collect(); | ||
assert_eq!(text[0].text, format!("{space}\n\n{NOT_LISTED}")); | ||
let mut done = answer(Ok(json!("fine"))).unwrap(); | ||
server.advise(&mut done); | ||
let mut other = answer(Err(anyhow::anyhow!("no such repository"))).unwrap(); | ||
server.advise(&mut other); | ||
let text: Vec<_> = other.content.iter().filter_map(|c| c.as_text()).collect(); | ||
assert_eq!(text[0].text, "no such repository"); | ||
} | ||
| ||
#[test] | ||
crates/knotless-mcp/src/resources.rs+2−2
| @@ -3,7 +3,7 @@ | ||
//! mirrors its page on knotless.dev under the `knotless://` scheme, and each | ||
//! answered as the tool that reads it answers. | ||
| ||
use crate::{File, Items, PullDiff, Server, Subject, failed}; | ||
use crate::{File, Items, PullDiff, Server, Subject}; | ||
use rmcp::ErrorData as McpError; | ||
use rmcp::model::{ReadResourceResult, ResourceContents, ResourceTemplate}; | ||
use serde_json::Value; | ||
| @@ -173,7 +173,7 @@ impl Server { | ||
Read::Diff(p) => self.diff(p).await, | ||
Read::File(p) => self.file(p).await, | ||
}; | ||
let value = answered.map_err(|err| McpError::internal_error(failed(&err), None))?; | ||
let value = answered.map_err(|err| McpError::internal_error(self.failed(&err), None))?; | ||
let contents = match value { | ||
Value::String(text) => ResourceContents::text(text, uri), | ||
// A binary file, which repo_read gives as base64. | ||
docs/design/access.md+12−0
| @@ -73,6 +73,18 @@ as the `dev.knotless.org` spaces organizations kept before their type had | ||
a lexicon, is out of a login's reach, and knotless reads none. | ||
A PDS without spaces drops the scope it does not know. | ||
| ||
The grant holds the collections the lexicon listed as the account | ||
approved, and no other: a write in a collection it left out is refused | ||
`Scope does not permit create in <space>`, however new the login. So each | ||
type's lexicon lists every collection knotless writes in its spaces, as | ||
`nsid::SPACE` and `nsid::ORG_SPACE` name them (`space::collections`): a | ||
test fails when one is missing, and a debug build panics on a space write | ||
outside them. A collection added to a lexicon reaches an account once | ||
`publish-lexicons` has published it from main (`deploy/README.md`), the | ||
PDS's cache of the lexicon has run out, and the account logs in again; | ||
until then the CLI and MCP say the PDS did not grant the write, not that | ||
the login is older. | ||
| ||
## Private repositories | ||
| ||
A private repository is the space | ||