git-ents.gitmain
⌘K
foforge
commit 49dbb93
Merge branch 'worktree-agent-sessions': first-class agent sessions

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).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 28 days ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1461,6 +1461,7 @@ "facet", "facet-git-tree", "facet-reflect", + "futures-util", "getrandom 0.4.3", "gix", "gix-hash",
crates/cli/ents-web/Cargo.toml @@ -21,6 +21,7 @@ facet = { workspace = true } facet-git-tree = { workspace = true } facet-reflect = "0.50.0-rc.5" +futures-util = { version = "0.3", default-features = false, features = ["alloc"] } getrandom = { workspace = true } gix = { workspace = true } gix-hash = { workspace = true }
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/router.rs @@ -106,6 +106,22 @@ get(pages::issues::show::<O>).post(pages::issues::edit::<O>), ) .route("/issues/{id}/comment", post(pages::issues::comment::<O>)) + .route( + "/agents", + get(pages::agents::list::<O>).post(pages::agents::create::<O>), + ) + .route("/agents/{id}", get(pages::agents::show::<O>)) + .route("/agents/{id}/confirm", post(pages::agents::confirm::<O>)) + .route("/agents/{id}/review", post(pages::agents::open_review::<O>)) + .route( + "/agents/{id}/chat", + get(pages::agent_chat::show::<O>).post(pages::agent_chat::send::<O>), + ) + .route( + "/agents/{id}/plan", + post(pages::agent_chat::commit_plan::<O>), + ) + .route("/agents/{id}/reopen", post(pages::agent_chat::reopen::<O>)) .route("/inbox", get(pages::inbox::list::<O>)) .route("/style.css", get(style)) .route("/ents.js", get(script))
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/exe.rs @@ -8,8 +8,8 @@ )] use crate::cli::{ - AccountAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, IssueAction, - MembersAction, RedactAction, ReviewAction, ToolchainAction, Top, + AccountAction, AgentAction, Cli, CommentAction, EffectAction, HookAction, InboxAction, + IssueAction, MembersAction, RedactAction, ReviewAction, ToolchainAction, Top, }; use crate::commands; use crate::error::Result; @@ -73,6 +73,7 @@ Top::Toolchain { action } => run_toolchain(action, out), Top::Comment { action } => run_comment(action, out), Top::Issue { action } => run_issue(action, out), + Top::Agent { action } => run_agent(action, out), Top::Review { action } => run_review(action, out), Top::Inbox { action } => run_inbox(action, out), Top::Redact { action } => run_redact(action, out), @@ -384,6 +385,75 @@ Ok(()) } +fn run_agent(action: AgentAction, out: &mut impl std::io::Write) -> Result<()> { + let root = LocalRoot::discover(".")?; + match action { + AgentAction::New { + prompt, + model, + toolchain, + base, + review_policy, + retry_of, + key, + } => { + let id = commands::agent::new( + &root, + prompt, + model, + toolchain, + base, + review_policy, + retry_of, + key, + )?; + let _ = writeln!(out, "started {id}"); + } + AgentAction::Plan { id, text, key } => { + commands::agent::plan(&root, &id, text, key)?; + let _ = writeln!(out, "drafted plan for {id}"); + } + AgentAction::Confirm { + id, + review_policy, + key, + } => { + commands::agent::confirm(&root, &id, review_policy, key)?; + let _ = writeln!(out, "confirmed {id}"); + } + AgentAction::List => { + for (id, session) in commands::agent::list(&root)? { + let _ = writeln!( + out, + "{}\t{:?}\t{}", + ents_forge::abbreviate_id(&id), + session.meta.status, + session.meta.member + ); + } + } + AgentAction::Show { id } => { + let session = commands::agent::show(&root, &id)?; + let _ = writeln!(out, "member: {}", session.meta.member); + let _ = writeln!(out, "status: {:?}", session.meta.status); + let _ = writeln!(out, "base: {}", session.meta.base_ref); + let _ = writeln!(out, "queued: {}", session.queued()); + let _ = writeln!( + out, + "awaiting confirmation: {}", + session.awaiting_confirmation() + ); + if let Some(plan) = &session.plan { + let _ = writeln!(out, "plan: {plan}"); + } + if let Some(branch) = &session.meta.result_branch { + let _ = writeln!(out, "result branch: {branch}"); + } + } + } + Ok(()) +} + fn run_review(action: ReviewAction, out: &mut impl std::io::Write) -> Result<()> { let root = LocalRoot::discover(".")?; match action {
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/cli/git-ents/tests/cli_help.rs @@ -40,6 +40,7 @@ "toolchain", "comment", "issue", + "agent", "review", "inbox", "redact",
crates/cli/git-ents/tests/hosted_root.rs @@ -243,7 +243,10 @@ &admin_root.objects, &admin_root.events, config_ref, - &ents_gate::Config { epoch: Some(1_000) }, + &ents_gate::Config { + epoch: Some(1_000), + ..ents_gate::Config::default() + }, &identity, "Enable the tip invariant", admin_root.mode(),
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);
Diff truncated (over 1 MiB).