From c7e153261e677fc6c94fff48ce5d23ccb559e155 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:22:34 +0100 Subject: [PATCH 1/9] Bind requests carrying a run credential to mission-run/RUN --- crates/fractal-api-core/src/api.rs | 65 +++++++++++++- crates/fractal-api-core/src/api/tests.rs | 84 +++++++++++++++++++ .../src/api/write_principal.rs | 11 ++- crates/fractal-drivers/src/contracts.rs | 2 +- crates/fractal-store/src/store.rs | 3 + .../src/store/run_credentials.rs | 80 ++++++++++++++++++ .../src/store/run_credentials_tests.rs | 48 +++++++++++ crates/fractal/src/main.rs | 30 +++++-- crates/fractal/src/main_tests.rs | 11 +++ docs/st3/large-value-sites.json | 1 + docs/st3/mission-graph-runtime.md | 8 +- .../run-credential-binds-mission-run.json | 11 +++ 12 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 crates/fractal-store/src/store/run_credentials.rs create mode 100644 crates/fractal-store/src/store/run_credentials_tests.rs create mode 100644 release-notes/run-credential-binds-mission-run.json diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 95db44c6e2f..00407ac0f54 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -7477,6 +7477,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); } @@ -7520,11 +7523,42 @@ 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); + +/// The environment variable a program attempt's effects carry their run credential in. +pub const RUN_CREDENTIAL_ENV: &str = "ST_RUN_CREDENTIAL"; + impl HarnessAncestry { fn agent(&self) -> Option<&str> { match self { Self::Bound(agent) => Some(agent), _ => None } @@ -7547,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; } @@ -8006,6 +8049,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. @@ -8063,7 +8116,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 { @@ -8088,9 +8143,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/tests.rs b/crates/fractal-api-core/src/api/tests.rs index f2052840cb8..58dd5731b05 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -27,6 +27,90 @@ 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); +} + #[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-store/src/store.rs b/crates/fractal-store/src/store.rs index 5c06094825e..0584fb1f516 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -66,6 +66,7 @@ pub mod schedule_active_run_tests; pub mod seat_queue_plan_tests; pub mod schedule_standby; pub mod mission_capacity; +pub mod run_credentials; pub mod marks_switch; pub mod glasses; pub mod mailbox_wakes; @@ -344,6 +345,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/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs new file mode 100644 index 00000000000..813578dece3 --- /dev/null +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -0,0 +1,80 @@ +//! 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 step statuses in which a program attempt runs. +const LIVE_STEP_STATES: [&str; 3] = ["ready", "working", "blocked"]; + +/// 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(); + } + + /// Publish a one-step mission, 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_missions::publish_carry(self, r#"version 2 +mission "land" state="ready" { + goal "Land one change." + 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..aa8107f174c --- /dev/null +++ b/crates/fractal-store/src/store/run_credentials_tests.rs @@ -0,0 +1,48 @@ +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); + + // 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 3419819b32c..c092e3d5196 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -10146,8 +10146,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>, @@ -10161,9 +10162,23 @@ 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) } +/// 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>, @@ -16167,11 +16182,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) } diff --git a/crates/fractal/src/main_tests.rs b/crates/fractal/src/main_tests.rs index ea9929b0bf7..99257ee58cc 100644 --- a/crates/fractal/src/main_tests.rs +++ b/crates/fractal/src/main_tests.rs @@ -1799,6 +1799,17 @@ 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()); +} + #[test] fn execution_actor_options_parse_and_fail_closed() { for (path, tail) in [ diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index df875d31391..bb869603df6 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -1576,6 +1576,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": 401, "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": 415, "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": 1367, "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 9b4a4c3d5ab..5e1895aaaf4 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -245,7 +245,13 @@ 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. The CLI defaults its +actor to `mission-run/$ST_MISSION_RUN` when the credential is present. 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/release-notes/run-credential-binds-mission-run.json b/release-notes/run-credential-binds-mission-run.json new file mode 100644 index 00000000000..b434d07fb82 --- /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, and `actor`/`requester` must be the run (filled in when absent). An environment carrying both a credential and `ST_AGENT` binds neither. The CLI defaults its actor to `mission-run/$ST_MISSION_RUN` when the credential is present and accepts `--as mission-run/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."} +} From 727f22931e5e086c99b891677fc25b0d7c7c35cf Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:46:03 +0100 Subject: [PATCH 2/9] Publish the run credential fixture mission by its own name --- crates/fractal-store/src/store/run_credentials.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index 813578dece3..a48708fcb48 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -59,7 +59,7 @@ impl Store { /// Publish a one-step mission, 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_missions::publish_carry(self, r#"version 2 + crate::store::tests_shared_seats::publish_mission(self, r#"version 2 mission "land" state="ready" { goal "Land one change." step "program" { agentless } From c311c6f199c1ea48bf9a1946ae1bad25fab86acd Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:28:13 +0100 Subject: [PATCH 3/9] Name the run credential variable in the store; a program waiting on a person stays bound --- crates/fractal-api-core/src/api.rs | 3 +-- crates/fractal-store/src/store/run_credentials.rs | 6 ++++-- crates/fractal-store/src/store/run_credentials_tests.rs | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 00407ac0f54..b0cbd1e68e4 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -7556,8 +7556,7 @@ impl std::fmt::Debug for RunCredential { #[derive(Clone, Debug)] pub struct BoundRun(pub crate::store::run_credentials::RunBinding); -/// The environment variable a program attempt's effects carry their run credential in. -pub const RUN_CREDENTIAL_ENV: &str = "ST_RUN_CREDENTIAL"; +pub use crate::store::run_credentials::RUN_CREDENTIAL_ENV; impl HarnessAncestry { fn agent(&self) -> Option<&str> { diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index a48708fcb48..5b0e0fb99b7 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -7,9 +7,11 @@ 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. -const LIVE_STEP_STATES: [&str; 3] = ["ready", "working", "blocked"]; +/// 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)] diff --git a/crates/fractal-store/src/store/run_credentials_tests.rs b/crates/fractal-store/src/store/run_credentials_tests.rs index aa8107f174c..17d180e07bc 100644 --- a/crates/fractal-store/src/store/run_credentials_tests.rs +++ b/crates/fractal-store/src/store/run_credentials_tests.rs @@ -30,6 +30,9 @@ fn a_run_credential_binds_its_run_only_while_its_attempt_runs() { 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); From 524d0e31f3f7ec820c66918d2c6fe3a6bfb1d907 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:51:25 +0100 Subject: [PATCH 4/9] Let the run credential fixture's mission write what its tests write --- crates/fractal-store/src/store/run_credentials.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index 5b0e0fb99b7..62f34cc17c2 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -59,11 +59,13 @@ impl Store { ).unwrap(); } - /// Publish a one-step mission, start a run of it and put its step at attempt 1, working. + /// Publish a one-step mission that may write `custom/example/*`, 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/*"; } step "program" { agentless } }"#, "declare-land"); let run = self.create_mission_run_inner(&crate::model::MissionRunRequest { From e7fc120af51d629b40f1e474615028b1f7d03958 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:24:17 +0100 Subject: [PATCH 5/9] Carry a bound run through the trusted Unix client and through handlers that hold a bound caller to itself --- crates/fractal-api-core/src/api.rs | 32 ++++++++++ crates/fractal-api-core/src/api/client_v0.rs | 33 ++++++++-- crates/fractal-api-core/src/api/launches.rs | 4 +- crates/fractal-api-core/src/api/operations.rs | 6 +- crates/fractal-api-core/src/api/records.rs | 12 ++-- crates/fractal-api-core/src/api/tests.rs | 64 +++++++++++++++++++ crates/fractal-secrets/src/client.rs | 24 +++++++ crates/fractal/src/main.rs | 2 + docs/st3/mission-graph-runtime.md | 6 +- docs/st3/program-engine.md | 8 ++- .../run-credential-binds-mission-run.json | 2 +- 11 files changed, 172 insertions(+), 21 deletions(-) diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index ed769606af5..ebb7942e31e 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -8039,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( 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 88f5922a56f..ce2008ba9f3 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -113,6 +113,70 @@ async fn a_run_credential_binds_requests_to_its_run() { assert_eq!(state.store.index().unwrap(), before); } +/// 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-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/src/main.rs b/crates/fractal/src/main.rs index f8b3f8792b0..10267850cbd 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -10153,6 +10153,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)? }; diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index 5e1895aaaf4..a8e6408588e 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -250,8 +250,10 @@ the daemon does not enforce it yet. A request whose caller's ancestry carries a 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. The CLI defaults its -actor to `mission-run/$ST_MISSION_RUN` when the credential is present. An older member would quarantine a +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/program-engine.md b/docs/st3/program-engine.md index 1a1a7856ebf..5fabe56c608 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -219,8 +219,12 @@ 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. **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/run-credential-binds-mission-run.json b/release-notes/run-credential-binds-mission-run.json index b434d07fb82..804c17710c0 100644 --- a/release-notes/run-credential-binds-mission-run.json +++ b/release-notes/run-credential-binds-mission-run.json @@ -3,7 +3,7 @@ "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, and `actor`/`requester` must be the run (filled in when absent). An environment carrying both a credential and `ST_AGENT` binds neither. The CLI defaults its actor to `mission-run/$ST_MISSION_RUN` when the credential is present and accepts `--as mission-run/RUN`. Nothing mints credentials yet; the program engine does once it dispatches attempts."}, + "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."}, From b64fd1c5130e8d34ae3632bcb2fe5125d990b73b Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:31:31 +0100 Subject: [PATCH 6/9] Let the run credential fixture's mission bind its test documents --- crates/fractal-store/src/store/run_credentials.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index 62f34cc17c2..4c22ded9eb2 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -59,13 +59,13 @@ impl Store { ).unwrap(); } - /// Publish a one-step mission that may write `custom/example/*`, start a run of it and put its - /// step at attempt 1, working. + /// 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/*"; } + authority { write "custom/example/*"; write "doc/runs/*"; } step "program" { agentless } }"#, "declare-land"); let run = self.create_mission_run_inner(&crate::model::MissionRunRequest { From 3dde080735808201796a9ef6498515c4594d114e Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:33:54 +0100 Subject: [PATCH 7/9] Count a step run's minted run credentials in tests --- crates/fractal-store/src/store/run_credentials.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index 4c22ded9eb2..8dcec08315a 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -59,6 +59,15 @@ impl Store { ).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 { From d2c69c9d1a446b9c74c49133ee4845d212ac6871 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 18:08:11 +0100 Subject: [PATCH 8/9] Read a mission's authority patterns as patterns, not document references --- crates/fractal-missions/src/graph.rs | 5 ++++- crates/fractal-store/src/graph.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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) -> 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)?; } } From 065dff639ad6839bf34444fa551c7a96aee7e74d Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 18:23:19 +0100 Subject: [PATCH 9/9] Drop a needless borrow in the bound identity test --- crates/fractal-api-core/src/api/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index ce2008ba9f3..769b22d4ef4 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -173,7 +173,7 @@ async fn a_bound_run_is_held_to_its_identity_by_document_writes() { 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()), + assert_eq!(BoundActor::of(Request::builder().extension(BoundAgent("agent/a".into())).body(()).unwrap().extensions()), Some(BoundActor::Agent("agent/a".into()))); }