diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 6c08391af1d..c8408afe64c 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -7478,6 +7478,9 @@ async fn serve_reserved_unix_with_lookup_ready( if let Some(agent) = ancestry.agent() { request.extensions_mut().insert(BoundAgent(agent.to_owned())); } + if let HarnessAncestry::Run(credential) = &ancestry { + request.extensions_mut().insert(credential.clone()); + } if matches!(ancestry, HarnessAncestry::Unavailable) { request.extensions_mut().insert(HarnessAncestry::Unavailable); } @@ -7521,11 +7524,41 @@ mod gateway_listener_tests; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum HarnessAncestry { Bound(String), + /// A program effect: an ancestor carries the run credential its attempt was started with + /// (docs/st3/program-engine.md §3). The guard resolves it to `mission-run/RUN`. + Run(RunCredential), Person, #[default] Unavailable, } +/// A run credential read from a caller's exec-time environment. Its `Debug` never shows the +/// secret, so ancestry diagnostics cannot leak it. +#[derive(Clone, PartialEq, Eq)] +pub struct RunCredential(String); + +impl RunCredential { + pub fn new(secret: impl Into) -> Self { + Self(secret.into()) + } + + pub fn secret(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for RunCredential { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("RunCredential(..)") + } +} + +/// The run a request's credential binds, set by the serving guard and never by a client. +#[derive(Clone, Debug)] +pub struct BoundRun(pub crate::store::run_credentials::RunBinding); + +pub use crate::store::run_credentials::RUN_CREDENTIAL_ENV; + impl HarnessAncestry { fn agent(&self) -> Option<&str> { match self { Self::Bound(agent) => Some(agent), _ => None } @@ -7548,6 +7581,15 @@ impl HarnessAncestry { fn of_environment<'a>(lookup: &impl Fn(&str) -> Option<&'a str>) -> Self { let primary = lookup("ST_AGENT"); let legacy = lookup("FRACTAL_AGENT"); + // A program effect acts as its run, never as an agent too: one environment naming both + // cannot be bound to either. + if let Some(credential) = lookup(RUN_CREDENTIAL_ENV) { + return if credential.is_empty() || primary.is_some() || legacy.is_some() { + Self::Unavailable + } else { + Self::Run(RunCredential::new(credential)) + }; + } if primary.is_none() && legacy.is_none() { return Self::Person; } @@ -7997,6 +8039,38 @@ pub fn authorize_worker(peer: Option<&LocalPeer>, observe: &dyn Fn() -> anyhow:: #[derive(Clone, Debug)] pub struct BoundAgent(pub String); +/// The identity a local request is bound to by its caller's ancestry: an agent harness, or the +/// mission run a program effect acts as. Handlers that hold a bound caller to its own identity +/// read this one extractor, so a bound run is never mistaken for an unbound person. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BoundActor { + Agent(String), + Run(String), +} + +impl BoundActor { + pub fn of(extensions: &axum::http::Extensions) -> Option { + extensions.get::().map(|agent| Self::Agent(agent.0.clone())) + .or_else(|| extensions.get::().map(|run| Self::Run(run.0.run.clone()))) + } + + /// The bound identity: the agent as the harness names it, or `mission-run/RUN`. + pub fn actor(&self) -> &str { + match self { Self::Agent(actor) | Self::Run(actor) => actor } + } +} + +impl axum::extract::OptionalFromRequestParts for BoundActor { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result, Self::Rejection> { + Ok(Self::of(&parts.extensions)) + } +} + // The serving boundary supplies the immutable ancestry binding and its identity store before // any route or admission effect runs, including for directly constructed routers. async fn guard_bound_actor( @@ -8007,6 +8081,16 @@ async fn guard_bound_actor( request.extensions_mut().remove::(); let ancestry_diagnostic = ancestry_diagnostics::RequestDiagnostic::capture(&request); let bound_agent = request.extensions().get::().map(|agent| agent.0.clone()); + // A run credential binds only while its attempt runs; one that no longer binds leaves the + // caller unverifiable, so it writes nothing. + request.extensions_mut().remove::(); + if let Some(credential) = request.extensions().get::().cloned() { + let lookup = store.clone(); + match tokio::task::spawn_blocking(move || lookup.run_binding(credential.secret())).await { + Ok(Ok(binding)) => { request.extensions_mut().insert(BoundRun(binding)); } + _ => { request.extensions_mut().insert(HarnessAncestry::Unavailable); } + } + } let client_request = request.uri().path().starts_with("/v1/client/"); // This guard is outside all router layers. Authenticate here, before ancestry admission, // independently of the ingress observe/enforce rollout. @@ -8064,7 +8148,9 @@ pub async fn guard_bound_request( bound_agent: Option<&str>, store: &Store, ) -> Result, ApiError> { - let Some(bound_agent) = bound_agent else { + // A bound run follows the rules of a bound agent, as the actor `mission-run/RUN`. + let bound_run = request.extensions().get::().map(|run| run.0.run.clone()); + let Some(bound_agent) = bound_agent.or(bound_run.as_deref()) else { return if matches!(request.extensions().get::(), Some(HarnessAncestry::Unavailable)) { guard_unverifiable_request(request).await } else { @@ -8089,9 +8175,11 @@ pub async fn guard_bound_request( && !client_v0::administrative_get(&request) { return Ok(request); } - let bound_identity = public_identity( - store, crate::store::identities::IdentityKind::Agent, bound_agent, - )?.identity; + let bound_identity = if bound_run.as_deref() == Some(bound_agent) { + bound_agent.to_owned() + } else { + public_identity(store, crate::store::identities::IdentityKind::Agent, bound_agent)?.identity + }; // Free mode lets an agent do what its person may do, but always as itself: a harness never // names a person, or another agent, as the local client-v0 actor. for person in [client_v0::LOCAL_PERSON_HEADER, wire_names::LOCAL_PERSON_HEADER_ALIAS] diff --git a/crates/fractal-api-core/src/api/client_v0.rs b/crates/fractal-api-core/src/api/client_v0.rs index de5d5e5b6e3..47f358ac40c 100644 --- a/crates/fractal-api-core/src/api/client_v0.rs +++ b/crates/fractal-api-core/src/api/client_v0.rs @@ -556,9 +556,25 @@ impl ClientSession { }; // Free mode: an agent's local session holds every scope a person's does. Its actions // still record the agent as their actor. - Ok(Self { - actor: person.into(), - authority_actor: person.into(), + Ok(Self::acting(person)) + } + + /// A program effect's local session, acting as `run` (docs/st3/program-engine.md §3). Only a + /// request its run credential binds to that same run may name it; run authority then decides + /// each write. + pub fn local_run(run: &str, bound_run: Option<&str>) -> Result { + if bound_run != Some(run) { + return Err(forbidden( + "a trusted Unix client acts as a mission run only when its run credential binds that run", + )); + } + Ok(Self::acting(run)) + } + + fn acting(actor: &str) -> Self { + Self { + actor: actor.into(), + authority_actor: actor.into(), pairing_grant: None, pairing_issued_here: false, grant_expires_at: None, @@ -566,14 +582,14 @@ impl ClientSession { transport: "unix", local_peer: false, peer_process: None, - custom_forms, + custom_forms: false, conversation_blocks: false, conversation_delivery_links: false, conversation_message_participants: false, assistant_content_handles: false, messages_paging: false, scopes: ALL_SCOPES.iter().map(|scope| (*scope).to_owned()).collect(), - }) + } } pub fn pairing() -> Self { @@ -775,10 +791,15 @@ pub fn authenticate( .headers() .get(LOCAL_PERSON_HEADER) .and_then(|value| value.to_str().ok()); - let mut session = ClientSession::local(person)?; + let bound_run = request.extensions().get::().map(|bound| bound.0.run.as_str()); + let mut session = match person.filter(|person| person.starts_with("mission-run/")) { + Some(run) => ClientSession::local_run(run, bound_run)?, + None => ClientSession::local(person)?, + }; session.peer_process = request.extensions().get::() .and_then(|peer| peer.peer_process.clone()); session.local_peer = request.extensions().get::().is_some() + || bound_run.is_some() || request.extensions().get::() .is_some_and(|peer| peer.ancestor.is_some()) || request.extensions().get::().is_some(); diff --git a/crates/fractal-api-core/src/api/launches.rs b/crates/fractal-api-core/src/api/launches.rs index bb88b6fcb0d..207ebf0afb6 100644 --- a/crates/fractal-api-core/src/api/launches.rs +++ b/crates/fractal-api-core/src/api/launches.rs @@ -1439,10 +1439,10 @@ fn ignored_authority_warnings( pub async fn apply_with_bound( State(state): State, - bound: Option>, + bound: Option, Json(request): Json, ) -> Result, ApiError> { - apply_with_authority(state, request, bound.as_ref().map(|bound| bound.0.0.as_str())).await + apply_with_authority(state, request, bound.as_ref().map(BoundActor::actor)).await } pub async fn get_mission( diff --git a/crates/fractal-api-core/src/api/operations.rs b/crates/fractal-api-core/src/api/operations.rs index b9c69f4a966..8f1ec388db5 100644 --- a/crates/fractal-api-core/src/api/operations.rs +++ b/crates/fractal-api-core/src/api/operations.rs @@ -233,7 +233,7 @@ pub async fn operational_repair_plan( pub async fn apply_operational_repair( State(state): State, - bound: Option>, + bound: Option, Json(request): Json, ) -> Result, ApiError> { if bound.is_some() || request.actor.is_some() { @@ -249,14 +249,14 @@ pub async fn apply_operational_repair( /// before mutation, even if it would silently ignore an unknown actor field. pub async fn apply_attributed_operational_repair( State(state): State, - bound: Option>, + bound: Option, Json(request): Json, ) -> Result, ApiError> { let bound = bound.ok_or_else(|| ApiError::bad(FractalError::new( "foreign-agent-actor", "attributed repair apply requires an authenticated harness binding", )))?; - apply_operational_repair_with_actor(state, request, Some(bound.0.0)).await + apply_operational_repair_with_actor(state, request, Some(bound.actor().to_owned())).await } async fn apply_operational_repair_with_actor( diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index fe8fc0150cc..06cfb90f1ec 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -45,17 +45,19 @@ pub(super) fn generic_claims_page( pub async fn put_document( State(state): State, - bound: Option>, + bound: Option, Json(request): Json, ) -> Result, ApiError> { + // A seat writes only its own seat documents; a run, which has none, writes none. if let Some(bound) = bound.as_ref() - && !crate::handoff::may_write(&bound.0.0, &request.name) + && !crate::handoff::may_write(bound.actor(), &request.name) { return Err(ApiError::bad(FractalError::new( "document-not-yours", format!( - "`{}` belongs to another seat; a seat writes only doc/agents/… for itself", - request.name + "`{}` belongs to a seat; a seat writes only doc/agents/… for itself, and `{}` is not that seat", + request.name, + bound.actor(), ), ))); } @@ -66,7 +68,7 @@ pub async fn put_document( &request.bytes, &request.expected_document, &request.idempotency_key, - bound.as_ref().map(|bound| bound.0.0.as_str()), + bound.as_ref().map(BoundActor::actor), ) .map_err(ApiError::bad)?; signal_changed(&state); diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index 91e13a6ca39..83c2a27aff2 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -29,6 +29,188 @@ fn incomplete_linux_environment_is_unavailable() { } } +#[cfg(target_os = "linux")] +#[test] +fn a_run_credential_in_the_environment_binds_a_run_and_never_an_agent_too() { + for (case, environment, expected) in [ + ("credential", b"ST_RUN_CREDENTIAL=secret\0".as_slice(), HarnessAncestry::Run(RunCredential::new("secret"))), + ("empty credential", b"ST_RUN_CREDENTIAL=\0".as_slice(), HarnessAncestry::Unavailable), + ("credential and agent", b"ST_RUN_CREDENTIAL=secret\0ST_AGENT=agent/fixture/worker\0".as_slice(), + HarnessAncestry::Unavailable), + ("credential and legacy agent", b"FRACTAL_AGENT=agent/fixture/worker\0ST_RUN_CREDENTIAL=secret\0".as_slice(), + HarnessAncestry::Unavailable), + ] { + assert_eq!(HarnessAncestry::of_linux_environment(environment), expected, "{case}"); + } + assert_eq!(format!("{:?}", RunCredential::new("secret")), "RunCredential(..)"); +} + +/// An effect's descendant that clears its own environment is still bound: the walk reads each +/// ancestor's exec-time environment and finds the credential the effect was started with. +#[cfg(target_os = "linux")] +#[test] +fn a_program_effects_descendant_binds_its_run() { + use std::io::BufRead; + let mut effect = std::process::Command::new("sh") + .args(["-c", "env -i MARKER=1 sleep 10 & echo $!; wait"]) + .env("ST_RUN_CREDENTIAL", "secret") + .env_remove("ST_AGENT") + .env_remove("FRACTAL_AGENT") + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut line = String::new(); + std::io::BufReader::new(effect.stdout.as_mut().unwrap()).read_line(&mut line).unwrap(); + let pid: u32 = line.trim().parse().unwrap(); + let peer = fractal_runtime::PeerProcessIdentity::capture_pid(pid).unwrap(); + let ancestry = harness_ancestor(pid, Some(&peer)); + effect.kill().unwrap(); + let _ = std::process::Command::new("kill").arg(pid.to_string()).status(); + effect.wait().unwrap(); + assert_eq!(ancestry, HarnessAncestry::Run(RunCredential::new("secret"))); +} + +/// A request whose ancestry carries a live run credential is attributed to `mission-run/RUN`, +/// may not name another actor, and writes nothing once its attempt has ended. +#[tokio::test] +async fn a_run_credential_binds_requests_to_its_run() { + let root = tempfile::tempdir().unwrap(); + let state = state(root.path()); + let step = state.store.start_program_step_for_tests(); + let secret = state.store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + let app = router(state.clone()).layer(from_fn_with_state((state.store.clone(), None), guard_bound_actor)); + let post = |subject: &str, actor: Option<&str>| { + let mut body = json!({"subject": subject, "kind": "custom.example.recorded", "fields": {}}); + if let Some(actor) = actor { body["actor"] = json!(actor); } + app.clone().oneshot(Request::builder().method("POST").uri("/v1/claims") + .header("content-type", "application/json") + .extension(RunCredential::new(secret.clone())) + .body(Body::from(body.to_string())).unwrap()) + }; + let response = post("custom/example/run-own", None).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let claims = state.store.claims_for("custom/example/run-own", Some("custom.example.recorded")).unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].actor.as_deref(), Some(step.run.as_str())); + assert!(step.run.starts_with("mission-run/")); + + let named = post("custom/example/run-named", Some(&step.run)).await.unwrap(); + assert_eq!(named.status(), StatusCode::OK); + for actor in ["person/operator", "agent/example/worker"] { + let before = state.store.index().unwrap(); + let refused = post("custom/example/run-foreign", Some(actor)).await.unwrap(); + assert_eq!(refused.status(), StatusCode::UNPROCESSABLE_ENTITY, "{actor}"); + let body = to_bytes(refused.into_body(), usize::MAX).await.unwrap(); + assert_eq!(serde_json::from_slice::(&body).unwrap()["code"], "foreign-agent-actor"); + assert_eq!(state.store.index().unwrap(), before); + } + + // The attempt ends: the credential no longer binds, and the caller writes nothing. + state.store.set_step_run_state_for_tests(&step.subject, "completed", 1); + let before = state.store.index().unwrap(); + let ended = post("custom/example/run-ended", None).await.unwrap(); + assert_eq!(ended.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(state.store.index().unwrap(), before); +} + +/// A program effect's `fractal apply` publishes a mission as its run, and as no other actor. +#[tokio::test] +async fn a_run_bound_request_publishes_as_its_run() { + let root = tempfile::tempdir().unwrap(); + let state = state(root.path()); + let step = state.store.start_program_step_for_tests(); + let secret = state.store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + let app = router(state.clone()).layer(from_fn_with_state((state.store.clone(), None), guard_bound_actor)); + let source = "version 2\nmission \"x/notes\" state=\"ready\" {\n goal \"Take notes.\"\n step \"work\" { }\n}\n"; + let apply = |actor: &str| { + let body = serde_json::to_string(&apply_request(&state, source, actor, actor)).unwrap(); + let request = Request::builder().method("POST").uri("/v1/intent/apply") + .header("content-type", "application/json") + .extension(RunCredential::new(secret.clone())) + .body(Body::from(body)).unwrap(); + let app = app.clone(); + async move { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice::(&body).unwrap()) + } + }; + for actor in ["person/operator", "agent/example/worker"] { + let (status, refused) = apply(actor).await; + assert_eq!((status, refused["code"].as_str()), (StatusCode::UNPROCESSABLE_ENTITY, Some("foreign-agent-actor")), "{actor} {refused}"); + } + assert!(state.store.latest_claim("mission/x/notes", Some("mission.published")).unwrap().is_none()); + let (status, published) = apply(&step.run).await; + assert_eq!(status, StatusCode::OK, "{published}"); + let claim = state.store.latest_claim("mission/x/notes", Some("mission.published")).unwrap().unwrap(); + assert_eq!(claim.actor.as_deref(), Some(step.run.as_str())); +} + +/// A program effect's CLI names its run on the trusted Unix client; only a request its run +/// credential binds to that same run may. +#[tokio::test] +async fn a_run_bound_unix_client_acts_as_its_run_and_no_other() { + let root = tempfile::tempdir().unwrap(); + let state = state(root.path()); + let step = state.store.start_program_step_for_tests(); + let secret = state.store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + let binding = state.store.run_binding(&secret).unwrap(); + let request = |person: &str, bound: bool| { + let mut request = Request::builder().method("POST").uri("/v1/client/actions") + .header(client_v0::LOCAL_PERSON_HEADER, person); + if bound { request = request.extension(BoundRun(binding.clone())); } + request.body(Body::empty()).unwrap() + }; + let session = client_v0::authenticate(&state, &request(&step.run, true), "unix").unwrap(); + assert_eq!((session.actor.as_str(), session.authority_actor.as_str()), (step.run.as_str(), step.run.as_str())); + assert!(session.local_peer); + for (person, bound) in [(step.run.as_str(), false), ("mission-run/another", true)] { + let refused = client_v0::authenticate(&state, &request(person, bound), "unix").unwrap_err(); + assert_eq!(refused.status, StatusCode::FORBIDDEN, "{person} bound={bound}"); + } +} + +/// Handlers that hold a bound caller to itself read one bound identity, so a bound run is never +/// an unbound person: it writes no seat's document, and it is the writer the rules decide. +#[tokio::test] +async fn a_bound_run_is_held_to_its_identity_by_document_writes() { + let root = tempfile::tempdir().unwrap(); + let state = state(root.path()); + let step = state.store.start_program_step_for_tests(); + let secret = state.store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + state.store.set_rule("runs-keep-out", &smallclaims::rules::Rule { + mode: smallclaims::rules::Mode::Enforce, + actors: vec!["mission-run/*".into()], + kinds: vec!["doc.bound".into()], + subjects: vec!["doc/locked/**".into()], + ..Default::default() + }, "person/owner").unwrap(); + let app = router(state.clone()).layer(from_fn_with_state((state.store.clone(), None), guard_bound_actor)); + let write = |name: &str, key: &str| { + let body = json!({"name": name, "bytes": b"notes".to_vec(), "expected_document": null, "idempotency_key": key}); + let request = Request::builder().method("POST").uri("/v1/documents") + .header("content-type", "application/json") + .extension(RunCredential::new(secret.clone())) + .body(Body::from(body.to_string())).unwrap(); + let app = app.clone(); + async move { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice::(&body).unwrap()) + } + }; + let (status, refused) = write("doc/agents/garden/ash/handoff", "seat").await; + assert_eq!((status, refused["code"].as_str()), (StatusCode::UNPROCESSABLE_ENTITY, Some("document-not-yours")), "{refused}"); + let (status, refused) = write("doc/locked/notes", "locked").await; + assert_eq!(refused["code"], "rule-denied", "{status} {refused}"); + let (status, written) = write("doc/runs/notes", "notes").await; + assert_eq!(status, StatusCode::OK, "{written}"); + assert_eq!(BoundActor::of(Request::builder().extension(BoundAgent("agent/a".into())).body(()).unwrap().extensions()), + Some(BoundActor::Agent("agent/a".into()))); +} + #[cfg(target_os = "linux")] #[test] fn empty_process_environment_is_unavailable() { diff --git a/crates/fractal-api-core/src/api/write_principal.rs b/crates/fractal-api-core/src/api/write_principal.rs index 982d128826b..c710258a228 100644 --- a/crates/fractal-api-core/src/api/write_principal.rs +++ b/crates/fractal-api-core/src/api/write_principal.rs @@ -15,10 +15,14 @@ impl WritePrincipal { } let agent = request.extensions().get::().map(|agent| agent.0.as_str()) .or_else(|| request.extensions().get::().map(|peer| peer.agent.as_str())); - if let Some(agent) = agent { - let actor = public_identity( + // A program effect writes as its run, `mission-run/RUN`. + let actor = match request.extensions().get::() { + Some(run) => Some(run.0.run.clone()), + None => agent.map(|agent| public_identity( &state.store, crate::store::identities::IdentityKind::Agent, agent, - )?.identity; + ).map(|identity| identity.identity)).transpose()?, + }; + if let Some(actor) = actor { if let Some(session) = request.extensions().get::() && (session.pairing_grant.is_some() || client_v0::acting_party(session)) && !is_bound_actor(&state.store, &session.authority_actor, &actor)? @@ -134,6 +138,7 @@ pub(super) async fn bind_request( .is_some_and(|session| session.pairing_grant.is_some()); if !paired && request.extensions().get::().is_none() + && request.extensions().get::().is_none() && request.extensions().get::().is_none() && !matches!(request.extensions().get::(), Some(HarnessAncestry::Unavailable)) { diff --git a/crates/fractal-drivers/src/contracts.rs b/crates/fractal-drivers/src/contracts.rs index bf9fce52851..dd0ab934880 100644 --- a/crates/fractal-drivers/src/contracts.rs +++ b/crates/fractal-drivers/src/contracts.rs @@ -23,7 +23,7 @@ fn identity_env(name: &str) -> bool { matches!( name, "ST_AGENT" | "ST_SUPERVISOR" | "ST3_SUBJECT" | "ST3_INCARNATION" - | "ST_GATE_CAPABILITY" | "ST3_TERMINAL_CAPABILITY" + | "ST_GATE_CAPABILITY" | "ST3_TERMINAL_CAPABILITY" | "ST_RUN_CREDENTIAL" ) || name.starts_with("ST_DRIVER_") || ((name.starts_with("ST_CLAUDE_") || name.starts_with("ST_PI_CHANNEL_") diff --git a/crates/fractal-missions/src/graph.rs b/crates/fractal-missions/src/graph.rs index 9cb975f1e2d..66f69bcd500 100644 --- a/crates/fractal-missions/src/graph.rs +++ b/crates/fractal-missions/src/graph.rs @@ -97,7 +97,10 @@ fn resolve_node_documents(node: &mut KdlNode, bindings: &BTreeMap { export default { [Symbol.for('fractal.program.v1')]: async (ctx) => { const first = await step(ctx, 'first', async () => ({ attempt: ctx.attempt })) + if (ctx.payload.mode === 'credential') { + await step(ctx, 'who', async () => (await ctx.spawn(['sh', '-c', 'printf %s "$ST_RUN_CREDENTIAL"'])).stdout) + } if (ctx.payload.mode === 'busy') for (;;) {} const waited = await step(ctx, 'wait', async () => { const script = 'sleep 1000 & echo $$ $! > "$PIDS.tmp"; mv "$PIDS.tmp" "$PIDS"; while [ ! -e "$GATE" ]; do sleep 0.05; done; echo opened' @@ -221,3 +224,32 @@ fn an_unresponsive_worker_fails_only_its_own_step() { // The host itself stayed up: only the busy worker was terminated. assert_eq!(fixture.engine.host_pid(), host); } + +/// An effect carries a credential that binds its run while the attempt runs and no longer once +/// it ends, and the journal is written as the run (docs/st3/program-engine.md §3). +#[test] +fn a_programs_effects_and_journal_act_as_its_run() { + let fixture = fixture(Liveness::default()); + let started = fixture.start("credential", "credential"); + fixture.until("the effect reports its credential", || pids(&started.pids).is_some()); + let who = fixture.store.claims_for(&started.step, Some(JOURNAL_KIND)).unwrap().into_iter() + .find(|claim| claim.body["fields"]["key"] == "who" && claim.body["fields"]["status"] == "completed") + .unwrap(); + let credential = who.body["fields"]["value"].as_str().unwrap().to_owned(); + let binding = fixture.store.run_binding(&credential).unwrap(); + assert_eq!((binding.run.as_str(), binding.step_run.as_str(), binding.attempt), (started.run.as_str(), started.step.as_str(), 1)); + // Passes over a running attempt mint nothing more, and the credential has no expiry of its own. + for _ in 0..5 { + fixture.reconciler.reconcile_once().unwrap(); + } + assert_eq!(fixture.store.run_credentials_issued_for_tests(&started.step), 1); + let capability = fixture.store.capability(&credential, crate::store::run_credentials::RUN_CREDENTIAL_KIND).unwrap(); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis(); + assert!(capability.expires_at_unix_ms > now + 100 * 365 * 24 * 60 * 60 * 1000); + std::fs::write(&started.gate, "").unwrap(); + fixture.until("the program completes", || fixture.status(&started.step) == "completed"); + assert_eq!(fixture.store.run_binding(&credential).unwrap_err().code, "run-credential-ended"); + let claims = fixture.store.claims_for(&started.step, Some(JOURNAL_KIND)).unwrap(); + assert!(!claims.is_empty()); + assert!(claims.iter().all(|claim| claim.actor.as_deref() == Some(started.run.as_str())), "{claims:#?}"); +} diff --git a/crates/fractal-secrets/src/client.rs b/crates/fractal-secrets/src/client.rs index 345f66aaa39..fef69e37fee 100644 --- a/crates/fractal-secrets/src/client.rs +++ b/crates/fractal-secrets/src/client.rs @@ -562,6 +562,21 @@ impl Client { Ok(client) } + /// Name the mission run a program effect acts as over the trusted Unix boundary. The daemon + /// accepts it only when the caller's run credential binds that run. + pub fn unix_run(path: impl Into, run: impl Into) -> Result { + let run = run.into(); + anyhow::ensure!( + run.strip_prefix("mission-run/").is_some_and(|id| { + !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + }), + "Unix run authority must be one concrete `mission-run/` subject" + ); + let mut client = Self::unix(path); + client.person = Some(run); + Ok(client) + } + pub async fn get(&self, path: &str) -> Result { let first_page = path.starts_with("/v1/client/") && !request_has_page_cursor(path); for attempt in 0..3 { @@ -1786,6 +1801,15 @@ fn decode_api_response(bytes: &[u8]) -> Result { mod tests { use super::*; + #[test] + fn a_unix_run_client_names_exactly_one_mission_run() { + let client = Client::unix_run("/tmp/socket", "mission-run/0123abcd").unwrap(); + assert_eq!(client.person.as_deref(), Some("mission-run/0123abcd")); + for run in ["mission-run/", "mission-run/a/b", "person/operator", "mission-run/a b"] { + assert!(Client::unix_run("/tmp/socket", run).is_err(), "{run}"); + } + } + #[tokio::test] async fn bind_422_warns_and_keeps_retrying() { use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; diff --git a/crates/fractal-store/src/graph.rs b/crates/fractal-store/src/graph.rs index 1d96f20fc65..8d2f73f03c3 100644 --- a/crates/fractal-store/src/graph.rs +++ b/crates/fractal-store/src/graph.rs @@ -4409,7 +4409,10 @@ fn collect_document_refs(node: &KdlNode, output: &mut BTreeSet) -> Resul let gate = node.name().value() == "gate"; let mission = node.name().value() == "mission"; for child in children.nodes() { - if !(gate && is_document_gate(child) || mission && child.name().value() == "provenance") { + // A mission's `authority` names subject patterns such as `doc/team/*`, not documents. + if !(gate && is_document_gate(child) + || mission && matches!(child.name().value(), "provenance" | "authority")) + { collect_document_refs(child, output)?; } } diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 1116beaa253..bc9377b9705 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -73,6 +73,7 @@ pub mod seat_queue_plan_tests; pub mod schedule_standby; pub mod mission_capacity; mod program_bundles; +pub mod run_credentials; pub mod marks_switch; pub mod glasses; pub mod mailbox_wakes; @@ -351,6 +352,8 @@ mod status_summary_tests; #[cfg(test)] mod run_eta_tests; #[cfg(test)] +mod run_credentials_tests; +#[cfg(test)] mod run_eta_review_tests; pub mod client_summary; pub mod unread_mail; diff --git a/crates/fractal-store/src/store/mission_capacity.rs b/crates/fractal-store/src/store/mission_capacity.rs index 848719f6f49..2ec51d3363e 100644 --- a/crates/fractal-store/src/store/mission_capacity.rs +++ b/crates/fractal-store/src/store/mission_capacity.rs @@ -96,7 +96,7 @@ pub(super) fn validate_mission( fn validate_barrier(connection: &Connection, origin: &str, barrier: &Barrier) -> Result<(), FractalError> { let Barrier { feature, code, later, .. } = barrier; - let refused = |reason: String| FractalError::new(*code, reason); + let refused = |reason: String| FractalError::new(code, reason); let membership = fleet_membership_tx(connection).map_err(internal)?; for member in membership.incarnations().filter(|member| member.end.is_none()) { // This host runs this build. diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs new file mode 100644 index 00000000000..8dcec08315a --- /dev/null +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -0,0 +1,93 @@ +//! Run credentials (docs/st3/program-engine.md §3): the engine mints one per program attempt and +//! passes it to the attempt's effects as `ST_RUN_CREDENTIAL`; the daemon binds a request carrying +//! it to `mission-run/RUN`. It is a local, hashed store capability whose subject is the step run +//! and whose incarnation is the attempt. It needs no revocation: it binds only while that attempt +//! is the step run's current one and the step is still live, so it dies with its attempt. +use super::*; + +/// The capability kind of a run credential. +pub const RUN_CREDENTIAL_KIND: &str = "program-run"; +/// The environment variable a program attempt's effects carry their run credential in. +pub const RUN_CREDENTIAL_ENV: &str = "ST_RUN_CREDENTIAL"; + +/// The step statuses in which a program attempt runs, including one waiting on a person's answer. +const LIVE_STEP_STATES: [&str; 4] = ["ready", "working", "blocked", "waiting-person"]; + +/// The actor a run credential binds: `run` is `mission-run/RUN`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RunBinding { + pub run: String, + pub step_run: String, + pub attempt: u32, +} + +fn ended(reason: &str) -> FractalError { + FractalError::new("run-credential-ended", format!("the run credential no longer binds: {reason}")) +} + +impl Store { + /// Mint the credential one program attempt's effects carry, valid for at most `lifetime_ms`. + pub fn issue_run_credential(&self, step_run: &str, attempt: u32, lifetime_ms: u64) -> Result { + self.issue_capability(RUN_CREDENTIAL_KIND, step_run, Some(&attempt.to_string()), lifetime_ms) + .map(|(secret, _)| secret) + } + + /// The run `secret` acts as, while its attempt is its step run's current, live attempt. + pub fn run_binding(&self, secret: &str) -> Result { + let capability = self.capability(secret, RUN_CREDENTIAL_KIND)?; + let attempt = capability.incarnation_id.as_deref().and_then(|attempt| attempt.parse().ok()) + .ok_or_else(|| FractalError::new("invalid-capability", "the run credential names no attempt"))?; + let step = self.step_run(&capability.subject).map_err(internal)? + .ok_or_else(|| ended("its step run does not exist"))?; + if step.attempt != attempt { + return Err(ended("its attempt is no longer the step run's current attempt")); + } + if !LIVE_STEP_STATES.contains(&step.status.as_str()) { + return Err(ended("its step run is no longer running")); + } + Ok(RunBinding { run: step.run, step_run: step.subject, attempt }) + } +} + +#[cfg(any(test, feature = "test-support"))] +impl Store { + /// Put a step run at `status` and `attempt`, as a dispatch, retry or end would. + pub fn set_step_run_state_for_tests(&self, subject: &str, status: &str, attempt: u32) { + self.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h2ec0013f005e owner=fractal_store::store::run_credentials TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET status=?2, attempt=?3 WHERE subject=?1", + params![subject, status, attempt], + ).unwrap(); + } + + /// How many run credentials were ever minted for `step_run`. + pub fn run_credentials_issued_for_tests(&self, step_run: &str) -> usize { + self.readers.get().query_row( + "SELECT COUNT(*) FROM capabilities WHERE kind=?1 AND subject=?2", + params![RUN_CREDENTIAL_KIND, step_run], + |row| row.get(0), + ).unwrap() + } + + /// Publish a one-step mission that may write `custom/example/*` and `doc/runs/*`, start a run + /// of it and put its step at attempt 1, working. + pub fn start_program_step_for_tests(&self) -> StepRunView { + crate::store::tests_shared_seats::publish_mission(self, r#"version 2 +mission "land" state="ready" { + goal "Land one change." + authority { write "custom/example/*"; write "doc/runs/*"; } + step "program" { agentless } +}"#, "declare-land"); + let run = self.create_mission_run_inner(&crate::model::MissionRunRequest { + mission: "land".into(), + revision: None, + workspace: "/tmp".into(), + requester: Some("person/operator".into()), + mode: None, + inputs: Default::default(), + idempotency_key: "run-land".into(), + }, None, None, None).unwrap(); + let step = run.steps.into_iter().find(|step| step.step == "program").unwrap(); + self.set_step_run_state_for_tests(&step.subject, "working", 1); + step + } +} diff --git a/crates/fractal-store/src/store/run_credentials_tests.rs b/crates/fractal-store/src/store/run_credentials_tests.rs new file mode 100644 index 00000000000..17d180e07bc --- /dev/null +++ b/crates/fractal-store/src/store/run_credentials_tests.rs @@ -0,0 +1,51 @@ +use super::run_credentials::{RUN_CREDENTIAL_KIND, RunBinding}; +use super::*; + +fn running_step() -> (Store, StepRunView) { + let store = Store::open_memory("node").unwrap(); + let step = store.start_program_step_for_tests(); + (store, step) +} + +fn set_step(store: &Store, subject: &str, status: &str, attempt: u32) { + store.set_step_run_state_for_tests(subject, status, attempt); +} + +#[test] +fn a_run_credential_binds_its_run_only_while_its_attempt_runs() { + let (store, step) = running_step(); + let secret = store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + // Scoped to the step run and attempt. + let capability = store.capability(&secret, RUN_CREDENTIAL_KIND).unwrap(); + assert_eq!((capability.subject.as_str(), capability.incarnation_id.as_deref()), (step.subject.as_str(), Some("1"))); + assert_eq!(store.run_binding(&secret).unwrap(), RunBinding { + run: step.run.clone(), + step_run: step.subject.clone(), + attempt: 1, + }); + assert!(step.run.starts_with("mission-run/")); + + // A retry is a new attempt: the old credential dies, the new one binds. + set_step(&store, &step.subject, "ready", 2); + assert_eq!(store.run_binding(&secret).unwrap_err().code, "run-credential-ended"); + let retried = store.issue_run_credential(&step.subject, 2, 60_000).unwrap(); + assert_eq!(store.run_binding(&retried).unwrap().attempt, 2); + // A program waiting on a person's answer still runs. + set_step(&store, &step.subject, "waiting-person", 2); + assert_eq!(store.run_binding(&retried).unwrap().attempt, 2); + + // The attempt's end ends its credential. + set_step(&store, &step.subject, "completed", 2); + assert_eq!(store.run_binding(&retried).unwrap_err().code, "run-credential-ended"); +} + +#[test] +fn a_run_credential_is_refused_when_unknown_expired_or_of_another_kind() { + let (store, step) = running_step(); + assert_eq!(store.run_binding("not-a-credential").unwrap_err().code, "invalid-capability"); + let (gate, _) = store.issue_capability("gate-result", &step.subject, Some("1"), 60_000).unwrap(); + assert_eq!(store.run_binding(&gate).unwrap_err().code, "invalid-capability"); + let expired = store.issue_run_credential(&step.subject, 1, 0).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + assert_eq!(store.run_binding(&expired).unwrap_err().code, "expired-capability"); +} diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index fa32f906af6..5341d9b75f0 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -1399,9 +1399,9 @@ struct MissionPublishArgs { /// Preview against this exact store index. #[arg(long, visible_alias = "at")] at_index: Option, - /// Complete person or agent subject authoring the publication. + /// Complete person, agent or run subject authoring the publication. #[arg(add = ArgValueCompleter::new(Complete(Entity::Actor)))] - #[arg(long = "as", value_parser = parse_publication_actor)] + #[arg(long = "as", value_parser = parse_mission_publication_actor)] actor: String, /// The workspace a run would use, where publish checks mission and step exec gates. #[arg(long, default_value = ".")] @@ -3389,10 +3389,11 @@ struct ApplyArgs { allow_empty: bool, #[arg(long, requires = "set")] confirm_retire: Option, - /// Complete person or agent subject authoring the publication. + /// Complete person, agent or run subject authoring the publication; defaults to the + /// harness's own seat, else to the run a program effect acts as. #[arg(add = ArgValueCompleter::new(Complete(Entity::Actor)))] - #[arg(long = "as", env = "ST_AGENT", value_parser = parse_publication_actor)] - actor: String, + #[arg(long = "as", env = "ST_AGENT", value_parser = parse_mission_publication_actor)] + actor: Option, } #[derive(Subcommand)] @@ -3452,9 +3453,10 @@ fn read_apply_files(files: &[PathBuf], allow_empty: bool) -> Result } async fn run_apply(client: &Client, args: ApplyArgs, json_output: bool) -> Result<()> { + let actor = own_publication_actor(args.actor.clone(), "apply")?; let intent = read_apply_files(&args.files, args.allow_empty)?; if args.set.is_some() { - return run_owned_set_apply(client, intent, args, json_output).await; + return run_owned_set_apply(client, intent, args, actor, json_output).await; } publish_mission_intent( client, @@ -3463,7 +3465,7 @@ async fn run_apply(client: &Client, args: ApplyArgs, json_output: bool) -> Resul file: PathBuf::new(), dry_run: args.dry_run, at_index: args.at_index, - actor: args.actor, + actor, workspace: args.workspace, no_gate_check: args.no_gate_check, check: args.check, @@ -3478,6 +3480,7 @@ async fn run_owned_set_apply( client: &Client, intent: IntentInput, args: ApplyArgs, + actor: String, json_output: bool, ) -> Result<()> { use fractal_daemon::store::owned_sets::{Options, Preview, Request, Source}; @@ -3510,7 +3513,7 @@ async fn run_owned_set_apply( let mut request = Request { intent, options, - actor: args.actor, + actor, idempotency_key: uuid::Uuid::now_v7().to_string(), program_bundles: BTreeMap::new(), }; @@ -6322,7 +6325,7 @@ fn guard_mutating_cli_actor( anyhow::ensure!(actor == own, "actor flag must equal ST_AGENT literally ({own})"); } let actor = match command { - Command::Apply(args) => Some(args.actor.as_str()), + Command::Apply(args) => args.actor.as_deref(), Command::Changes(change_cli::ChangesArgs { command: change_cli::ChangesCommand::Sync { actor, .. }, }) | Command::Gate { @@ -10163,6 +10166,8 @@ fn execution_client(endpoint: &Endpoint, actor: String) -> Result { }; let client = if actor.starts_with("agent/") { Client::unix_agent(socket.as_ref(), actor)? + } else if actor.starts_with("mission-run/") { + Client::unix_run(socket.as_ref(), actor)? } else { Client::unix_as(socket.as_ref(), actor)? }; @@ -10171,8 +10176,9 @@ fn execution_client(endpoint: &Endpoint, actor: String) -> Result { .with_outage_wait(DAEMON_WAIT.get().copied().unwrap_or_default(), true)) } -/// The actor of a terminal or client-v0 command: an explicit person or agent, else the harness's -/// own seat, else the configured person. Free mode lets an agent act, always as itself. +/// The actor of a terminal or client-v0 command: an explicit person, agent or run, else the +/// harness's own seat, else the run a program effect acts as, else the configured person. Free +/// mode lets an agent act, always as itself. fn configured_actor( explicit: Option<&str>, configured: Option<&str>, @@ -10186,9 +10192,38 @@ fn configured_actor( { return Ok(own); } + if let Some(run) = run_actor( + fractal_drivers::contracts::env("ST_RUN_CREDENTIAL").as_deref(), + fractal_drivers::contracts::env("ST_MISSION_RUN").as_deref(), + ) { + return Ok(run); + } configured_human(None, configured, command) } +/// The actor of a publication whose `--as` defaults to the caller's own identity: the explicit or +/// `ST_AGENT` subject, else the run a program effect acts as (docs/st3/program-engine.md §3). +fn own_publication_actor(explicit: Option, command: &str) -> Result { + explicit + .or_else(|| { + run_actor( + fractal_drivers::contracts::env("ST_RUN_CREDENTIAL").as_deref(), + fractal_drivers::contracts::env("ST_MISSION_RUN").as_deref(), + ) + }) + .with_context(|| { + format!("fractal {command} needs `--as person/NAME`, `--as agent/PATH` or `--as mission-run/RUN`") + }) +} + +/// A program effect carries its attempt's run credential and acts as its run +/// (docs/st3/program-engine.md §3); the daemon binds the credential to the same run. +fn run_actor(credential: Option<&str>, run: Option<&str>) -> Option { + credential.filter(|credential| !credential.is_empty())?; + let run = run.map(|run| run.trim_start_matches("mission-run/")).filter(|run| !run.is_empty())?; + Some(format!("mission-run/{run}")) +} + fn configured_human( explicit: Option<&str>, configured: Option<&str>, @@ -16236,11 +16271,14 @@ fn planning_session_intent( publication_document(session) } -/// A complete `person/NAME` or an agent. Free mode lets an agent do what its person may do, and -/// the daemon still records the agent itself as the actor. +/// A complete `person/NAME`, an agent, or a `mission-run/RUN` a program effect acts as. Free mode +/// lets an agent do what its person may do, and the daemon still records the agent itself as the +/// actor; a run is only what its credential binds. fn parse_actor_subject(actor: &str) -> std::result::Result { - if actor.starts_with("agent/") && actor.len() > "agent/".len() { - return Ok(actor.to_owned()); + for family in ["agent/", "mission-run/"] { + if actor.starts_with(family) && actor.len() > family.len() { + return Ok(actor.to_owned()); + } } parse_person_subject(actor) } @@ -16296,6 +16334,22 @@ fn parse_publication_actor(actor: &str) -> std::result::Result { Ok(actor.to_owned()) } +/// The author of a mission publication: a person, an agent, or a run, which publishes what its +/// `authority` block's `publish` rules grant (docs/st3/program-engine.md §3). Starting, revising +/// and cancelling runs stay with the program API and the person or agent commands. +fn parse_mission_publication_actor(actor: &str) -> std::result::Result { + if actor + .strip_prefix("mission-run/") + .is_some_and(|run| !run.is_empty() && !run.ends_with('/')) + { + return Ok(actor.to_owned()); + } + parse_publication_actor(actor).map_err(|_| { + "mission publication authority must be an explicit `person/NAME`, `agent/PATH` or `mission-run/RUN` subject" + .into() + }) +} + #[cfg(test)] mod native_seed_driver_tests; diff --git a/crates/fractal/src/main_tests.rs b/crates/fractal/src/main_tests.rs index ea9929b0bf7..0befc68f3d4 100644 --- a/crates/fractal/src/main_tests.rs +++ b/crates/fractal/src/main_tests.rs @@ -1799,6 +1799,52 @@ fn a_harness_cannot_act_as_another_agent() { ); } +#[test] +fn a_program_effect_acts_as_its_run() { + assert_eq!(run_actor(Some("secret"), Some("abc")).as_deref(), Some("mission-run/abc")); + assert_eq!(run_actor(Some("secret"), Some("mission-run/abc")).as_deref(), Some("mission-run/abc")); + assert_eq!(run_actor(None, Some("abc")), None); + assert_eq!(run_actor(Some(""), Some("abc")), None); + assert_eq!(run_actor(Some("secret"), Some("")), None); + assert_eq!(parse_actor_subject("mission-run/abc").as_deref(), Ok("mission-run/abc")); + assert!(parse_actor_subject("mission-run/").is_err()); +} + +/// A run publishes missions as itself: `apply` and `missions publish` take `--as mission-run/RUN`, +/// and `apply` defaults to it when it holds a run credential. The commands a run's authority cannot +/// grant still take only a person or an agent. +#[test] +fn a_run_is_a_mission_publication_actor() { + for actor in ["person/operator", "agent/fleet/worker", "mission-run/abc"] { + assert_eq!(parse_mission_publication_actor(actor).as_deref(), Ok(actor)); + } + for actor in ["mission-run/", "mission-run/abc/", "mission/abc", "person/", "abc"] { + assert!(parse_mission_publication_actor(actor).is_err(), "{actor}"); + } + assert!(parse_publication_actor("mission-run/abc").is_err()); + for command in [ + vec!["st", "apply", "notes.kdl", "--as", "mission-run/abc"], + vec!["st", "missions", "publish", "notes.kdl", "--as", "mission-run/abc"], + ] { + assert!(Cli::try_parse_from(&command).is_ok(), "{command:?}"); + } + for command in [ + vec!["st", "missions", "retire", "x/notes", "--as", "mission-run/abc"], + vec!["st", "agents", "rollout", "fleet/worker", "--as", "mission-run/abc"], + ] { + assert!(Cli::try_parse_from(&command).is_err(), "{command:?}"); + } + let Command::Apply(args) = Cli::try_parse_from(["st", "apply", "notes.kdl", "--as", "mission-run/abc"]) + .unwrap() + .command + else { + panic!("expected apply") + }; + assert_eq!(own_publication_actor(args.actor, "apply").unwrap(), "mission-run/abc"); + // Without `--as`, apply resolves its actor from its environment when it runs. + assert!(Cli::try_parse_from(["st", "apply", "notes.kdl"]).is_ok()); +} + #[test] fn execution_actor_options_parse_and_fail_closed() { for (path, tail) in [ @@ -8598,7 +8644,7 @@ async fn publish_test_mission(client: &Client, root: &Path, goal: &str) { confirm_retire: None, dry_run: false, at_index: None, - actor: "person/test".into(), + actor: Some("person/test".into()), workspace: root.to_owned(), no_gate_check: false, check: false, diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 0624ecb1c95..46c2e823b1d 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -1579,6 +1579,7 @@ {"id": "lv-h2e8ef1166b77", "path": "crates/fractal-store/src/store/message_point_qualification_shared_delivery.rs", "kind": "unresolved-sql-sink", "fingerprint": "62e9f69e82a20355d1df3e3832188e2faeca99b9ca03eecd7d53916fd4b3c6b0", "owner": "fractal_store::store::message_point_qualification_shared_delivery", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 402, "excerpt": "sql , params ! [ normalized , bare , subject ] , | row | { Ok ( ( row . get ( 0 ) ? , row . get ( 1 ) ? , row . get ( 2 ) ? ) ) }"}, {"id": "lv-h2eace97aaa95", "path": "crates/fractal-delivery/src/store/message_point_qualification/tests.rs", "kind": "sql-template", "fingerprint": "63588b9cdab6795d7e7ccfe87a9cb74c1ba477e9978064bbc5bc90bb521215f5", "owner": "fractal_delivery::store::message_point_qualification::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 598, "excerpt": "INSERT INTO desired(subject,kind,revision,claim_id,body) VALUES('message/selected','message','r','sent',?1)"}, {"id": "lv-h2eb3b0d71dfa", "path": "crates/smallclaims/src/store/checkpoint.rs", "kind": "unresolved-sql-sink", "fingerprint": "d4a2e52a9158cd5573935d2222690a70c9ecc8d502b20cf5593f4635fbd86519", "owner": "smallclaims::store::checkpoint", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 424, "excerpt": "[ & claim . id ]"}, + {"id": "lv-h2ec0013f005e", "path": "crates/fractal-store/src/store/run_credentials.rs", "kind": "sql-template", "fingerprint": "2aeb47d036569eb578a251a37dd39dd6a61bf061d06b53ee0fa291cc3b24f873", "owner": "fractal_store::store::run_credentials", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 55, "excerpt": "UPDATE step_runs SET status=?2, attempt=?3 WHERE subject=?1"}, {"id": "lv-h2eccd4ddae86", "path": "crates/fractal-store/src/store/tests.rs", "kind": "sql-template", "fingerprint": "94dedd5db98c1e80254ce79345f6724cee48d90b3acfe79f248d3adebe5f661a", "owner": "fractal_store::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1375, "excerpt": "UPDATE projection_health SET status='stale' WHERE aggregate='graph'"}, {"id": "lv-h2ecd69fcc8f6", "path": "crates/fractal-store/src/store/completion_state_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "dc372daedd324b47f1261987900c1a08eff3ccdf253bfcf666d0ebecf17142ad", "owner": "fractal_store::store::completion_state_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 241, "excerpt": "& store , | _ | ( )"}, {"id": "lv-h2ed47d41170d", "path": "crates/fractal-store/src/store/message_canonical_sort_tests.rs", "kind": "sql-template", "fingerprint": "43a749b59671cc07d56fb696fafe0ca0baf98feb73d187d0cfa440c237cf56ef", "owner": "fractal_store::store::message_canonical_sort_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 329, "excerpt": "UPDATE claims SET accepted_at_unix_ms=x'00' WHERE id='healthy'"}, diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index 2d71f976fae..6e041994183 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -245,7 +245,15 @@ a mission ID without `mission/`; `write` takes a subject, or a registered family block, a repeated block or rule, an unknown verb, any other `*`, and the block in a nested or loop mission. Rules are stored in canonical order, so reordering them is not a revision; any other change is. A mission without the block grants its runs nothing. The block is stored with the revision; -the daemon does not yet bind a run actor or enforce it. An older member would quarantine a +the daemon does not enforce it yet. A request whose caller's ancestry carries a live run credential +(`ST_RUN_CREDENTIAL`, one per program attempt) is bound to `mission-run/RUN`: like a bound agent, it +may not call internal routes, send a person header or name another `actor` or `requester`, and its +writes are attributed to the run. The credential binds only while its attempt is the step run's +current, live attempt; one that no longer binds leaves the caller unverifiable, and an +environment that carries both a credential and `ST_AGENT` binds neither. A bound run writes no +seat's `doc/agents/…` document. The CLI defaults its actor to `mission-run/$ST_MISSION_RUN` when the +credential is present; the daemon accepts that actor from the trusted Unix client only when the +request's credential binds the same run. An older member would quarantine a revision that declares it (it hashes the mission without the field), so publication holds such a revision until every member advertises `mission_authority=1` on its `daemon.started` (`mission-authority-unavailable`, also an `apply --dry-run` blocker), as for `capacity` diff --git a/docs/st3/operational-state/cli-commands.json b/docs/st3/operational-state/cli-commands.json index d337b7da94f..80023b07008 100644 --- a/docs/st3/operational-state/cli-commands.json +++ b/docs/st3/operational-state/cli-commands.json @@ -44,7 +44,7 @@ "changes": {"job": "Read Git change state and explicitly synchronize retained metadata", "why": "Expose source, ref, index and evidence freshness while retaining the PR path", "actor": "person or agent", "mode": "read-or-controlled-sync", "model": "Git change observation and local derived receipts", "inputs": "configured repository name, full Change-Id, offline flag and bounded list cursor", "defaults": "feature disabled; list limit 50; one designated reader per repository", "output": "bounded list, exact change detail or typed sync admission", "success": "indexed state with explicit freshness or unavailable PR fallback", "actions": "list show sync", "authority": "read.projections for reads; control.work for sync; operator-owned transport", "recovery": "use the named reader or the PR path; configure transport before activation", "human_example": "st changes list --repository fixture/garden --offline", "json_example": "st changes list --repository fixture/garden --offline --json", "workflow": "product/git-changes"}, "ui": {"job": "Open the terminal interface", "why": "Spaces collect conversations, Home, missions and agent terminals", "actor": "person", "mode": "interactive", "model": "client projections", "inputs": "space, demo, local or client options", "defaults": "last space; paired device when available, otherwise local daemon", "output": "interactive terminal; demo dump supports text snapshots", "success": "terminal restored when the UI exits", "actions": "existing typed model actions", "authority": "configured person or paired session", "recovery": "reconnect or pair with st devices complete", "human_example": "st ui --space harbor", "json_example": "st ui --help --json", "workflow": "product/ui"}, "demo": {"job": "Play a scripted demo of st in the terminal interface", "why": "Screencasts need a seeded fleet whose agents act the same way every time and cost no tokens", "actor": "person", "mode": "interactive", "model": "demo KDL file played into its own instance", "inputs": "demo file and --instance", "defaults": "advance key F8; beats without after wait for it", "output": "interactive terminal; failed beats listed after quitting", "success": "every beat ran and Ctrl+Q stopped the demo's daemon and agents", "actions": "play check", "authority": "local person process in an instance; unavailable in agent seats", "recovery": "read state/st3-demo/play.log, fix the file and play again", "human_example": "st --instance demo demo play examples/demos/release-day.kdl", "json_example": "st demo --help --json", "workflow": "product/demos"}, - "apply": {"job": "Validate and publish authored KDL", "why": "Seats, missions and schedules share one preview and atomic publication", "actor": "person or agent", "mode": "read-or-fenced-mutation", "model": "publication-preview or owned-set", "inputs": "version-2 KDL files; optional owned-set source and revision flags", "defaults": "plain upsert; dry runs execute no commands unless --check", "output": "resolved preview or publication receipt; gate answers on stderr", "success": "valid preview or atomic publication under captured subject heads", "actions": "dry-run check apply", "authority": "explicit publication actor or ST_AGENT", "recovery": "correct blockers or refresh stale fences", "human_example": "st apply seats.kdl missions.kdl schedules.kdl --as person/operator --dry-run", "json_example": "st apply missions/demo.kdl --as person/operator --dry-run --json", "workflow": "product/apply"}, + "apply": {"job": "Validate and publish authored KDL", "why": "Seats, missions and schedules share one preview and atomic publication", "actor": "person, agent or run", "mode": "read-or-fenced-mutation", "model": "publication-preview or owned-set", "inputs": "version-2 KDL files; optional owned-set source and revision flags", "defaults": "plain upsert; dry runs execute no commands unless --check", "output": "resolved preview or publication receipt; gate answers on stderr", "success": "valid preview or atomic publication under captured subject heads", "actions": "dry-run check apply", "authority": "explicit publication actor, ST_AGENT, or the run of a held run credential", "recovery": "correct blockers or refresh stale fences", "human_example": "st apply seats.kdl missions.kdl schedules.kdl --as person/operator --dry-run", "json_example": "st apply missions/demo.kdl --as person/operator --dry-run --json", "workflow": "product/apply"}, "validate": {"job": "Validate authored KDL offline", "why": "Catch declaration errors and inspect mission revisions without a daemon", "actor": "person or agent", "mode": "read", "model": "offline declarations", "inputs": "one or more version-2 KDL files parsed independently", "defaults": "literal local host; no configuration or live graph lookup", "output": "subject kinds and IDs plus mission IDs and revisions", "success": "every file parses and passes pure declaration validation", "actions": "none", "authority": "local file access only; not an authorization or deployment preview", "recovery": "correct the named file and validate again", "human_example": "st validate seats.kdl missions.kdl", "json_example": "st validate missions.kdl --json", "workflow": "operator/authoring"}, "owned-sets": {"job": "Publish and inspect complete owned membership", "why": "Omission retires managed declarations under source and graph fences", "actor": "person or agent", "mode": "read-or-fenced-mutation", "model": "owned-set", "inputs": "complete version-2 KDL bundle, repository, ref, SHA, source sequence and expected set revision", "defaults": "no adoption or retirement confirmation", "output": "preview, publication receipt or set status", "success": "atomic selected membership or exact commit rollout state", "actions": "apply ls show status", "authority": "explicit publication actor or read scope", "recovery": "refresh fences and preview retirement before retrying", "human_example": "st sets show garden", "json_example": "st sets status garden --sha 0123456789abcdef0123456789abcdef01234567 --json", "workflow": "product/owned-sets"}, "daemon": { "job": "Run the local control daemon", "why": "Serve local projections and reconciliation", "actor": "operator", "mode": "mutation", "model": "daemon.v0", "inputs": "config and local listeners", "defaults": "Unix socket only", "output": "lifecycle diagnostics", "success": "daemon is ready", "actions": "stop via service manager", "authority": "local operator", "recovery": "doctor then restart", "human_example": "st up", "json_example": "st up --json", "workflow": "operator" }, diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md index b4f02ec5219..bfef0751199 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -233,8 +233,18 @@ beside `ST_AGENT`, nearest ancestor first; one environment carrying both binds n binds a request to `mission-run/RUN` only while the credential's attempt is its step run's current, live attempt, so the credential dies with its attempt and needs no revocation; one that no longer binds leaves the caller unverifiable. A run-bound request follows the bound-agent rules: no internal -routes, no person headers, and `actor`/`requester` must be the run. The CLI defaults its actor to -the run when the credential is present. The same-UID boundary is cooperative, as for agents. +routes, no person headers other than its own run, and `actor`/`requester` must be the run. Handlers +that hold a bound caller to itself read one bound identity (`BoundActor`) for agents and runs +alike, so a run writes no seat's `doc/agents/…` document and is the writer its document writes +name. The CLI defaults its actor to the run when the credential is present and names it on the +trusted Unix client, which the daemon accepts only from a request its credential binds to that +same run. The same-UID boundary is cooperative, as for agents. + +**Publication.** A run publishes with `fractal apply` like any other author: `apply` and +`fractal missions publish` take `--as mission-run/RUN`, and `apply` without `--as` publishes as +the harness's own seat, else as the run whose credential it holds. Its `publish` rules decide +which missions it may publish. The commands no run authority grants (agent lifecycle, owned sets, +mission retirement, run outcomes) still take only a person or an agent. **Journal.** The engine writes `program.step` claims as `mission-run/RUN`; a run may always journal on its own step runs, whatever it declares. diff --git a/release-notes/program-attempts-act-as-their-run.json b/release-notes/program-attempts-act-as-their-run.json new file mode 100644 index 00000000000..3709c3d2754 --- /dev/null +++ b/release-notes/program-attempts-act-as-their-run.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Each program attempt starts with a run credential in its effects' environment (`ST_RUN_CREDENTIAL`), so their requests act as `mission-run/RUN`, and the program journal (`program.step`) is written as that run. An effect publishes with `fractal apply` as its run: `apply` and `missions publish` accept `--as mission-run/RUN`, and `apply` without `--as` defaults to the run whose credential it holds", + "replay": {"status": "changed", "detail": "`program.step` claims carry actor `mission-run/RUN` instead of no actor. Their fields and idempotency keys are unchanged."}, + "schema": {"status": "none", "detail": "No table or migration change. Each start or resume of an attempt adds one row to the local `capabilities` table (hashed secret)."}, + "rules": {"status": "changed", "detail": "Program effects no longer act as an unbound local person: the engine mints one credential when an attempt starts or resumes (never on a refused, already running or host-down dispatch), with no expiry of its own, and the daemon binds its effects to their run while that attempt is the step's live attempt, however long it waits, and refuses their writes once it has ended."}, + "compatibility": {"status": "none", "detail": "Program steps are new in this milestone; no earlier journal exists."}, + "downtime": {"status": "none", "detail": "No restart or migration beyond a normal deploy."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "A resumed attempt is started with a fresh credential; an effect from an ended attempt cannot write."} +} diff --git a/release-notes/run-credential-binds-mission-run.json b/release-notes/run-credential-binds-mission-run.json new file mode 100644 index 00000000000..804c17710c0 --- /dev/null +++ b/release-notes/run-credential-binds-mission-run.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "A request whose caller's ancestry carries a live run credential (`ST_RUN_CREDENTIAL`) is bound to `mission-run/RUN`: it acts only as that run, its writes are attributed to it, and the credential dies with its program attempt", + "replay": {"status": "none", "detail": "No claim kind or projection change. Claims written by a run-bound request name `mission-run/RUN` as their actor, a subject the store already accepts as an actor."}, + "schema": {"status": "none", "detail": "No table or migration change. Run credentials are rows of the existing local `capabilities` table (kind `program-run`, subject the step run, incarnation the attempt; the secret is stored hashed)."}, + "rules": {"status": "changed", "detail": "New bound identity. The ancestry walk reads `ST_RUN_CREDENTIAL` beside `ST_AGENT`; the serving guard resolves it to its run while the attempt is the step run's current, live attempt, and otherwise leaves the caller unverifiable (writes refused). A run-bound request follows the bound-agent rules: no internal routes, no person header other than its own run, and `actor`/`requester` must be the run (filled in when absent). An environment carrying both a credential and `ST_AGENT` binds neither. Handlers that hold a bound caller to itself (documents, publication, attributed repair) read one bound identity for agents and runs, so a run writes no seat's `doc/agents/…` document and is the writer the rules decide. The CLI defaults its actor to `mission-run/$ST_MISSION_RUN` when the credential is present and accepts `--as mission-run/RUN`; the trusted Unix client carries it, and the daemon accepts it only from a request its credential binds to that run. Nothing mints credentials yet; the program engine does once it dispatches attempts."}, + "compatibility": {"status": "changed", "detail": "Processes without `ST_RUN_CREDENTIAL` are bound exactly as before. `ST_RUN_CREDENTIAL` joins the identity variables whose conflicting `FRACTAL_` spelling is ignored."}, + "downtime": {"status": "none", "detail": "No restart or migration beyond a normal deploy."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "A caller refused for an ended credential runs again under its next attempt's credential."} +}