Add non-secret agent_provider and agent_default_model fields to the
refs/meta/config typed tree (kept in ents-gate for now; the move to
ents-model stays deferred until config grows enough non-gate fields to
migrate in one pass). The API token stays out of repo data entirely — it
lives only in the GIT_ENTS_CREDENTIALS_FILE deployment seam.
Add git ents config {show,set} as the first production write path for
refs/meta/config (mirroring account/members): set reads-merges-writes
so narrowing one field never clobbers epoch/workers or the other agent
field. Wire the web new-session form to fall back to the configured
default model, keeping the compiled-in DEFAULT_MODEL as the floor.
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/git-ents/src/cli.rs
@@ -102,6 +102,17 @@
#[facet(args::subcommand)]
action: AccountAction,
},
+ /// Manage this repository's forge-wide configuration at
+ /// `refs/meta/config`: today, non-secret agent-runtime defaults (a
+ /// provider name, a default model id) alongside the gate's own
+ /// `epoch`/`workers` fields. The API token is never here; it lives in
+ /// the deployment-time credential seam
+ /// (`GIT_ENTS_CREDENTIALS_FILE`), never in a signed, replicated tree.
+ Config {
+ /// The config action to run.
+ #[facet(args::subcommand)]
+ action: ConfigAction,
+ },
/// Manage the configured effects at `refs/meta/effects/<name>` and run
/// them locally.
Effect {
@@ -317,6 +328,32 @@
},
}
+/// `git ents config` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum ConfigAction {
+ /// Show this repository's current forge-wide configuration.
+ Show,
+ /// Narrow the agent-runtime defaults. Each flag is independent: omit
+ /// one to leave whatever it currently holds untouched rather than
+ /// clearing it.
+ Set {
+ /// The agent runtime's provider name (e.g. `anthropic`);
+ /// non-secret -- the API token itself is never a flag here, only
+ /// `git-ents`'s own `GIT_ENTS_CREDENTIALS_FILE` seam.
+ #[facet(args::named)]
+ agent_provider: Option<String>,
+ /// The agent runtime's default model id (e.g.
+ /// `claude-sonnet-5`), used whenever a session start omits its
+ /// own `--model`.
+ #[facet(args::named)]
+ agent_default_model: Option<String>,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
/// `git ents effect` actions.
#[derive(Facet)]
#[repr(u8)]
crates/kernel/ents-gate/src/config.rs
@@ -23,11 +23,14 @@
///
/// `model.sdoc` defines no Config entity yet, so this struct is the
/// first (and currently only) definition of the config tree's shape; it
-/// lives here rather than in `ents-model` because these are the only
-/// fields any crate reads today. When configuration grows non-gate fields
-/// (description, role rules, ...), the entity moves to `ents-model` and
-/// that change is a storage migration like any other struct change
-/// (`meta-ref.migration`).
+/// lives here rather than in `ents-model` because these were, until now,
+/// the only fields any crate reads. `agent_provider`/`agent_default_model`
+/// (below) are the first fields this crate itself has no use for — they
+/// exist purely for other crates (`ents-web`'s new-session form) to read
+/// — but they stay here rather than moving to `ents-model` today: that
+/// move is still deferred until configuration grows enough non-gate
+/// fields to justify the storage migration (`meta-ref.migration`) in one
+/// pass, rather than one field at a time.
///
/// `epoch` is `None` on a config written before verification was turned
/// on. Once it is `Some`, the gate applies the tip invariant to every
@@ -61,6 +64,21 @@
/// narrowing lands. Empty by default: no worker is designated until a
/// signed config write adds one.
pub workers: Vec<MemberId>,
+ /// The agent runtime's provider name (e.g. `"anthropic"`), forge-wide;
+ /// `None` until a signed config write sets one. Non-secret by
+ /// construction — the credential that authenticates to the provider
+ /// never lives here or anywhere in `refs/meta/*`, only in the
+ /// deployment-time seam (`git-ents`'s `credentials.rs`,
+ /// `GIT_ENTS_CREDENTIALS_FILE`). The agent runtime that would consume
+ /// this is not built yet; storing and reading it is this field's
+ /// entire job for now.
+ pub agent_provider: Option<String>,
+ /// The agent runtime's default model id (e.g. `"claude-sonnet-5"`),
+ /// forge-wide; `None` until a signed config write sets one. Read as a
+ /// fallback ahead of any hardcoded default a caller carries — today,
+ /// `ents-web`'s new-agent-session form falls back to it before its own
+ /// compiled-in constant.
+ pub agent_default_model: Option<String>,
}
/// The config recorded by the tree of the commit at `oid`, or an
@@ -119,3 +137,21 @@
) -> Result<Vec<MemberId>> {
Ok(current_config(refs, objects)?.workers)
}
+
+/// The forge-wide agent provider name currently in force, read from
+/// `refs/meta/config`'s tip; `None` when the config ref does not exist or
+/// names no provider. Public (unlike [`designated_workers`], which only
+/// this crate's gate consults) because the only consumer today, an agent
+/// runtime, does not live in this crate.
+pub fn agent_provider(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<String>> {
+ Ok(current_config(refs, objects)?.agent_provider)
+}
+
+/// The forge-wide default agent model id currently in force, read from
+/// `refs/meta/config`'s tip; `None` when the config ref does not exist or
+/// names no default — callers fall back to their own compiled-in default
+/// (`ents-web`'s new-agent-session form, today) rather than treating
+/// `None` as an error.
+pub fn agent_default_model(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<String>> {
+ Ok(current_config(refs, objects)?.agent_default_model)
+}
crates/kernel/ents-gate/src/lib.rs
@@ -155,7 +155,7 @@
mod verdict;
mod verify;
-pub use config::Config;
+pub use config::{Config, agent_default_model, agent_provider};
pub use error::{Error, Result};
pub use verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict};
pub use verify::{Update, verify};
crates/cli/ents-web/src/pages/agents.rs
@@ -61,6 +61,7 @@
.map(|entry| (entry.refname, entry.error))
.collect();
let default_base = default_base_ref(&state);
+ let default_model = resolved_default_model(&state);
Ok(super::layout_split(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
@@ -79,7 +80,7 @@
}
div.card {
div.card-header { "Start an Agent Session" }
- (new_form(&session, &default_base))
+ (new_form(&session, &default_base, &default_model))
}
}
},
@@ -696,18 +697,19 @@
/// The start-a-session form (`POST /agents`, `docs/agent-sessions-plan.adoc`'s
/// Phase 3, "mobile-critical"): a prompt textarea, a base-branch text input
/// pre-filled with `default_base` ([`default_base_ref`]), a model text
-/// input pre-filled with [`DEFAULT_MODEL`], and a closed two-option review
-/// policy picker defaulting to `manual` (mirrors
-/// `crate::pages::commits::start_review_form`'s identical closed-verdict
-/// picker) -- deliberately no toolchain or retry field: the plan's own
-/// words are "complexity lives in the session doc, not the form."
-fn new_form(session: &Session, default_base: &str) -> Markup {
+/// input pre-filled with `default_model` ([`resolved_default_model`]), and
+/// a closed two-option review policy picker defaulting to `manual`
+/// (mirrors `crate::pages::commits::start_review_form`'s identical
+/// closed-verdict picker) -- deliberately no toolchain or retry field: the
+/// plan's own words are "complexity lives in the session doc, not the
+/// form."
+fn new_form(session: &Session, default_base: &str, default_model: &str) -> Markup {
html! {
form method="post" action="/agents" {
(super::csrf_input(session))
label { "prompt" textarea name="prompt" {} }
label { "base branch" input type="text" name="base_ref" value=(default_base); }
- label { "model" input type="text" name="model" value=(DEFAULT_MODEL); }
+ label { "model" input type="text" name="model" value=(default_model); }
div {
p.muted { "review policy" }
div.picker {
@@ -737,11 +739,26 @@
}
}
-/// The model id [`new_form`] pre-fills and [`NewForm::model`] defaults to
-/// when a submission omits the field entirely -- the same default id this
-/// codebase's own fixtures and `git-ents::agent_worker` tests already use.
+/// The model id [`resolved_default_model`] falls back to when
+/// `refs/meta/config` names no `agent_default_model` (or does not exist
+/// yet) -- the same default id this codebase's own fixtures and
+/// `git-ents::agent_worker` tests already use.
const DEFAULT_MODEL: &str = "claude-sonnet-5";
+/// The model id [`new_form`] pre-fills and [`NewForm::model`] falls back to
+/// when a submission omits the field entirely: `refs/meta/config`'s
+/// `agent_default_model` (`ents_gate::agent_default_model`) when a signed
+/// config write has set one, else [`DEFAULT_MODEL`] -- a forge-wide
+/// default lets an operator change what every new session starts at
+/// without touching this crate's own compiled-in fallback, which stays as
+/// the floor for a repository that has never configured one.
+fn resolved_default_model<O: Find>(state: &AppState<O>) -> String {
+ ents_gate::agent_default_model(state.refs.as_ref(), &*state.objects())
+ .ok()
+ .flatten()
+ .unwrap_or_else(|| DEFAULT_MODEL.to_owned())
+}
+
/// The base ref [`new_form`] pre-fills: `refs/heads/<branch>` for the
/// served repository's own current `HEAD` branch
/// ([`super::RepoHeader::from_state`]), or plain `HEAD` when that cannot
@@ -782,10 +799,11 @@
/// [`default_base_ref`]).
#[serde(default = "default_base_ref_field")]
base_ref: String,
- /// The model id the run executes against; defaults to
- /// [`DEFAULT_MODEL`].
- #[serde(default = "default_model_field")]
- model: String,
+ /// The model id the run executes against; `None` when a submission
+ /// omits the field entirely, resolved by [`resolved_default_model`]
+ /// (see [`create`]).
+ #[serde(default)]
+ model: Option<String>,
/// The session's initially resolved review policy: `auto` or `manual`;
/// defaults to `manual` (see [`default_review_policy`]).
#[serde(default = "default_review_policy")]
@@ -794,11 +812,6 @@
csrf: String,
}
-/// [`NewForm::model`]'s serde default -- see [`DEFAULT_MODEL`].
-fn default_model_field() -> String {
- DEFAULT_MODEL.to_owned()
-}
-
/// `POST /agents`: start an agent session owned by the current signing
/// identity's resolved member (`ents_forge::agent::new`), signed
/// (`roots.web-signing`) on behalf of the current session
@@ -824,10 +837,11 @@
super::require_csrf(&session, &form.csrf)?;
let member = session_owner(&state);
let identity = state.identity.as_ref();
+ let model = form.model.unwrap_or_else(|| resolved_default_model(&state));
let new = NewAgentSession {
member,
prompt: form.prompt,
- model: form.model,
+ model,
toolchains: Vec::new(),
base_ref: form.base_ref,
review_policy: form
crates/cli/git-ents/src/commands/mod.rs
@@ -12,6 +12,7 @@
pub mod agent;
pub mod bootstrap;
pub mod comment;
+pub mod config;
pub mod effect;
pub mod inbox;
pub mod issue;
crates/cli/git-ents/src/commands/config.rs
@@ -1,0 +1,96 @@
+//! `git ents config`: forge-wide, non-secret agent-runtime defaults
+//! (provider name, default model) recorded in `refs/meta/config` alongside
+//! the gate's own `epoch`/`workers` fields (`ents_gate::Config`).
+//!
+//! The API token is deliberately not here and never will be: it lives only
+//! in the deployment-time credential seam (`crate::credentials`,
+//! `GIT_ENTS_CREDENTIALS_FILE`), never in a signed, replicated,
+//! multi-reader tree.
+
+use ents_gate::Config;
+use ents_model::namespace;
+use ents_receive::{Identity, propose_entity};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::Result;
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents config show`: this repository's current forge-wide
+/// configuration -- [`Config::default`] (every field unset) when
+/// `refs/meta/config` has no tip yet, the same "absent means unconfigured"
+/// reading every `ents_gate::config` reader already gives.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure, or an unreadable config
+/// tree.
+pub fn show(root: &LocalRoot) -> Result<Config> {
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "CONFIG_REF is a fixed, compile-time-known-valid refname literal"
+ )]
+ let name: gix::refs::FullName = namespace::CONFIG_REF
+ .try_into()
+ .expect("fixed, valid refname");
+ let Some(tip) = root.refs.get(name.as_ref())? else {
+ return Ok(Config::default());
+ };
+ let tree = super::commit_tree(&root.objects, tip)?;
+ Ok(facet_git_tree::deserialize::<Config>(&tree, &root.objects)?)
+}
+
+/// `git ents config set`: narrow the agent-runtime defaults. Each argument
+/// is an independent optional narrowing -- omit one to leave whatever it
+/// currently holds untouched, rather than resetting it to `None`. Reads
+/// the current config first and writes the merged whole back, so a `set`
+/// naming only `agent_provider` cannot clobber an `agent_default_model`
+/// set earlier, or the gate's own `epoch`/`workers`.
+///
+/// # Errors
+///
+/// Propagates a ref-store/object read failure or a signing failure;
+/// otherwise see [`crate::mutate::outcome_to_result`] for how a reached
+/// refusal renders.
+pub fn set(
+ root: &LocalRoot,
+ agent_provider: Option<String>,
+ agent_default_model: Option<String>,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let mut config = show(root)?;
+ if let Some(provider) = agent_provider {
+ config.agent_provider = Some(provider);
+ }
+ if let Some(model) = agent_default_model {
+ config.agent_default_model = Some(model);
+ }
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "CONFIG_REF is a fixed, compile-time-known-valid refname literal"
+ )]
+ let name: gix::refs::FullName = namespace::CONFIG_REF
+ .try_into()
+ .expect("fixed, valid refname");
+ let identity = Identity {
+ actor: actor(&signer),
+ author: None,
+ sign: &|payload| signer.sign(payload),
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ name,
+ &config,
+ &identity,
+ "Set agent config",
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
crates/cli/git-ents/tests/config.rs
@@ -1,0 +1,65 @@
+//! Integration coverage for `git ents config` against a real local
+//! composition root (`roots.local`): narrowing the agent-runtime defaults
+//! at `refs/meta/config` without disturbing fields `set` was not told to
+//! touch, and reading an unconfigured repository back as
+//! `ents_gate::Config::default()`.
+
+#![allow(clippy::expect_used, reason = "integration test")]
+
+mod common;
+
+use ents_gate::Config;
+use git_ents::commands::config;
+use git_ents::root::LocalRoot;
+
+/// A repository that has never had `refs/meta/config` written reads back
+/// as every field unset -- the same "absent means unconfigured" reading
+/// `ents_gate::config`'s own readers give, and what lets an old config
+/// (predating `agent_provider`/`agent_default_model`) keep parsing.
+// @relation(roots.local, scope=function, role=Verifies)
+#[test]
+fn absent_config_reads_as_default() {
+ let fixture = common::Fixture::new(1);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+
+ let config = config::show(&root).expect("shows");
+ assert_eq!(config, Config::default());
+}
+
+/// `set` narrows one field at a time: a later call naming only
+/// `agent_default_model` must not clobber an `agent_provider` an earlier
+/// call set, and neither call ever touches `workers`/`epoch` (unset by
+/// either call).
+// @relation(roots.local, scope=function, role=Verifies)
+#[test]
+fn set_narrows_without_clobbering_other_fields() {
+ let fixture = common::Fixture::new(1);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+
+ config::set(
+ &root,
+ Some("anthropic".to_owned()),
+ None,
+ Some(fixture.key_path.clone()),
+ )
+ .expect("sets provider");
+ let config = config::show(&root).expect("shows");
+ assert_eq!(config.agent_provider.as_deref(), Some("anthropic"));
+ assert_eq!(config.agent_default_model, None);
+ assert!(config.workers.is_empty());
+ assert_eq!(config.epoch, None);
+
+ config::set(
+ &root,
+ None,
+ Some("claude-sonnet-5".to_owned()),
+ Some(fixture.key_path.clone()),
+ )
+ .expect("sets default model");
+ let config = config::show(&root).expect("shows");
+ assert_eq!(config.agent_provider.as_deref(), Some("anthropic"));
+ assert_eq!(
+ config.agent_default_model.as_deref(),
+ Some("claude-sonnet-5")
+ );
+}