diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index e4ddebc0..6bbdeb60 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -59,6 +59,11 @@ pub enum SocketReply { hive_name: Option, swarm_name: Option, }, + /// `create_repo` result — the new repo's full name + clone URL. + RepoCreated { + full_name: String, + clone_url: String, + }, } impl From for SocketReply { @@ -100,6 +105,13 @@ impl From for SocketReply { hive_name, swarm_name, }, + hive_sh4re::Response::RepoCreated { + full_name, + clone_url, + } => Self::RepoCreated { + full_name, + clone_url, + }, } } } @@ -910,6 +922,35 @@ impl AgentServer { .await } + #[tool( + description = "Create a git repo through hive-c0re. You CANNOT create repos with your \ + own forge token (creation is disabled) — this is the only path. The repo is created in \ + the c0re-owned `agents` org, you're added as a write collaborator (not owner), and the \ + default branch gets branch protection so merges require an operator-team approval — you \ + cannot merge your own PRs. `repo` is a single name segment (letters, digits, `-`, `_`, \ + `.`). Returns the new repo's full name + clone URL; clone it over \ + `http://localhost:3000/agents/.git` and push/open PRs as normal." + )] + async fn create_repo(&self, Parameters(args): Parameters) -> String { + let log = format!("{args:?}"); + run_tool_envelope("create_repo", log, async move { + let (resp, retries) = self + .dispatch(hive_sh4re::Request::CreateRepo { repo: args.repo }) + .await; + let s = match resp { + Ok(SocketReply::RepoCreated { + full_name, + clone_url, + }) => format!("created repo {full_name} — clone: {clone_url}"), + Ok(SocketReply::Err(m)) => format!("create_repo failed: {m}"), + Ok(other) => format!("create_repo unexpected response: {other:?}"), + Err(e) => format!("create_repo transport error: {e:#}"), + }; + annotate_retries(s, retries) + }) + .await + } + #[tool( description = "Schedule a reminder that lands in this agent's own inbox at a future \ time (sender will appear as `reminder`). Use for self-paced follow-ups: 'check task \ @@ -1505,6 +1546,13 @@ pub struct SetStatusArgs { pub text: String, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CreateRepoArgs { + /// Repo name — a single segment of letters, digits, `-`, `_`, `.` + /// (no leading `-`/`.`). The repo is created as `agents/`. + pub repo: String, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetAgentMetaArgs { /// Logical name of the agent to query (e.g. `"iris"`, `"manager"`). diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 4b61bedb..0032911b 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -183,6 +183,7 @@ pub(crate) async fn dispatch_shared( |()| hive_sh4re::Response::Ok, ) } + hive_sh4re::Request::CreateRepo { repo } => handle_create_repo(agent, repo).await, hive_sh4re::Request::AckTurn => handle_ack_turn(coord, agent), hive_sh4re::Request::RequeueInflight => handle_requeue_inflight(coord, agent), hive_sh4re::Request::GracefulStopComplete => { @@ -304,6 +305,46 @@ fn handle_set_status(coord: &Arc, text: &str) -> hive_sh4re::Respon hive_sh4re::Response::Ok } +/// Validate an agent-supplied repo name: a single safe slug segment, no +/// path traversal. Forgejo validates server-side too, but rejecting early +/// gives a clear message and avoids building odd API paths. +fn valid_repo_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 100 + && !name.starts_with(['-', '.']) + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +/// `CreateRepo` — create a repo for `agent` *through hive-c0re* in the +/// c0re-owned `agents` org with operator-team branch protection (#1787). +/// The sanctioned create path now that agents can't create repos directly. +async fn handle_create_repo(agent: &str, repo: &str) -> hive_sh4re::Response { + if !valid_repo_name(repo) { + return hive_sh4re::Response::Err { + message: format!( + "invalid repo name {repo:?} — single segment of letters, digits, '-', '_', '.' \ + (no leading '-'/'.', max 100 chars)" + ), + }; + } + let Some(core_token) = crate::forge::core_token() else { + return hive_sh4re::Response::Err { + message: "forge unavailable (no core token) — cannot create repo".to_owned(), + }; + }; + match crate::forge::create_agent_repo(agent, repo, &core_token).await { + Ok(full_name) => hive_sh4re::Response::RepoCreated { + clone_url: format!("{}/{full_name}.git", crate::forge::FORGE_HTTP), + full_name, + }, + Err(e) => hive_sh4re::Response::Err { + message: format!("create repo {repo:?} failed: {e:#}"), + }, + } +} + /// `GetAgentMeta` — identity + live status for `name` (defaults to the /// caller). Reads the live container-view status and the hive/swarm /// display names. diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 4a6fba4d..e44e8dc6 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -316,7 +316,7 @@ async fn ensure_repo_creation_disabled(name: &str) { tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success"); } Err(e) => { - tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error") + tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error"); } } } @@ -984,12 +984,12 @@ async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> /// bypass branch protection), and the default branch gets the operator /// merge gate. This is the sanctioned create path now that agents can't /// create repos directly (`max_repo_creation = 0`). Idempotent. -pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result<()> { +pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result { ensure_org_repo(AGENTS_ORG, repo, core_token).await?; add_collaborator(AGENTS_ORG, repo, agent, "write", core_token).await?; apply_operator_branch_protection(repo, core_token).await?; tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate"); - Ok(()) + Ok(format!("{AGENTS_ORG}/{repo}")) } /// Per-agent forge sync: ensure the agent has a forgejo user + token, diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 8cdb9f47..bc4a3623 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -572,6 +572,13 @@ pub enum Request { /// per-kind semantics in /// `docs/conventions.md::Loose-ends wire shape`. CancelLooseEnd { kind: CancelLooseEndKind, id: i64 }, + /// Create a git repo *through hive-c0re* (#1787). Agents can't create + /// repos with their own forge token (`max_repo_creation = 0`); this is + /// the sanctioned path. hive-c0re creates `repo` in the c0re-owned + /// `agents` org, adds the calling agent as a write collaborator (not + /// owner), and applies operator-team branch protection so the author + /// can't merge its own PRs. Returns the new repo's full name. + CreateRepo { repo: String }, /// Mark every message popped since the last `AckTurn` as handled. /// Harness↔broker pairing fired after `TurnOutcome::Ok`. See /// `docs/conventions.md::Broker delivery + ack cycle`. @@ -753,6 +760,12 @@ pub enum Response { /// `ListSchedules` result. Snapshot of every schedule. /// Returned on the manager socket only. Schedules { schedules: Vec }, + /// `CreateRepo` result: the new repo's full name (`agents/`) + /// and clone URL, so the agent can immediately `git clone` it. + RepoCreated { + full_name: String, + clone_url: String, + }, /// `ListDescendants` result: all descendant containers, with running /// status. Ordered by topology depth (parents before children), then /// alphabetically within each depth tier. @@ -952,6 +965,10 @@ pub enum ToolGroup { Scheduling, /// `get_logs` - *(privileged)* Diagnostics, + /// `create_repo` — create git repos through hive-c0re (the only path + /// now that agents can't create them directly; see #1787). Opt-in per + /// agent so the operator controls who can spin up repos. + Forge, /// `run`, `status` (via `mcp__bash__*`) Execution, /// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and @@ -991,6 +1008,7 @@ impl ToolGroup { "list_schedules", ], Self::Diagnostics => &["get_logs"], + Self::Forge => &["create_repo"], Self::Execution => &["run", "status"], Self::WebTools => &[], } @@ -1044,6 +1062,7 @@ impl ToolGroup { Self::Approvals, Self::Scheduling, Self::Diagnostics, + Self::Forge, Self::Execution, Self::WebTools, ]; @@ -1060,6 +1079,7 @@ impl ToolGroup { Self::Approvals => "approvals", Self::Scheduling => "scheduling", Self::Diagnostics => "diagnostics", + Self::Forge => "forge", Self::Execution => "execution", Self::WebTools => "web_tools", } @@ -1088,6 +1108,9 @@ impl ToolGroup { Self::Diagnostics => { "get_logs — read a sub-agent container's systemd journal (privileged)" } + Self::Forge => { + "create_repo — create git repos through hive-c0re (operator-gated merge)" + } Self::Execution => { "run, status — run shell commands via mcp__bash__run / mcp__bash__status" }