1use std::path::{Path, PathBuf};
59
60use serde::{Deserialize, Serialize};
61
62use crate::harness_command::HarnessCommand;
63use crate::profiles::ProfileRow;
64use crate::{HarnessHomes, HarnessId};
65
66pub const CONTROLLED_PROFILE_HARNESSES: &[&str] = &[
70 HarnessId::HERMES,
71 HarnessId::OPENCLAW,
72 HarnessId::ORCHESTRATOR,
73];
74
75pub const CODEX_REFUSAL: &str =
77 "codex profiles are file-authored: a profile IS a `[profiles.<name>]` table in \
78 `$CODEX_HOME/config.toml`, created by adding that table and deleted by removing it. Codex \
79 publishes no `codex profile create|delete` verb a client can call, so supercode names the \
80 door rather than editing another harness's config file behind its back";
81
82pub const PRESET_REFUSAL: &str =
84 "a supercode preset is CODE — one of the compiled-in preset bundles, not a config home a verb \
85 can make or remove. supercode publishes no preset create/delete verb, so the profile noun \
86 refuses rather than inventing one";
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum ProfileVerb {
92 Create,
94 Delete,
96}
97
98impl ProfileVerb {
99 pub const fn as_str(self) -> &'static str {
101 match self {
102 Self::Create => "create",
103 Self::Delete => "delete",
104 }
105 }
106}
107
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ProfileMutation {
111 pub harness: String,
113 pub name: String,
115 #[serde(default, alias = "template")]
118 pub from: Option<String>,
119 #[serde(default)]
122 pub workspace: Option<String>,
123 #[serde(default)]
126 pub homes: HarnessHomes,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ProfileMutationOutcome {
132 pub harness: String,
134 pub verb: String,
136 pub ran: String,
138 pub name: String,
140 #[serde(skip_serializing_if = "Option::is_none")]
143 pub profile: Option<ProfileRow>,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub deleted: Option<bool>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ProfileControlError {
152 Unsupported(String),
154 Invalid(String),
156 Failed(String),
158}
159
160impl std::fmt::Display for ProfileControlError {
161 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Self::Unsupported(message) | Self::Invalid(message) | Self::Failed(message) => {
164 formatter.write_str(message)
165 }
166 }
167 }
168}
169
170impl std::error::Error for ProfileControlError {}
171
172type Result<T> = std::result::Result<T, ProfileControlError>;
173
174pub fn supports_profile_control(harness: &str) -> bool {
176 CONTROLLED_PROFILE_HARNESSES.contains(&harness)
177}
178
179fn unsupported_harness(harness: &str) -> String {
183 match harness {
184 HarnessId::CODEX => CODEX_REFUSAL.to_string(),
185 HarnessId::SUPERCODE => PRESET_REFUSAL.to_string(),
186 other => crate::profiles::ProfileError::UnsupportedHarness {
187 harness: other.to_string(),
188 }
189 .to_string(),
190 }
191}
192
193fn harness_program(harness: &str) -> Result<String> {
195 crate::harness_command::harness_program(harness).map_err(|detail| {
196 ProfileControlError::Unsupported(detail.unwrap_or_else(|| unsupported_harness(harness)))
197 })
198}
199
200fn hermes_home(homes: &HarnessHomes) -> PathBuf {
204 homes
207 .hermes
208 .parent()
209 .map_or_else(|| PathBuf::from("."), Path::to_path_buf)
210}
211
212pub fn mutate(verb: ProfileVerb, mutation: &ProfileMutation) -> Result<ProfileMutationOutcome> {
215 if !supports_profile_control(&mutation.harness) {
216 return Err(ProfileControlError::Unsupported(unsupported_harness(
217 &mutation.harness,
218 )));
219 }
220 let name = mutation.name.trim();
221 if name.is_empty() {
222 return Err(ProfileControlError::Invalid(format!(
223 "`profiles.{}` needs the profile name to act on",
224 verb.as_str()
225 )));
226 }
227 if matches!(verb, ProfileVerb::Delete)
228 && (mutation.from.is_some() || mutation.workspace.is_some())
229 {
230 return Err(ProfileControlError::Invalid(
231 "`profiles.delete` sets no fields; pass definition fields to `profiles.create`".into(),
232 ));
233 }
234 if mutation.harness == HarnessId::ORCHESTRATOR {
237 return orchestrator_mutate(verb, name, mutation);
238 }
239 let command = match mutation.harness.as_str() {
240 HarnessId::HERMES => hermes_command(verb, name, mutation)?,
241 HarnessId::OPENCLAW => openclaw_command(verb, name, mutation)?,
242 other => return Err(ProfileControlError::Unsupported(unsupported_harness(other))),
243 };
244 let ran = command.narrate();
245 command.run().map_err(ProfileControlError::Failed)?;
246 let read = read_back(&mutation.harness, name, &mutation.homes, &ran)?;
248 match verb {
249 ProfileVerb::Delete => {
250 if read.is_some() {
251 return Err(ProfileControlError::Failed(format!(
252 "`{ran}` reported success but `{name}` is still a {} profile",
253 mutation.harness
254 )));
255 }
256 Ok(ProfileMutationOutcome {
257 harness: mutation.harness.clone(),
258 verb: verb.as_str().to_string(),
259 ran,
260 name: name.to_string(),
261 profile: None,
262 deleted: Some(true),
263 })
264 }
265 ProfileVerb::Create => {
266 let profile = read.ok_or_else(|| {
267 ProfileControlError::Failed(format!(
268 "`{ran}` reported success but `{}` has no profile `{name}` afterwards",
269 mutation.harness
270 ))
271 })?;
272 Ok(ProfileMutationOutcome {
273 harness: mutation.harness.clone(),
274 verb: verb.as_str().to_string(),
275 ran,
276 name: profile.name.clone(),
277 profile: Some(profile),
278 deleted: None,
279 })
280 }
281 }
282}
283
284fn orchestrator_mutate(
291 verb: ProfileVerb,
292 name: &str,
293 mutation: &ProfileMutation,
294) -> Result<ProfileMutationOutcome> {
295 if mutation.from.is_some() {
296 return Err(ProfileControlError::Unsupported(
297 "an orchestrator profile is a FOLDER the package's `save()` writes from an empty \
298 record (`docs/ORCHESTRATOR-IR.md` §6); the operator door has no clone verb, so \
299 supercode refuses rather than dropping `from`"
300 .into(),
301 ));
302 }
303 if mutation.workspace.is_some() {
304 return Err(ProfileControlError::Unsupported(
305 "an orchestrator profile IS its own home (`<root>/profiles/<name>`), and where its \
306 worker runs is the `worker.cwd` key inside that folder's `config.yaml`, not a \
307 creation argument; supercode refuses rather than dropping `workspace`"
308 .into(),
309 ));
310 }
311 let root = mutation.homes.orchestrator.clone();
312 let op = match verb {
313 ProfileVerb::Create => "profiles.create",
314 ProfileVerb::Delete => "profiles.delete",
315 };
316 let args = serde_json::json!({ "name": name });
317 let answer =
320 crate::orchestrator_door::call(&root, op, &args, "default").map_err(
321 |error| match error {
322 crate::orchestrator_door::DoorError::Refused(message) => {
323 ProfileControlError::Failed(message)
324 }
325 crate::orchestrator_door::DoorError::Failed(message) => {
326 ProfileControlError::Failed(message)
327 }
328 },
329 )?;
330 let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
331 let read = read_back(&mutation.harness, name, &mutation.homes, &ran)?;
334 match verb {
335 ProfileVerb::Delete => {
336 if read.is_some() {
337 return Err(ProfileControlError::Failed(format!(
338 "`{ran}` reported success but `{name}` is still an orchestrator profile"
339 )));
340 }
341 Ok(ProfileMutationOutcome {
342 harness: mutation.harness.clone(),
343 verb: verb.as_str().to_string(),
344 ran,
345 name: name.to_string(),
346 profile: None,
347 deleted: Some(true),
348 })
349 }
350 ProfileVerb::Create => {
351 let profile = read.ok_or_else(|| {
352 ProfileControlError::Failed(format!(
353 "`{ran}` reported success but the orchestrator has no profile `{name}` \
354 afterwards"
355 ))
356 })?;
357 Ok(ProfileMutationOutcome {
358 harness: mutation.harness.clone(),
359 verb: verb.as_str().to_string(),
360 ran,
361 name: profile.name.clone(),
362 profile: Some(profile),
363 deleted: None,
364 })
365 }
366 }
367}
368
369fn read_back(
376 harness: &str,
377 name: &str,
378 homes: &HarnessHomes,
379 ran: &str,
380) -> Result<Option<ProfileRow>> {
381 let rows = crate::profiles::list_profiles(homes, Some(harness)).map_err(|error| {
382 ProfileControlError::Failed(format!(
383 "`{ran}` succeeded but the profile store could not be re-read: {error}"
384 ))
385 })?;
386 Ok(rows
387 .iter()
388 .find(|row| row.name == name)
389 .or_else(|| rows.iter().find(|row| row.name.eq_ignore_ascii_case(name)))
390 .cloned())
391}
392
393fn hermes_command(
398 verb: ProfileVerb,
399 name: &str,
400 mutation: &ProfileMutation,
401) -> Result<HarnessCommand> {
402 if mutation.workspace.is_some() {
403 return Err(ProfileControlError::Unsupported(
404 "a hermes profile IS its own home (`HERMES_HOME/profiles/<name>`); `hermes profile \
405 create` has no workspace flag, so supercode refuses rather than dropping the field"
406 .into(),
407 ));
408 }
409 let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
410 command.env(
411 "HERMES_HOME",
412 hermes_home(&mutation.homes).to_string_lossy(),
413 );
414 command.arg("profile");
415 match verb {
416 ProfileVerb::Create => {
417 command.arg("create");
418 if let Some(from) = mutation
419 .from
420 .as_deref()
421 .map(str::trim)
422 .filter(|from| !from.is_empty())
423 {
424 command.args(["--clone-from", from]);
425 }
426 command.arg("--no-alias");
430 command.arg(name);
431 }
432 ProfileVerb::Delete => {
433 command.args(["delete", "--yes", name]);
436 }
437 }
438 Ok(command)
439}
440
441fn openclaw_command(
446 verb: ProfileVerb,
447 name: &str,
448 mutation: &ProfileMutation,
449) -> Result<HarnessCommand> {
450 if mutation.from.is_some() {
451 return Err(ProfileControlError::Unsupported(
452 "`openclaw agents add` takes a workspace, a model and bindings; it has no clone / \
453 template source, so supercode refuses rather than dropping `from`"
454 .into(),
455 ));
456 }
457 let mut command = HarnessCommand::new(harness_program(HarnessId::OPENCLAW)?);
458 command.env(
462 "OPENCLAW_STATE_DIR",
463 mutation.homes.openclaw.to_string_lossy(),
464 );
465 command.env(
466 "OPENCLAW_CONFIG_PATH",
467 mutation
468 .homes
469 .openclaw
470 .join("openclaw.json")
471 .to_string_lossy(),
472 );
473 command.arg("agents");
474 match verb {
475 ProfileVerb::Create => {
476 let workspace = mutation
477 .workspace
478 .as_deref()
479 .map(str::trim)
480 .filter(|workspace| !workspace.is_empty())
481 .ok_or_else(|| {
482 ProfileControlError::Invalid(
483 "`openclaw agents add` requires the new agent's workspace directory in \
484 non-interactive mode (its own message: \"Non-interactive agent creation \
485 requires --workspace\"), so `profiles.create --harness openclaw` needs \
486 `workspace`"
487 .into(),
488 )
489 })?;
490 command.args(["add", name, "--workspace", workspace]);
491 command.args(["--non-interactive", "--json"]);
492 }
493 ProfileVerb::Delete => {
494 command.args(["delete", name, "--force", "--json"]);
497 }
498 }
499 Ok(command)
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 fn homes(root: &Path) -> HarnessHomes {
507 HarnessHomes {
508 hermes: root.join("hermes_home/state.db"),
509 openclaw: root.join("openclaw_home"),
510 ..HarnessHomes::default()
511 }
512 }
513
514 #[test]
515 fn hermes_translates_the_uniform_row_onto_its_own_verb() {
516 let root = PathBuf::from("/tmp/orch21-unit");
517 let command = hermes_command(
518 ProfileVerb::Create,
519 "coder",
520 &ProfileMutation {
521 harness: HarnessId::HERMES.into(),
522 name: "coder".into(),
523 from: Some("default".into()),
524 homes: homes(&root),
525 ..ProfileMutation::default()
526 },
527 )
528 .unwrap();
529 assert_eq!(
530 command.narrate(),
531 "hermes profile create --clone-from default --no-alias coder"
532 );
533 assert_eq!(
535 command.env,
536 vec![(
537 "HERMES_HOME".to_string(),
538 root.join("hermes_home").to_string_lossy().into_owned()
539 )]
540 );
541 }
542
543 #[test]
544 fn hermes_delete_is_non_interactive() {
545 let command = hermes_command(
546 ProfileVerb::Delete,
547 "coder",
548 &ProfileMutation {
549 harness: HarnessId::HERMES.into(),
550 name: "coder".into(),
551 homes: homes(&PathBuf::from("/tmp/orch21-unit")),
552 ..ProfileMutation::default()
553 },
554 )
555 .unwrap();
556 assert_eq!(command.narrate(), "hermes profile delete --yes coder");
557 }
558
559 #[test]
560 fn openclaw_carries_the_workspace_its_own_verb_demands() {
561 let root = PathBuf::from("/tmp/orch21-unit");
562 let command = openclaw_command(
563 ProfileVerb::Create,
564 "ops",
565 &ProfileMutation {
566 harness: HarnessId::OPENCLAW.into(),
567 name: "ops".into(),
568 workspace: Some("/tmp/orch21-unit/ws".into()),
569 homes: homes(&root),
570 ..ProfileMutation::default()
571 },
572 )
573 .unwrap();
574 assert_eq!(
575 command.narrate(),
576 "openclaw agents add ops --workspace /tmp/orch21-unit/ws --non-interactive --json"
577 );
578 assert!(
579 command.secrets.is_empty(),
580 "these verbs carry no credential"
581 );
582 }
583
584 #[test]
585 fn openclaw_create_without_a_workspace_is_refused_in_the_harnesss_own_words() {
586 let error = openclaw_command(
587 ProfileVerb::Create,
588 "ops",
589 &ProfileMutation {
590 harness: HarnessId::OPENCLAW.into(),
591 name: "ops".into(),
592 homes: homes(&PathBuf::from("/tmp/orch21-unit")),
593 ..ProfileMutation::default()
594 },
595 )
596 .unwrap_err();
597 assert!(matches!(error, ProfileControlError::Invalid(_)), "{error}");
598 assert!(error.to_string().contains("--workspace"), "{error}");
599 }
600
601 #[test]
602 fn openclaw_refuses_a_field_it_has_no_verb_for() {
603 let error = openclaw_command(
604 ProfileVerb::Create,
605 "ops",
606 &ProfileMutation {
607 harness: HarnessId::OPENCLAW.into(),
608 name: "ops".into(),
609 from: Some("main".into()),
610 workspace: Some("/tmp/ws".into()),
611 homes: homes(&PathBuf::from("/tmp/orch21-unit")),
612 ..ProfileMutation::default()
613 },
614 )
615 .unwrap_err();
616 assert!(
617 matches!(error, ProfileControlError::Unsupported(_)),
618 "{error}"
619 );
620 }
621
622 #[test]
625 fn the_orchestrator_is_controlled_and_refuses_the_fields_it_has_no_home_for() {
626 assert!(supports_profile_control(HarnessId::ORCHESTRATOR));
627 for (mutation, needle) in [
628 (
629 ProfileMutation {
630 harness: HarnessId::ORCHESTRATOR.into(),
631 name: "ops".into(),
632 from: Some("default".into()),
633 ..ProfileMutation::default()
634 },
635 "no clone verb",
636 ),
637 (
638 ProfileMutation {
639 harness: HarnessId::ORCHESTRATOR.into(),
640 name: "ops".into(),
641 workspace: Some("/tmp/ws".into()),
642 ..ProfileMutation::default()
643 },
644 "`worker.cwd` key",
645 ),
646 ] {
647 let error = orchestrator_mutate(ProfileVerb::Create, "ops", &mutation).unwrap_err();
648 assert!(
649 matches!(error, ProfileControlError::Unsupported(_)),
650 "{error}"
651 );
652 assert!(error.to_string().contains(needle), "{error}");
653 }
654 }
655
656 #[test]
657 fn codex_and_presets_refuse_every_mutating_verb() {
658 for (harness, needle) in [
659 (HarnessId::CODEX, "[profiles.<name>]"),
660 (HarnessId::SUPERCODE, "CODE"),
661 ] {
662 for verb in [ProfileVerb::Create, ProfileVerb::Delete] {
663 let error = mutate(
664 verb,
665 &ProfileMutation {
666 harness: harness.into(),
667 name: "review".into(),
668 ..ProfileMutation::default()
669 },
670 )
671 .unwrap_err();
672 assert!(
673 matches!(error, ProfileControlError::Unsupported(_)),
674 "{harness}: {error}"
675 );
676 assert!(error.to_string().contains(needle), "{harness}: {error}");
677 }
678 }
679 }
680
681 #[test]
682 fn a_harness_without_profiles_refuses_with_the_read_sides_sentence() {
683 let error = mutate(
684 ProfileVerb::Create,
685 &ProfileMutation {
686 harness: HarnessId::CLAUDE_CODE.into(),
687 name: "coder".into(),
688 ..ProfileMutation::default()
689 },
690 )
691 .unwrap_err();
692 assert!(
693 matches!(error, ProfileControlError::Unsupported(_)),
694 "{error}"
695 );
696 assert!(
697 error.to_string().contains("has no profile concept"),
698 "{error}"
699 );
700 }
701}