Compare commits

...
Author SHA1 Message Date
beardthelion a23c784742 fix(gl): thread caller-identity repo resolution through MCP webhook tools (#159) 2026-11-10 08:42:58 -06:00
beardthelion 2c347429b3 Merge remote-tracking branch 'origin/main' into pr-159 2026-11-10 08:38:53 -06:00
carlitos973 e5b1b0c8eb fix(gl): thread identity resolution and status checking through webhook list
Apply the same fix already applied to create/delete to the list
subcommand, per review feedback on this PR:

- gl webhook list now accepts --dir and resolves the caller identity
  the same way create/delete do, instead of always falling back to
  the node root DID for bare repo names
- cmd_list checks the HTTP status before parsing, so a non-2xx error
  (e.g. repo not found, not authorized) surfaces as an error instead
  of silently rendering as an empty webhook list
- list output now prints the fully-qualified owner/repo, matching the
  fix already applied to the create/delete hints

Also tightens resolve_owners doc comment: it falls back to the node
DID on any keypair-load failure, not only when no keypair exists.
2026-07-06 08:37:37 -07:00
carlitos973 d42a809342 fix(gl): print fully-qualified owner/repo in webhook create delete hint
Per CodeRabbit review on #159: the resolve_owner destructuring shadowed
repo with the bare name, so the printed delete hint lost the owner
qualification -- only correct by coincidence if the caller runs it
under the same identity used at create time. Print owner/repo
explicitly so the hint always works regardless of default identity.
2026-07-06 07:07:15 -07:00
carlitos973 4335535639 fix(gl): resolve webhook owner from caller identity, not node DID
resolve_owner() in webhook.rs unconditionally fetched the node's own
root DID via GET / and used it as the repo owner, ignoring both an
explicit owner/repo argument and the caller's own loaded identity.
This made gl webhook create/list/delete effectively unusable for any
repo not owned by the node operator itself -- every call resolved to
the wrong owner and 404d.

cert.rs's resolve_repo already has the correct pattern: check for an
explicit slash first, then prefer the caller's own keypair, falling
back to the node DID only if no local identity is available. This
applies that same pattern to webhook create/list/delete.

Verified against a live self-hosted node: webhook create/list/delete
all resolve the correct owner now. Existing webhook test suite passes
unchanged (8/8).
2026-07-06 06:48:13 -07:00
+102 -18
View File
@@ -963,7 +963,7 @@ async fn call_tool(
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_else(|| vec!["*"]);
let owner = resolve_owner(&args, &client).await?;
let (owner, name) = resolve_owner_repo(&args, repo, &keypair, &client).await?;
let body = serde_json::to_vec(&json!({
"url": url,
"secret": args["secret"],
@@ -971,7 +971,7 @@ async fn call_tool(
}))?;
let resp = crate::http::read_json(
client
.post(&format!("/api/v1/repos/{owner}/{repo}/hooks"), &body)
.post(&format!("/api/v1/repos/{owner}/{name}/hooks"), &body)
.await?,
"create webhook",
)
@@ -981,19 +981,12 @@ async fn call_tool(
"webhook_list" => {
let repo = args["repo"].as_str().context("missing 'repo'")?;
// Owner-gated route: an explicit `owner` arg wins, otherwise default to
// the signing keypair's short DID (NOT the node DID that resolve_owner
// returns). The request is signed by this keypair, so the path owner
// must match it or the node returns 403/404.
let owner = if let Some(o) = args.get("owner").and_then(|v| v.as_str()) {
o.to_string()
} else {
let kp = keypair
.as_ref()
.context("no identity found — run `gl identity new` first")?;
let did = kp.did().to_string();
did.split(':').next_back().unwrap_or(&did).to_string()
};
// Owner-gated route: `owner/repo` splits first, an explicit `owner`
// arg wins next, otherwise the signing keypair's short DID (NOT the
// node DID that resolve_owner returns). The request is signed by
// this keypair, so the path owner must match it or the node
// returns 403/404.
let (owner, name) = resolve_owner_repo(&args, repo, &keypair, &client).await?;
// Owner-gated AND auth-required route (list_webhooks 401s a headerless
// caller, then 403s a non-owner). get_maybe_signed signs when an
// identity is present (so the owner is authorized) and otherwise sends
@@ -1004,7 +997,7 @@ async fn call_tool(
// failing client-side before the node is contacted.
let resp = crate::http::read_json(
client
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{repo}/hooks"))
.get_maybe_signed(&format!("/api/v1/repos/{owner}/{name}/hooks"))
.await?,
"webhook_list",
)
@@ -1015,11 +1008,11 @@ async fn call_tool(
"webhook_delete" => {
let repo = args["repo"].as_str().context("missing 'repo'")?;
let id = args["id"].as_str().context("missing 'id'")?;
let owner = resolve_owner(&args, &client).await?;
let (owner, name) = resolve_owner_repo(&args, repo, &keypair, &client).await?;
let body = serde_json::to_vec(&json!({}))?;
let resp = crate::http::read_json(
client
.delete(&format!("/api/v1/repos/{owner}/{repo}/hooks/{id}"), &body)
.delete(&format!("/api/v1/repos/{owner}/{name}/hooks/{id}"), &body)
.await?,
"delete webhook",
)
@@ -1345,6 +1338,36 @@ async fn resolve_owner(args: &Value, client: &NodeClient) -> Result<String> {
Ok(did.split(':').next_back().unwrap_or(did).to_string())
}
/// Resolve (owner, repo) for the webhook tools, matching the CLI's
/// `resolve_owner_repo_pair` contract: an `owner/repo` string splits first, an
/// explicit `owner` arg wins next, then the signing keypair's short DID. The
/// last resort is the node root DID via `resolve_owner`, reachable only by
/// callers that have no keypair (the unsigned webhook_list arm) — for a bare
/// repo name under a signed caller the path owner must be the caller's DID or
/// the node 403/404s the request under the wrong namespace.
async fn resolve_owner_repo(
args: &Value,
repo: &str,
keypair: &Option<gitlawb_core::identity::Keypair>,
client: &NodeClient,
) -> Result<(String, String)> {
if let Some((o, r)) = repo.split_once('/') {
return Ok((o.to_string(), r.to_string()));
}
if let Some(o) = args.get("owner").and_then(|v| v.as_str()) {
return Ok((o.to_string(), repo.to_string()));
}
if let Some(kp) = keypair {
let did = kp.did().to_string();
return Ok((
did.split(':').next_back().unwrap_or(&did).to_string(),
repo.to_string(),
));
}
let owner = resolve_owner(args, client).await?;
Ok((owner, repo.to_string()))
}
/// Parse git pkt-line info/refs response into a list of {ref, sha} objects.
fn parse_info_refs(bytes: &[u8]) -> Value {
let mut refs = Vec::new();
@@ -3161,4 +3184,65 @@ mod tests {
assert!(err.to_string().contains("404"), "got: {err}");
_m.assert_async().await;
}
#[tokio::test]
async fn resolve_owner_repo_splits_slash_form() {
let server = mockito::Server::new_async().await;
let (owner, name) = resolve_owner_repo(
&json!({}),
"alice/proj",
&None,
&NodeClient::new(server.url(), None),
)
.await
.unwrap();
assert_eq!((owner.as_str(), name.as_str()), ("alice", "proj"));
}
#[tokio::test]
async fn resolve_owner_repo_owner_arg_beats_keypair() {
let server = mockito::Server::new_async().await;
let kp = gitlawb_core::identity::Keypair::generate();
let (owner, name) = resolve_owner_repo(
&json!({"owner": "bob"}),
"proj",
&Some(kp.clone()),
&NodeClient::new(server.url(), None),
)
.await
.unwrap();
assert_eq!((owner.as_str(), name.as_str()), ("bob", "proj"));
}
#[tokio::test]
async fn resolve_owner_repo_bare_repo_uses_keypair_did() {
let mut server = mockito::Server::new_async().await;
// A root mock answering with a different DID must never be consulted:
// under a signed caller the path owner is the keypair's short DID.
let _m = server
.mock("GET", "/")
.with_status(200)
.with_body(r#"{"did":"did:key:z6MkNodeOperator"}"#)
.expect(0)
.create_async()
.await;
let kp = gitlawb_core::identity::Keypair::generate();
let short = kp
.did()
.to_string()
.split(':')
.next_back()
.unwrap()
.to_string();
let (owner, name) = resolve_owner_repo(
&json!({}),
"proj",
&Some(kp),
&NodeClient::new(server.url(), None),
)
.await
.unwrap();
assert_eq!((owner.as_str(), name.as_str()), (short.as_str(), "proj"));
_m.assert_async().await;
}
}