Skip to main content

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}