agent surface: create_repo through hive-c0re (#1787)

Closes the #1787 loop — the sanctioned create path now that agents
can't create repos directly. Adds:

- wire: Request::CreateRepo{repo} + Response::RepoCreated{full_name,
  clone_url} (hive-sh4re).
- agent_server: dispatch_shared arm + handle_create_repo — validates the
  repo name, then forge::create_agent_repo (org-owned repo, agent=write
  collaborator, operator-team branch protection). Returns the full name
  + clone url so the agent can git clone immediately.
- MCP: create_repo tool + CreateRepoArgs in the harness.
- a new opt-in ToolGroup::Forge (=[create_repo]) so the operator
  controls which agents can spin up repos (least privilege).

Workspace clippy -D warnings, cargo test, nix fmt all green.
This commit is contained in:
atlas 2026-06-19 12:57:01 +02:00 committed by mara
commit f1d54ce12c
4 changed files with 115 additions and 3 deletions

View file

@ -59,6 +59,11 @@ pub enum SocketReply {
hive_name: Option<String>,
swarm_name: Option<String>,
},
/// `create_repo` result — the new repo's full name + clone URL.
RepoCreated {
full_name: String,
clone_url: String,
},
}
impl From<hive_sh4re::Response> for SocketReply {
@ -100,6 +105,13 @@ impl From<hive_sh4re::Response> 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/<repo>.git` and push/open PRs as normal."
)]
async fn create_repo(&self, Parameters(args): Parameters<CreateRepoArgs>) -> 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/<repo>`.
pub repo: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetAgentMetaArgs {
/// Logical name of the agent to query (e.g. `"iris"`, `"manager"`).

View file

@ -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<Coordinator>, 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.

View file

@ -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<String> {
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,

View file

@ -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<WireSchedule> },
/// `CreateRepo` result: the new repo's full name (`agents/<repo>`)
/// 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"
}