The full agent-sessions feature per docs/agent-sessions-plan.adoc: a
session is a forge entity, execution is an effect. Entity + lifecycle
invariants (gate rules, stateright), the agent-exec effect with claim
CAS and atomic three-ref finalize, the Agents web surface, both
planning paths (headless agent-plan effect + planning chat), the
auto-review follow-on effect, and per-member BYOK credentials with a
redaction audit. 829/829 workspace tests on the merged tree.
Still needed before a live hosted run: the agent-runtime toolchain,
Sprite sandbox-to-host sync-back, effect registration + credentials on
the deployment, and the owner’s spec sections (model.agent-session,
agent namespace, credential seam, branch ACLs).
crates/cli/ents-web/src/lib.rs
@@ -114,6 +114,7 @@
pub mod identity;
pub(crate) mod markdown;
pub mod pages;
+pub mod planner;
pub mod render;
pub mod router;
pub mod session;
crates/cli/ents-web/src/state.rs
@@ -32,6 +32,7 @@
use crate::auth::ChallengeStore;
use crate::identity::SigningIdentity;
+use crate::planner::{Planner, UnconfiguredPlanner};
use crate::session::SessionStore;
/// Who may mutate through this deployment's web UI — injected by the
@@ -101,6 +102,11 @@
/// unless the composition root said otherwise via
/// [`AppState::with_access`].
pub access: AccessPolicy,
+ /// The planning-chat page's LLM seam
+ /// (`docs/agent-sessions-plan.adoc`'s Phase 4): [`UnconfiguredPlanner`]
+ /// unless the composition root said otherwise via
+ /// [`AppState::with_planner`].
+ pub planner: Box<dyn Planner>,
}
impl<O> AppState<O> {
@@ -124,6 +130,7 @@
path,
sessions: SessionStore::default(),
access: AccessPolicy::Trusted,
+ planner: Box::new(UnconfiguredPlanner),
}
}
@@ -139,6 +146,17 @@
self
}
+ /// Replace the default [`UnconfiguredPlanner`] — a real Planner is
+ /// wired the same consuming-builder way once one exists (per-member
+ /// credentials, `docs/agent-sessions-plan.adoc`'s Phase 6), so every
+ /// existing `new` caller stays untouched until a composition root
+ /// opts in.
+ #[must_use]
+ pub fn with_planner(mut self, planner: Box<dyn Planner>) -> Self {
+ self.planner = planner;
+ self
+ }
+
/// Lock the object store for the duration of one request.
///
/// Poisoning recovers rather than propagating (mirrors
crates/cli/ents-web/tests/router.rs
@@ -13,7 +13,7 @@
use axum::http::{Request, StatusCode, header};
use ents_kiln::Toolchain;
use ents_model::{Account, Effect, MemberId, Provenance, Redaction, ResultRecord, Status};
-use ents_receive::{Mode, NullEventSink};
+use ents_receive::{Identity, Mode, NullEventSink};
use ents_testutil::{
CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, record_result, write_commit,
write_meta_entity,
@@ -997,7 +997,9 @@
);
let detail = get_body(&router, &format!("/issues/{issue_id}")).await;
assert!(
- detail.contains(&format!("class=\"side-row active\" href=\"/issues/{issue_id}\"")),
+ detail.contains(&format!(
+ "class=\"side-row active\" href=\"/issues/{issue_id}\""
+ )),
"the viewed issue highlights in the sidebar"
);
}
@@ -1094,6 +1096,7 @@
"/commits",
"/reviews",
"/issues",
+ "/agents",
"/comments",
"/meta",
"/account",
@@ -1103,7 +1106,9 @@
"the rail links {href}"
);
}
- for label in ["Dashboard", "Code", "Commits", "Reviews", "Issues", "Threads"] {
+ for label in [
+ "Dashboard", "Code", "Commits", "Reviews", "Issues", "Agents", "Threads",
+ ] {
assert!(
overview.contains(&format!("title=\"{label}\"")),
"the rail tooltips {label}"
@@ -3259,3 +3264,930 @@
"a revoked member is signed out, not left holding a dead session"
);
}
+
+// ---------------------------------------------------------------------
+// `crate::pages::agents` (`docs/agent-sessions-plan.adoc`'s Phase 3).
+// ---------------------------------------------------------------------
+
+/// The actor signature every [`Identity`] the agent tests below build
+/// carries -- shaped exactly like [`FixtureIdentity::actor`]. Only this
+/// half factors into a helper: [`Identity::sign`] borrows its closure
+/// (`ents_web::identity`'s own doc explains why: the closure must live in
+/// the caller's own stack frame), so each test still builds its own
+/// `Identity` literal around this, the same shape
+/// `ents_web::receive_identity!` expands to.
+fn fixture_actor(name: &'static str) -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: name.into(),
+ email: format!("{name}@ents.test").into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ }
+}
+
+/// `POST /agents`, seeding `prompt` as a fresh session's initial thread
+/// turn -- what the agent tests below start a session through, exercising
+/// the actual signed-write path (`ents_forge::agent::new`) rather than
+/// poking the ref store directly. Asserts the write succeeded (a redirect
+/// to the new session's own page) and returns its id, read from that
+/// redirect's `Location` (`/agents/<id>`).
+async fn seed_agent_via_web(
+ router: &axum::Router,
+ state: &AppState<ObjectStore>,
+ prompt: &str,
+) -> String {
+ let (cookie, csrf) = session_cookie_and_csrf(router, state, "/agents").await;
+ let form = format!(
+ "prompt={}&base_ref=refs%2Fheads%2Fmain&model=claude-sonnet-5&review_policy=manual&csrf={csrf}",
+ prompt.replace(' ', "+")
+ );
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/agents")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(form))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "agent session create did not succeed: {:?}",
+ response.status()
+ );
+ response
+ .headers()
+ .get(header::LOCATION)
+ .expect("a successful agent create redirects to the new session")
+ .to_str()
+ .expect("ascii")
+ .strip_prefix("/agents/")
+ .expect("redirect targets /agents/<id>")
+ .to_owned()
+}
+
+/// A freshly started session (no plan yet) renders as `planning` on both
+/// the index and its own detail page, offers no confirm form, and never
+/// leaks its seed prompt -- a thread turn like any other, never rendered
+/// (`ents_forge::agent::AgentSession`'s own "never rendered" contract).
+#[tokio::test]
+// @relation(lens.parity, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn agents_index_and_detail_render_a_freshly_started_session_as_planning_with_no_thread_leak()
+{
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(21),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "top secret task instructions").await;
+
+ let index = get_body(&router, "/agents").await;
+ assert!(index.contains(ents_forge::abbreviate_id(&id)));
+ assert!(index.contains("planning"));
+ assert!(
+ !index.contains("top secret task instructions"),
+ "thread content (the seed prompt) must never render on the list page"
+ );
+
+ let detail = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(detail.contains("planning"));
+ assert!(detail.contains("No plan has been drafted yet."));
+ assert!(
+ !detail.contains("Confirm plan"),
+ "no confirm form before a plan exists"
+ );
+ assert!(
+ !detail.contains("top secret task instructions"),
+ "thread content (the seed prompt) must never render on the detail page either"
+ );
+}
+
+/// `roots.web-session`: starting a session is a state-changing route, so a
+/// `POST /agents` with no CSRF field at all is rejected, and one with the
+/// wrong token is a bad request -- the same gate every mutation in this
+/// crate runs behind.
+#[tokio::test]
+// @relation(roots.web-session, scope=function, role=Verifies)
+async fn agent_create_is_rejected_without_a_valid_csrf_token() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(22),
+ });
+ let router = ents_web::router(Arc::clone(&state));
+
+ let no_csrf = router
+ .clone()
+ .oneshot(
+ Request::post("/agents")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(Body::from(
+ "prompt=sneaky&base_ref=HEAD&model=claude-sonnet-5&review_policy=manual",
+ ))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ !no_csrf.status().is_success() && !no_csrf.status().is_redirection(),
+ "a POST with no csrf field must not start a session"
+ );
+
+ let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/agents").await;
+ let wrong = router
+ .oneshot(
+ Request::post("/agents")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(
+ "prompt=sneaky&base_ref=HEAD&model=claude-sonnet-5&review_policy=manual&csrf=not-the-token",
+ ))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(wrong.status(), StatusCode::BAD_REQUEST);
+}
+
+/// A plan drafted (via `ents_forge::agent::revise_plan`, standing in for
+/// Phase 4's not-yet-built planning surface) puts a session in `awaiting
+/// confirmation` with a one-tap Confirm form; posting it
+/// (`POST /agents/{id}/confirm`) is a signed, CSRF-checked mutation that
+/// binds the plan's hash and reads back as `queued`, with the confirm form
+/// gone.
+#[tokio::test]
+// @relation(lens.parity, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn confirming_an_awaiting_session_transitions_it_to_queued_through_a_signed_post() {
+ let seed = 23u8;
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(seed),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "draft a fix").await;
+
+ let key = Keypair::from_seed(seed);
+ let identity = Identity {
+ actor: fixture_actor("filer"),
+ author: None,
+ sign: &|payload| key.sign(payload),
+ };
+ ents_forge::agent::revise_plan(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ "do the thing".to_owned(),
+ &identity,
+ state.mode,
+ )
+ .expect("revise_plan reaches an outcome");
+
+ let detail = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(detail.contains("awaiting confirmation"));
+ assert!(
+ detail.contains("Confirm plan"),
+ "a plan with no current confirm shows the one-tap confirm form"
+ );
+
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/agents").await;
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/agents/{id}/confirm"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!("csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "{:?}",
+ response.status()
+ );
+
+ let detail = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(detail.contains("queued"));
+ assert!(
+ !detail.contains("Confirm plan"),
+ "a queued session no longer shows the confirm form"
+ );
+}
+
+/// Every one of the six derived states
+/// (`docs/agent-sessions-plan.adoc`'s Phase 3: planning, awaiting
+/// confirmation, queued, running, done, failed) renders distinctly on the
+/// index, the running session's own sandbox name renders verbatim, the
+/// done/failed sessions show their result branch/failure detail -- and no
+/// session's thread content ever appears on either the index or any
+/// detail page, whatever its state.
+#[tokio::test]
+// @relation(lens.parity, scope=function, role=Verifies)
+async fn agents_index_renders_every_derived_state_distinctly_and_never_leaks_thread_content() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+
+ let base = |member: &str, seconds: i64| {
+ ents_forge::agent::SessionMeta::new(
+ MemberId::new(member),
+ seconds,
+ "claude-sonnet-5",
+ vec![],
+ "refs/heads/main",
+ ents_forge::agent::ReviewPolicy::Manual,
+ None,
+ )
+ };
+
+ let planning = ents_forge::agent::AgentSession {
+ meta: base("jdc", 100),
+ plan: None,
+ confirm: None,
+ thread: vec![b"thread marker planning".to_vec()],
+ };
+ write_meta_entity(
+ &refs,
+ &objects,
+ ents_model::namespace::agent_session_ref("s-planning").expect("valid"),
+ &planning,
+ None,
+ 100,
+ );
+
+ let mut awaiting = ents_forge::agent::AgentSession {
+ meta: base("jdc", 200),
+ plan: Some("draft plan".to_owned()),
+ confirm: None,
+ thread: vec![b"thread marker awaiting".to_vec()],
+ };
+ awaiting.meta.status = ents_forge::agent::Status::Ready;
+ write_meta_entity(
+ &refs,
+ &objects,
+ ents_model::namespace::agent_session_ref("s-awaiting").expect("valid"),
+ &awaiting,
+ None,
+ 200,
+ );
+
+ let mut queued = ents_forge::agent::AgentSession {
+ meta: base("jdc", 300),
+ plan: Some("confirmed plan".to_owned()),
+ confirm: None,
+ thread: vec![b"thread marker queued".to_vec()],
+ };
+ queued.meta.status = ents_forge::agent::Status::Ready;
+ let hash = queued.plan_hash().expect("plan set");
+ queued.confirm = Some(ents_forge::agent::Confirm::new(
+ hash,
+ ents_forge::agent::ReviewPolicy::Manual,
+ ));
+ write_meta_entity(
+ &refs,
+ &objects,
+ ents_model::namespace::agent_session_ref("s-queued").expect("valid"),
+ &queued,
+ None,
+ 300,
+ );
+
+ let mut running = ents_forge::agent::AgentSession {
+ meta: base("jdc", 400),
+ plan: Some("confirmed plan".to_owned()),
+ confirm: None,
+ thread: vec![b"thread marker running".to_vec()],
+ };
+ running.meta.status = ents_forge::agent::Status::Running;
+ running.meta.sprite = Some("sprite-42".to_owned());
+ running.meta.worker = Some(MemberId::new("worker-bot"));
+ running.meta.started = Some(400);
+ write_meta_entity(
+ &refs,
+ &objects,
+ ents_model::namespace::agent_session_ref("s-running").expect("valid"),
+ &running,
+ None,
+ 400,
+ );
+
+ let mut done = ents_forge::agent::AgentSession {
+ meta: base("jdc", 500),
+ plan: Some("confirmed plan".to_owned()),
+ confirm: None,
+ thread: vec![b"thread marker done".to_vec()],
+ };
+ done.meta.status = ents_forge::agent::Status::Done;
+ done.meta.result_branch = Some("agent/jdc/deadbeef".to_owned());
+ done.meta.finished = Some(500);
+ write_meta_entity(
+ &refs,
+ &objects,
+ ents_model::namespace::agent_session_ref("s-done").expect("valid"),
+ &done,
+ None,
+ 500,
+ );
+
+ let mut failed = ents_forge::agent::AgentSession {
+ meta: base("jdc", 600),
+ plan: Some("confirmed plan".to_owned()),
+ confirm: None,
+ thread: vec![b"thread marker failed".to_vec()],
+ };
+ failed.meta.status = ents_forge::agent::Status::Failed(ents_forge::agent::FailureReason {
+ detail: "sandbox died".to_owned(),
+ });
+ failed.meta.finished = Some(600);
+ write_meta_entity(
+ &refs,
+ &objects,
+ ents_model::namespace::agent_session_ref("s-failed").expect("valid"),
+ &failed,
+ None,
+ 600,
+ );
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(24),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state.clone());
+
+ let index = get_body(&router, "/agents").await;
+ for label in [
+ "planning",
+ "awaiting confirmation",
+ "queued",
+ "running",
+ "done",
+ "failed",
+ ] {
+ assert!(index.contains(label), "the index shows the {label} state");
+ }
+ for marker in [
+ "thread marker planning",
+ "thread marker awaiting",
+ "thread marker queued",
+ "thread marker running",
+ "thread marker done",
+ "thread marker failed",
+ ] {
+ assert!(
+ !index.contains(marker),
+ "thread content must never render on the list page: {marker}"
+ );
+ }
+
+ let running_detail = get_body(&router, "/agents/s-running").await;
+ assert!(
+ running_detail.contains("sprite-42"),
+ "the sandbox name renders verbatim while running"
+ );
+ assert!(!running_detail.contains("thread marker running"));
+
+ let done_detail = get_body(&router, "/agents/s-done").await;
+ assert!(done_detail.contains("agent/jdc/deadbeef"));
+ assert!(!done_detail.contains("thread marker done"));
+
+ let failed_detail = get_body(&router, "/agents/s-failed").await;
+ assert!(failed_detail.contains("sandbox died"));
+ assert!(!failed_detail.contains("thread marker failed"));
+}
+
+/// The "result record" ref [`crate::pages::agents::show`] displays for a
+/// terminal session is derived from the chain's own confirmed-and-queued
+/// commit -- exactly what `git-ents::agent_worker::run_agent_exec` reads
+/// as its own dispatched oid -- and reads back as "not yet recorded" until
+/// a result actually lands at that derived ref, then as recorded once one
+/// does.
+#[tokio::test]
+// @relation(lens.parity, effect.results-writeback, scope=function, role=Verifies)
+async fn result_record_ref_is_derived_from_the_chains_own_confirmed_commit() {
+ let seed = 25u8;
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(seed),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "ship the fix").await;
+
+ let key = Keypair::from_seed(seed);
+ let identity = Identity {
+ actor: fixture_actor("filer"),
+ author: None,
+ sign: &|payload| key.sign(payload),
+ };
+
+ ents_forge::agent::revise_plan(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ "do the thing".to_owned(),
+ &identity,
+ state.mode,
+ )
+ .expect("revise_plan reaches an outcome");
+ ents_forge::agent::confirm(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ None,
+ &identity,
+ state.mode,
+ )
+ .expect("confirm reaches an outcome");
+
+ let ref_name = ents_model::namespace::agent_session_ref(&id).expect("valid");
+ let confirmed_tip = state
+ .refs
+ .get(ref_name.as_ref())
+ .expect("read")
+ .expect("confirmed tip exists");
+
+ ents_forge::agent::claim(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ ents_forge::agent::ClaimAgentSession {
+ worker: MemberId::new("worker-bot"),
+ sprite: "sprite-9".to_owned(),
+ },
+ &identity,
+ state.mode,
+ )
+ .expect("claim reaches an outcome");
+ ents_forge::agent::finish(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ ents_forge::agent::FinishAgentSession {
+ outcome: ents_forge::agent::FinishOutcome::Done,
+ result_branch: Some("agent/filer/deadbeef".to_owned()),
+ thread: vec![b"final log".to_vec()],
+ },
+ &identity,
+ state.mode,
+ )
+ .expect("finish reaches an outcome");
+
+ let expected_short = ents_effect::run::short_oid(confirmed_tip);
+ let result_ref =
+ ents_model::namespace::result_ref("agent-exec", &expected_short).expect("valid");
+ let result_ref_display = result_ref.as_bstr().to_string();
+
+ let before = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(before.contains(&result_ref_display));
+ assert!(before.contains("not yet recorded"));
+
+ let record = ResultRecord::new("agent-exec", confirmed_tip, Status::Pass);
+ ents_receive::propose_entity(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ result_ref.clone(),
+ &record,
+ &identity,
+ "Record agent-exec result",
+ state.mode,
+ )
+ .expect("reaches an outcome");
+
+ let after = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(after.contains(&result_ref_display));
+ assert!(!after.contains("not yet recorded"));
+}
+
+// ---------------------------------------------------------------------
+// `crate::pages::agents`'s manual one-tap "Open review"
+// (`docs/agent-sessions-plan.adoc`'s Phase 5).
+// ---------------------------------------------------------------------
+
+/// Push a real `refs/heads/<branch>` tip directly through `state`'s own ref
+/// store -- a branch ref needs no signature at all
+/// (`gate.principled-split`), mirroring `git-ents`'s own
+/// `tests/agent.rs::advance_branch` but against this crate's `AppState`
+/// rather than a `LocalRoot`.
+fn advance_branch(state: &AppState<ObjectStore>, branch: &str, seconds: i64) -> gix_hash::ObjectId {
+ let tree = state.objects().write(&Tree::empty()).expect("tree");
+ let oid = write_commit(
+ &*state.objects(),
+ &CommitSpec {
+ tree,
+ parents: vec![],
+ message: format!("agent-exec output for {branch}"),
+ seconds,
+ },
+ None,
+ );
+ let name: gix::refs::FullName = format!("refs/heads/{branch}")
+ .try_into()
+ .expect("valid refname");
+ state
+ .refs
+ .transaction(&[gix_ref_store::RefEdit {
+ name,
+ expected: gix_ref_store::Expected::Any,
+ new: Some(oid),
+ }])
+ .expect("moves the ref");
+ oid
+}
+
+/// A `Done`, `manual`-policy session with a result branch offers the
+/// one-tap "Open review" form; posting it (`POST /agents/{id}/review`)
+/// opens a review of the branch's own tip through the same signed web path
+/// `crate::pages::commits::review` uses for a commit-page review, after
+/// which the form no longer renders -- the review it would open already
+/// exists (`docs/agent-sessions-plan.adoc`'s Phase 5 acceptance: "manual
+/// yields none and the session page offers one-tap open").
+#[tokio::test]
+// @relation(model.review, model.review-pin, roots.web-signing, roots.web-session, lens.parity, scope=function, role=Verifies)
+async fn manual_session_offers_a_one_tap_open_review_form_that_opens_one() {
+ let seed = 30u8;
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(seed),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "ship the fix").await;
+
+ let key = Keypair::from_seed(seed);
+ let identity = Identity {
+ actor: fixture_actor("filer"),
+ author: None,
+ sign: &|payload| key.sign(payload),
+ };
+ ents_forge::agent::revise_plan(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ "do the thing".to_owned(),
+ &identity,
+ state.mode,
+ )
+ .expect("revise_plan reaches an outcome");
+ ents_forge::agent::confirm(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ None,
+ &identity,
+ state.mode,
+ )
+ .expect("confirm reaches an outcome");
+ ents_forge::agent::claim(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ ents_forge::agent::ClaimAgentSession {
+ worker: MemberId::new("worker-bot"),
+ sprite: "sprite-9".to_owned(),
+ },
+ &identity,
+ state.mode,
+ )
+ .expect("claim reaches an outcome");
+ let branch = "agent/filer/deadbeef";
+ ents_forge::agent::finish(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ ents_forge::agent::FinishAgentSession {
+ outcome: ents_forge::agent::FinishOutcome::Done,
+ result_branch: Some(branch.to_owned()),
+ thread: vec![b"final log".to_vec()],
+ },
+ &identity,
+ state.mode,
+ )
+ .expect("finish reaches an outcome");
+ advance_branch(&state, branch, 1_100);
+
+ let detail = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(detail.contains("done"));
+ assert!(
+ detail.contains("Open review"),
+ "a done, manual session with a result branch offers the one-tap open: {detail}"
+ );
+
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/agents").await;
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/agents/{id}/review"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!("csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "{:?}",
+ response.status()
+ );
+
+ let after = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(
+ !after.contains("Open review"),
+ "once opened, the one-tap form must not render again"
+ );
+}
+
+/// `POST /agents/{id}/review` without a valid CSRF token is rejected --
+/// this is a state-changing route like every other signed mutation
+/// (`roots.web-session`).
+#[tokio::test]
+// @relation(roots.web-session, scope=function, role=Verifies)
+async fn open_review_is_rejected_without_a_valid_csrf_token() {
+ let seed = 31u8;
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(seed),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "ship the fix").await;
+
+ let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/agents").await;
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/agents/{id}/review"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from("csrf=not-the-token"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+}
+
+// ---------------------------------------------------------------------
+// `crate::pages::agent_chat` (`docs/agent-sessions-plan.adoc`'s Phase 4:
+// the laptop planning-chat page).
+// ---------------------------------------------------------------------
+
+/// `POST path` with a signed-in session's cookie and a form-encoded body,
+/// returning the response -- the chat-page tests' own thin wrapper around
+/// the same request shape [`agent_create_is_rejected_without_a_valid_csrf_token`]
+/// builds inline, factored out here since every test below needs at least
+/// one.
+async fn post_form(
+ router: &axum::Router,
+ path: &str,
+ cookie: &str,
+ body: String,
+) -> axum::http::Response<Body> {
+ router
+ .clone()
+ .oneshot(
+ Request::post(path)
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(body))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call")
+}
+
+/// A freshly started (`planning`) session's detail page links to its own
+/// planning-chat page, which itself renders the composer and the seeded
+/// prompt (the one place a thread blob is deliberately rendered -- see
+/// `crate::pages::agent_chat`'s own doc: it is the very page that wrote
+/// it, for the member who wrote it).
+#[tokio::test]
+// @relation(lens.parity, scope=function, role=Verifies)
+async fn agent_detail_links_to_chat_and_chat_renders_the_composer_and_prompt() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(30),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "reproduce the flaky test").await;
+
+ let detail = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(detail.contains(&format!("/agents/{id}/chat")));
+
+ let chat = get_body(&router, &format!("/agents/{id}/chat")).await;
+ assert!(chat.contains("data-agent-chat"));
+ assert!(chat.contains("reproduce the flaky test"));
+ assert!(!chat.contains("Reopen for planning"));
+}
+
+/// `POST /agents/{id}/chat` appends the member's message and the injected
+/// `Planner`'s reply (`ents_web::planner::UnconfiguredPlanner`, the
+/// default every composition root installs) in one commit, both of which
+/// then render on a reload of the chat page.
+#[tokio::test]
+// @relation(lens.parity, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn agent_chat_send_appends_the_turn_pair_and_renders_the_stub_planners_reply() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(31),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "prompt").await;
+
+ let (cookie, csrf) =
+ session_cookie_and_csrf(&router, &state, &format!("/agents/{id}/chat")).await;
+ let response = post_form(
+ &router,
+ &format!("/agents/{id}/chat"),
+ &cookie,
+ format!("message=what+should+the+plan+look+like%3F&csrf={csrf}"),
+ )
+ .await;
+ assert!(response.status().is_redirection());
+
+ let session =
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id).expect("shows");
+ assert_eq!(
+ session.thread.len(),
+ 3,
+ "prompt + user turn + assistant turn"
+ );
+
+ let chat = get_body(&router, &format!("/agents/{id}/chat")).await;
+ assert!(chat.contains("what should the plan look like?"));
+ assert!(chat.to_lowercase().contains("not configured"));
+}
+
+/// `roots.web-session`: `POST /agents/{id}/chat` is a state-changing route,
+/// refused without a valid CSRF token exactly like every other mutation in
+/// this crate.
+#[tokio::test]
+// @relation(roots.web-session, scope=function, role=Verifies)
+async fn agent_chat_send_is_rejected_without_a_valid_csrf_token() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(32),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "prompt").await;
+
+ let (cookie, _csrf) =
+ session_cookie_and_csrf(&router, &state, &format!("/agents/{id}/chat")).await;
+ let response = post_form(
+ &router,
+ &format!("/agents/{id}/chat"),
+ &cookie,
+ "message=sneaky&csrf=wrong".to_owned(),
+ )
+ .await;
+ assert!(!response.status().is_success() && !response.status().is_redirection());
+
+ let session =
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id).expect("shows");
+ assert_eq!(
+ session.thread.len(),
+ 1,
+ "the bad-csrf message must not land"
+ );
+}
+
+/// `docs/agent-sessions-plan.adoc`'s Phase 4 acceptance: once a session is
+/// confirmed and queued, `POST /agents/{id}/chat` refuses rather than
+/// silently un-queueing it; `POST /agents/{id}/reopen` is the explicit
+/// un-queue that returns it to `planning`, after which chatting works
+/// again.
+#[tokio::test]
+// @relation(scope=function, role=Verifies)
+async fn agent_chat_refuses_a_queued_session_until_explicitly_reopened() {
+ let seed = 33u8;
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(seed),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "prompt").await;
+
+ let key = Keypair::from_seed(seed);
+ let identity = Identity {
+ actor: fixture_actor("filer"),
+ author: None,
+ sign: &|payload| key.sign(payload),
+ };
+ ents_forge::agent::revise_plan(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ "do the thing".to_owned(),
+ &identity,
+ state.mode,
+ )
+ .expect("revises");
+ ents_forge::agent::confirm(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &id,
+ None,
+ &identity,
+ state.mode,
+ )
+ .expect("confirms");
+ assert!(
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id)
+ .expect("shows")
+ .queued()
+ );
+
+ let (cookie, csrf) =
+ session_cookie_and_csrf(&router, &state, &format!("/agents/{id}/chat")).await;
+ let refused = post_form(
+ &router,
+ &format!("/agents/{id}/chat"),
+ &cookie,
+ format!("message=let%27s+change+something&csrf={csrf}"),
+ )
+ .await;
+ assert!(
+ !refused.status().is_success() && !refused.status().is_redirection(),
+ "a queued session must refuse a chat message rather than silently un-queue"
+ );
+
+ let queued_chat = get_body(&router, &format!("/agents/{id}/chat")).await;
+ assert!(queued_chat.contains("Reopen for planning"));
+
+ let reopened = post_form(
+ &router,
+ &format!("/agents/{id}/reopen"),
+ &cookie,
+ format!("csrf={csrf}"),
+ )
+ .await;
+ assert!(reopened.status().is_redirection());
+
+ let session =
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id).expect("shows");
+ assert_eq!(session.meta.status, ents_forge::agent::Status::Planning);
+ assert!(session.confirm.is_none());
+
+ let sent = post_form(
+ &router,
+ &format!("/agents/{id}/chat"),
+ &cookie,
+ format!("message=let%27s+change+something&csrf={csrf}"),
+ )
+ .await;
+ assert!(
+ sent.status().is_redirection(),
+ "chatting works again once the session is reopened"
+ );
+}
+
+/// `POST /agents/{id}/plan` commits the plan editor's text via
+/// `ents_forge::agent::revise_plan`, transitioning the session to
+/// `ready`/awaiting-confirmation -- the same commit path
+/// `docs/agent-sessions-plan.adoc`'s Phase 4 names for both this chat page
+/// and the headless `agent-plan` effect.
+#[tokio::test]
+// @relation(lens.parity, roots.web-signing, roots.web-session, scope=function, role=Verifies)
+async fn agent_plan_commit_transitions_to_ready_awaiting_confirmation() {
+ let state = build_state(FixtureIdentity {
+ name: "filer",
+ key: Keypair::from_seed(34),
+ });
+ let router = ents_web::router(state.clone());
+ let id = seed_agent_via_web(&router, &state, "prompt").await;
+
+ let (cookie, csrf) =
+ session_cookie_and_csrf(&router, &state, &format!("/agents/{id}/chat")).await;
+ let response = post_form(
+ &router,
+ &format!("/agents/{id}/plan"),
+ &cookie,
+ format!("plan=1.+read+the+test%0A2.+fix+it&csrf={csrf}"),
+ )
+ .await;
+ assert!(response.status().is_redirection());
+
+ let session =
+ ents_forge::agent::show(state.refs.as_ref(), &*state.objects(), &id).expect("shows");
+ assert_eq!(session.meta.status, ents_forge::agent::Status::Ready);
+ assert!(session.awaiting_confirmation());
+
+ let detail = get_body(&router, &format!("/agents/{id}")).await;
+ assert!(detail.contains("Confirm plan"));
+}
crates/cli/git-ents/src/cli.rs
@@ -9,6 +9,7 @@
use facet::Facet;
use figue::{self as args, FigueBuiltins};
+pub use ents_forge::agent::AgentAction;
pub use ents_forge::comment::CommentAction;
pub use ents_forge::issue::IssueAction;
pub use ents_forge::review::ReviewAction;
@@ -128,6 +129,16 @@
#[facet(args::subcommand)]
action: IssueAction,
},
+ /// Manage agent sessions at `refs/meta/agent-sessions/<id>`: start one,
+ /// draft or redraft its plan, and confirm it for execution
+ /// (`docs/agent-sessions-plan.adoc`). Claiming and running a queued
+ /// session is the effect worker's job (`git-ents hook post-receive` on
+ /// the hosted root), not a subcommand here.
+ Agent {
+ /// The agent action to run.
+ #[facet(args::subcommand)]
+ action: AgentAction,
+ },
/// Review a commit: a verdict plus a body at
/// `refs/meta/reviews/<target>/<member>`, with a retention pin at
/// `refs/meta/pins/reviews/<target>/<member>` keeping the reviewed
crates/cli/git-ents/src/hook.rs
@@ -327,9 +327,10 @@
let Some(effect) = read_effect(&root.refs, &root.objects, &effect_name)? else {
continue;
};
- // `run_one` no longer resolves toolchain names itself: resolve and
- // materialize this effect's declared toolchains here, before
- // handing the run loop an already-materialized slice.
+ // `run_one`/`run_agent_exec` no longer resolve toolchain names
+ // themselves: resolve and materialize this effect's declared
+ // toolchains here, before handing the run loop an
+ // already-materialized slice.
let mut toolchains = Vec::with_capacity(effect.toolchains.len());
for toolchain_name in &effect.toolchains {
let (_, recipe) =
@@ -337,20 +338,84 @@
let bin = ents_kiln::toolchain::materialize(&recipe, &root.objects, toolchain_cache)?;
toolchains.push((toolchain_name.clone(), bin));
}
- run_one(
- &root.refs,
- &root.objects,
- &root.events,
- executor,
- scratch,
- &toolchains,
- oid,
- &effect,
- result_ref,
- &author,
- |payload| signer.sign(payload),
- Mode::Mandatory,
- )?;
+
+ // The `agent-exec` effect (`docs/agent-sessions-plan.adoc`'s Phase
+ // 2) needs the bespoke dispatch/claim/finalize handling
+ // `crate::agent_worker::run_agent_exec` provides — a plain
+ // pass/fail result on a single ref, `run_one`'s own contract,
+ // cannot express "claim, run a sandbox, and land the session's
+ // terminal state, its result, and its result branch atomically."
+ // Every other effect keeps the ordinary single-ref path.
+ if effect_name == crate::agent_worker::AGENT_EXEC_NAME {
+ crate::agent_worker::run_agent_exec(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ executor,
+ scratch,
+ &toolchains,
+ &effect.run,
+ oid,
+ ents_model::MemberId::new(crate::root::HOSTED_WORKER_NAME),
+ crate::root::HOSTED_WORKER_NAME.to_owned(),
+ &author,
+ &|payload| signer.sign(payload),
+ Mode::Mandatory,
+ &root.credentials,
+ )?;
+ } else if effect_name == crate::plan_worker::AGENT_PLAN_NAME {
+ // The `agent-plan` effect (`docs/agent-sessions-plan.adoc`'s
+ // Phase 4) needs the same bespoke handling `agent-exec` does,
+ // for the same reason: a plain pass/fail result on a single
+ // ref cannot express "draft a plan and land it atomically with
+ // this effect's own result."
+ crate::plan_worker::run_agent_plan(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ executor,
+ scratch,
+ &toolchains,
+ &effect.run,
+ oid,
+ &author,
+ &|payload| signer.sign(payload),
+ Mode::Mandatory,
+ &root.credentials,
+ )?;
+ } else if effect_name == crate::review_worker::AGENT_REVIEW_NAME {
+ // The `agent-review` effect (`docs/agent-sessions-plan.adoc`'s
+ // Phase 5) needs its own bespoke handling too, but for the
+ // opposite reason `agent-exec`/`agent-plan` do: it runs no
+ // sandboxed command at all (`crate::review_worker`'s own doc),
+ // so `executor`/`toolchains`/`effect.run` — resolved above for
+ // every effect uniformly — are simply unused for this one.
+ crate::review_worker::run_agent_review(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ oid,
+ ents_model::MemberId::new(crate::root::HOSTED_WORKER_NAME),
+ &author,
+ &|payload| signer.sign(payload),
+ Mode::Mandatory,
+ )?;
+ } else {
+ run_one(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ executor,
+ scratch,
+ &toolchains,
+ oid,
+ &effect,
+ result_ref,
+ &author,
+ |payload| signer.sign(payload),
+ Mode::Mandatory,
+ )?;
+ }
ran = ran.saturating_add(1);
}
Ok(ran)
crates/cli/git-ents/src/lib.rs
@@ -73,13 +73,17 @@
//! assert!(root.refs.get(name.as_ref()).expect("reads").is_some());
//! ```
+pub mod agent_worker;
pub mod cli;
pub mod commands;
+pub mod credentials;
pub mod error;
pub mod exe;
pub mod hook;
pub mod mutate;
pub mod package;
+pub mod plan_worker;
+pub mod review_worker;
pub mod root;
pub mod sign;
crates/cli/git-ents/src/root.rs
@@ -53,6 +53,7 @@
use ents_receive::{Mode, NullEventSink};
use gix_ref_store::LooseRefStore;
+use crate::credentials::CredentialStore;
use crate::error::{Error, Result};
/// A real, on-disk object store: the repository's own odb, opened for
@@ -188,6 +189,12 @@
/// The fixed `Executor` this root wires (`roots.single-node-hosted`): a
/// `SpriteExecutor` targeting [`HOSTED_WORKER_NAME`].
pub executor: Box<dyn Executor>,
+ /// Per-member BYOK credentials (`roots.config-isolation`), read once
+ /// from [`crate::credentials::CREDENTIALS_FILE_VAR`] — the seam
+ /// [`crate::agent_worker::run_agent_exec`]/
+ /// [`crate::plan_worker::run_agent_plan`] resolve a session's own
+ /// member's credential from before injecting it into a sandbox launch.
+ pub credentials: CredentialStore,
}
/// The Sprite name (and commit author name) the single-node hosted root's
@@ -208,19 +215,23 @@
///
/// [`Error::Repo`] or [`Error::Refs`] if `path` is not a git
/// repository; [`Error::Receive`] if the reconciliation scan itself
- /// fails to read repository state.
+ /// fails to read repository state; [`Error::Io`]/[`Error::InvalidArgument`]
+ /// if [`crate::credentials::CREDENTIALS_FILE_VAR`] names a credentials
+ /// file that cannot be read or is malformed.
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_owned();
let refs = LooseRefStore::open(&path)?;
let objects = QuarantineObjects::open(&path)?;
let events = ents_receive::MemoryEventSink::default();
ents_receive::reconcile(&refs, &objects, &events)?;
+ let credentials = CredentialStore::from_env()?;
Ok(Self {
path,
refs,
objects,
events,
executor: Box::new(ents_effect::SpriteExecutor::new(HOSTED_WORKER_NAME)),
+ credentials,
})
}
crates/forge/ents-forge/src/lib.rs
@@ -1,6 +1,7 @@
-//! The forge domain: the [`Issue`], [`comment::Comment`], and
-//! [`review::Review`] entities, and the command business logic driving
-//! each — kernel-independent, unlike `ents-model`'s remaining entities,
+//! The forge domain: the [`Issue`], [`comment::Comment`],
+//! [`review::Review`], and [`agent::AgentSession`] entities, and the
+//! command business logic driving each — kernel-independent, unlike
+//! `ents-model`'s remaining entities,
//! because a comment or review command needs `ents-anchor` (to capture and
//! project a code anchor) and `ents-receive` (to propose the mutation),
//! neither of which a purely declarative vocabulary crate like
@@ -46,6 +47,13 @@
//! lens offers over these entities is one of this crate's library
//! functions; frontends only wire stores and render.
//!
+//! [`agent::AgentSession`] (`refs/meta/agent-sessions/<id>`,
+//! `namespace::agent_session_ref`) is Phase 1 of
+//! `docs/agent-sessions-plan.adoc`: no `model.agent-session` spec section
+//! exists yet (an owner item the plan itself names), so its own module docs
+//! cite only the `meta-ref.*` and `model.extensibility` ids above that
+//! already apply to any hash-identified, additively-evolving typed tree.
+//!
//! # Examples
//!
//! Build an [`Issue`], and a [`comment::Comment`] anchored to a stand-in
@@ -88,6 +96,7 @@
mod error;
+pub mod agent;
pub mod comment;
pub mod issue;
pub mod review;
@@ -193,6 +202,13 @@
#[case::comment(comment::Comment::SHAPE.type_identifier, "Comment")]
#[case::issue(Issue::SHAPE.type_identifier, "Issue")]
#[case::review(review::Review::SHAPE.type_identifier, "Review")]
+ #[case::agent_session(agent::AgentSession::SHAPE.type_identifier, "AgentSession")]
+ #[case::agent_session_meta(agent::SessionMeta::SHAPE.type_identifier, "SessionMeta")]
+ #[case::agent_toolchain_pin(agent::ToolchainPin::SHAPE.type_identifier, "ToolchainPin")]
+ #[case::agent_confirm(agent::Confirm::SHAPE.type_identifier, "Confirm")]
+ #[case::agent_status(agent::Status::SHAPE.type_identifier, "Status")]
+ #[case::agent_failure_reason(agent::FailureReason::SHAPE.type_identifier, "FailureReason")]
+ #[case::agent_review_policy(agent::ReviewPolicy::SHAPE.type_identifier, "ReviewPolicy")]
// @relation(model.extensibility, scope=function, role=Verifies)
fn every_entity_shape_name_tracks_its_struct_declaration(
#[case] reflected: &str,
crates/forge/ents-forge/tests/round_trip.rs
@@ -37,6 +37,53 @@
}
}
+proptest! {
+ #![proptest_config(ProptestConfig::with_cases(64))]
+
+ // @relation(meta-ref.typed-tree, scope=function, role=Verifies)
+ #[test]
+ fn agent_session_round_trips_for_any_fields_and_collection_lengths(
+ member in any::<String>(),
+ created in any::<i64>(),
+ model in any::<String>(),
+ toolchain_names in prop::collection::vec(any::<String>(), 0..4),
+ base_ref in any::<String>(),
+ plan in proptest::option::of(any::<String>()),
+ thread in prop::collection::vec(prop::collection::vec(any::<u8>(), 0..16), 0..4),
+ ) {
+ use ents_forge::agent::{AgentSession, Confirm, ReviewPolicy, SessionMeta, ToolchainPin};
+
+ let toolchains: Vec<ToolchainPin> = toolchain_names
+ .into_iter()
+ .map(|name| ToolchainPin::new(name, gix_hash::ObjectId::null(gix_hash::Kind::Sha1)))
+ .collect();
+ let meta = SessionMeta::new(
+ MemberId::new(member),
+ created,
+ model,
+ toolchains,
+ base_ref,
+ ReviewPolicy::Manual,
+ None,
+ );
+ // A confirm can only exist alongside a plan in practice (the command
+ // layer refuses otherwise), but the typed tree itself places no such
+ // constraint on what round-trips — exercised here as `plan`'s own
+ // hash, present only when `plan` is.
+ let confirm = plan
+ .as_deref()
+ .map(|text| Confirm::new(
+ gix_object::compute_hash(gix_hash::Kind::Sha1, gix_object::Kind::Blob, text.as_bytes())
+ .expect("hashing cannot fail"),
+ ReviewPolicy::Auto,
+ ));
+ let session = AgentSession { meta, plan, confirm, thread };
+ let (id, store) = serialize(&session).expect("serialize");
+ let back: AgentSession = deserialize(&id, &store).expect("deserialize");
+ prop_assert_eq!(back, session);
+ }
+}
+
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
crates/kernel/ents-effect/src/definition.rs
@@ -56,6 +56,143 @@
Ok(())
}
+/// The canonical `agent-exec` effect's own name
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2) — the final segment of
+/// `refs/meta/effects/agent-exec` (`model.effect-definition`).
+pub const AGENT_EXEC_NAME: &str = "agent-exec";
+
+/// `agent-exec`'s trigger: every author-written `refs/meta/agent-sessions/*`
+/// tip — every commit entering the agent-sessions namespace
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2, "An `agent-exec` effect
+/// subscribed via `meta(...)` to the agent namespace"). `meta()`'s own
+/// grammar rule (`query.meta`) only forbids matching an effect-written
+/// namespace — `refs/meta/results/*` or `refs/meta/index/*` — and
+/// `refs/meta/agent-sessions/*` is neither, so this glob needs no
+/// grammar extension (`query.no-extensions`); this module's own tests pin
+/// that against the real parser.
+pub const AGENT_EXEC_TRIGGER: &str = "meta(refs/meta/agent-sessions/*)";
+
+/// The canonical `agent-exec` [`Effect`] definition
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2): fires once per commit
+/// entering the agent-sessions namespace. `toolchains` and `run` are a
+/// deployment's own choice — this constructor only fixes the two fields
+/// that make the effect *this* effect, `name` and `trigger`
+/// (`model.effect-definition`); a real deployment still writes its own
+/// signed commit onto `refs/meta/effects/agent-exec` through the ordinary
+/// admin-only path (`effect.admin-only`), this fixture is not that write.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::definition::{agent_exec, validate};
+///
+/// let effect = agent_exec(vec!["agent-runtime".to_owned()], "git-ents agent-exec run");
+/// assert_eq!(effect.name, "agent-exec");
+/// validate(&effect).expect("the canonical trigger validates");
+/// ```
+#[must_use]
+pub fn agent_exec(toolchains: Vec<String>, run: impl Into<String>) -> Effect {
+ Effect {
+ name: AGENT_EXEC_NAME.to_owned(),
+ trigger: AGENT_EXEC_TRIGGER.to_owned(),
+ toolchains,
+ run: run.into(),
+ }
+}
+
+/// The canonical `agent-plan` effect's own name
+/// (`docs/agent-sessions-plan.adoc`'s Phase 4, "headless plan drafting is a
+/// second effect (`agent-plan`)") — the final segment of
+/// `refs/meta/effects/agent-plan`.
+pub const AGENT_PLAN_NAME: &str = "agent-plan";
+
+/// `agent-plan`'s trigger: identical to [`AGENT_EXEC_TRIGGER`] — every
+/// author-written `refs/meta/agent-sessions/*` tip. Both effects share one
+/// query grammar; the plan's own words are "the runner decides by
+/// inspecting the tip" — what tells `agent-plan` and `agent-exec` apart is
+/// never the trigger, only each effect's own dispatch predicate
+/// (`ents_forge::agent::dispatch_plan` here, `ents_forge::agent::dispatch`
+/// for `agent-exec`) and its own results namespace
+/// (`refs/meta/results/agent-plan/*`, distinct from `agent-exec`'s).
+pub const AGENT_PLAN_TRIGGER: &str = AGENT_EXEC_TRIGGER;
+
+/// The canonical `agent-plan` [`Effect`] definition
+/// (`docs/agent-sessions-plan.adoc`'s Phase 4): headless plan drafting,
+/// firing on every commit entering the agent-sessions namespace exactly
+/// like `agent-exec` — the runner's own dispatch predicate is what makes it
+/// a cheap no-op except when a session is `planning`, carries a prompt,
+/// and has no plan leaf yet. `toolchains` and `run` are a deployment's own
+/// choice, exactly as [`agent_exec`]'s own doc explains for its two fixed
+/// fields.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::definition::{agent_plan, validate};
+///
+/// let effect = agent_plan(vec!["agent-runtime".to_owned()], "git-ents agent-plan draft");
+/// assert_eq!(effect.name, "agent-plan");
+/// validate(&effect).expect("the canonical trigger validates");
+/// ```
+#[must_use]
+pub fn agent_plan(toolchains: Vec<String>, run: impl Into<String>) -> Effect {
+ Effect {
+ name: AGENT_PLAN_NAME.to_owned(),
+ trigger: AGENT_PLAN_TRIGGER.to_owned(),
+ toolchains,
+ run: run.into(),
+ }
+}
+
+/// The canonical `agent-review` effect's own name
+/// (`docs/agent-sessions-plan.adoc`'s Phase 5, "Auto-open is a follow-on
+/// effect") — the final segment of `refs/meta/effects/agent-review`.
+pub const AGENT_REVIEW_NAME: &str = "agent-review";
+
+/// `agent-review`'s trigger: every `agent-exec` result recorded `pass`
+/// (`query.results`) — the follow-on's own words, "subscribed via
+/// `results(agent-exec)`," resolved to `query.grammar`'s actual two-argument
+/// `results(effect, status)` form. Only a `pass` result is a completed run
+/// with a result branch to review; a `fail`/`error` result names a run that
+/// never reached `Done`, for which there is nothing to open a review of.
+/// This module's own tests pin the exact syntax against the real parser,
+/// mirroring [`AGENT_EXEC_TRIGGER`] and [`AGENT_PLAN_TRIGGER`]'s own tests.
+pub const AGENT_REVIEW_TRIGGER: &str = "results(agent-exec, pass)";
+
+/// The canonical `agent-review` [`Effect`] definition
+/// (`docs/agent-sessions-plan.adoc`'s Phase 5): opening a review is pure
+/// repository mutation (a signed commit onto the review's own entity ref
+/// plus its retention pin) with no sandboxed command to run at all, unlike
+/// [`agent_exec`] and [`agent_plan`] — so unlike those two constructors,
+/// this one takes no `toolchains`/`run` parameters to fix: an effect
+/// definition still carries the two fields (`model.effect-definition`
+/// requires them of every effect), but this handler
+/// (`git_ents::review_worker::run_agent_review`) never resolves a toolchain
+/// or invokes an [`ents_effect`]-crate `Executor` for it, so there is
+/// nothing meaningful a caller could fix them to.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::definition::{agent_review, validate};
+///
+/// let effect = agent_review();
+/// assert_eq!(effect.name, "agent-review");
+/// assert!(effect.toolchains.is_empty());
+/// validate(&effect).expect("the canonical trigger validates");
+/// ```
+#[must_use]
+pub fn agent_review() -> Effect {
+ Effect {
+ name: AGENT_REVIEW_NAME.to_owned(),
+ trigger: AGENT_REVIEW_TRIGGER.to_owned(),
+ toolchains: Vec::new(),
+ run: "no sandboxed command: agent-review is pure repository mutation, handled entirely \
+ by its composition-root handler"
+ .to_owned(),
+ }
+}
+
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, reason = "unit test")]
@@ -102,4 +239,97 @@
fn validate_rejects_an_invalid_toolchain_name() {
assert!(validate(&effect("rev(refs/heads/main)", &["../escape"])).is_err());
}
+
+ // ---- The canonical `agent-exec` definition
+ // (`docs/agent-sessions-plan.adoc`'s Phase 2) ----
+
+ #[rstest]
+ // @relation(query.grammar, scope=function, role=Verifies)
+ fn agent_exec_trigger_parses_against_the_real_query_grammar() {
+ AGENT_EXEC_TRIGGER
+ .parse::<ents_query::Query>()
+ .expect("the canonical agent-exec trigger parses");
+ }
+
+ #[rstest]
+ // @relation(effect.validation, query.meta, scope=function, role=Verifies)
+ fn agent_exec_definition_validates() {
+ let effect = agent_exec(vec!["agent-runtime".to_owned()], "git-ents agent-exec run");
+ assert_eq!(effect.name, AGENT_EXEC_NAME);
+ validate(&effect).expect("the canonical agent-exec definition validates");
+ }
+
+ /// `query.meta` forbids `meta(glob)` from matching only
+ /// `refs/meta/results/*` and `refs/meta/index/*` — the agent-sessions
+ /// namespace is neither, so it must never be rejected as
+ /// effect-written the way `validate_rejects_a_meta_glob_naming_an_effect_written_namespace`
+ /// proves the results namespace is.
+ #[rstest]
+ // @relation(query.meta, scope=function, role=Verifies)
+ fn the_agent_sessions_namespace_is_not_rejected_as_effect_written() {
+ assert!(
+ validate(&effect(AGENT_EXEC_TRIGGER, &[])).is_ok(),
+ "refs/meta/agent-sessions/* is an author-written namespace, not one of \
+ query.meta's forbidden effect-written namespaces (refs/meta/results/*, \
+ refs/meta/index/*)"
+ );
+ }
+
+ // ---- The canonical `agent-plan` definition
+ // (`docs/agent-sessions-plan.adoc`'s Phase 4) ----
+
+ #[rstest]
+ // @relation(query.grammar, scope=function, role=Verifies)
+ fn agent_plan_trigger_parses_against_the_real_query_grammar() {
+ AGENT_PLAN_TRIGGER
+ .parse::<ents_query::Query>()
+ .expect("the canonical agent-plan trigger parses");
+ }
+
+ #[rstest]
+ // @relation(effect.validation, query.meta, scope=function, role=Verifies)
+ fn agent_plan_definition_validates() {
+ let effect = agent_plan(
+ vec!["agent-runtime".to_owned()],
+ "git-ents agent-plan draft",
+ );
+ assert_eq!(effect.name, AGENT_PLAN_NAME);
+ validate(&effect).expect("the canonical agent-plan definition validates");
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn agent_plan_and_agent_exec_share_a_trigger_but_not_a_name() {
+ assert_eq!(AGENT_PLAN_TRIGGER, AGENT_EXEC_TRIGGER);
+ assert_ne!(AGENT_PLAN_NAME, AGENT_EXEC_NAME);
+ }
+
+ // ---- The canonical `agent-review` definition
+ // (`docs/agent-sessions-plan.adoc`'s Phase 5) ----
+
+ #[rstest]
+ // @relation(query.grammar, query.results, scope=function, role=Verifies)
+ fn agent_review_trigger_parses_against_the_real_query_grammar() {
+ let query: ents_query::Query = AGENT_REVIEW_TRIGGER
+ .parse()
+ .expect("the canonical agent-review trigger parses");
+ assert_eq!(query.results_dependencies(), ["agent-exec"]);
+ }
+
+ #[rstest]
+ // @relation(effect.validation, scope=function, role=Verifies)
+ fn agent_review_definition_validates() {
+ let effect = agent_review();
+ assert_eq!(effect.name, AGENT_REVIEW_NAME);
+ assert!(effect.toolchains.is_empty());
+ validate(&effect).expect("the canonical agent-review definition validates");
+ }
+
+ #[rstest]
+ // @relation(scope=function, role=Verifies)
+ fn agent_review_is_downstream_of_agent_exec_pass_only() {
+ assert!(AGENT_REVIEW_TRIGGER.contains("pass"));
+ assert_ne!(AGENT_REVIEW_NAME, AGENT_EXEC_NAME);
+ assert_ne!(AGENT_REVIEW_NAME, AGENT_PLAN_NAME);
+ }
}
crates/kernel/ents-effect/src/docker.rs
@@ -73,20 +73,34 @@
/// use ents_effect::executor::EXIT_MARKER;
/// use std::path::Path;
///
-/// let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test");
+/// let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test", &[]);
/// assert!(args.contains(&"/tmp/s/work:/work".to_owned()));
/// assert!(args.contains(&"debian:stable-slim".to_owned()));
/// let script = args.last().expect("has a script");
/// assert!(script.contains("cargo test") && script.contains(EXIT_MARKER));
/// ```
#[must_use]
-pub fn run_args(workdir: &Path, toolchains: &[(String, PathBuf)], command: &str) -> Vec<String> {
+pub fn run_args(
+ workdir: &Path,
+ toolchains: &[(String, PathBuf)],
+ command: &str,
+ env: &[(String, String)],
+) -> Vec<String> {
let mut args = vec![
"run".to_owned(),
"--rm".to_owned(),
"-v".to_owned(),
format!("{}:{WORKDIR}", workdir.display()),
];
+ // Passed as literal argv entries, never through a shell — `docker run`
+ // itself sets these on the container's environment, so a secret (a
+ // BYOK credential, `SandboxInputs::env`) needs no shell quoting at all
+ // here (unlike `crate::sprite::SpriteExecutor`, which ships the whole
+ // command as one remote shell-script string).
+ for (var, secret) in env {
+ args.push("-e".to_owned());
+ args.push(format!("{var}={secret}"));
+ }
let mut sandbox_dirs = Vec::with_capacity(toolchains.len());
for (name, host_dir) in toolchains {
let sandbox_dir = format!("{TOOLCHAINS_DIR}/{name}/bin");
@@ -115,7 +129,12 @@
// @relation(effect.result-taxonomy, scope=function)
fn run(&self, inputs: &SandboxInputs<'_>) -> Result<RunOutput> {
ensure_docker()?;
- let args = run_args(inputs.workdir, inputs.toolchains, inputs.command);
+ let args = run_args(
+ inputs.workdir,
+ inputs.toolchains,
+ inputs.command,
+ inputs.env,
+ );
let output = Command::new("docker")
.args(&args)
.output()
@@ -150,7 +169,7 @@
#[rstest]
// @relation(effect.execution, scope=function, role=Verifies)
fn run_args_binds_the_workdir() {
- let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test");
+ let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test", &[]);
assert_eq!(
args,
vec![
@@ -171,7 +190,7 @@
#[rstest]
// @relation(effect.result-taxonomy, scope=function, role=Verifies)
fn run_args_script_completes_with_the_exit_marker() {
- let args = run_args(Path::new("/w"), &[], "true");
+ let args = run_args(Path::new("/w"), &[], "true", &[]);
let script = args.last().expect("has a script");
assert!(
script.contains(crate::executor::EXIT_MARKER),
@@ -184,7 +203,7 @@
// @relation(effect.execution, effect.toolchains, scope=function, role=Verifies)
fn run_args_binds_each_toolchain_read_only_and_activates_it() {
let toolchains = vec![("rust".to_owned(), PathBuf::from("/cache/rust/bin"))];
- let args = run_args(Path::new("/w"), &toolchains, "cargo test");
+ let args = run_args(Path::new("/w"), &toolchains, "cargo test", &[]);
assert!(args.contains(&"/cache/rust/bin:/toolchains/rust/bin:ro".to_owned()));
let last = args.last().expect("has a command");
assert!(last.contains("export PATH=/toolchains/rust/bin:$PATH; cargo test"));
@@ -193,10 +212,23 @@
#[rstest]
// @relation(effect.execution, scope=function, role=Verifies)
fn run_args_uses_the_minimal_base_image() {
- let args = run_args(Path::new("/w"), &[], "true");
+ let args = run_args(Path::new("/w"), &[], "true", &[]);
assert_eq!(
args.get(args.len().saturating_sub(4)).map(String::as_str),
Some(IMAGE)
);
}
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn run_args_passes_env_as_literal_docker_flags_not_shell_text() {
+ let env = vec![("ANTHROPIC_API_KEY".to_owned(), "sk-ant-abc".to_owned())];
+ let args = run_args(Path::new("/w"), &[], "true", &env);
+ assert!(args.contains(&"-e".to_owned()));
+ assert!(args.contains(&"ANTHROPIC_API_KEY=sk-ant-abc".to_owned()));
+ // Never folded into the shell script itself — that's the sprite
+ // backend's own quoting concern, not docker's.
+ let script = args.last().expect("has a script");
+ assert!(!script.contains("sk-ant-abc"));
+ }
}
crates/kernel/ents-effect/src/executor.rs
@@ -29,6 +29,14 @@
/// The run command, exactly as stored on the effect definition
/// (`model.effect-definition`).
pub command: &'a str,
+ /// Extra environment variables to inject into the launched command,
+ /// deployment state a composition root resolves (a per-member BYOK
+ /// credential, `roots.config-isolation`) and hands down for exactly
+ /// this one run — never read from repository data, never written back
+ /// to it. Every backend injects these the same way it launches the
+ /// command at all; empty for every ordinary effect run, which has
+ /// nothing to inject.
+ pub env: &'a [(String, String)],
}
/// What a completed run reported. Only `Pass` or `Fail`
@@ -83,7 +91,7 @@
/// }
///
/// let dir = tempfile::tempdir().expect("tempdir");
-/// let inputs = SandboxInputs { workdir: dir.path(), toolchains: &[], command: "true" };
+/// let inputs = SandboxInputs { workdir: dir.path(), toolchains: &[], command: "true", env: &[] };
/// let output = AlwaysPass.run(&inputs).expect("infallible");
/// assert_eq!(output.status, RunStatus::Pass);
/// ```
@@ -125,6 +133,49 @@
format!("export PATH={path}:$PATH; {command}")
}
+/// Prefix `command` with an `export` for each of `env`'s pairs, single-quoted
+/// so an arbitrary secret value (a BYOK credential,
+/// [`SandboxInputs::env`]) round-trips through a `sh -c` script unmodified
+/// regardless of its own content — used by every backend that ships the
+/// command as one shell-script string to a remote or containerized shell
+/// ([`crate::sprite::SpriteExecutor`], [`crate::docker::DockerExecutor`]).
+/// The unsandboxed backend needs no such quoting: it sets the child
+/// process's environment directly via `Command::envs`, never folding a
+/// secret into shell source at all.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::executor::inject_env;
+///
+/// let env = vec![("ANTHROPIC_API_KEY".to_owned(), "sk-ant-abc".to_owned())];
+/// assert_eq!(
+/// inject_env("run-agent", &env),
+/// "export ANTHROPIC_API_KEY='sk-ant-abc'; run-agent"
+/// );
+/// assert_eq!(inject_env("run-agent", &[]), "run-agent");
+/// ```
+#[must_use]
+pub fn inject_env(command: &str, env: &[(String, String)]) -> String {
+ if env.is_empty() {
+ return command.to_owned();
+ }
+ let exports = env
+ .iter()
+ .map(|(var, secret)| format!("export {var}={}", shell_single_quote(secret)))
+ .collect::<Vec<_>>()
+ .join("; ");
+ format!("{exports}; {command}")
+}
+
+/// Single-quote `value` for a POSIX shell, escaping any embedded `'` by
+/// closing the quote, emitting an escaped literal quote, and reopening it —
+/// the standard `'\''` trick, so a credential containing an apostrophe (or
+/// any other shell metacharacter) still round-trips as one literal string.
+fn shell_single_quote(value: &str) -> String {
+ format!("'{}'", value.replace('\'', r"'\''"))
+}
+
/// The sentinel [`wrap_exit_marker`] appends after the wrapped command, so
/// a CLI-driven backend can tell "the command completed and exited with
/// this status" apart from "the CLI or its transport failed" — the
@@ -227,6 +278,38 @@
assert_eq!(activate("run", &[]), "run");
}
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn inject_env_is_identity_with_no_env() {
+ assert_eq!(inject_env("run", &[]), "run");
+ }
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn inject_env_exports_every_pair_before_the_command() {
+ let env = vec![
+ ("A".to_owned(), "one".to_owned()),
+ ("B".to_owned(), "two".to_owned()),
+ ];
+ assert_eq!(
+ inject_env("run", &env),
+ "export A='one'; export B='two'; run"
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn inject_env_round_trips_a_secret_with_an_embedded_quote_through_a_real_shell() {
+ let env = vec![("SECRET".to_owned(), "it's a secret".to_owned())];
+ let script = inject_env("printf '%s' \"$SECRET\"", &env);
+ let output = std::process::Command::new("sh")
+ .arg("-c")
+ .arg(&script)
+ .output()
+ .expect("sh runs");
+ assert_eq!(String::from_utf8_lossy(&output.stdout), "it's a secret");
+ }
+
#[rstest]
#[case::pass("out\n__ENTS_EFFECT_EXIT=0\n", Some((RunStatus::Pass, "out")))]
#[case::fail("out\n__ENTS_EFFECT_EXIT=1\n", Some((RunStatus::Fail, "out")))]
crates/kernel/ents-effect/src/materialize.rs
@@ -23,8 +23,8 @@
use gix_hash::ObjectId;
use gix_object::bstr::ByteSlice as _;
-use gix_object::tree::EntryKind;
-use gix_object::{Find, Kind, TreeRef};
+use gix_object::tree::{Entry, EntryKind, EntryMode};
+use gix_object::{Find, Kind, Tree, TreeRef, Write};
use crate::error::{Error, Result};
@@ -155,6 +155,139 @@
Ok(())
}
+/// The reverse of [`checkout`]: recursively read `src`'s current on-disk
+/// state and write it as a git tree, preserving the executable bit and
+/// symlink targets `checkout` itself would restore. A directory walk can
+/// never discover a gitlink (there is no way for a plain host directory to
+/// carry one), so unlike `checkout` this has no submodule case to refuse.
+///
+/// This is `docs/agent-sessions-plan.adoc`'s Phase 2 finalize's other
+/// half from `checkout`: after [`crate::Executor::run`] completes, a
+/// composition root reads the checked-out workdir's now-current state back
+/// through this function to build the sandbox's output tree — the tree the
+/// result branch's commit carries. Every effect backend built so far in
+/// this crate (`UnsandboxedExecutor` today; a future `SpriteExecutor` that
+/// syncs its sandbox's filesystem back onto `workdir` before returning)
+/// leaves its command's file-level effects on `workdir` itself, so this
+/// reads the same host directory [`SandboxInputs::workdir`] named, not a
+/// second, backend-specific location.
+///
+/// # Errors
+///
+/// [`Error::NotUtf8`] for a non-UTF-8 entry name or symlink target;
+/// [`Error::Io`] for a host filesystem failure reading `src` or one of its
+/// entries; [`Error::ObjectWrite`] if writing a blob or tree object fails.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::materialize::write_tree;
+/// use ents_testutil::ObjectStore;
+///
+/// let dir = tempfile::tempdir().expect("tempdir");
+/// std::fs::write(dir.path().join("README"), b"hello\n").expect("write");
+/// std::fs::create_dir(dir.path().join("sub")).expect("mkdir");
+/// std::fs::write(dir.path().join("sub/leaf.txt"), b"leaf\n").expect("write");
+///
+/// let objects = ObjectStore::default();
+/// let tree = write_tree(&objects, dir.path()).expect("writes");
+///
+/// let checked_out = tempfile::tempdir().expect("tempdir");
+/// ents_effect::materialize::checkout(&objects, tree, checked_out.path()).expect("checkout");
+/// assert_eq!(
+/// std::fs::read_to_string(checked_out.path().join("README")).expect("read"),
+/// "hello\n"
+/// );
+/// assert_eq!(
+/// std::fs::read_to_string(checked_out.path().join("sub").join("leaf.txt")).expect("read"),
+/// "leaf\n"
+/// );
+/// ```
+// @relation(effect.execution, scope=function)
+pub fn write_tree(objects: &(impl Find + Write), src: &Path) -> Result<ObjectId> {
+ let read_dir = std::fs::read_dir(src).map_err(|source| Error::Io {
+ path: src.to_owned(),
+ source,
+ })?;
+
+ let mut entries = Vec::new();
+ for dir_entry in read_dir {
+ let dir_entry = dir_entry.map_err(|source| Error::Io {
+ path: src.to_owned(),
+ source,
+ })?;
+ let path = dir_entry.path();
+ let name = dir_entry
+ .file_name()
+ .into_string()
+ .map_err(|_not_utf8| Error::NotUtf8(path.clone()))?;
+ let file_type = dir_entry.file_type().map_err(|source| Error::Io {
+ path: path.clone(),
+ source,
+ })?;
+
+ let (mode, oid) = if file_type.is_dir() {
+ (
+ EntryMode::from(EntryKind::Tree),
+ write_tree(objects, &path)?,
+ )
+ } else if file_type.is_symlink() {
+ let target = std::fs::read_link(&path).map_err(|source| Error::Io {
+ path: path.clone(),
+ source,
+ })?;
+ let target = target
+ .to_str()
+ .ok_or_else(|| Error::NotUtf8(path.clone()))?;
+ let oid = objects.write_buf(Kind::Blob, target.as_bytes())?;
+ (EntryMode::from(EntryKind::Link), oid)
+ } else {
+ let bytes = std::fs::read(&path).map_err(|source| Error::Io {
+ path: path.clone(),
+ source,
+ })?;
+ let kind = if is_executable(&path)? {
+ EntryKind::BlobExecutable
+ } else {
+ EntryKind::Blob
+ };
+ (
+ EntryMode::from(kind),
+ objects.write_buf(Kind::Blob, &bytes)?,
+ )
+ };
+ entries.push(Entry {
+ mode,
+ filename: name.into(),
+ oid,
+ });
+ }
+ // `gix_object::Tree::write_to` debug-asserts its entries are sorted by
+ // its own `Ord` (git's tree-entry order, a directory compared as if it
+ // carried a trailing `/`) — a plain directory walk has no such
+ // ordering, so this sorts before handing the tree to `objects.write`.
+ entries.sort();
+ Ok(objects.write(&Tree { entries })?)
+}
+
+#[cfg(unix)]
+fn is_executable(path: &Path) -> Result<bool> {
+ use std::os::unix::fs::PermissionsExt as _;
+ let mode = std::fs::metadata(path)
+ .map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })?
+ .permissions()
+ .mode();
+ Ok(mode & 0o111 != 0)
+}
+
+#[cfg(not(unix))]
+fn is_executable(_path: &Path) -> Result<bool> {
+ Ok(false)
+}
+
fn make_dir(path: &Path) -> Result<()> {
std::fs::create_dir_all(path).map_err(|source| Error::Io {
path: path.to_owned(),
@@ -438,4 +571,84 @@
"content\n"
);
}
+
+ // -----------------------------------------------------------------
+ // write_tree: checkout's reverse.
+ // -----------------------------------------------------------------
+
+ #[test]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn write_tree_round_trips_plain_files_and_subdirectories_through_checkout() {
+ let objects = ObjectStore::default();
+ let src = tempfile::tempdir().expect("tempdir");
+ std::fs::write(src.path().join("README"), b"hello\n").expect("write");
+ std::fs::create_dir(src.path().join("sub")).expect("mkdir");
+ std::fs::write(src.path().join("sub/leaf.txt"), b"leaf\n").expect("write");
+
+ let tree = write_tree(&objects, src.path()).expect("writes");
+
+ let dest = tempfile::tempdir().expect("tempdir");
+ checkout(&objects, tree, dest.path()).expect("checkout");
+ assert_eq!(
+ std::fs::read_to_string(dest.path().join("README")).expect("read"),
+ "hello\n"
+ );
+ assert_eq!(
+ std::fs::read_to_string(dest.path().join("sub").join("leaf.txt")).expect("read"),
+ "leaf\n"
+ );
+ }
+
+ #[test]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn write_tree_preserves_the_executable_bit() {
+ let objects = ObjectStore::default();
+ let src = tempfile::tempdir().expect("tempdir");
+ let script = src.path().join("run.sh");
+ std::fs::write(&script, b"#!/bin/sh\necho hi\n").expect("write");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+ let mut perms = std::fs::metadata(&script).expect("stat").permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&script, perms).expect("chmod");
+ }
+
+ let tree = write_tree(&objects, src.path()).expect("writes");
+ let dest = tempfile::tempdir().expect("tempdir");
+ checkout(&objects, tree, dest.path()).expect("checkout");
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+ let mode = std::fs::metadata(dest.path().join("run.sh"))
+ .expect("stat")
+ .permissions()
+ .mode();
+ assert_eq!(
+ mode & 0o111,
+ 0o111,
+ "the executable bit must survive the round trip"
+ );
+ }
+ }
+
+ #[test]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn write_tree_entries_are_sorted_for_serialization() {
+ // A directory walk yields entries in whatever order the host
+ // filesystem happens to return them, never guaranteed to already be
+ // git's own tree-entry order; `write_tree` must sort them itself so
+ // `Tree::write_to`'s debug assertion never trips.
+ let objects = ObjectStore::default();
+ let src = tempfile::tempdir().expect("tempdir");
+ for name in ["z-file", "a-file", "m-dir"] {
+ if name == "m-dir" {
+ std::fs::create_dir(src.path().join(name)).expect("mkdir");
+ } else {
+ std::fs::write(src.path().join(name), b"x").expect("write");
+ }
+ }
+ write_tree(&objects, src.path()).expect("writes without tripping the sort assertion");
+ }
}
crates/kernel/ents-effect/src/run.rs
@@ -170,6 +170,11 @@
workdir: &workdir,
toolchains,
command: &effect.run,
+ // An ordinary effect run has no per-member credential to inject
+ // (`roots.config-isolation`'s BYOK seam is `agent_worker`'s and
+ // `plan_worker`'s own concern, in `git-ents`); every backend still
+ // accepts the field uniformly, it is simply empty here.
+ env: &[],
};
let output = executor.run(&inputs)?;
let status = match output.status {
crates/kernel/ents-effect/src/sprite.rs
@@ -25,7 +25,7 @@
use crate::error::{Error, Result};
use crate::executor::{
- Executor, RunOutput, SandboxInputs, activate, parse_exit_marker, wrap_exit_marker,
+ Executor, RunOutput, SandboxInputs, activate, inject_env, parse_exit_marker, wrap_exit_marker,
};
/// Where the workdir is unpacked inside the Sprite.
@@ -273,13 +273,19 @@
}
/// The in-Sprite script for one run: enter the synced workdir, run the
-/// activated command wrapped by [`wrap_exit_marker`]. A `cd` failure (the
-/// workdir sync silently lost) exits before the marker can print, so it
-/// surfaces as infrastructure, not as a recorded `fail` — the same
-/// discrimination the marker gives a dying transport
-/// (`effect.result-taxonomy`).
-fn run_script(activated: &str) -> String {
- format!("cd {WORKDIR} || exit 70\n{}", wrap_exit_marker(activated))
+/// activated command — with `env`'s pairs exported first
+/// ([`inject_env`], `roots.config-isolation`: a per-member BYOK credential
+/// the composition root resolved, injected only here, at sandbox launch,
+/// never written to any tree this crate builds) — wrapped by
+/// [`wrap_exit_marker`]. A `cd` failure (the workdir sync silently lost)
+/// exits before the marker can print, so it surfaces as infrastructure, not
+/// as a recorded `fail` — the same discrimination the marker gives a dying
+/// transport (`effect.result-taxonomy`).
+fn run_script(activated: &str, env: &[(String, String)]) -> String {
+ format!(
+ "cd {WORKDIR} || exit 70\n{}",
+ wrap_exit_marker(&inject_env(activated, env))
+ )
}
impl Executor for SpriteExecutor {
@@ -296,7 +302,7 @@
sandbox_dirs.push((toolchain_name.clone(), sandbox_dir));
}
- let script = run_script(&activate(inputs.command, &sandbox_dirs));
+ let script = run_script(&activate(inputs.command, &sandbox_dirs), inputs.env);
let output = Command::new("sprite")
.args(["exec", "-s", &self.name, "--", "sh", "-c", &script])
.output()
@@ -340,7 +346,7 @@
#[rstest]
// @relation(effect.result-taxonomy, scope=function, role=Verifies)
fn run_script_gates_the_exit_marker_on_a_successful_cd() {
- let script = run_script("cargo test");
+ let script = run_script("cargo test", &[]);
// A failed cd exits before the marker can print, so a lost workdir
// sync surfaces as infrastructure, never as a recorded fail.
assert!(script.starts_with("cd /work || exit 70\n"));
@@ -348,6 +354,14 @@
assert!(script.contains("cargo test"));
}
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn run_script_exports_env_before_the_activated_command() {
+ let env = vec![("ANTHROPIC_API_KEY".to_owned(), "sk-ant-abc".to_owned())];
+ let script = run_script("cargo test", &env);
+ assert!(script.contains("export ANTHROPIC_API_KEY='sk-ant-abc'; cargo test"));
+ }
+
#[rstest]
// @relation(effect.execution, scope=function, role=Verifies)
fn unpack_script_kills_orphans_before_wiping_the_destination() {
crates/kernel/ents-effect/src/unsandboxed.rs
@@ -31,6 +31,13 @@
.arg("-c")
.arg(activate(inputs.command, &dirs))
.current_dir(inputs.workdir)
+ // Set directly on the child's environment rather than folded
+ // into the shell script `activate` built above: a secret (a
+ // BYOK credential, `SandboxInputs::env`) never needs shell
+ // quoting at all this way, unlike the sandboxed backends that
+ // must ship the whole command as one script string
+ // (`crate::executor::inject_env`).
+ .envs(inputs.env.iter().cloned())
.output()
.map_err(|e| Error::Spawn {
program: "sh".to_owned(),
@@ -65,6 +72,7 @@
workdir: dir.path(),
toolchains: &[],
command: "true",
+ env: &[],
};
let output = UnsandboxedExecutor.run(&inputs).expect("runs");
assert_eq!(output.status, RunStatus::Pass);
@@ -78,6 +86,7 @@
workdir: dir.path(),
toolchains: &[],
command: "false",
+ env: &[],
};
let output = UnsandboxedExecutor
.run(&inputs)
@@ -96,9 +105,25 @@
workdir: dir.path(),
toolchains: &toolchains,
command: "echo $PATH",
+ env: &[],
};
let output = UnsandboxedExecutor.run(&inputs).expect("runs");
assert!(output.log.contains("bin"));
let _: Vec<(String, PathBuf)> = toolchains;
}
+
+ #[rstest]
+ // @relation(roots.config-isolation, scope=function, role=Verifies)
+ fn unsandboxed_injects_env_directly_on_the_child_process() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let env = vec![("ENTS_TEST_CREDENTIAL".to_owned(), "sk-ant-test".to_owned())];
+ let inputs = SandboxInputs {
+ workdir: dir.path(),
+ toolchains: &[],
+ command: "printf '%s' \"$ENTS_TEST_CREDENTIAL\"",
+ env: &env,
+ };
+ let output = UnsandboxedExecutor.run(&inputs).expect("runs");
+ assert_eq!(output.log, "sk-ant-test");
+ }
}
crates/kernel/ents-gate-rules/src/lib.rs
@@ -59,6 +59,20 @@
//! canonical infrastructure, which needs more trust than an ordinary
//! append).
//!
+//! A second rule grows the set again, for Phase 1b of
+//! `docs/agent-sessions-plan.adoc` (the agent-sessions plan)'s lifecycle
+//! invariants, restated over three new session-specific facts
+//! ([`session_running`], [`session_plan_hash`], [`session_confirm_hash`]):
+//!
+//! - [`running_violation`](GateRules::running_violation) — a commit whose
+//! agent-session meta status is `Running` must descend from a parent
+//! whose own tree recorded a confirm leaf binding that parent's
+//! plan-leaf hash; absent that parent, a session could reach `Running`
+//! without ever having been confirmed against the plan a worker is about
+//! to execute (`docs/agent-sessions-plan.adoc`'s Phase 1b: "a commit
+//! whose meta status is running requires a parent whose tree contains a
+//! confirm leaf binding that parent's plan-leaf hash").
+//!
//! Two invariants are deliberately *not* encoded here, the gap marked
//! rather than papered over:
//!
@@ -147,6 +161,20 @@
relation context(Oid, Oid);
/// Objects the repository already has, or that arrive in this pack.
relation object_exists(Oid);
+ /// A commit whose decoded agent-session meta status is `Running`
+ /// (`ents_forge::agent::Status::Running`,
+ /// `docs/agent-sessions-plan.adoc`'s Phase 1b) — the fact a real
+ /// extractor would derive from the commit's typed tree.
+ relation session_running(Oid);
+ /// (commit, plan-leaf content hash) — the agent session's plan-leaf
+ /// hash as recorded in this commit's own tree
+ /// (`AgentSession::plan_hash`), absent when the tree carries no plan
+ /// leaf.
+ relation session_plan_hash(Oid, Oid);
+ /// (commit, bound hash) — the plan-leaf hash this commit's own confirm
+ /// leaf binds (`AgentSession::confirm`), absent when the tree carries
+ /// no confirm leaf.
+ relation session_confirm_hash(Oid, Oid);
// ---- IDB: derived relations ----
@@ -183,6 +211,15 @@
relation admin_signed(Oid);
admin_signed(c.clone()) <-- signed_by(c, k), member(k, Role::Admin);
+ /// A `Running` commit whose immediate parent's own tree carries a
+ /// confirm leaf binding that same parent's plan-leaf hash — the parent
+ /// was `queued` the instant before the claim that produced this commit
+ /// (`docs/agent-sessions-plan.adoc`'s Phase 1b).
+ relation session_running_confirmed(Oid);
+ session_running_confirmed(c.clone()) <--
+ session_running(c), parent(c, p),
+ session_confirm_hash(p, h), session_plan_hash(p, h);
+
// ---- Denial rules: any row here rejects the transaction ----
/// Fast-forward-only: the new tip must descend from the old tip.
@@ -229,6 +266,14 @@
effect_admin_violation(r.clone(), c.clone()) <--
introduced(r, c), if r.starts_with("refs/meta/effects/"),
!admin_signed(c);
+
+ /// A commit whose agent-session meta status is `Running` must have a
+ /// parent whose own tree carries a confirm leaf binding that parent's
+ /// plan-leaf hash — otherwise the session reached `Running` without a
+ /// confirmed plan (`docs/agent-sessions-plan.adoc`'s Phase 1b).
+ relation running_violation(Ref, Oid);
+ running_violation(r.clone(), c.clone()) <--
+ introduced(r, c), session_running(c), !session_running_confirmed(c);
}
/// Facts for one proposed transaction. In the real system these would be
@@ -251,6 +296,12 @@
pub context: Vec<(Oid, Oid)>,
/// See [`GateRules::object_exists`].
pub object_exists: Vec<(Oid,)>,
+ /// See [`GateRules::session_running`].
+ pub session_running: Vec<(Oid,)>,
+ /// See [`GateRules::session_plan_hash`].
+ pub session_plan_hash: Vec<(Oid, Oid)>,
+ /// See [`GateRules::session_confirm_hash`].
+ pub session_confirm_hash: Vec<(Oid, Oid)>,
}
/// Run every denial rule to a fixpoint over `facts`. An empty result means
@@ -266,6 +317,9 @@
anchor: facts.anchor,
context: facts.context,
object_exists: facts.object_exists,
+ session_running: facts.session_running,
+ session_plan_hash: facts.session_plan_hash,
+ session_confirm_hash: facts.session_confirm_hash,
..GateRules::default()
};
rules.run();
@@ -296,6 +350,12 @@
"effect-admin: {r}: {c} not signed by an admin-registered member"
));
}
+ for (r, c) in &rules.running_violation {
+ out.push(format!(
+ "session-running: {r}: {c}'s session status is Running but no parent's confirm \
+ leaf binds that parent's plan-leaf hash"
+ ));
+ }
out.sort();
out
}
@@ -307,6 +367,7 @@
const ISSUE: &str = "refs/meta/issues/g";
const COMMENT: &str = "refs/meta/comments/g2";
const EFFECT: &str = "refs/meta/effects/ci";
+ const AGENT_SESSION: &str = "refs/meta/agent-sessions/g";
fn base() -> Facts {
Facts {
@@ -439,4 +500,65 @@
assert!(v.iter().any(|m| m.starts_with("effect-admin:")), "{v:?}");
assert!(!v.iter().any(|m| m.starts_with("signature:")), "{v:?}");
}
+
+ // ---- Agent-session lifecycle: `running_violation` ----
+ // (`docs/agent-sessions-plan.adoc`'s Phase 1b).
+
+ /// RED: `c1` claims the session (its status decodes as `Running`), but
+ /// its parent `g`'s tree carries no confirm leaf at all — `g` was never
+ /// queued, so the claim should never have been admitted.
+ #[test]
+ fn a_claim_whose_parent_was_never_confirmed_is_rejected() {
+ let mut f = base();
+ f.ref_update = vec![(AGENT_SESSION.into(), Some("g".into()), "c1".into())];
+ f.parent = vec![("c1".into(), "g".into())];
+ f.signed_by = vec![("c1".into(), "key:joey".into())];
+ f.session_running = vec![("c1".into(),)];
+ f.session_plan_hash = vec![("g".into(), "hash:plan-a".into())];
+ // No session_confirm_hash at all: `g` is awaiting confirmation, not
+ // queued.
+ let v = gate(f);
+ assert!(
+ v.iter()
+ .any(|m| m.starts_with("session-running:") && m.contains("c1")),
+ "{v:?}"
+ );
+ }
+
+ /// RED (the stale-confirm variant): `g`'s confirm leaf binds an older
+ /// plan hash than the one `g`'s own plan leaf now carries — a revision
+ /// that should have dropped the confirm, per
+ /// `ents_forge::agent::command::revise_plan`'s contract, evidently did
+ /// not. The claim built on top of it is still rejected.
+ #[test]
+ fn a_claim_whose_parent_confirm_binds_a_stale_plan_hash_is_rejected() {
+ let mut f = base();
+ f.ref_update = vec![(AGENT_SESSION.into(), Some("g".into()), "c1".into())];
+ f.parent = vec![("c1".into(), "g".into())];
+ f.signed_by = vec![("c1".into(), "key:joey".into())];
+ f.session_running = vec![("c1".into(),)];
+ f.session_plan_hash = vec![("g".into(), "hash:plan-b".into())];
+ f.session_confirm_hash = vec![("g".into(), "hash:plan-a".into())];
+ let v = gate(f);
+ assert!(
+ v.iter()
+ .any(|m| m.starts_with("session-running:") && m.contains("c1")),
+ "{v:?}"
+ );
+ }
+
+ /// GREEN: `c1` claims a session whose parent `g` was queued — `g`'s
+ /// confirm leaf binds exactly `g`'s own plan-leaf hash. The rule's
+ /// relation is empty; nothing else in the base fixture fires either.
+ #[test]
+ fn a_claim_whose_parent_was_confirmed_against_its_own_plan_passes() {
+ let mut f = base();
+ f.ref_update = vec![(AGENT_SESSION.into(), Some("g".into()), "c1".into())];
+ f.parent = vec![("c1".into(), "g".into())];
+ f.signed_by = vec![("c1".into(), "key:joey".into())];
+ f.session_running = vec![("c1".into(),)];
+ f.session_plan_hash = vec![("g".into(), "hash:plan-a".into())];
+ f.session_confirm_hash = vec![("g".into(), "hash:plan-a".into())];
+ assert!(gate(f).is_empty());
+ }
}
crates/kernel/ents-gate/src/config.rs
@@ -1,20 +1,30 @@
-//! The verification epoch, read from `refs/meta/config` (`gate.epoch`).
+//! The verification epoch and designated-worker roster, read from
+//! `refs/meta/config` (`gate.epoch`, and this crate's own doc on
+//! "Finer-grained, config-stored refname rules").
use facet::Facet;
use gix_hash::ObjectId;
use gix_object::Find;
use gix_ref_store::RefStoreRead;
+use ents_model::MemberId;
+
use crate::error::{Error, Result};
use crate::object::expect_commit;
/// The slice of `refs/meta/config`'s typed tree the gate consults: the
-/// verification epoch (`gate.epoch`).
+/// verification epoch (`gate.epoch`) and the designated-worker roster this
+/// crate's own module doc names as "a later, additive narrowing" —
+/// `docs/agent-sessions-plan.adoc`'s Phase 2a is the first consumer,
+/// narrowing agent-session advance authorization
+/// (`ents-gate`'s `owner_mutation`); a future narrowing of canonical
+/// `refs/meta/results/<effect>/*` (`effect.official`) would read the same
+/// field.
///
/// `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 the epoch is the only
-/// field any crate reads today. When configuration grows non-gate fields
+/// 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`).
@@ -31,7 +41,7 @@
/// ```
/// use ents_gate::Config;
///
-/// let config = Config { epoch: Some(1_700_000_000) };
+/// let config = Config { epoch: Some(1_700_000_000), ..Config::default() };
/// let (root, store) = facet_git_tree::serialize(&config).expect("serialize");
/// let back: Config = facet_git_tree::deserialize(&root, &store).expect("deserialize");
/// assert_eq!(back, config);
@@ -42,6 +52,25 @@
/// When the tip invariant came into force, seconds since the Unix
/// epoch; `None` while verification has never been enabled.
pub epoch: Option<u64>,
+ /// Members trusted, alongside an entity's genesis signer and any
+ /// admin-registered member, to advance an owner-mutation-gated
+ /// namespace on another member's behalf — today, an agent session's
+ /// claim/finish advance (`docs/agent-sessions-plan.adoc`'s Phase 2a);
+ /// eventually the same roster narrowing `refs/meta/results/<effect>/*`
+ /// per `effect.official`'s "designated worker keys", once that
+ /// narrowing lands. Empty by default: no worker is designated until a
+ /// signed config write adds one.
+ pub workers: Vec<MemberId>,
+}
+
+/// The config recorded by the tree of the commit at `oid`, or an
+/// [`Error::Entity`] when the tree does not parse as [`Config`] — an
+/// unreadable config fails closed rather than silently disabling the
+/// gate.
+fn config_at_commit(objects: &dyn Find, oid: ObjectId) -> Result<Config> {
+ let commit = expect_commit(objects, oid)?;
+ facet_git_tree::deserialize(&commit.tree, objects)
+ .map_err(|source| Error::Entity { oid, source })
}
/// The epoch recorded by the config tree of the commit at `oid`, or an
@@ -49,16 +78,13 @@
/// unreadable config fails closed rather than silently disabling the
/// gate.
pub(crate) fn epoch_at_commit(objects: &dyn Find, oid: ObjectId) -> Result<Option<u64>> {
- let commit = expect_commit(objects, oid)?;
- let config: Config = facet_git_tree::deserialize(&commit.tree, objects)
- .map_err(|source| Error::Entity { oid, source })?;
- Ok(config.epoch)
+ Ok(config_at_commit(objects, oid)?.epoch)
}
-/// The epoch currently in force, read from `refs/meta/config`'s tip;
-/// `None` when the config ref does not exist or records no epoch.
-// @relation(gate.epoch, gate.policy-as-state, scope=function)
-pub(crate) fn current_epoch(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<u64>> {
+/// `refs/meta/config`'s current tree, or [`Config::default`] when the
+/// config ref does not exist yet — the same "absent means no narrowing in
+/// force" reading [`current_epoch`] already gives absence.
+fn current_config(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Config> {
#[expect(
clippy::expect_used,
clippy::unwrap_in_result,
@@ -69,7 +95,27 @@
.try_into()
.expect("CONFIG_REF is a valid refname");
match refs.get(name.as_ref())? {
- Some(tip) => epoch_at_commit(objects, tip),
- None => Ok(None),
+ Some(tip) => config_at_commit(objects, tip),
+ None => Ok(Config::default()),
}
}
+
+/// The epoch currently in force, read from `refs/meta/config`'s tip;
+/// `None` when the config ref does not exist or records no epoch.
+// @relation(gate.epoch, gate.policy-as-state, scope=function)
+pub(crate) fn current_epoch(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<u64>> {
+ Ok(current_config(refs, objects)?.epoch)
+}
+
+/// The designated-worker roster currently in force, read from
+/// `refs/meta/config`'s tip; empty when the config ref does not exist or
+/// designates no workers — [`crate::verify::verify`]'s AgentSession advance
+/// rule ORs this into the existing genesis-signer/admin check
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2a).
+// @relation(gate.policy-as-state, scope=function)
+pub(crate) fn designated_workers(
+ refs: &dyn RefStoreRead,
+ objects: &dyn Find,
+) -> Result<Vec<MemberId>> {
+ Ok(current_config(refs, objects)?.workers)
+}
crates/kernel/ents-gate/src/lib.rs
@@ -71,10 +71,15 @@
//! (`meta-ref.inbox`) — `refs/meta/effects/*` is admin-only
//! (`effect.admin-only`), and self-attested members are refused
//! canonical refs until promoted (`model.member-provenance`).
-//! Finer-grained, config-stored refname rules (for example designating
-//! worker keys for one effect's results namespace, `effect.official`)
-//! are a later, additive narrowing: they arrive with a Config entity in
-//! `ents-model`, not a new gate.
+//! Finer-grained, config-stored refname rules are a later, additive
+//! narrowing read from the same [`Config`] the epoch already lives on
+//! (`Config::workers`): `docs/agent-sessions-plan.adoc`'s Phase 2a is the
+//! first one, admitting a designated worker to advance any member's agent
+//! session (∪ genesis signer, ∪ admins) — `verify::owner_mutation`'s
+//! `Namespace::AgentSession` arm. Designating worker keys for one effect's
+//! canonical results namespace (`effect.official`) is the same roster,
+//! unbuilt until a caller needs it; `Config` itself moves to `ents-model`
+//! only once configuration grows fields no gate rule reads.
//!
//! Acceptance-time semantics: a signature is judged against the member
//! entity *currently in force* — the member ref's tip in the same
@@ -111,7 +116,7 @@
//! // 2. The epoch-setting commit is the first gated tip of refs/meta/config.
//! let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
//! let epoch_tip = write_meta_entity(
-//! &refs, &objects, config_ref, &Config { epoch: Some(200) }, Some(&key), 200,
+//! &refs, &objects, config_ref, &Config { epoch: Some(200), ..Config::default() }, Some(&key), 200,
//! );
//!
//! // 3. From here on, every meta-ref update is judged by the tip invariant.
crates/kernel/ents-gate/src/verify.rs
@@ -244,12 +244,22 @@
}
// gate.owner-mutation: a hash-identified entity's ref advances only
- // under its genesis signer (∪ admins); a review advances only under
- // the member its refname names. Creation stays provenance-keyed,
- // already judged by `authorize` above.
+ // under its genesis signer (∪ admins, ∪ designated workers for an
+ // agent session — `docs/agent-sessions-plan.adoc`'s Phase 2a); a
+ // review advances only under the member its refname names. Creation
+ // stays provenance-keyed, already judged by `authorize` above.
// @relation(gate.owner-mutation, scope=function)
- if let Some(refusal) = owner_mutation(objects, &update.name, old, new, &members, &id, &member)?
- {
+ let workers = config::designated_workers(refs, objects)?;
+ if let Some(refusal) = owner_mutation(
+ objects,
+ &update.name,
+ old,
+ new,
+ &members,
+ &id,
+ &member,
+ &workers,
+ )? {
return Ok(Verdict::Fail(refusal));
}
@@ -611,7 +621,14 @@
Namespace::Effect | Namespace::Toolchain => {
bind_natural_key(objects, name, commit.tree, "name", &final_segment(name))
}
- Namespace::Comment | Namespace::Issue => bind_hash_identified(objects, name, new),
+ // An agent session is hash-identified exactly like a comment or an
+ // issue: the refname's final segment must be the genesis oid, and
+ // the all-roots walk must find no doppelgänger
+ // (`docs/agent-sessions-plan.adoc`'s Phase 1b, `meta-ref.
+ // identity-binding`).
+ Namespace::Comment | Namespace::Issue | Namespace::AgentSession => {
+ bind_hash_identified(objects, name, new)
+ }
Namespace::Review => {
let Some((target, member)) = namespace::parse_review_ref(name.as_ref()) else {
return Ok(binding_refusal(
@@ -775,11 +792,18 @@
}
/// Ownership keys mutation (`gate.owner-mutation`): a hash-identified
-/// entity's ref advances only under its genesis signer or an
-/// admin-registered member; a review advances only under the member its
-/// refname names. Creation stays provenance-keyed (judged by `authorize`),
-/// so this fires only on an advance.
+/// entity's ref advances only under its genesis signer, an admin-registered
+/// member, or — for an agent session only — a designated worker
+/// (`docs/agent-sessions-plan.adoc`'s Phase 2a, `workers`); a review
+/// advances only under the member its refname names. Creation stays
+/// provenance-keyed (judged by `authorize`), so this fires only on an
+/// advance.
// @relation(gate.owner-mutation, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "a private continuation of verify(); grouping these into a struct would only rename \
+ the arguments, same rationale as bootstrap()'s own expect"
+)]
fn owner_mutation(
objects: &dyn Find,
name: &FullName,
@@ -788,6 +812,7 @@
members: &[Enrolled],
signer_id: &MemberId,
signer: &Member,
+ workers: &[MemberId],
) -> Result<Option<Refusal>> {
let Some(namespace) = namespace::classify(name.as_ref()) else {
return Ok(None);
@@ -802,6 +827,8 @@
};
let is_admin = signer.provenance == Provenance::AdminRegistered;
match namespace {
+ // A comment or issue's mutation owner is exactly its genesis
+ // signer (∪ admins).
Namespace::Comment | Namespace::Issue => {
// Creation is provenance-keyed; only an advance is owner-keyed.
if old.is_none() {
@@ -825,6 +852,46 @@
)))
}
}
+ // An agent session's mutation owner is its genesis signer (∪
+ // admins, ∪ designated workers): Phase 1b kept this owner-only
+ // because `ents-gate` had no notion of "a claim, once made,
+ // authorizes a different signer"; Phase 2a is that machinery — a
+ // signer listed in `refs/meta/config`'s `workers` roster
+ // (`Config::workers`) may advance any member's session, exactly the
+ // additive narrowing this crate's own module doc predicted for a
+ // future `effect.official` roster over `refs/meta/results/*`. A
+ // worker's advance still passes through every other check
+ // unchanged: `gate.fast-forward` still refuses a non-descendant
+ // tip, and `gate.identity-binding` still refuses a mismatched
+ // refname — a designated worker gains a new signer authorized to
+ // advance the ref, never a way around what "advance" means.
+ Namespace::AgentSession => {
+ // Creation is provenance-keyed; only an advance is owner-keyed.
+ if old.is_none() {
+ return Ok(None);
+ }
+ if is_admin {
+ return Ok(None);
+ }
+ if workers.iter().any(|worker| worker == signer_id) {
+ return Ok(None);
+ }
+ let genesis = all_roots(objects, new)?;
+ let genesis_signer = match genesis.first() {
+ Some(root) => commit_signer(objects, members, *root)?,
+ None => None,
+ };
+ if genesis_signer.as_ref() == Some(signer_id) {
+ Ok(None)
+ } else {
+ Ok(refuse(format!(
+ "{signer_id} is neither the member whose signature this entity's genesis \
+ carries, an admin-registered member, nor a designated worker, so may not \
+ advance {}",
+ name.as_bstr()
+ )))
+ }
+ }
Namespace::Review => {
let Some((_, member)) = namespace::parse_review_ref(name.as_ref()) else {
return Ok(None);