supercode_harness/profiles_control.rs
1//! ORCH-21 — the `profile` noun at the CONTROLLED tier: create and delete a
2//! routed config home through the HARNESS'S OWN verb, with supercode as the
3//! uniform client.
4//!
5//! Charter (`docs/plans/orchestration-domain-11-2026-09-02.md` §0.4):
6//! **supercode never owns a harness's config plane.** It makes no directory,
7//! writes no config file, and removes nothing itself. Each verb below is a
8//! subprocess of the harness's own CLI, run in the harness's own home:
9//!
10//! * **Hermes** — `hermes profile create <name>` / `hermes profile delete
11//! --yes <name>` with `HERMES_HOME` pointing at the ROOT home the caller
12//! addressed (`HERMES_HOME/profiles/<name>` is what the verb makes;
13//! upstream `hermes_constants.get_default_hermes_root` derives the profiles
14//! root from that variable). `--clone-from` carries the uniform `from`.
15//! * **OpenClaw** — `openclaw agents add <id> --workspace <dir>
16//! --non-interactive --json` / `openclaw agents delete <id> --force --json`,
17//! pointed at the caller's state dir through openclaw's own environment
18//! contract. At the pin these edit `openclaw.json` directly (`agents.list`);
19//! `agents delete` additionally offers the change to a RUNNING gateway when
20//! one is reachable, resolving that endpoint itself — neither verb takes a
21//! `--url`/`--token` flag, so supercode passes no credential here.
22//! * **The orchestrator** — its own package (ORC-13). A profile IS a folder
23//! whose files are the model's serialization (`docs/ORCHESTRATOR-IR.md`
24//! §6), so `create` is a new folder record followed by the package's own
25//! `save()`, and `delete` removes the record and has the daemon RENAME the
26//! folder into `profiles/.trash/<name>-<stamp>/` — nothing is ever
27//! unlinked. Both go through [`crate::orchestrator_door`]: the daemon's
28//! socket while it is up, `node bin/orchestrator.mjs profiles.create|delete`
29//! when it is down. supercode still writes no file of that folder itself.
30//! * **Codex** — refused. A Codex profile is a `[profiles.<name>]` TABLE that
31//! a human (or a tool) authors in `$CODEX_HOME/config.toml`; Codex publishes
32//! no `codex profile create|delete` verb, so supercode names that door
33//! rather than editing another harness's config file behind its back.
34//! * **supercode presets** — refused. A preset is compiled-in CODE
35//! ([`crate::presets::RESERVED_PRESET_NAMES`]), not a directory a verb can
36//! make.
37//!
38//! The three tier rules inherited from ORCH-18 hold here unchanged:
39//!
40//! 1. **The harness's answer is the answer.** After the verb exits 0 the row
41//! is re-read through the ORCH-10 loader ([`crate::profiles`]) and
42//! returned. A non-zero exit surfaces the harness's own stderr as the
43//! error — never a silent success, never a supercode-invented row.
44//! 2. **The command is narrated.** Every outcome carries `ran`: the exact
45//! argv that was executed, credentials redacted.
46//! 3. **A verb the harness does not publish is refused**
47//! ([`ProfileControlError::Unsupported`] → `UnsupportedAction`), never
48//! faked.
49//!
50//! One deliberate flag supercode does NOT let the harness default: nothing
51//! here writes outside the home the caller addressed. `hermes profile create`
52//! also drops a wrapper script into `~/.local/bin/<name>` (upstream
53//! `profiles.py::_get_wrapper_dir`, which ignores `HERMES_HOME`), so the
54//! non-interactive client form passes `--no-alias` — a programmatic
55//! `profiles.create` against an isolated home must not install an executable
56//! on the caller's PATH. The flag is in `ran`, so the choice is visible.
57
58use std::path::{Path, PathBuf};
59
60use serde::{Deserialize, Serialize};
61
62use crate::harness_command::HarnessCommand;
63use crate::profiles::ProfileRow;
64use crate::{HarnessHomes, HarnessId};
65
66/// Harnesses whose profiles supercode can MUTATE through their own CLI verb.
67/// Strictly narrower than [`crate::profiles::PROFILE_HARNESSES`]: Codex
68/// profiles and supercode presets are readable but not controllable.
69pub const CONTROLLED_PROFILE_HARNESSES: &[&str] = &[
70 HarnessId::HERMES,
71 HarnessId::OPENCLAW,
72 HarnessId::ORCHESTRATOR,
73];
74
75/// Why Codex refuses `profiles.create` / `profiles.delete`.
76pub 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
82/// Why supercode's own presets refuse the same two verbs.
83pub 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/// One uniform mutating verb over the profile noun.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum ProfileVerb {
92 /// Make a new named config home.
93 Create,
94 /// Remove a named config home.
95 Delete,
96}
97
98impl ProfileVerb {
99 /// Uniform spelling used in the RPC method and in outcomes.
100 pub const fn as_str(self) -> &'static str {
101 match self {
102 Self::Create => "create",
103 Self::Delete => "delete",
104 }
105 }
106}
107
108/// One mutating request, in the uniform Domain 11 vocabulary.
109#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ProfileMutation {
111 /// Harness that owns the profile.
112 pub harness: String,
113 /// Profile / agent name to create or delete.
114 pub name: String,
115 /// Existing profile the new one is cloned from, where the harness has a
116 /// verb for it (Hermes `--clone-from`). Refused elsewhere, never dropped.
117 #[serde(default, alias = "template")]
118 pub from: Option<String>,
119 /// Workspace directory for the new agent — required by
120 /// `openclaw agents add` in non-interactive mode.
121 #[serde(default)]
122 pub workspace: Option<String>,
123 /// Storage roots, so an isolated home is addressed the same way the read
124 /// side addresses it.
125 #[serde(default)]
126 pub homes: HarnessHomes,
127}
128
129/// What one mutation did, with the harness's own row read back afterwards.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ProfileMutationOutcome {
132 /// Harness that ran the verb.
133 pub harness: String,
134 /// Uniform verb that was asked for.
135 pub verb: String,
136 /// The exact harness command that ran, credentials redacted.
137 pub ran: String,
138 /// Affected profile name, as the harness's own store spells it.
139 pub name: String,
140 /// The profile as the harness's own store reports it AFTER the verb.
141 /// Absent for `delete`.
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub profile: Option<ProfileRow>,
144 /// `true` on a successful `delete`.
145 #[serde(skip_serializing_if = "Option::is_none")]
146 pub deleted: Option<bool>,
147}
148
149/// Why a profile mutation could not be performed.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ProfileControlError {
152 /// The harness has no verb for what was asked (refused, never faked).
153 Unsupported(String),
154 /// The request itself is incoherent.
155 Invalid(String),
156 /// The harness verb ran and failed; the message carries its stderr.
157 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
174/// Whether `harness` can have its profiles mutated at all.
175pub fn supports_profile_control(harness: &str) -> bool {
176 CONTROLLED_PROFILE_HARNESSES.contains(&harness)
177}
178
179/// The refusal sentence for a harness that cannot be controlled: the reason
180/// the HARNESS gives, when it has profiles, and otherwise the same sentence
181/// `profiles.list` answers with.
182fn 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
193/// The harness's own executable, refused in the profile noun's words.
194fn 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
200/// `HERMES_HOME` for a profile mutation: the ROOT home, never a profile's own
201/// home — `HERMES_HOME/profiles/<name>` is what the verb creates or removes,
202/// and upstream derives that profiles root from this variable.
203fn hermes_home(homes: &HarnessHomes) -> PathBuf {
204 // `HarnessHomes::hermes` addresses `state.db`; HERMES_HOME is its parent,
205 // the same derivation the read side uses.
206 homes
207 .hermes
208 .parent()
209 .map_or_else(|| PathBuf::from("."), Path::to_path_buf)
210}
211
212/// Perform one mutation: translate to the harness's own verb, run it, then
213/// re-read the row through the ORCH-10 loader.
214pub 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 // ORC-13: the orchestrator's verb is not a CLI subprocess but its own
235 // package's operator door, so it branches before the command table.
236 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 // The harness's own store is the answer: re-read, never echo the request.
247 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
284// ---------------------------------------------------------------------------
285// The orchestrator — its own package's operator door (ORC-13)
286// ---------------------------------------------------------------------------
287
288/// One orchestrator profile mutation: through the package's door, then
289/// re-read through the ORCH-10 loader like every other harness's row.
290fn 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 Volter Harness 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; Volter Harness 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 // Profile verbs act on the HOME, not inside a profile; `default` is the
318 // root folder and always exists, so it is the door's context.
319 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 // The FOLDER is the answer, re-read through the same loader
332 // `profiles list` uses — never the door's echo of what it wrote.
333 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
369/// The profile the harness's own store holds under `name` after the verb.
370///
371/// Both harnesses normalize the id they are handed (Hermes lowercases in
372/// `normalize_profile_name`, OpenClaw in `normalizeAgentId`), so an exact miss
373/// falls back to a case-insensitive match and the ROW's own spelling is what
374/// the outcome reports.
375fn 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
393// ---------------------------------------------------------------------------
394// Hermes — `hermes profile create | delete` over HERMES_HOME
395// ---------------------------------------------------------------------------
396
397fn 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 Volter Harness 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 // The wrapper script lands in `~/.local/bin`, OUTSIDE the home the
427 // caller addressed; a programmatic create never installs an
428 // executable on the caller's PATH.
429 command.arg("--no-alias");
430 command.arg(name);
431 }
432 ProfileVerb::Delete => {
433 // `hermes profile delete` prompts for the profile name typed back
434 // unless `--yes` is passed; a prompt on a null stdin would hang.
435 command.args(["delete", "--yes", name]);
436 }
437 }
438 Ok(command)
439}
440
441// ---------------------------------------------------------------------------
442// OpenClaw — `openclaw agents add | delete` over its own state dir
443// ---------------------------------------------------------------------------
444
445fn 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 Volter Harness refuses rather than dropping `from`"
454 .into(),
455 ));
456 }
457 let mut command = HarnessCommand::new(harness_program(HarnessId::OPENCLAW)?);
458 // Point the spawned CLI at the SAME state the read side addresses, using
459 // openclaw's own environment contract (`OPENCLAW_STATE_DIR` names the
460 // state dir; `OPENCLAW_CONFIG_PATH` names the config file inside it).
461 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 // Without `--force` the verb prompts on a TTY and refuses off one
495 // ("Non-interactive session. Re-run with --force.").
496 command.args(["delete", name, "--force", "--json"]);
497 }
498 }
499 Ok(command)
500}