Watch
0
0
Fork
You've already forked hyperhive
0

add external gitlab accounts: stored in bao, linked per agent, allowed in squid, usable with glab

This commit is contained in:
atlas 2026-11-02 16:11:25 +01:00
commit d6e983995d

View file

@ -1,14 +1,16 @@
//! The hosts an agent's linked accounts reach, kept as derived entries of the
//! egress allow list (`crate::egress_allow`), so a restricted agent with a
//! linked GitHub, external matrix or external forge account reaches that
//! service through its hive's proxy.
//! linked GitHub, external matrix, external forge or GitLab account reaches
//! that service through its hive's proxy.
//!
//! An entry is marked `derived` and carries the account as its `source`
//! (`link:github`, `link:matrix:<account>`, `link:forge:<label>`), so the
//! backend environment's entries (`crate::backend_egress`) and each account's
//! are replaced and dropped independently. A hive renders it like any other.
//! (`link:github`, `link:matrix:<account>`, `link:forge:<label>`,
//! `link:gitlab:<label>`), so
//! the backend environment's entries (`crate::backend_egress`) and each
//! account's are replaced and dropped independently. A hive renders it like
//! any other.
//!
//! The controller alone writes the three leaves the entries come from; the
//! The controller alone writes the four leaves the entries come from; the
//! agent's store policy grants it `create` on its own `backend-env` leaf and
//! nothing else (`swarm-secret-client/src/policy.rs`), so what these leaves
//! hold is the operator's choice, unlike the backend environment's.
@ -55,6 +57,10 @@ pub(crate) enum Link<'a> {
label: &'a str,
url: &'a str,
},
Gitlab {
label: &'a str,
url: &'a str,
},
}
pub(crate) fn github_source() -> String {
@ -69,12 +75,17 @@ pub(crate) fn forge_source(label: &str) -> String {
format!("{LINK_SOURCE_PREFIX}forge:{label}")
}
pub(crate) fn gitlab_source(label: &str) -> String {
format!("{LINK_SOURCE_PREFIX}gitlab:{label}")
}
impl Link<'_> {
fn source(&self) -> String {
match self {
Self::Github => github_source(),
Self::Matrix { account, .. } => matrix_source(account),
Self::Forge { label, .. } => forge_source(label),
Self::Gitlab { label, .. } => gitlab_source(label),
}
}
@ -87,7 +98,7 @@ impl Link<'_> {
let hosts: Vec<Option<&str>> = match self {
Self::Github => GITHUB_HOSTS.iter().map(|h| Some(*h)).collect(),
Self::Matrix { homeserver, .. } => homeserver.iter().map(|u| host_of(u)).collect(),
Self::Forge { url, .. } => vec![host_of(url)],
Self::Forge { url, .. } | Self::Gitlab { url, .. } => vec![host_of(url)],
};
let mut entries = Vec::new();
for host in hosts {
@ -221,6 +232,13 @@ async fn wanted_entries(
},
None => continue,
},
AccountKind::Gitlab => match account.host.as_deref() {
Some(url) => Link::Gitlab {
label: &account.name,
url,
},
None => continue,
},
AccountKind::Matrix | AccountKind::Backend => continue,
};
entries.extend(link.entries(agent, base));
@ -370,6 +388,18 @@ mod tests {
forge.entries("atlas", &base()),
[entry("forge.example.org", "atlas", Some("link:forge:oss"))]
);
let gitlab = Link::Gitlab {
label: "work",
url: "https://GitLab.Example.org:8443/sub",
};
assert_eq!(
gitlab.entries("atlas", &base()),
[entry(
"gitlab.example.org",
"atlas",
Some("link:gitlab:work")
)]
);
}
#[test]
@ -385,6 +415,8 @@ mod tests {
] {
let forge = Link::Forge { label: "x", url };
assert_eq!(hosts(&forge), Vec::<String>::new(), "{url:?}");
let gitlab = Link::Gitlab { label: "x", url };
assert_eq!(hosts(&gitlab), Vec::<String>::new(), "{url:?}");
}
// Controls: a public name gets one, and a matrix account with no
// homeserver has none to derive.
@ -393,6 +425,11 @@ mod tests {
url: "https://forge.example.org",
};
assert_eq!(hosts(&forge), ["forge.example.org"]);
let gitlab = Link::Gitlab {
label: "x",
url: "https://gitlab.example.org",
};
assert_eq!(hosts(&gitlab), ["gitlab.example.org"]);
let matrix = Link::Matrix {
account: "work",
homeserver: None,
@ -475,6 +512,22 @@ mod tests {
Path(("pr1ma".to_owned(), parts.to_owned()))
}
/// Link `atlas`'s GitLab account `work` through the route.
async fn link_gitlab(state: &AppState) {
let _ = crate::gitlab_account::put_gitlab_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
axum::Json(
serde_json::from_value(
json!({"url": "https://gitlab.example.org:8443/", "token": "t"}),
)
.expect("a body"),
),
)
.await
.expect("linked");
}
#[tokio::test]
async fn linking_and_unlinking_through_the_routes_moves_the_entries() {
let nats = TestNats::start();
@ -501,6 +554,7 @@ mod tests {
)
.await
.expect("linked");
link_gitlab(&state).await;
let _ = crate::matrix_account::put_matrix_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
@ -514,9 +568,10 @@ mod tests {
.await
.expect("linked");
let forge = entry("forge.example.org", "atlas", Some("link:forge:oss"));
let gitlab = entry("gitlab.example.org", "atlas", Some("link:gitlab:work"));
let matrix = entry("matrix.example.org", "atlas", Some("link:matrix:work"));
let mut all = github_entries("atlas");
all.extend([forge.clone(), matrix.clone()]);
all.extend([forge.clone(), gitlab.clone(), matrix.clone()]);
assert_eq!(list_of(&client).await, sorted(all));
// The backend environment's own save and delete leave them alone.
@ -535,14 +590,14 @@ mod tests {
crate::linked_accounts::delete_backend_env(State(state.clone()), path("atlas"))
.await
.expect("deleted");
assert_eq!(list_of(&client).await.len(), 2 + GITHUB_HOSTS.len());
assert_eq!(list_of(&client).await.len(), 3 + GITHUB_HOSTS.len());
crate::linked_accounts::delete_github_account(State(state.clone()), path("atlas"))
.await
.expect("deleted");
assert_eq!(
list_of(&client).await,
sorted(vec![forge.clone(), matrix.clone()])
sorted(vec![forge.clone(), gitlab.clone(), matrix.clone()])
);
crate::linked_accounts::delete_forge_account(
State(state.clone()),
@ -550,6 +605,16 @@ mod tests {
)
.await
.expect("deleted");
assert_eq!(
list_of(&client).await,
sorted(vec![gitlab.clone(), matrix.clone()])
);
crate::linked_accounts::delete_gitlab_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
)
.await
.expect("deleted");
assert_eq!(list_of(&client).await, [matrix]);
crate::linked_accounts::delete_matrix_account(
State(state.clone()),
@ -571,6 +636,10 @@ mod tests {
"swarm/agents/atlas/matrix/work",
json!({"value": "t", "homeserver": "https://matrix.example.org"}),
);
let store = store.with(
"swarm/agents/atlas/gitlab/work",
json!({"value": "t", "url": "https://gitlab.example.org:8443"}),
);
// The agent's own account has a homeserver of its own and no entry.
let store = store.with(
"swarm/agents/atlas/matrix/main",
@ -583,6 +652,7 @@ mod tests {
// An orphan: iris has no leaf for it.
entry("github.com", "iris", Some("link:github")),
// An orphan on a live agent whose forge leaf is gone.
entry("gitlab.example.org", "atlas", Some("link:gitlab:old")),
entry("forge.example.org", "atlas", Some("link:forge:old")),
entry("llm.example.com", "atlas", Some("backend-env")),
entry("crates.io", "atlas", None),
@ -596,6 +666,7 @@ mod tests {
assert_eq!(reconcile(&client, &store, &live).await.expect("pass"), 0);
let mut want = github_entries("atlas");
want.extend([
entry("gitlab.example.org", "atlas", Some("link:gitlab:work")),
entry("matrix.example.org", "atlas", Some("link:matrix:work")),
entry("llm.example.com", "atlas", Some("backend-env")),
entry("crates.io", "atlas", None),
@ -607,6 +678,38 @@ mod tests {
assert_eq!(list_of(&client).await, sorted(want));
}
/// The host of a GitLab entry is the controller-written leaf's `url`. The
/// one object an agent can create in the store is its `backend-env` leaf,
/// and a GitLab host named there yields no `link:gitlab` entry.
#[tokio::test]
async fn a_gitlab_entry_comes_from_the_controller_leaf_and_nothing_an_agent_writes() {
let agent_written = FakeStore::default().with(
"swarm/agents/atlas/backend-env",
json!({"env": "GITLAB_HOST=https://evil.example.com\nANTHROPIC_BASE_URL=https://evil.example.com\n"}),
);
let got = wanted_entries(&agent_written, "atlas", &base())
.await
.expect("store answers");
assert_eq!(got, []);
// The control: the controller's leaf yields the entry.
let leaf = agent_written.with(
"swarm/agents/atlas/gitlab/work",
json!({"value": "t", "url": "https://gitlab.example.org:8443/sub"}),
);
let got = wanted_entries(&leaf, "atlas", &base())
.await
.expect("store answers");
assert_eq!(
got,
[entry(
"gitlab.example.org",
"atlas",
Some("link:gitlab:work")
)]
);
}
#[tokio::test]
async fn a_store_error_prunes_nothing_and_adds_nothing() {
let nats = TestNats::start();