agents: name the uid behind every model request
Intent lives in agent.json; posture is measured fresh from process credentials per request, so resume and compaction cannot replay a stale uid as history. `isolated` needs account, uid, worker binding and the root-owned mapping to agree — a passwd entry alone reads principal-drift.
This commit is contained in:
parent
ee496a7c6e
commit
dcd44a0c1d
16 changed files with 893 additions and 28 deletions
|
|
@ -74,6 +74,31 @@ pub async fn get_agent(
|
|||
Ok(Json(agent))
|
||||
}
|
||||
|
||||
/// GET /v1/agents/:id/principal — fresh process-credential posture.
|
||||
///
|
||||
/// This is inspection, not admission. It deliberately reports the current
|
||||
/// monolithic server as acting-as-human for dedicated agents even when the
|
||||
/// requested passwd entry exists, because no per-agent worker is carrying the
|
||||
/// turn yet.
|
||||
pub async fn get_agent_principal(
|
||||
State(server): State<Arc<SouveraineServer>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<crate::core::principal::PrincipalHealth>, ApiError> {
|
||||
let agent = server.agents.get(&id).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "agent_not_found".to_string(),
|
||||
message: e.to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(crate::core::principal::observe(
|
||||
&agent,
|
||||
"inspection",
|
||||
)))
|
||||
}
|
||||
|
||||
/// GET /v1/agents/:id/itinerary — the agent's current route, projected from
|
||||
/// its canonical memfs file. Absence is a valid empty state, not a 404: an
|
||||
/// agent exists before it lays out a route and after it clears one.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
|
|||
"/v1/agents/:id/itinerary",
|
||||
get(handlers::get_agent_itinerary),
|
||||
)
|
||||
.route(
|
||||
"/v1/agents/:id/principal",
|
||||
get(handlers::get_agent_principal),
|
||||
)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth::require_agent_token,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,88 @@ pub struct AgentState {
|
|||
pub souveraine: SouveraineConfig,
|
||||
}
|
||||
|
||||
/// The local kernel-identity shape requested for an agent on this node.
|
||||
///
|
||||
/// This is durable intent, not runtime proof. A dedicated agent is not
|
||||
/// isolated until a privileged admission maps the agent identity to the
|
||||
/// account and a worker is actually executing with that account's uid.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PrincipalIntent {
|
||||
Dedicated,
|
||||
#[default]
|
||||
BorrowedUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AgentPrincipalConfig {
|
||||
#[serde(default)]
|
||||
pub intent: PrincipalIntent,
|
||||
/// Required for `dedicated`; absent for `borrowed-user`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AgentPrincipalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
intent: PrincipalIntent::BorrowedUser,
|
||||
account: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentPrincipalConfig {
|
||||
pub fn dedicated(account: impl Into<String>) -> Self {
|
||||
Self {
|
||||
intent: PrincipalIntent::Dedicated,
|
||||
account: Some(account.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn borrowed_user() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Validate only the unprivileged request shape. Account existence,
|
||||
/// collision, node commission, ownership, and worker state belong to the
|
||||
/// privileged admission edge and must be checked again there.
|
||||
pub fn validate_request(&self) -> anyhow::Result<()> {
|
||||
match self.intent {
|
||||
PrincipalIntent::BorrowedUser => {
|
||||
if self.account.is_some() {
|
||||
anyhow::bail!("borrowed-user principal must not name a dedicated account");
|
||||
}
|
||||
}
|
||||
PrincipalIntent::Dedicated => {
|
||||
let account = self
|
||||
.account
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("dedicated principal requires an account"))?;
|
||||
let valid = account.len() <= 31
|
||||
&& account
|
||||
.bytes()
|
||||
.enumerate()
|
||||
.all(|(index, byte)| match (index, byte) {
|
||||
(0, b'a'..=b'z' | b'_') => true,
|
||||
(_, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-') => true,
|
||||
_ => false,
|
||||
});
|
||||
if !valid {
|
||||
anyhow::bail!(
|
||||
"invalid dedicated account `{account}`; use a lowercase Unix account name"
|
||||
);
|
||||
}
|
||||
if matches!(account, "root" | "souveraine" | "souveraine-session") {
|
||||
anyhow::bail!("account `{account}` is reserved and cannot be an agent principal");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The itinerary as a surface can render it.
|
||||
///
|
||||
/// The canonical copy remains `system/dynamic/itinerary.md` in the agent's
|
||||
|
|
@ -199,6 +281,11 @@ pub struct SouveraineConfig {
|
|||
/// failing, so a stale value degrades to the system voice.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub voice_id: Option<String>,
|
||||
/// Durable local principal intent. Existing records deserialize as
|
||||
/// borrowed-user until they are migrated explicitly; display names are
|
||||
/// never used to silently infer an account mapping.
|
||||
#[serde(default)]
|
||||
pub principal: AgentPrincipalConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -213,6 +300,11 @@ pub struct CreateAgentRequest {
|
|||
pub tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Requested authority shape for this node. Omission is the safe
|
||||
/// compatibility posture: borrowed-user. A dedicated request records an
|
||||
/// unadmitted agent; it never creates a Unix account from this HTTP call.
|
||||
#[serde(default)]
|
||||
pub principal: AgentPrincipalConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -227,6 +319,11 @@ pub struct UpdateAgentRequest {
|
|||
pub memory_blocks: Option<Vec<MemoryBlock>>,
|
||||
#[serde(default)]
|
||||
pub tools: Option<Vec<String>>,
|
||||
/// Durable intent only. Recording `dedicated` never creates a Unix
|
||||
/// account — admission is a separate privileged transition, and until it
|
||||
/// runs the agent reports `unadmitted` or `acting-as-human`.
|
||||
#[serde(default)]
|
||||
pub principal: Option<AgentPrincipalConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -237,6 +334,33 @@ pub struct AgentFilters {
|
|||
pub tags: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod principal_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dedicated_principal_requires_a_safe_non_reserved_account() {
|
||||
assert!(AgentPrincipalConfig::dedicated("annie")
|
||||
.validate_request()
|
||||
.is_ok());
|
||||
assert!(AgentPrincipalConfig::dedicated("Annie")
|
||||
.validate_request()
|
||||
.is_err());
|
||||
assert!(AgentPrincipalConfig::dedicated("souveraine")
|
||||
.validate_request()
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borrowed_user_cannot_smuggle_an_account_mapping() {
|
||||
let principal = AgentPrincipalConfig {
|
||||
intent: PrincipalIntent::BorrowedUser,
|
||||
account: Some("casey".to_string()),
|
||||
};
|
||||
assert!(principal.validate_request().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
pub id: String,
|
||||
|
|
|
|||
|
|
@ -449,12 +449,36 @@ impl Backend for LocalBackend {
|
|||
}),
|
||||
memory_blocks: None,
|
||||
tools: None,
|
||||
principal: None,
|
||||
};
|
||||
self.server.agents.update(agent_id, update).await?;
|
||||
tracing::info!(agent = %agent_id, model = %model, "agent llm_config model updated via settings");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_agent_principal(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
principal: crate::api::models::AgentPrincipalConfig,
|
||||
) -> Result<()> {
|
||||
let update = crate::api::models::UpdateAgentRequest {
|
||||
name: None,
|
||||
description: None,
|
||||
llm_config: None,
|
||||
memory_blocks: None,
|
||||
tools: None,
|
||||
principal: Some(principal.clone()),
|
||||
};
|
||||
self.server.agents.update(agent_id, update).await?;
|
||||
tracing::info!(
|
||||
agent = %agent_id,
|
||||
intent = ?principal.intent,
|
||||
account = principal.account.as_deref().unwrap_or("-"),
|
||||
"agent principal intent recorded; admission still pending"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn take_pending_surfacings(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
|
|
|
|||
|
|
@ -276,6 +276,20 @@ pub trait Backend: Send + Sync {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Record which local principal this agent is *meant* to run as. Writing
|
||||
/// intent is not admission: no account is created, no UID is bound, and
|
||||
/// health keeps reporting `unadmitted` until the privileged executor runs.
|
||||
/// The default refuses rather than returning a silent success — a stamp
|
||||
/// that did not persist must not read as one that did.
|
||||
async fn update_agent_principal(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
principal: crate::api::models::AgentPrincipalConfig,
|
||||
) -> Result<()> {
|
||||
let _ = (agent_id, principal);
|
||||
anyhow::bail!("this backend cannot record principal intent")
|
||||
}
|
||||
|
||||
/// Drain heartbeat surfacings stashed since the last session. When the
|
||||
/// CronSensor fires a background turn while no UI is connected, the
|
||||
/// subconscious's surfacings are stashed to disk; this returns and clears
|
||||
|
|
|
|||
|
|
@ -181,6 +181,23 @@ impl Backend for RemoteBackend {
|
|||
.collect())
|
||||
}
|
||||
|
||||
async fn update_agent_principal(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
principal: crate::api::models::AgentPrincipalConfig,
|
||||
) -> Result<()> {
|
||||
let req = self
|
||||
.client
|
||||
.patch(self.url(&format!("/v1/agents/{agent_id}")))
|
||||
.json(&serde_json::json!({ "principal": principal }));
|
||||
self.auth_req(req)
|
||||
.send()
|
||||
.await
|
||||
.context("PATCH /v1/agents/:id")?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn new_conversation(&self, agent_id: &str) -> Result<String> {
|
||||
// Remote: same as ensure_conversation for now — server always creates fresh
|
||||
self.ensure_conversation(agent_id).await
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ impl ArchivistEngine {
|
|||
.unwrap_or_else(|| self.providers.default_provider()),
|
||||
};
|
||||
let synthesis = self
|
||||
.run_synthesis(&llm, &model, &raw, start_date, end_date)
|
||||
.run_synthesis(agent_id, &llm, &model, &raw, start_date, end_date)
|
||||
.await?;
|
||||
|
||||
let output_label = format!("{SYNTHESIS_DIR}/{end_date}");
|
||||
|
|
@ -247,6 +247,7 @@ impl ArchivistEngine {
|
|||
/// No tool loop — the Archivist produces a record, it doesn't act.
|
||||
async fn run_synthesis(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
llm: &Arc<dyn LlmProvider>,
|
||||
model: &str,
|
||||
raw_journal: &str,
|
||||
|
|
@ -259,10 +260,22 @@ impl ArchivistEngine {
|
|||
<journal>\n{raw_journal}\n</journal>"
|
||||
);
|
||||
|
||||
let principal_block = self
|
||||
.agents
|
||||
.get(agent_id)
|
||||
.await
|
||||
.ok()
|
||||
.map(|agent| {
|
||||
crate::core::principal::observe(&agent, "archivist").model_system_block()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"[RUNTIME PRINCIPAL — unavailable: agent record could not be loaded. No authority is granted.]".to_string()
|
||||
});
|
||||
let request = ChatCompletionRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![
|
||||
Message::text("system", system_prompt),
|
||||
Message::text("system", principal_block),
|
||||
Message::text("user", user_content),
|
||||
],
|
||||
temperature: Some(0.3),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub mod memory;
|
|||
pub mod model_cache;
|
||||
pub mod nervous;
|
||||
pub mod prompt;
|
||||
pub mod principal;
|
||||
pub mod reflection;
|
||||
pub mod seeds;
|
||||
pub mod sensorium;
|
||||
|
|
|
|||
377
src/core/principal.rs
Normal file
377
src/core/principal.rs
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
//! Runtime principal observation and model-facing posture.
|
||||
//!
|
||||
//! Agent records carry intent. This module measures the process credentials
|
||||
//! afresh for each inference request; it never treats the prompt projection as
|
||||
//! an authorization decision. Actual authority gates must inspect peer/process
|
||||
//! credentials again at their own boundary.
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::models::{AgentState, PrincipalIntent};
|
||||
|
||||
pub const PRINCIPAL_MAP_DIR: &str = "/etc/souveraine/agent-principals.d";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PrincipalPosture {
|
||||
Isolated,
|
||||
BorrowedUser,
|
||||
Unadmitted,
|
||||
ActingAsHuman,
|
||||
PrincipalDrift,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrincipalHealth {
|
||||
pub agent_id: String,
|
||||
pub display_name: String,
|
||||
pub principal_intent: PrincipalIntent,
|
||||
pub expected_account: Option<String>,
|
||||
pub expected_uid: Option<u32>,
|
||||
/// NSS facts for the expected account. The health contract reports the
|
||||
/// state root and shell because an agent principal with a login shell or
|
||||
/// a home inside the human's tree is admission that went wrong.
|
||||
pub expected_home: Option<String>,
|
||||
pub expected_shell: Option<String>,
|
||||
pub account_exists: bool,
|
||||
pub node_mapping_exists: bool,
|
||||
pub node_mapping_matches: bool,
|
||||
pub effective_account: String,
|
||||
pub effective_uid: u32,
|
||||
pub node_id: String,
|
||||
pub worker_pid: Option<u32>,
|
||||
pub trigger: String,
|
||||
pub observed_at: chrono::DateTime<Utc>,
|
||||
pub posture: PrincipalPosture,
|
||||
/// What this observation still does not prove. Kept on the wire so a
|
||||
/// surface cannot quietly turn an account row into a green admission.
|
||||
pub proof_limit: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodePrincipalMapping {
|
||||
pub agent_id: String,
|
||||
pub agent_seed_id: String,
|
||||
pub account: String,
|
||||
pub uid: u32,
|
||||
pub node_id: String,
|
||||
pub state_root: String,
|
||||
pub admitted_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NssAccount {
|
||||
pub name: String,
|
||||
pub uid: u32,
|
||||
pub home: String,
|
||||
pub shell: String,
|
||||
}
|
||||
|
||||
/// Observe the effective process and compare it with one agent's durable
|
||||
/// intent. This process-level fact is honest for today's monolithic server:
|
||||
/// dedicated agents report acting-as-human rather than inheriting a green
|
||||
/// state from a passwd entry they are not actually running under.
|
||||
pub fn observe(agent: &AgentState, trigger: impl Into<String>) -> PrincipalHealth {
|
||||
let effective_uid = unsafe { libc::geteuid() };
|
||||
let effective = nss_account_by_uid(effective_uid);
|
||||
let effective_account = effective
|
||||
.as_ref()
|
||||
.map(|record| record.name.clone())
|
||||
.unwrap_or_else(|| format!("uid:{effective_uid}"));
|
||||
|
||||
let expected_account = agent.souveraine.principal.account.clone();
|
||||
let expected = expected_account.as_deref().and_then(nss_account_by_name);
|
||||
let account_exists = expected.is_some();
|
||||
let expected_uid = expected.as_ref().map(|record| record.uid);
|
||||
let mapping = load_node_mapping(&agent.id);
|
||||
let node_mapping_exists = mapping.is_some();
|
||||
let node_mapping_matches = mapping.as_ref().is_some_and(|mapping| {
|
||||
mapping.agent_id == agent.id
|
||||
&& Some(mapping.account.as_str()) == expected_account.as_deref()
|
||||
&& Some(mapping.uid) == expected_uid
|
||||
});
|
||||
|
||||
// A future package-owned worker unit sets this marker after binding the
|
||||
// agent ID to its uid. It is awareness evidence only; an authority daemon
|
||||
// still uses SO_PEERCRED and the root-owned mapping, never this variable.
|
||||
let worker_agent = std::env::var("SOUVERAINE_WORKER_AGENT_ID").ok();
|
||||
let worker_matches = worker_agent.as_deref() == Some(agent.id.as_str());
|
||||
|
||||
let posture = decide_posture(
|
||||
agent.souveraine.principal.intent,
|
||||
expected_uid,
|
||||
effective_uid,
|
||||
worker_matches,
|
||||
node_mapping_matches,
|
||||
);
|
||||
|
||||
PrincipalHealth {
|
||||
agent_id: agent.id.clone(),
|
||||
display_name: agent.name.clone(),
|
||||
principal_intent: agent.souveraine.principal.intent,
|
||||
expected_account,
|
||||
expected_uid,
|
||||
expected_home: expected.as_ref().map(|record| record.home.clone()),
|
||||
expected_shell: expected.as_ref().map(|record| record.shell.clone()),
|
||||
account_exists,
|
||||
node_mapping_exists,
|
||||
node_mapping_matches,
|
||||
effective_account,
|
||||
effective_uid,
|
||||
node_id: hostname_or_unknown(),
|
||||
worker_pid: worker_matches.then(std::process::id),
|
||||
trigger: trigger.into(),
|
||||
observed_at: Utc::now(),
|
||||
posture,
|
||||
proof_limit: "process credentials and root mapping only; data ownership, cgroup, executable, node commission signature, and peer credentials are not yet verified".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The posture table, kept pure so the safety property is testable without a
|
||||
/// process: `isolated` needs the account, the matching UID, the worker binding
|
||||
/// and the root-owned mapping to agree. A passwd entry alone never reaches it.
|
||||
fn decide_posture(
|
||||
intent: PrincipalIntent,
|
||||
expected_uid: Option<u32>,
|
||||
effective_uid: u32,
|
||||
worker_matches: bool,
|
||||
node_mapping_matches: bool,
|
||||
) -> PrincipalPosture {
|
||||
if intent == PrincipalIntent::BorrowedUser {
|
||||
return PrincipalPosture::BorrowedUser;
|
||||
}
|
||||
match expected_uid {
|
||||
None => PrincipalPosture::Unadmitted,
|
||||
Some(uid) if uid == effective_uid => {
|
||||
if worker_matches && node_mapping_matches {
|
||||
PrincipalPosture::Isolated
|
||||
} else {
|
||||
PrincipalPosture::PrincipalDrift
|
||||
}
|
||||
}
|
||||
// Linux's ordinary human uid range. A diagnostic label, not an
|
||||
// authorization input; unknown or system controllers stay drift
|
||||
// rather than being mislabeled as the human.
|
||||
Some(_) if effective_uid >= 1000 && effective_uid != u32::MAX => {
|
||||
PrincipalPosture::ActingAsHuman
|
||||
}
|
||||
Some(_) => PrincipalPosture::PrincipalDrift,
|
||||
}
|
||||
}
|
||||
|
||||
impl PrincipalHealth {
|
||||
/// Fresh, non-persisted system context for a model call. The wording is
|
||||
/// intentionally operational: it tells hosted modes how to handle borrowed
|
||||
/// reach and tells dedicated agents when the worker boundary is absent.
|
||||
pub fn model_system_block(&self) -> String {
|
||||
let expected = self.expected_account.as_deref().unwrap_or("none");
|
||||
let posture = match self.posture {
|
||||
PrincipalPosture::Isolated => {
|
||||
"The process uid and worker binding match this agent. This is runtime posture, not a blanket capability grant."
|
||||
}
|
||||
PrincipalPosture::BorrowedUser => {
|
||||
"You are intentionally operating through the human user's account. Readable files, groups, sockets, credentials, and decrypted data are borrowed reach, not your property. Stay inside the named task and workspace. Do not widen permissions, ACLs, groups, links, remotes, publication, or sharing without explicit consent. Never read or disclose another agent's private memory merely because this uid can reach it."
|
||||
}
|
||||
PrincipalPosture::Unadmitted => {
|
||||
"Your durable intent is a dedicated account, but that account is not admitted on this node. This compatibility process is not an isolated worker. Personal and step-up authority must remain closed."
|
||||
}
|
||||
PrincipalPosture::ActingAsHuman => {
|
||||
"Your durable intent is a dedicated account, but this turn is executing as the human user. Do not claim isolation. Treat all human-readable reach as borrowed and keep personal and step-up authority closed until the worker boundary is repaired."
|
||||
}
|
||||
PrincipalPosture::PrincipalDrift => {
|
||||
"The requested account and live worker evidence disagree. Do not claim isolation or exercise personal/step-up authority until Agent Health is repaired."
|
||||
}
|
||||
};
|
||||
|
||||
format!(
|
||||
"[RUNTIME PRINCIPAL — fresh observation, not conversation memory]\n\
|
||||
agent_id: {}\n\
|
||||
display_name: {}\n\
|
||||
principal_intent: {:?}\n\
|
||||
expected_account: {}\n\
|
||||
effective_account: {}\n\
|
||||
effective_uid: {}\n\
|
||||
node_id: {}\n\
|
||||
trigger: {}\n\
|
||||
observed_at: {}\n\
|
||||
posture: {:?}\n\
|
||||
{}\n\
|
||||
Authorization gates recheck kernel credentials; this block never grants authority.",
|
||||
self.agent_id,
|
||||
self.display_name,
|
||||
self.principal_intent,
|
||||
expected,
|
||||
self.effective_account,
|
||||
self.effective_uid,
|
||||
self.node_id,
|
||||
self.trigger,
|
||||
self.observed_at.to_rfc3339(),
|
||||
self.posture,
|
||||
posture,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn hostname_or_unknown() -> String {
|
||||
std::fs::read_to_string("/etc/hostname")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
pub fn nss_account_by_uid(uid: u32) -> Option<NssAccount> {
|
||||
let mut passwd = unsafe { std::mem::zeroed::<libc::passwd>() };
|
||||
let mut result = std::ptr::null_mut();
|
||||
let mut buffer = vec![0u8; passwd_buffer_size()];
|
||||
let rc = unsafe {
|
||||
libc::getpwuid_r(
|
||||
uid,
|
||||
&mut passwd,
|
||||
buffer.as_mut_ptr().cast(),
|
||||
buffer.len(),
|
||||
&mut result,
|
||||
)
|
||||
};
|
||||
if rc != 0 || result.is_null() || passwd.pw_name.is_null() {
|
||||
return None;
|
||||
}
|
||||
let name = unsafe { CStr::from_ptr(passwd.pw_name) }
|
||||
.to_str()
|
||||
.ok()?
|
||||
.to_string();
|
||||
let home = c_field(passwd.pw_dir);
|
||||
let shell = c_field(passwd.pw_shell);
|
||||
Some(NssAccount {
|
||||
name,
|
||||
uid: passwd.pw_uid,
|
||||
home,
|
||||
shell,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn nss_account_by_name(name: &str) -> Option<NssAccount> {
|
||||
let name = CString::new(name).ok()?;
|
||||
let mut passwd = unsafe { std::mem::zeroed::<libc::passwd>() };
|
||||
let mut result = std::ptr::null_mut();
|
||||
let mut buffer = vec![0u8; passwd_buffer_size()];
|
||||
let rc = unsafe {
|
||||
libc::getpwnam_r(
|
||||
name.as_ptr(),
|
||||
&mut passwd,
|
||||
buffer.as_mut_ptr().cast(),
|
||||
buffer.len(),
|
||||
&mut result,
|
||||
)
|
||||
};
|
||||
if rc != 0 || result.is_null() || passwd.pw_name.is_null() {
|
||||
return None;
|
||||
}
|
||||
let name = unsafe { CStr::from_ptr(passwd.pw_name) }
|
||||
.to_str()
|
||||
.ok()?
|
||||
.to_string();
|
||||
let home = c_field(passwd.pw_dir);
|
||||
let shell = c_field(passwd.pw_shell);
|
||||
Some(NssAccount {
|
||||
name,
|
||||
uid: passwd.pw_uid,
|
||||
home,
|
||||
shell,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn node_mapping_path(agent_id: &str) -> std::path::PathBuf {
|
||||
std::path::Path::new(PRINCIPAL_MAP_DIR).join(format!("{agent_id}.json"))
|
||||
}
|
||||
|
||||
pub fn load_node_mapping(agent_id: &str) -> Option<NodePrincipalMapping> {
|
||||
let raw = std::fs::read_to_string(node_mapping_path(agent_id)).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
fn c_field(value: *const libc::c_char) -> String {
|
||||
if value.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
unsafe { CStr::from_ptr(value) }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn passwd_buffer_size() -> usize {
|
||||
let suggested = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
|
||||
if suggested > 0 {
|
||||
suggested as usize
|
||||
} else {
|
||||
16 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use PrincipalIntent::{BorrowedUser, Dedicated};
|
||||
use PrincipalPosture as P;
|
||||
|
||||
#[test]
|
||||
fn a_passwd_entry_alone_is_never_isolation() {
|
||||
// Account exists and the process is even running as it — but no
|
||||
// worker binding and no root-owned mapping. This is the shape a
|
||||
// hand-created `useradd annie` produces, and it must not read green.
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 1003, false, false),
|
||||
P::PrincipalDrift
|
||||
);
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 1003, true, false),
|
||||
P::PrincipalDrift
|
||||
);
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 1003, false, true),
|
||||
P::PrincipalDrift
|
||||
);
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 1003, true, true),
|
||||
P::Isolated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dedicated_agent_running_as_the_human_says_so() {
|
||||
assert_eq!(decide_posture(Dedicated, None, 1000, false, false), P::Unadmitted);
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 1000, false, false),
|
||||
P::ActingAsHuman
|
||||
);
|
||||
// A system uid that is not hers is drift, not the human.
|
||||
assert_eq!(
|
||||
decide_posture(Dedicated, Some(1003), 950, false, false),
|
||||
P::PrincipalDrift
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_borrowed_mode_never_drifts_into_isolation() {
|
||||
for mapping in [false, true] {
|
||||
for worker in [false, true] {
|
||||
assert_eq!(
|
||||
decide_posture(BorrowedUser, Some(1000), 1000, worker, mapping),
|
||||
P::BorrowedUser
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_uid_resolves_through_nss() {
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
let record = nss_account_by_uid(uid).expect("current effective uid should resolve");
|
||||
assert_eq!(record.uid, uid);
|
||||
assert!(!record.name.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -186,9 +186,23 @@ impl ReflectionEngine {
|
|||
];
|
||||
|
||||
for _round in 0..REFLECTION_MAX_TOOL_ROUNDS {
|
||||
let principal_block = agent
|
||||
.as_ref()
|
||||
.map(|agent| {
|
||||
crate::core::principal::observe(agent, "reflection").model_system_block()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"[RUNTIME PRINCIPAL — unavailable: agent record could not be loaded. No authority is granted.]".to_string()
|
||||
});
|
||||
let mut request_messages = chat_messages.clone();
|
||||
let system_prefix = request_messages
|
||||
.iter()
|
||||
.take_while(|message| message.role == "system")
|
||||
.count();
|
||||
request_messages.insert(system_prefix, Message::text("system", principal_block));
|
||||
let request = ChatCompletionRequest {
|
||||
model: model.to_string(),
|
||||
messages: chat_messages.clone(),
|
||||
messages: request_messages,
|
||||
temperature: Some(0.3),
|
||||
max_tokens: self.max_tokens,
|
||||
stream: None,
|
||||
|
|
|
|||
109
src/main.rs
109
src/main.rs
|
|
@ -147,9 +147,12 @@ enum Commands {
|
|||
|
||||
/// List the beings that live here
|
||||
#[command(
|
||||
long_about = "Show every configured agent — who they are, which model they speak through, what triggers know them."
|
||||
long_about = "Show every configured agent — who they are, which model they speak through, what triggers know them.\n\nWith no subcommand, lists agents."
|
||||
)]
|
||||
Agents,
|
||||
Agents {
|
||||
#[command(subcommand)]
|
||||
command: Option<AgentsCommand>,
|
||||
},
|
||||
|
||||
/// List or set models
|
||||
#[command(
|
||||
|
|
@ -274,6 +277,24 @@ and — with [federation].auto_wake — spawns the full server to answer them."
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AgentsCommand {
|
||||
/// Record which local Unix principal an agent is meant to run as
|
||||
#[command(
|
||||
long_about = "Write durable principal intent onto an agent record.\n\nThis creates no account, binds no UID, and grants nothing. A dedicated agent\nstays `unadmitted` until the privileged admission executor runs, and reports\n`acting-as-human` while her turns still execute as the invoking human.\n\nEXAMPLES:\n souveraine agents principal Annie --dedicated annie\n souveraine agents principal Kitty --borrowed-user"
|
||||
)]
|
||||
Principal {
|
||||
/// Agent name or id
|
||||
agent: String,
|
||||
/// Local account this agent is meant to run as
|
||||
#[arg(long, value_name = "ACCOUNT", conflicts_with = "borrowed_user")]
|
||||
dedicated: Option<String>,
|
||||
/// This mode intentionally runs through the invoking human's account
|
||||
#[arg(long)]
|
||||
borrowed_user: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScheduleAction {
|
||||
/// List all schedules
|
||||
|
|
@ -454,7 +475,24 @@ async fn main() -> anyhow::Result<()> {
|
|||
)
|
||||
.await?
|
||||
}
|
||||
Commands::Agents => run_agents(config, cli.json, cli.local).await?,
|
||||
Commands::Agents { command } => match command {
|
||||
None => run_agents(config, cli.json, cli.local).await?,
|
||||
Some(AgentsCommand::Principal {
|
||||
agent,
|
||||
dedicated,
|
||||
borrowed_user,
|
||||
}) => {
|
||||
run_agent_principal(
|
||||
config,
|
||||
agent.clone(),
|
||||
dedicated.clone(),
|
||||
*borrowed_user,
|
||||
cli.json,
|
||||
cli.local,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
},
|
||||
Commands::Model {
|
||||
model,
|
||||
json,
|
||||
|
|
@ -1514,6 +1552,71 @@ async fn run_reflect(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Stamp durable principal intent onto an agent record.
|
||||
///
|
||||
/// Intent is not admission. Nothing here creates an account, binds a UID, or
|
||||
/// grants a capability — a dedicated agent starts reading `unadmitted` and
|
||||
/// `acting-as-human`, which is stricter than the `borrowed-user` default she
|
||||
/// carried before, not looser.
|
||||
async fn run_agent_principal(
|
||||
config: Arc<RwLock<ConsciousnessConfig>>,
|
||||
agent: String,
|
||||
dedicated: Option<String>,
|
||||
borrowed_user: bool,
|
||||
json: bool,
|
||||
force_local: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
use crate::api::models::AgentPrincipalConfig;
|
||||
|
||||
let principal = match (dedicated, borrowed_user) {
|
||||
(Some(account), _) => AgentPrincipalConfig::dedicated(account),
|
||||
(None, true) => AgentPrincipalConfig::borrowed_user(),
|
||||
(None, false) => {
|
||||
anyhow::bail!("say which: --dedicated <account> or --borrowed-user")
|
||||
}
|
||||
};
|
||||
principal.validate_request()?;
|
||||
|
||||
let base = dirs::home_dir().unwrap_or_default().join(".souveraine");
|
||||
let agent_id = resolve_agent_id_from_disk(&base, &agent)?;
|
||||
|
||||
let (backend, mode) = resolve_backend(&config, force_local, json, false).await?;
|
||||
backend
|
||||
.update_agent_principal(&agent_id, principal.clone())
|
||||
.await?;
|
||||
|
||||
let nss = principal
|
||||
.account
|
||||
.as_deref()
|
||||
.and_then(crate::core::principal::nss_account_by_name);
|
||||
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"agent_id": agent_id,
|
||||
"intent": principal.intent,
|
||||
"account": principal.account,
|
||||
"account_uid": nss.as_ref().map(|a| a.uid),
|
||||
"admitted": false,
|
||||
"mode": mode,
|
||||
}))?
|
||||
);
|
||||
} else {
|
||||
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
println!(" {} ({} mode)", agent, mode);
|
||||
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
match (&principal.account, &nss) {
|
||||
(Some(name), Some(a)) => println!(" intent dedicated → {} (uid {})", name, a.uid),
|
||||
(Some(name), None) => println!(" intent dedicated → {} (absent from this node)", name),
|
||||
(None, _) => println!(" intent borrowed-user → the invoking human"),
|
||||
}
|
||||
println!(" admitted no — no account created, no UID bound");
|
||||
println!(" health reads acting-as-human until a worker carries her turns\n");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_agents(
|
||||
config: Arc<RwLock<ConsciousnessConfig>>,
|
||||
json: bool,
|
||||
|
|
|
|||
|
|
@ -250,6 +250,12 @@ impl AgentInventory {
|
|||
}
|
||||
|
||||
pub async fn create(&self, request: CreateAgentRequest) -> anyhow::Result<AgentState> {
|
||||
// This is deliberately only request validation. The ordinary server
|
||||
// is not privileged and /v1/agents is not an account-management API.
|
||||
// A dedicated intent remains visibly unadmitted until the system-tier
|
||||
// admission executor creates and binds its worker principal.
|
||||
request.principal.validate_request()?;
|
||||
|
||||
let uuid = Uuid::new_v4().to_string();
|
||||
let agent_dir = self.agents_dir.join(&uuid);
|
||||
let memfs = self.memfs_dir.join(&uuid).join("memory");
|
||||
|
|
@ -344,6 +350,7 @@ impl AgentInventory {
|
|||
archivist_model: None,
|
||||
provider: None,
|
||||
voice_id: None,
|
||||
principal: request.principal,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -484,6 +491,10 @@ impl AgentInventory {
|
|||
}
|
||||
agent.memory_blocks = self.load_memory_blocks(agent_id).await?;
|
||||
}
|
||||
if let Some(principal) = updates.principal {
|
||||
principal.validate_request()?;
|
||||
agent.souveraine.principal = principal;
|
||||
}
|
||||
|
||||
agent.updated_at = Utc::now();
|
||||
|
||||
|
|
|
|||
|
|
@ -871,9 +871,23 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
|||
let mut intrusive_signals: Vec<IntrusiveSignal> = Vec::new();
|
||||
|
||||
for _round in 0..max_rounds {
|
||||
let principal_block = primary_agent
|
||||
.as_ref()
|
||||
.map(|agent| {
|
||||
crate::core::principal::observe(agent, "subconscious").model_system_block()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"[RUNTIME PRINCIPAL — unavailable: primary agent record could not be loaded. No authority is granted.]".to_string()
|
||||
});
|
||||
let mut request_messages = messages.clone();
|
||||
let system_prefix = request_messages
|
||||
.iter()
|
||||
.take_while(|message| message.role == "system")
|
||||
.count();
|
||||
request_messages.insert(system_prefix, Message::text("system", principal_block));
|
||||
let request = ChatCompletionRequest {
|
||||
model: model.to_string(),
|
||||
messages: messages.clone(),
|
||||
messages: request_messages,
|
||||
temperature: Some(0.3),
|
||||
max_tokens: self.max_tokens,
|
||||
stream: None,
|
||||
|
|
@ -1077,9 +1091,23 @@ Resolve entries: `[YYYY-MM-DD HH:MM] RESOLVED — note`"#;
|
|||
now and respond with your observations — source, content, urgency, \
|
||||
exactly as instructed. If nothing notable, respond with just: none",
|
||||
));
|
||||
let principal_block = primary_agent
|
||||
.as_ref()
|
||||
.map(|agent| {
|
||||
crate::core::principal::observe(agent, "subconscious-final").model_system_block()
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"[RUNTIME PRINCIPAL — unavailable: primary agent record could not be loaded. No authority is granted.]".to_string()
|
||||
});
|
||||
let mut final_messages = messages.clone();
|
||||
let system_prefix = final_messages
|
||||
.iter()
|
||||
.take_while(|message| message.role == "system")
|
||||
.count();
|
||||
final_messages.insert(system_prefix, Message::text("system", principal_block));
|
||||
let final_request = ChatCompletionRequest {
|
||||
model: model.to_string(),
|
||||
messages: messages.clone(),
|
||||
messages: final_messages,
|
||||
temperature: Some(0.3),
|
||||
max_tokens: self.max_tokens,
|
||||
stream: None,
|
||||
|
|
|
|||
|
|
@ -170,9 +170,23 @@ impl SubagentRunner for ServerSubagentRunner {
|
|||
));
|
||||
}
|
||||
|
||||
// A fork inherits the parent's logical principal intent, but its
|
||||
// live uid is still observed afresh. The block is request-local
|
||||
// and therefore cannot be replayed after the fork returns.
|
||||
let principal = crate::core::principal::observe(&agent, "subagent");
|
||||
let mut request_messages = messages.clone();
|
||||
let system_prefix = request_messages
|
||||
.iter()
|
||||
.take_while(|message| message.role == "system")
|
||||
.count();
|
||||
request_messages.insert(
|
||||
system_prefix,
|
||||
BifrostMessage::text("system", principal.model_system_block()),
|
||||
);
|
||||
|
||||
let req = ChatCompletionRequest {
|
||||
model: model.clone(),
|
||||
messages: messages.clone(),
|
||||
messages: request_messages,
|
||||
stream: Some(false),
|
||||
max_tokens: None,
|
||||
temperature,
|
||||
|
|
|
|||
|
|
@ -306,9 +306,23 @@ pub(crate) async fn run_turn(
|
|||
tracing::warn!("turn: max_rounds is 0 — sending NO tool definitions to model");
|
||||
}
|
||||
|
||||
// Runtime principal posture is measured for every inference request.
|
||||
// It never enters `messages`, so resume and microcompaction cannot
|
||||
// replay a stale uid fact as conversation history.
|
||||
let principal = crate::core::principal::observe(&agent, "primary");
|
||||
let mut request_messages = messages.clone();
|
||||
let system_prefix = request_messages
|
||||
.iter()
|
||||
.take_while(|message| message.role == "system")
|
||||
.count();
|
||||
request_messages.insert(
|
||||
system_prefix,
|
||||
BifrostMessage::text("system", principal.model_system_block()),
|
||||
);
|
||||
|
||||
let req = ChatCompletionRequest {
|
||||
model: model.clone(),
|
||||
messages: messages.clone(),
|
||||
messages: request_messages,
|
||||
stream: Some(false),
|
||||
max_tokens,
|
||||
temperature,
|
||||
|
|
|
|||
120
src/ui/setup.rs
120
src/ui/setup.rs
|
|
@ -17,7 +17,9 @@ use ratatui::{
|
|||
Frame,
|
||||
};
|
||||
|
||||
use crate::api::models::{CreateAgentRequest, LlmConfig, MemoryBlock};
|
||||
use crate::api::models::{
|
||||
AgentPrincipalConfig, CreateAgentRequest, LlmConfig, MemoryBlock,
|
||||
};
|
||||
|
||||
/// Describes what kind of input a form slot accepts.
|
||||
enum SlotKind {
|
||||
|
|
@ -280,6 +282,9 @@ pub struct SetupState {
|
|||
|
||||
// ── Agent fields ──
|
||||
pub agent_name: String,
|
||||
/// Empty means an intentional borrowed-user mode. A non-empty value
|
||||
/// records dedicated intent; privileged node admission remains separate.
|
||||
pub account_name: String,
|
||||
pub model_handle: String,
|
||||
|
||||
// ── Model discovery ──
|
||||
|
|
@ -311,6 +316,16 @@ impl SetupState {
|
|||
SetupFlow::FederationSync => SetupStep::FederationConfig,
|
||||
};
|
||||
let letta_agents = discover_letta_agents();
|
||||
let agent_name = if flow == SetupFlow::FreshInstall {
|
||||
"Souveraine"
|
||||
} else {
|
||||
"Ani"
|
||||
};
|
||||
let account_name = if flow == SetupFlow::FreshInstall {
|
||||
"souvie"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
Self {
|
||||
flow,
|
||||
|
|
@ -318,7 +333,8 @@ impl SetupState {
|
|||
complete: false,
|
||||
bifrost_url: "http://127.0.0.1:3360".to_string(),
|
||||
bifrost_key: String::new(),
|
||||
agent_name: "Souveraine".to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
account_name: account_name.to_string(),
|
||||
model_handle: default_model.to_string(),
|
||||
models_rx: None,
|
||||
models_fetching: false,
|
||||
|
|
@ -328,7 +344,7 @@ impl SetupState {
|
|||
peer_agent_id: String::new(),
|
||||
created_agent_id: None,
|
||||
creation_error: None,
|
||||
form: Self::form_for_step(step, default_model),
|
||||
form: Self::form_for_step(step, default_model, agent_name, account_name),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -391,7 +407,12 @@ impl SetupState {
|
|||
}
|
||||
}
|
||||
|
||||
fn form_for_step(step: SetupStep, default_model: &str) -> FormState {
|
||||
fn form_for_step(
|
||||
step: SetupStep,
|
||||
default_model: &str,
|
||||
agent_name: &str,
|
||||
account_name: &str,
|
||||
) -> FormState {
|
||||
match step {
|
||||
SetupStep::Welcome => FormState {
|
||||
slots: vec![],
|
||||
|
|
@ -412,7 +433,8 @@ impl SetupState {
|
|||
},
|
||||
SetupStep::CreateAgent => FormState {
|
||||
slots: vec![
|
||||
FormSlot::text("Agent Name", "Ani", false),
|
||||
FormSlot::text("Agent Name", agent_name, false),
|
||||
FormSlot::text("Unix Account", account_name, false),
|
||||
FormSlot::model("Model", default_model),
|
||||
],
|
||||
focus: 0,
|
||||
|
|
@ -459,7 +481,8 @@ impl SetupState {
|
|||
}
|
||||
CreateAgent => {
|
||||
self.agent_name = self.form.slots[0].value();
|
||||
self.model_handle = self.form.slots[1].value();
|
||||
self.account_name = self.form.slots[1].value();
|
||||
self.model_handle = self.form.slots[2].value();
|
||||
ImportOrFederation
|
||||
}
|
||||
ImportOrFederation => Complete,
|
||||
|
|
@ -470,7 +493,12 @@ impl SetupState {
|
|||
}
|
||||
};
|
||||
let default_model = &self.model_handle;
|
||||
self.form = Self::form_for_step(self.step, default_model);
|
||||
self.form = Self::form_for_step(
|
||||
self.step,
|
||||
default_model,
|
||||
&self.agent_name,
|
||||
&self.account_name,
|
||||
);
|
||||
}
|
||||
|
||||
/// Go back one step.
|
||||
|
|
@ -485,7 +513,8 @@ impl SetupState {
|
|||
}
|
||||
CreateAgent => {
|
||||
self.agent_name = self.form.slots[0].value();
|
||||
self.model_handle = self.form.slots[1].value();
|
||||
self.account_name = self.form.slots[1].value();
|
||||
self.model_handle = self.form.slots[2].value();
|
||||
match self.flow {
|
||||
SetupFlow::FreshInstall => ApiConfig,
|
||||
SetupFlow::ImportAgent => ImportOrFederation,
|
||||
|
|
@ -500,7 +529,12 @@ impl SetupState {
|
|||
Complete => ImportOrFederation,
|
||||
};
|
||||
let default_model = &self.model_handle;
|
||||
self.form = Self::form_for_step(self.step, default_model);
|
||||
self.form = Self::form_for_step(
|
||||
self.step,
|
||||
default_model,
|
||||
&self.agent_name,
|
||||
&self.account_name,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a `CreateAgentRequest` from the current wizard state.
|
||||
|
|
@ -524,6 +558,11 @@ impl SetupState {
|
|||
}],
|
||||
tools: Vec::new(),
|
||||
tags: vec!["souveraine".to_string(), "created-by-setup".to_string()],
|
||||
principal: if self.account_name.trim().is_empty() {
|
||||
AgentPrincipalConfig::borrowed_user()
|
||||
} else {
|
||||
AgentPrincipalConfig::dedicated(self.account_name.trim())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -549,7 +588,8 @@ impl SetupState {
|
|||
}
|
||||
SetupStep::CreateAgent => {
|
||||
self.agent_name = self.form.slots[0].value();
|
||||
self.model_handle = self.form.slots[1].value();
|
||||
self.account_name = self.form.slots[1].value();
|
||||
self.model_handle = self.form.slots[2].value();
|
||||
}
|
||||
SetupStep::FederationConfig => {
|
||||
self.peer_url = self.form.slots[0].value();
|
||||
|
|
@ -766,8 +806,39 @@ impl SetupState {
|
|||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Principal slot. Empty is a conscious borrowed-user choice; a name
|
||||
// records dedicated intent and remains unadmitted until the
|
||||
// privileged node edge binds its worker.
|
||||
let account_focused = 1 == self.form.focus && !self.form.is_submit_focused();
|
||||
let account_style = if account_focused {
|
||||
Style::default().fg(Color::Rgb(180, 230, 160))
|
||||
} else {
|
||||
Style::default().fg(Color::Gray)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(" Unix Account:", account_style)));
|
||||
let account = self.form.slots[1].display_value();
|
||||
let account_cursor = if account_focused { " \u{2591}" } else { "" };
|
||||
let account_value = if account.is_empty() {
|
||||
"(borrow Casey's account)".to_string()
|
||||
} else {
|
||||
account
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}{}", account_value, account_cursor),
|
||||
if self.form.slots[1].display_value().is_empty() {
|
||||
Style::default().fg(Color::Rgb(110, 110, 110))
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
},
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" dedicated accounts begin unadmitted; setup never runs useradd",
|
||||
Style::default().fg(Color::Rgb(80, 110, 90)),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Model slot
|
||||
let model_focused = 1 == self.form.focus && !self.form.is_submit_focused();
|
||||
let model_focused = 2 == self.form.focus && !self.form.is_submit_focused();
|
||||
let model_style = if model_focused {
|
||||
Style::default().fg(Color::Rgb(100, 220, 255))
|
||||
} else {
|
||||
|
|
@ -775,7 +846,7 @@ impl SetupState {
|
|||
};
|
||||
lines.push(Line::from(Span::styled(" Model:", model_style)));
|
||||
|
||||
let model = &self.form.slots[1];
|
||||
let model = &self.form.slots[2];
|
||||
let mv = model.display_value();
|
||||
let m_fg = if model_focused {
|
||||
Color::Rgb(200, 240, 255)
|
||||
|
|
@ -972,6 +1043,17 @@ impl SetupState {
|
|||
format!(" Agent: {}", self.agent_name),
|
||||
Style::default().fg(Color::White),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(
|
||||
" Principal: {}",
|
||||
if self.account_name.trim().is_empty() {
|
||||
"borrowed-user".to_string()
|
||||
} else {
|
||||
format!("dedicated / {} (admission pending)", self.account_name)
|
||||
}
|
||||
),
|
||||
Style::default().fg(Color::Rgb(150, 200, 160)),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(
|
||||
" API: {} ({})",
|
||||
|
|
@ -1179,13 +1261,13 @@ mod tests {
|
|||
state.advance(); // ApiConfig
|
||||
state.advance(); // CreateAgent
|
||||
|
||||
// Focus is on slot 0 (name). Advance to slot 1 (model picker).
|
||||
state.form.focus = 1;
|
||||
// Focus is on slot 0 (name). Model is slot 2 after the principal.
|
||||
state.form.focus = 2;
|
||||
|
||||
// Inject model variants into the picker
|
||||
if let SlotKind::ModelPicker {
|
||||
ref mut variants, ..
|
||||
} = state.form.slots[1].kind
|
||||
} = state.form.slots[2].kind
|
||||
{
|
||||
variants.push("model-a".to_string());
|
||||
variants.push("model-b".to_string());
|
||||
|
|
@ -1193,12 +1275,12 @@ mod tests {
|
|||
}
|
||||
|
||||
// Current value should be the default (kimi-k2.6 isn't in variants yet)
|
||||
assert_eq!(state.form.slots[1].value(), "kimi-k2.6");
|
||||
assert_eq!(state.form.slots[2].value(), "kimi-k2.6");
|
||||
|
||||
// Simulate a fetch that populates variants properly synced with value
|
||||
if let SlotKind::ModelPicker {
|
||||
ref mut variants, ..
|
||||
} = state.form.slots[1].kind
|
||||
} = state.form.slots[2].kind
|
||||
{
|
||||
variants.clear();
|
||||
variants.push("kimi-k2.6".to_string());
|
||||
|
|
@ -1209,11 +1291,11 @@ mod tests {
|
|||
// Left → cycle backwards to claude-3.7
|
||||
let key = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
|
||||
state.handle_key(key);
|
||||
assert_eq!(state.form.slots[1].value(), "claude-3.7");
|
||||
assert_eq!(state.form.slots[2].value(), "claude-3.7");
|
||||
|
||||
// Right → cycle forwards to kimi-k2.6
|
||||
let key = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
|
||||
state.handle_key(key);
|
||||
assert_eq!(state.form.slots[1].value(), "kimi-k2.6");
|
||||
assert_eq!(state.form.slots[2].value(), "kimi-k2.6");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue