Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 70 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ ClawShell supports OAuth-based authentication as an alternative to static API ke

### 5. Seamless Integration

- **Drop-in Sidecar**: Deploys alongside OpenClaw without requiring re-install — the `clawshell onboard` command automatically configures OpenClaw to point at ClawShell's address and forwards all requests upstream.
- **Drop-in Sidecar**: The `clawshell onboard` wizard configures exactly one downstream LLM client per run — either OpenClaw or [Hermes Agent](https://github.com/NousResearch/hermes-agent) — to route all requests through ClawShell's proxy. See [Agent Target (pick one)](#agent-target-pick-one).
- **No External Dependencies**: Uses Unix file system permissions to protect secrets. No IdP, Vault, or external key management service required.

### 6. Ultra Lightweight and Scalable
Expand Down Expand Up @@ -140,12 +140,13 @@ cargo build --release --target x86_64-unknown-linux-musl

The `onboard` command is an interactive setup wizard that must be run with `sudo`. It:

1. Creates the `clawshell` system user.
2. Creates and secures `/etc/clawshell` (mode 700) and `/var/log/clawshell`.
3. Walks you through provider selection, API key entry, and virtual key generation.
4. Writes the ClawShell config to `/etc/clawshell/clawshell.toml`.
5. Updates your OpenClaw configuration to route through ClawShell.
6. Starts the ClawShell daemon.
1. Asks which downstream agent to wire through ClawShell — **OpenClaw** or **Hermes Agent** (exactly one per run).
2. Creates the `clawshell` system user.
3. Creates and secures `/etc/clawshell` (mode 700) and `/var/log/clawshell`.
4. Walks you through provider selection, API key entry, and virtual key generation.
5. Writes the ClawShell config to `/etc/clawshell/clawshell.toml`.
6. Wires the chosen agent through ClawShell (patches `~/.openclaw/openclaw.json` for OpenClaw, or runs `hermes config set` for Hermes).
7. Starts the ClawShell daemon.

```bash
sudo clawshell onboard
Expand Down Expand Up @@ -295,6 +296,68 @@ sudo clawshell migrate-config --config /etc/clawshell/clawshell.toml

See [`clawshell.example.toml`](clawshell.example.toml) for a full example.

### Agent Target (pick one)

`sudo clawshell onboard` begins with a single, mandatory choice:

```
=== Agent Target ===
? Which downstream agent should ClawShell wire through?
> OpenClaw
Hermes Agent
```

Each onboard run configures **exactly one** downstream client. There's no "also configure the other one" path — switching later means re-running `sudo clawshell onboard` and picking the other target. The prompt has no default preselection, so you pick explicitly every time.

#### OpenClaw target

When you pick OpenClaw, the wizard:

- Backs up `~/.openclaw/openclaw.json` (numbered `.bak` files, mode 000).
- Shells out to `openclaw config set` to patch three paths: `env.CLAWSHELL_API_KEY`, `agents.defaults.models.clawshell/<model>`, and `models.providers.clawshell`.
- Writes a `get-email-messages` skill bundle to `<openclaw_root>/skills/` when email integration is enabled.
- Offers to run `openclaw models set clawshell` and `openclaw gateway restart` at the end.

This is the historical onboarding flow and is unchanged by the target-selection rework.

#### Hermes Agent target

When you pick [Hermes Agent](https://github.com/NousResearch/hermes-agent), the wizard:

- Skips every OpenClaw step — `~/.openclaw/` is **not** touched.
- Writes a `get-email-messages` skill bundle to `~/.hermes/skills/` (owned by your invoking user, not root) when email integration is enabled. Hermes auto-discovers skills from that directory.
- Shells out to `hermes config set` to write:

| Key | Value |
|---|---|
| `model.provider` | `custom` |
| `model.base_url` | `http://<server_host>:<server_port>/v1` |
| `model.default` | the model ID you chose during onboard |
| `model.api_key` | your ClawShell **virtual** key (never the real upstream key) |

The `hermes` binary must be on your `PATH`. ClawShell drops root privileges before invoking it so writes land under your normal user account, not root's.

#### Manual Hermes configuration

If you'd rather skip the wizard's Hermes integration, run the equivalent commands from your user account (not root):

```bash
hermes config set model.provider custom
hermes config set model.base_url http://127.0.0.1:18790/v1
hermes config set model.default <your-model-id>
hermes config set model.api_key <your-clawshell-virtual-key>
```

Then verify with `hermes config show`.

#### Reverting Hermes

Hermes has no `config unset` subcommand. To detach Hermes from ClawShell, set the provider back to auto-detect and Hermes will pick another upstream based on the credentials it still has:

```bash
hermes config set model.provider auto
```

### Uninstall

```bash
Expand Down
266 changes: 266 additions & 0 deletions src/hermes_cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
//! Minimal runner for shelling out to the Hermes Agent CLI during onboarding.
//!
//! Mirrors the shape of `openclaw_cli::OpenclawRunner`: a trait so tests can
//! inject a fake, and a `Real*` implementation that drops root privileges
//! when clawshell itself was invoked with `sudo` (Hermes lives under the
//! user's `~/.hermes/`, not root's).
//!
//! This runner is deliberately narrow — it only knows how to invoke
//! `hermes config set <key> <value>` sequences built by
//! `crate::onboard::hermes_config_set_commands`.

use crate::onboard;
use std::error::Error;

#[cfg(unix)]
use std::os::unix::process::CommandExt;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HermesCommandOutput {
pub success: bool,
pub status_code: Option<i32>,
pub stdout: String,
pub stderr: String,
}

pub trait HermesRunner {
fn run(&mut self, args: &[String]) -> Result<HermesCommandOutput, String>;
}

#[derive(Debug, Default)]
pub struct RealHermesRunner;

impl HermesRunner for RealHermesRunner {
fn run(&mut self, args: &[String]) -> Result<HermesCommandOutput, String> {
let mut command = std::process::Command::new("hermes");
command.args(args.iter().map(String::as_str));
#[cfg(unix)]
{
if nix::unistd::geteuid().is_root() {
let (uid, gid) = resolve_non_root_ids()?;
command.uid(uid);
command.gid(gid);
let (username, home_dir) = resolve_non_root_user_env(uid)?;
command.env("HOME", home_dir);
command.env("USER", &username);
command.env("LOGNAME", &username);
}
}
let output = command
.output()
.map_err(|error| format!("failed to spawn `hermes`: {error}"))?;
Ok(HermesCommandOutput {
success: output.status.success(),
status_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
}

#[cfg(unix)]
fn resolve_non_root_ids() -> Result<(u32, u32), String> {
if let (Some(uid), Some(gid)) = (parse_env_u32("SUDO_UID"), parse_env_u32("SUDO_GID"))
&& uid > 0
&& gid > 0
{
return Ok((uid, gid));
}

if let Ok(user_name) = std::env::var("SUDO_USER")
&& !user_name.trim().is_empty()
&& user_name != "root"
{
match nix::unistd::User::from_name(&user_name) {
Ok(Some(user)) => {
let uid = user.uid.as_raw();
let gid = user.gid.as_raw();
if uid > 0 && gid > 0 {
return Ok((uid, gid));
}
}
Ok(None) => {}
Err(error) => {
return Err(format!(
"failed to resolve SUDO_USER '{user_name}' for non-root hermes execution: {error}"
));
}
}
}

Err(
"refusing to run `hermes` as root; please run clawshell with sudo from a regular user account."
.to_string(),
)
}

#[cfg(unix)]
fn parse_env_u32(name: &str) -> Option<u32> {
std::env::var(name).ok()?.parse::<u32>().ok()
}

#[cfg(unix)]
fn resolve_non_root_user_env(uid: u32) -> Result<(String, String), String> {
if let Ok(user_name) = std::env::var("SUDO_USER")
&& !user_name.trim().is_empty()
&& user_name != "root"
&& let Ok(Some(user)) = nix::unistd::User::from_name(user_name.trim())
&& user.uid.as_raw() == uid
{
let home = user.dir.to_string_lossy().to_string();
if !home.is_empty() {
return Ok((user.name, home));
}
}

match nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid)) {
Ok(Some(user)) => {
let home = user.dir.to_string_lossy().to_string();
if home.is_empty() {
return Err(format!(
"failed to resolve home directory for uid {uid} when running `hermes`."
));
}
Ok((user.name, home))
}
Ok(None) => Err(format!(
"failed to resolve account metadata for uid {uid} when running `hermes`."
)),
Err(error) => Err(format!(
"failed to resolve uid {uid} for non-root hermes execution: {error}"
)),
}
}

/// Apply the onboarding configuration to Hermes by running the sequence of
/// `hermes config set` commands built by `onboard::hermes_config_set_commands`.
/// Fails fast on the first non-zero exit.
pub fn apply_onboard_hermes_config<R: HermesRunner>(
runner: &mut R,
config: &onboard::OnboardConfig,
) -> Result<(), Box<dyn Error>> {
let commands = onboard::hermes_config_set_commands(config);
for args in commands {
let human = format!("hermes {}", args.join(" "));
let output = runner
.run(&args)
.map_err(|e| format!("failed to run `{human}`: {e}"))?;
if !output.success {
let status = output
.status_code
.map(|c| c.to_string())
.unwrap_or_else(|| "unknown".to_string());
let stderr = output.stderr.trim();
return Err(format!("`{human}` exited with status {status}: {stderr}").into());
}
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use crate::onboard::{OnboardAuthMethod, OnboardConfig, OnboardTarget};
use std::collections::VecDeque;

fn test_config() -> OnboardConfig {
OnboardConfig {
provider: "openai".to_string(),
model: "gpt-5.2".to_string(),
auth_method: OnboardAuthMethod::StaticKey,
real_api_key: "sk-real-key-123".to_string(),
virtual_api_key: "{clawshell-virtual-key-openai}".to_string(),
target: OnboardTarget::Hermes,
server_host: "127.0.0.1".to_string(),
server_port: 18790,
email: None,
}
}

#[derive(Default)]
struct FakeHermesRunner {
calls: Vec<Vec<String>>,
responses: VecDeque<Result<HermesCommandOutput, String>>,
}

impl HermesRunner for FakeHermesRunner {
fn run(&mut self, args: &[String]) -> Result<HermesCommandOutput, String> {
self.calls.push(args.to_vec());
self.responses.pop_front().unwrap_or_else(|| {
Ok(HermesCommandOutput {
success: true,
status_code: Some(0),
stdout: String::new(),
stderr: String::new(),
})
})
}
}

fn ok() -> HermesCommandOutput {
HermesCommandOutput {
success: true,
status_code: Some(0),
stdout: String::new(),
stderr: String::new(),
}
}

#[test]
fn applies_all_four_config_set_calls_in_order() {
let mut runner = FakeHermesRunner::default();
for _ in 0..4 {
runner.responses.push_back(Ok(ok()));
}

apply_onboard_hermes_config(&mut runner, &test_config()).unwrap();

assert_eq!(runner.calls.len(), 4);
assert_eq!(runner.calls[0][..3], ["config", "set", "model.provider"]);
assert_eq!(runner.calls[0][3], "custom");
assert_eq!(runner.calls[1][2], "model.base_url");
assert_eq!(runner.calls[1][3], "http://127.0.0.1:18790/v1");
assert_eq!(runner.calls[2][2], "model.default");
assert_eq!(runner.calls[3][2], "model.api_key");
assert_eq!(runner.calls[3][3], "{clawshell-virtual-key-openai}");
}

#[test]
fn stops_on_first_failure_and_reports_stderr() {
let mut runner = FakeHermesRunner::default();
runner.responses.push_back(Ok(ok()));
runner.responses.push_back(Ok(HermesCommandOutput {
success: false,
status_code: Some(2),
stdout: String::new(),
stderr: "invalid key".to_string(),
}));

let err =
apply_onboard_hermes_config(&mut runner, &test_config()).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("status 2"), "msg: {msg}");
assert!(msg.contains("invalid key"), "msg: {msg}");
assert!(
msg.contains("hermes config set model.base_url"),
"msg: {msg}"
);

// First two calls ran; last two were never attempted.
assert_eq!(runner.calls.len(), 2);
}

#[test]
fn propagates_spawn_errors() {
let mut runner = FakeHermesRunner::default();
runner
.responses
.push_back(Err("no such binary: hermes".to_string()));

let err =
apply_onboard_hermes_config(&mut runner, &test_config()).expect_err("should fail");
let msg = err.to_string();
assert!(msg.contains("failed to run"), "msg: {msg}");
assert!(msg.contains("no such binary"), "msg: {msg}");
}
}
Loading
Loading