use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::harness_command::HarnessCommand;
use crate::profiles::ProfileRow;
use crate::{HarnessHomes, HarnessId};
pub const CONTROLLED_PROFILE_HARNESSES: &[&str] = &[
HarnessId::HERMES,
HarnessId::OPENCLAW,
HarnessId::ORCHESTRATOR,
];
pub const CODEX_REFUSAL: &str =
"codex profiles are file-authored: a profile IS a `[profiles.<name>]` table in \
`$CODEX_HOME/config.toml`, created by adding that table and deleted by removing it. Codex \
publishes no `codex profile create|delete` verb a client can call, so supercode names the \
door rather than editing another harness's config file behind its back";
pub const PRESET_REFUSAL: &str =
"a supercode preset is CODE — one of the compiled-in preset bundles, not a config home a verb \
can make or remove. supercode publishes no preset create/delete verb, so the profile noun \
refuses rather than inventing one";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfileVerb {
Create,
Delete,
}
impl ProfileVerb {
pub const fn as_str(self) -> &'static str {
match self {
Self::Create => "create",
Self::Delete => "delete",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileMutation {
pub harness: String,
pub name: String,
#[serde(default, alias = "template")]
pub from: Option<String>,
#[serde(default)]
pub workspace: Option<String>,
#[serde(default)]
pub homes: HarnessHomes,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileMutationOutcome {
pub harness: String,
pub verb: String,
pub ran: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileRow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileControlError {
Unsupported(String),
Invalid(String),
Failed(String),
}
impl std::fmt::Display for ProfileControlError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unsupported(message) | Self::Invalid(message) | Self::Failed(message) => {
formatter.write_str(message)
}
}
}
}
impl std::error::Error for ProfileControlError {}
type Result<T> = std::result::Result<T, ProfileControlError>;
pub fn supports_profile_control(harness: &str) -> bool {
CONTROLLED_PROFILE_HARNESSES.contains(&harness)
}
fn unsupported_harness(harness: &str) -> String {
match harness {
HarnessId::CODEX => CODEX_REFUSAL.to_string(),
HarnessId::SUPERCODE => PRESET_REFUSAL.to_string(),
other => crate::profiles::ProfileError::UnsupportedHarness {
harness: other.to_string(),
}
.to_string(),
}
}
fn harness_program(harness: &str) -> Result<String> {
crate::harness_command::harness_program(harness).map_err(|detail| {
ProfileControlError::Unsupported(detail.unwrap_or_else(|| unsupported_harness(harness)))
})
}
fn hermes_home(homes: &HarnessHomes) -> PathBuf {
homes
.hermes
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf)
}
pub fn mutate(verb: ProfileVerb, mutation: &ProfileMutation) -> Result<ProfileMutationOutcome> {
if !supports_profile_control(&mutation.harness) {
return Err(ProfileControlError::Unsupported(unsupported_harness(
&mutation.harness,
)));
}
let name = mutation.name.trim();
if name.is_empty() {
return Err(ProfileControlError::Invalid(format!(
"`profiles.{}` needs the profile name to act on",
verb.as_str()
)));
}
if matches!(verb, ProfileVerb::Delete)
&& (mutation.from.is_some() || mutation.workspace.is_some())
{
return Err(ProfileControlError::Invalid(
"`profiles.delete` sets no fields; pass definition fields to `profiles.create`".into(),
));
}
if mutation.harness == HarnessId::ORCHESTRATOR {
return orchestrator_mutate(verb, name, mutation);
}
let command = match mutation.harness.as_str() {
HarnessId::HERMES => hermes_command(verb, name, mutation)?,
HarnessId::OPENCLAW => openclaw_command(verb, name, mutation)?,
other => return Err(ProfileControlError::Unsupported(unsupported_harness(other))),
};
let ran = command.narrate();
command.run().map_err(ProfileControlError::Failed)?;
let read = read_back(&mutation.harness, name, &mutation.homes, &ran)?;
match verb {
ProfileVerb::Delete => {
if read.is_some() {
return Err(ProfileControlError::Failed(format!(
"`{ran}` reported success but `{name}` is still a {} profile",
mutation.harness
)));
}
Ok(ProfileMutationOutcome {
harness: mutation.harness.clone(),
verb: verb.as_str().to_string(),
ran,
name: name.to_string(),
profile: None,
deleted: Some(true),
})
}
ProfileVerb::Create => {
let profile = read.ok_or_else(|| {
ProfileControlError::Failed(format!(
"`{ran}` reported success but `{}` has no profile `{name}` afterwards",
mutation.harness
))
})?;
Ok(ProfileMutationOutcome {
harness: mutation.harness.clone(),
verb: verb.as_str().to_string(),
ran,
name: profile.name.clone(),
profile: Some(profile),
deleted: None,
})
}
}
}
fn orchestrator_mutate(
verb: ProfileVerb,
name: &str,
mutation: &ProfileMutation,
) -> Result<ProfileMutationOutcome> {
if mutation.from.is_some() {
return Err(ProfileControlError::Unsupported(
"an orchestrator profile is a FOLDER the package's `save()` writes from an empty \
record (`docs/ORCHESTRATOR-IR.md` §6); the operator door has no clone verb, so \
supercode refuses rather than dropping `from`"
.into(),
));
}
if mutation.workspace.is_some() {
return Err(ProfileControlError::Unsupported(
"an orchestrator profile IS its own home (`<root>/profiles/<name>`), and where its \
worker runs is the `worker.cwd` key inside that folder's `config.yaml`, not a \
creation argument; supercode refuses rather than dropping `workspace`"
.into(),
));
}
let root = mutation.homes.orchestrator.clone();
let op = match verb {
ProfileVerb::Create => "profiles.create",
ProfileVerb::Delete => "profiles.delete",
};
let args = serde_json::json!({ "name": name });
let answer =
crate::orchestrator_door::call(&root, op, &args, "default").map_err(
|error| match error {
crate::orchestrator_door::DoorError::Refused(message) => {
ProfileControlError::Failed(message)
}
crate::orchestrator_door::DoorError::Failed(message) => {
ProfileControlError::Failed(message)
}
},
)?;
let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
let read = read_back(&mutation.harness, name, &mutation.homes, &ran)?;
match verb {
ProfileVerb::Delete => {
if read.is_some() {
return Err(ProfileControlError::Failed(format!(
"`{ran}` reported success but `{name}` is still an orchestrator profile"
)));
}
Ok(ProfileMutationOutcome {
harness: mutation.harness.clone(),
verb: verb.as_str().to_string(),
ran,
name: name.to_string(),
profile: None,
deleted: Some(true),
})
}
ProfileVerb::Create => {
let profile = read.ok_or_else(|| {
ProfileControlError::Failed(format!(
"`{ran}` reported success but the orchestrator has no profile `{name}` \
afterwards"
))
})?;
Ok(ProfileMutationOutcome {
harness: mutation.harness.clone(),
verb: verb.as_str().to_string(),
ran,
name: profile.name.clone(),
profile: Some(profile),
deleted: None,
})
}
}
}
fn read_back(
harness: &str,
name: &str,
homes: &HarnessHomes,
ran: &str,
) -> Result<Option<ProfileRow>> {
let rows = crate::profiles::list_profiles(homes, Some(harness)).map_err(|error| {
ProfileControlError::Failed(format!(
"`{ran}` succeeded but the profile store could not be re-read: {error}"
))
})?;
Ok(rows
.iter()
.find(|row| row.name == name)
.or_else(|| rows.iter().find(|row| row.name.eq_ignore_ascii_case(name)))
.cloned())
}
fn hermes_command(
verb: ProfileVerb,
name: &str,
mutation: &ProfileMutation,
) -> Result<HarnessCommand> {
if mutation.workspace.is_some() {
return Err(ProfileControlError::Unsupported(
"a hermes profile IS its own home (`HERMES_HOME/profiles/<name>`); `hermes profile \
create` has no workspace flag, so supercode refuses rather than dropping the field"
.into(),
));
}
let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
command.env(
"HERMES_HOME",
hermes_home(&mutation.homes).to_string_lossy(),
);
command.arg("profile");
match verb {
ProfileVerb::Create => {
command.arg("create");
if let Some(from) = mutation
.from
.as_deref()
.map(str::trim)
.filter(|from| !from.is_empty())
{
command.args(["--clone-from", from]);
}
command.arg("--no-alias");
command.arg(name);
}
ProfileVerb::Delete => {
command.args(["delete", "--yes", name]);
}
}
Ok(command)
}
fn openclaw_command(
verb: ProfileVerb,
name: &str,
mutation: &ProfileMutation,
) -> Result<HarnessCommand> {
if mutation.from.is_some() {
return Err(ProfileControlError::Unsupported(
"`openclaw agents add` takes a workspace, a model and bindings; it has no clone / \
template source, so supercode refuses rather than dropping `from`"
.into(),
));
}
let mut command = HarnessCommand::new(harness_program(HarnessId::OPENCLAW)?);
command.env(
"OPENCLAW_STATE_DIR",
mutation.homes.openclaw.to_string_lossy(),
);
command.env(
"OPENCLAW_CONFIG_PATH",
mutation
.homes
.openclaw
.join("openclaw.json")
.to_string_lossy(),
);
command.arg("agents");
match verb {
ProfileVerb::Create => {
let workspace = mutation
.workspace
.as_deref()
.map(str::trim)
.filter(|workspace| !workspace.is_empty())
.ok_or_else(|| {
ProfileControlError::Invalid(
"`openclaw agents add` requires the new agent's workspace directory in \
non-interactive mode (its own message: \"Non-interactive agent creation \
requires --workspace\"), so `profiles.create --harness openclaw` needs \
`workspace`"
.into(),
)
})?;
command.args(["add", name, "--workspace", workspace]);
command.args(["--non-interactive", "--json"]);
}
ProfileVerb::Delete => {
command.args(["delete", name, "--force", "--json"]);
}
}
Ok(command)
}
#[cfg(test)]
mod tests {
use super::*;
fn homes(root: &Path) -> HarnessHomes {
HarnessHomes {
hermes: root.join("hermes_home/state.db"),
openclaw: root.join("openclaw_home"),
..HarnessHomes::default()
}
}
#[test]
fn hermes_translates_the_uniform_row_onto_its_own_verb() {
let root = PathBuf::from("/tmp/orch21-unit");
let command = hermes_command(
ProfileVerb::Create,
"coder",
&ProfileMutation {
harness: HarnessId::HERMES.into(),
name: "coder".into(),
from: Some("default".into()),
homes: homes(&root),
..ProfileMutation::default()
},
)
.unwrap();
assert_eq!(
command.narrate(),
"hermes profile create --clone-from default --no-alias coder"
);
assert_eq!(
command.env,
vec![(
"HERMES_HOME".to_string(),
root.join("hermes_home").to_string_lossy().into_owned()
)]
);
}
#[test]
fn hermes_delete_is_non_interactive() {
let command = hermes_command(
ProfileVerb::Delete,
"coder",
&ProfileMutation {
harness: HarnessId::HERMES.into(),
name: "coder".into(),
homes: homes(&PathBuf::from("/tmp/orch21-unit")),
..ProfileMutation::default()
},
)
.unwrap();
assert_eq!(command.narrate(), "hermes profile delete --yes coder");
}
#[test]
fn openclaw_carries_the_workspace_its_own_verb_demands() {
let root = PathBuf::from("/tmp/orch21-unit");
let command = openclaw_command(
ProfileVerb::Create,
"ops",
&ProfileMutation {
harness: HarnessId::OPENCLAW.into(),
name: "ops".into(),
workspace: Some("/tmp/orch21-unit/ws".into()),
homes: homes(&root),
..ProfileMutation::default()
},
)
.unwrap();
assert_eq!(
command.narrate(),
"openclaw agents add ops --workspace /tmp/orch21-unit/ws --non-interactive --json"
);
assert!(
command.secrets.is_empty(),
"these verbs carry no credential"
);
}
#[test]
fn openclaw_create_without_a_workspace_is_refused_in_the_harnesss_own_words() {
let error = openclaw_command(
ProfileVerb::Create,
"ops",
&ProfileMutation {
harness: HarnessId::OPENCLAW.into(),
name: "ops".into(),
homes: homes(&PathBuf::from("/tmp/orch21-unit")),
..ProfileMutation::default()
},
)
.unwrap_err();
assert!(matches!(error, ProfileControlError::Invalid(_)), "{error}");
assert!(error.to_string().contains("--workspace"), "{error}");
}
#[test]
fn openclaw_refuses_a_field_it_has_no_verb_for() {
let error = openclaw_command(
ProfileVerb::Create,
"ops",
&ProfileMutation {
harness: HarnessId::OPENCLAW.into(),
name: "ops".into(),
from: Some("main".into()),
workspace: Some("/tmp/ws".into()),
homes: homes(&PathBuf::from("/tmp/orch21-unit")),
..ProfileMutation::default()
},
)
.unwrap_err();
assert!(
matches!(error, ProfileControlError::Unsupported(_)),
"{error}"
);
}
#[test]
fn the_orchestrator_is_controlled_and_refuses_the_fields_it_has_no_home_for() {
assert!(supports_profile_control(HarnessId::ORCHESTRATOR));
for (mutation, needle) in [
(
ProfileMutation {
harness: HarnessId::ORCHESTRATOR.into(),
name: "ops".into(),
from: Some("default".into()),
..ProfileMutation::default()
},
"no clone verb",
),
(
ProfileMutation {
harness: HarnessId::ORCHESTRATOR.into(),
name: "ops".into(),
workspace: Some("/tmp/ws".into()),
..ProfileMutation::default()
},
"`worker.cwd` key",
),
] {
let error = orchestrator_mutate(ProfileVerb::Create, "ops", &mutation).unwrap_err();
assert!(
matches!(error, ProfileControlError::Unsupported(_)),
"{error}"
);
assert!(error.to_string().contains(needle), "{error}");
}
}
#[test]
fn codex_and_presets_refuse_every_mutating_verb() {
for (harness, needle) in [
(HarnessId::CODEX, "[profiles.<name>]"),
(HarnessId::SUPERCODE, "CODE"),
] {
for verb in [ProfileVerb::Create, ProfileVerb::Delete] {
let error = mutate(
verb,
&ProfileMutation {
harness: harness.into(),
name: "review".into(),
..ProfileMutation::default()
},
)
.unwrap_err();
assert!(
matches!(error, ProfileControlError::Unsupported(_)),
"{harness}: {error}"
);
assert!(error.to_string().contains(needle), "{harness}: {error}");
}
}
}
#[test]
fn a_harness_without_profiles_refuses_with_the_read_sides_sentence() {
let error = mutate(
ProfileVerb::Create,
&ProfileMutation {
harness: HarnessId::CLAUDE_CODE.into(),
name: "coder".into(),
..ProfileMutation::default()
},
)
.unwrap_err();
assert!(
matches!(error, ProfileControlError::Unsupported(_)),
"{error}"
);
assert!(
error.to_string().contains("has no profile concept"),
"{error}"
);
}
}