feat: add hermes runtime integration
This commit is contained in:
@@ -57,6 +57,8 @@ Thumbs.db
|
||||
*.temp
|
||||
/test_*
|
||||
|
||||
|
||||
.codex-logs/
|
||||
dev_docs/
|
||||
devplan.md
|
||||
AGENTS.md
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<a href="#product-tour">Produktueberblick</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime-Integrationen</a> |
|
||||
<a href="#resource-management">Ressourcenverwaltung</a> |
|
||||
<a href="#get-started">Erste Schritte</a>
|
||||
</p>
|
||||
@@ -53,6 +54,7 @@
|
||||
|
||||
Wichtige aktuelle Produkt- und Dokumentations-Updates.
|
||||
|
||||
- [2026-04-29] Hermes-Runtime-Integration hinzugefuegt, inklusive Webtop-basierter Instanzbereitstellung, Agent-Control-Plane-Registrierung, AI-Gateway-Injection, channel- und skill-Bootstrap sowie `.hermes` Import/Export. Siehe [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md).
|
||||
- [2026-04-08] Skill-Verwaltung und Skill-Scanning wurden der Plattform hinzugefuegt. Details siehe [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52).
|
||||
- [2026-03-26] Die AI-Gateway-Dokumentation wurde erweitert und deckt nun Modell-Governance, Audit und Trace, Kostenrechnung sowie Risikokontrolle genauer ab. Siehe [AI Gateway Guide](./docs/aigateway.md).
|
||||
- [2026-03-20] ClawManager hat sich zu einer breiteren Control Plane fuer AI-Agent-Workspaces entwickelt, mit staerkerer Runtime-Steuerung, wiederverwendbaren Ressourcen und Security-Scanning-Workflows.
|
||||
@@ -84,6 +86,21 @@ Es eignet sich besonders fuer:
|
||||
- Betriebsteams, die Runtime-Sichtbarkeit, Command-Dispatch und Desired-State-Kontrolle benoetigen
|
||||
- Entwicklungsteams, die Agent-Workspaces ueber wiederverwendbare Ressourcen statt ueber manuelle Konfiguration bereitstellen wollen
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime-Integrationen
|
||||
|
||||
ClawManager unterstuetzt jetzt OpenClaw und Hermes als verwaltete Runtime-Integrationen. Hermes wird auf der Webtop-Basis integriert: Desktop-Zugriff laeuft ueber Port `3001`, persistente Daten liegen unter `/config/.hermes`, und der eingebettete Hermes agent verbindet sich mit ClawManager, um Runtime-Status, Health, Metriken, Commands, channel-Injection, skill-Injection und Skill-Package-Synchronisierung zu verarbeiten.
|
||||
|
||||
Hermes-Instanzen erhalten die gleichen plattformverwalteten Faehigkeiten wie OpenClaw-artige Workspaces:
|
||||
|
||||
- AI-Gateway-Injection ueber OpenAI-kompatible Umgebungsvariablen
|
||||
- Agent-Bootstrap und sessionbasierte Registrierung
|
||||
- channel- und skill-Bootstrap-Payloads fuer Runtime-seitige Konfiguration
|
||||
- `install_skill`, `collect_skill_package`, Health-Check- und System-Info-Commands
|
||||
- `.hermes` Workspace-Import und -Export fuer persistente Runtime-Daten
|
||||
|
||||
Runtime-Autoren koennen dem [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md), dem [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md) und der [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md) folgen, um kompatible Agents zu bauen.
|
||||
|
||||
<a id="get-started"></a>
|
||||
## Erste Schritte
|
||||
|
||||
@@ -191,6 +208,9 @@ Siehe [Developer Guide (English)](./docs/developer-guide.md).
|
||||
- [AI Gateway Guide (English)](./docs/aigateway.md)
|
||||
- [Security / Skill Scanner Guide (English)](./docs/security-skill-scanner.md)
|
||||
- [Resource Management Guide (English)](./docs/resource-management.md)
|
||||
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
|
||||
- [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)
|
||||
- [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)
|
||||
- [Developer Guide (English)](./docs/developer-guide.md)
|
||||
|
||||
## Lizenz
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<a href="#product-tour">製品紹介</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime 連携</a> |
|
||||
<a href="#resource-management">リソース管理</a> |
|
||||
<a href="#get-started">はじめに</a>
|
||||
</p>
|
||||
@@ -53,6 +54,7 @@
|
||||
|
||||
最近の重要な製品アップデートとドキュメント更新です。
|
||||
|
||||
- [2026-04-29] Hermes Runtime 連携を追加しました。Webtop ベースのインスタンス作成、Agent Control Plane 登録、AI Gateway 注入、channel と skill のブートストラップ、`.hermes` のインポート/エクスポートに対応しています。詳しくは [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md) を参照してください。
|
||||
- [2026-04-08] プラットフォームに Skill 管理と Skill スキャンのワークフローを追加しました。詳細は [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52) を参照してください。
|
||||
- [2026-03-26] AI Gateway ドキュメントを更新し、モデルガバナンス、監査とトレース、コスト計算、リスク制御の説明を強化しました。詳しくは [AI Gateway Guide](./docs/aigateway.md) を参照してください。
|
||||
- [2026-03-20] ClawManager は、AI エージェントワークスペース向けのより広いコントロールプレーンへと進化し、ランタイム制御、再利用可能なリソース、安全スキャンのワークフローを強化しました。
|
||||
@@ -84,6 +86,21 @@ ClawManager は、AI エージェントインスタンスの運用を Kubernetes
|
||||
- ランタイムの可観測性、コマンド配布、 desired state 管理が必要な運用チーム
|
||||
- 手作業の設定ではなく、再利用可能なリソースで Agent ワークスペースを届けたい開発チーム
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime 連携
|
||||
|
||||
ClawManager は、OpenClaw と Hermes の両方を管理対象 Runtime としてサポートします。Hermes は Webtop ベースラインで統合されます。デスクトップアクセスは `3001` ポートを使い、永続データは `/config/.hermes` に保存され、内蔵 Hermes agent が ClawManager に接続してランタイム状態、ヘルス、メトリクスを報告し、コマンド、channel 注入、skill 注入、skill パッケージ同期を処理します。
|
||||
|
||||
Hermes インスタンスでは、OpenClaw スタイルのワークスペースと同じプラットフォーム管理機能を利用できます。
|
||||
|
||||
- OpenAI 互換環境変数による AI Gateway 注入
|
||||
- bootstrap token と session token による Agent 登録
|
||||
- Runtime 側設定向けの channel と skill ブートストラップ payload
|
||||
- `install_skill`、`collect_skill_package`、ヘルスチェック、システム情報収集コマンド
|
||||
- 永続 Runtime データを移行するための `.hermes` ワークスペースのインポート/エクスポート
|
||||
|
||||
Runtime 開発者は、[Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)、[Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)、[Skill Content MD5 Spec](./docs/skill-content-md5-spec.md) を参照して互換 agent を実装できます。
|
||||
|
||||
<a id="get-started"></a>
|
||||
## はじめに
|
||||
|
||||
@@ -191,6 +208,9 @@ ClawManager は、React フロントエンド、Go バックエンド、状態
|
||||
- [AI Gateway Guide (English)](./docs/aigateway.md)
|
||||
- [Security / Skill Scanner Guide (English)](./docs/security-skill-scanner.md)
|
||||
- [Resource Management Guide (English)](./docs/resource-management.md)
|
||||
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
|
||||
- [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)
|
||||
- [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)
|
||||
- [Developer Guide (English)](./docs/developer-guide.md)
|
||||
|
||||
## ライセンス
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<a href="#product-tour">제품 소개</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime 연동</a> |
|
||||
<a href="#resource-management">리소스 관리</a> |
|
||||
<a href="#get-started">시작하기</a>
|
||||
</p>
|
||||
@@ -53,6 +54,7 @@
|
||||
|
||||
최근의 중요한 제품 및 문서 업데이트입니다.
|
||||
|
||||
- [2026-04-29] Hermes Runtime 연동을 추가했습니다. Webtop 기반 인스턴스 생성, Agent Control Plane 등록, AI Gateway 주입, channel 및 skill 부트스트랩, `.hermes` 가져오기/내보내기 흐름을 지원합니다. 자세한 내용은 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)를 참고하세요.
|
||||
- [2026-04-08] 플랫폼에 Skill 관리와 Skill 스캔 워크플로우가 추가되었습니다. 자세한 내용은 [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52)를 참고하세요.
|
||||
- [2026-03-26] AI Gateway 문서를 업데이트하여 모델 거버넌스, 감사와 추적, 비용 계산, 리스크 제어 설명을 강화했습니다. 자세한 내용은 [AI Gateway Guide](./docs/aigateway.md)를 참고하세요.
|
||||
- [2026-03-20] ClawManager는 AI Agent 워크스페이스를 위한 더 넓은 컨트롤 플레인으로 발전했으며, 런타임 제어, 재사용 가능한 리소스, 보안 스캔 워크플로우가 강화되었습니다.
|
||||
@@ -84,6 +86,21 @@ ClawManager는 AI Agent 인스턴스 운영을 Kubernetes 위로 확장하고,
|
||||
- 런타임 가시성, 명령 배포, desired state 제어가 필요한 운영 팀
|
||||
- 수동 설정 대신 재사용 가능한 리소스로 Agent 워크스페이스를 제공하고 싶은 개발 팀
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime 연동
|
||||
|
||||
ClawManager는 이제 OpenClaw와 Hermes를 모두 관리형 Runtime 연동으로 지원합니다. Hermes는 Webtop 기준으로 통합됩니다. 데스크톱 접근은 `3001` 포트를 사용하고, 영구 데이터는 `/config/.hermes`에 저장되며, 내장 Hermes agent가 ClawManager에 연결해 런타임 상태, 헬스, 메트릭을 보고하고 명령, channel 주입, skill 주입, skill 패키지 동기화를 처리합니다.
|
||||
|
||||
Hermes 인스턴스는 OpenClaw 스타일 워크스페이스와 동일한 플랫폼 관리 기능을 사용할 수 있습니다.
|
||||
|
||||
- OpenAI 호환 환경 변수를 통한 AI Gateway 주입
|
||||
- bootstrap token 및 session token 기반 Agent 등록
|
||||
- Runtime 측 설정을 위한 channel 및 skill 부트스트랩 payload
|
||||
- `install_skill`, `collect_skill_package`, 헬스 체크, 시스템 정보 수집 명령
|
||||
- 영구 Runtime 데이터 이전을 위한 `.hermes` 워크스페이스 가져오기/내보내기
|
||||
|
||||
Runtime 개발자는 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md), [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md), [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)를 참고해 호환 agent를 구현할 수 있습니다.
|
||||
|
||||
<a id="get-started"></a>
|
||||
## 시작하기
|
||||
|
||||
@@ -191,6 +208,9 @@ ClawManager는 React 프런트엔드, Go 백엔드, 상태 저장용 MySQL, 그
|
||||
- [AI Gateway Guide (English)](./docs/aigateway.md)
|
||||
- [Security / Skill Scanner Guide (English)](./docs/security-skill-scanner.md)
|
||||
- [Resource Management Guide (English)](./docs/resource-management.md)
|
||||
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
|
||||
- [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)
|
||||
- [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)
|
||||
- [Developer Guide (English)](./docs/developer-guide.md)
|
||||
|
||||
## 라이선스
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<a href="#product-tour">Explore the Product</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime Integrations</a> |
|
||||
<a href="#resource-management">Resource Management</a> |
|
||||
<a href="#get-started">Get Started</a>
|
||||
</p>
|
||||
@@ -53,6 +54,7 @@
|
||||
|
||||
Recent highlights from the latest product and documentation updates.
|
||||
|
||||
- [2026-04-29] Added Hermes runtime integration support, including Webtop-based instance provisioning, Agent Control Plane registration, AI Gateway injection, channel and skill bootstrap, and `.hermes` import/export workflows. See the [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md).
|
||||
- [2026-04-08] Added skill management and skill scanning workflows to the platform, via [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52).
|
||||
- [2026-03-26] AI Gateway documentation was refreshed with stronger coverage for model governance, audit and trace, cost accounting, and risk control. See the [AI Gateway Guide](./docs/aigateway.md).
|
||||
- [2026-03-20] ClawManager evolved into a broader control plane for AI agent workspaces, with stronger runtime control, reusable resources, and security scanning workflows.
|
||||
@@ -84,6 +86,21 @@ It is designed for:
|
||||
- operators who need runtime visibility, command dispatch, and desired-state control
|
||||
- builders who want governed AI access and reusable resource injection instead of manual per-instance setup
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime Integrations
|
||||
|
||||
ClawManager now supports both OpenClaw and Hermes as managed runtime integrations. Hermes is integrated on the Webtop baseline: desktop access uses port `3001`, persistent data lives under `/config/.hermes`, and the embedded Hermes agent connects back to ClawManager for runtime status, health, metrics, commands, channel injection, skill injection, and skill package synchronization.
|
||||
|
||||
Hermes instances receive the same platform-managed capabilities as OpenClaw-style workspaces:
|
||||
|
||||
- AI Gateway environment injection through OpenAI-compatible variables
|
||||
- Agent bootstrap and session-based registration
|
||||
- channel and skill bootstrap payloads for runtime-side configuration
|
||||
- `install_skill`, `collect_skill_package`, health check, and system info commands
|
||||
- `.hermes` workspace import and export for persistent runtime data
|
||||
|
||||
Runtime authors can follow the [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md), the [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md), and the [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md) to build compatible agents.
|
||||
|
||||
## Get Started
|
||||
|
||||
ClawManager now has clearer entry points for both full Kubernetes deployments and lightweight cluster setups. If you want to evaluate the product quickly, start with the guide that matches your environment and then follow the first-use walkthrough.
|
||||
@@ -187,6 +204,9 @@ See the [Developer Guide](./docs/developer-guide.md).
|
||||
- [AI Gateway Guide](./docs/aigateway.md)
|
||||
- [Security / Skill Scanner Guide](./docs/security-skill-scanner.md)
|
||||
- [Resource Management Guide](./docs/resource-management.md)
|
||||
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
|
||||
- [Generic Runtime Agent Integration Guide](./docs/runtime-agent-integration-guide.md)
|
||||
- [Skill Content MD5 Spec](./docs/skill-content-md5-spec.md)
|
||||
- [Developer Guide](./docs/developer-guide.md)
|
||||
|
||||
## License
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<a href="#product-tour">了解产品</a> |
|
||||
<a href="#ai-gateway">AI Gateway</a> |
|
||||
<a href="#agent-control-plane">Agent Control Plane</a> |
|
||||
<a href="#runtime-integrations">Runtime 接入</a> |
|
||||
<a href="#resource-management">资源管理</a> |
|
||||
<a href="#get-started">快速开始</a>
|
||||
</p>
|
||||
@@ -53,6 +54,7 @@
|
||||
|
||||
这里展示最近的重要产品与文档更新。
|
||||
|
||||
- [2026-04-29] 新增 Hermes Runtime 接入支持,覆盖基于 Webtop 的实例创建、Agent Control Plane 注册、AI Gateway 注入、channel 与 skill 引导注入,以及 `.hermes` 导入导出流程。见 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)。
|
||||
- [2026-04-08] 平台新增了 Skill 管理与 Skill 扫描工作流,见 [Merged PR #52](https://github.com/Yuan-lab-LLM/ClawManager/pull/52)。
|
||||
- [2026-03-26] AI Gateway 文档已更新,补充了模型治理、审计追踪、成本核算与风险控制能力,见 [AI Gateway Guide](./docs/aigateway.md)。
|
||||
- [2026-03-20] ClawManager 进一步演进为面向 AI Agent 工作空间的控制平面,强化了运行时控制、可复用资源与安全扫描工作流。
|
||||
@@ -84,6 +86,21 @@ ClawManager 将 AI Agent 实例的运行、治理与运维能力带到 Kubernete
|
||||
- 需要运行时可观测性、命令下发与期望态控制的运维团队
|
||||
- 希望以可复用资源而不是手工配置方式交付 Agent 工作空间的开发团队
|
||||
|
||||
<a id="runtime-integrations"></a>
|
||||
## Runtime 接入
|
||||
|
||||
ClawManager 现在同时支持 OpenClaw 与 Hermes 作为受管 Runtime 接入。Hermes 按 Webtop 基线集成:桌面访问使用 `3001` 端口,持久化数据目录为 `/config/.hermes`,镜像内置的 Hermes agent 回连 ClawManager,上报运行时状态、健康信息、监测数据,并接收命令、channel 注入、skill 注入与 skill 包同步。
|
||||
|
||||
Hermes 实例可以获得与 OpenClaw 风格工作空间一致的平台能力:
|
||||
|
||||
- 通过 OpenAI 兼容环境变量注入 AI Gateway
|
||||
- 基于 bootstrap token 与 session token 的 Agent 注册
|
||||
- 用于 Runtime 侧配置的 channel 与 skill 引导 payload
|
||||
- `install_skill`、`collect_skill_package`、健康检查与系统信息采集命令
|
||||
- `.hermes` 工作空间导入导出,用于持久化 Runtime 数据迁移
|
||||
|
||||
Runtime 开发方可以参考 [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)、[通用 Runtime Agent 接入指南](./docs/runtime-agent-integration-guide.md) 与 [Skill Content MD5 规范](./docs/skill-content-md5-spec.md) 实现兼容 agent。
|
||||
|
||||
<a id="get-started"></a>
|
||||
## 快速开始
|
||||
|
||||
@@ -191,6 +208,9 @@ ClawManager 是一个 Kubernetes 原生平台,包含 React 前端、Go 后端
|
||||
- [AI Gateway Guide(英文)](./docs/aigateway.md)
|
||||
- [Security / Skill Scanner Guide(英文)](./docs/security-skill-scanner.md)
|
||||
- [Resource Management Guide(英文)](./docs/resource-management.md)
|
||||
- [Hermes Runtime Guide](./docs/hermes-runtime-agent-development.md)
|
||||
- [通用 Runtime Agent 接入指南](./docs/runtime-agent-integration-guide.md)
|
||||
- [Skill Content MD5 规范](./docs/skill-content-md5-spec.md)
|
||||
- [Developer Guide(英文)](./docs/developer-guide.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
@@ -199,6 +199,8 @@ func main() {
|
||||
instances.POST("/:id/sync", instanceHandler.ForceSync)
|
||||
instances.GET("/:id/openclaw/export", instanceHandler.ExportOpenClaw)
|
||||
instances.POST("/:id/openclaw/import", instanceHandler.ImportOpenClaw)
|
||||
instances.GET("/:id/hermes/export", instanceHandler.ExportHermes)
|
||||
instances.POST("/:id/hermes/import", instanceHandler.ImportHermes)
|
||||
instances.GET("/:id/skills", skillHandler.ListInstanceSkills)
|
||||
instances.POST("/:id/skills", skillHandler.AttachSkillToInstance)
|
||||
instances.DELETE("/:id/skills/:skillId", skillHandler.RemoveSkillFromInstance)
|
||||
|
||||
@@ -43,7 +43,7 @@ data:
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu',
|
||||
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu',
|
||||
status ENUM('creating', 'running', 'stopped', 'error', 'deleting') DEFAULT 'creating',
|
||||
cpu_cores DECIMAL(10,2) NOT NULL,
|
||||
memory_gb INT NOT NULL,
|
||||
@@ -170,7 +170,7 @@ data:
|
||||
002_add_webtop_instance_type.sql: |
|
||||
USE clawreef;
|
||||
ALTER TABLE instances
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu';
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
|
||||
003_add_system_image_settings.sql: |
|
||||
USE clawreef;
|
||||
CREATE TABLE IF NOT EXISTS system_image_settings (
|
||||
@@ -192,9 +192,12 @@ data:
|
||||
005_update_openclaw_default_image.sql: |
|
||||
USE clawreef;
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest'
|
||||
WHERE instance_type = 'openclaw'
|
||||
AND image = 'ericpearlee/openclaw:v2026.3.24';
|
||||
AND image IN (
|
||||
'ericpearlee/openclaw:v2026.3.24',
|
||||
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
);
|
||||
006_add_openclaw_config_center.sql: |
|
||||
USE clawreef;
|
||||
SET @openclaw_snapshot_column_exists = (
|
||||
@@ -279,6 +282,27 @@ data:
|
||||
INDEX idx_openclaw_snapshot_instance (instance_id),
|
||||
INDEX idx_openclaw_snapshot_bundle (bundle_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
011_add_hermes_instance_type.sql: |
|
||||
USE clawreef;
|
||||
ALTER TABLE instances
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
|
||||
012_update_agents_runtime_default_images.sql: |
|
||||
USE clawreef;
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest'
|
||||
WHERE instance_type = 'openclaw'
|
||||
AND image IN (
|
||||
'ericpearlee/openclaw:v2026.3.24',
|
||||
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
);
|
||||
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest'
|
||||
WHERE instance_type = 'hermes'
|
||||
AND image IN (
|
||||
'registry.example.com/hermes-webtop:latest',
|
||||
'lscr.io/linuxserver/webtop:ubuntu-xfce'
|
||||
);
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS instances (
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu',
|
||||
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu',
|
||||
status ENUM('creating', 'running', 'stopped', 'error', 'deleting') DEFAULT 'creating',
|
||||
cpu_cores INT NOT NULL,
|
||||
memory_gb INT NOT NULL,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ALTER TABLE instances
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu';
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest'
|
||||
WHERE instance_type = 'openclaw'
|
||||
AND image = 'ericpearlee/openclaw:v2026.3.24';
|
||||
AND image IN (
|
||||
'ericpearlee/openclaw:v2026.3.24',
|
||||
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE instances
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
|
||||
@@ -0,0 +1,15 @@
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest'
|
||||
WHERE instance_type = 'openclaw'
|
||||
AND image IN (
|
||||
'ericpearlee/openclaw:v2026.3.24',
|
||||
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
);
|
||||
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest'
|
||||
WHERE instance_type = 'hermes'
|
||||
AND image IN (
|
||||
'registry.example.com/hermes-webtop:latest',
|
||||
'lscr.io/linuxserver/webtop:ubuntu-xfce'
|
||||
);
|
||||
@@ -85,7 +85,7 @@ type PublishConfigRevisionRequest struct {
|
||||
type CreateInstanceRequest struct {
|
||||
Name string `json:"name" binding:"required,min=3,max=50"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=openclaw ubuntu debian centos custom webtop"`
|
||||
Type string `json:"type" binding:"required,oneof=openclaw ubuntu debian centos custom webtop hermes"`
|
||||
CPUCores float64 `json:"cpu_cores" binding:"required,min=0.1,max=32"`
|
||||
MemoryGB int `json:"memory_gb" binding:"required,min=1,max=128"`
|
||||
DiskGB int `json:"disk_gb" binding:"required,min=10,max=1000"`
|
||||
@@ -595,8 +595,8 @@ func (h *InstanceHandler) PublishConfigRevision(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(instance.Type, "openclaw") {
|
||||
utils.Error(c, http.StatusBadRequest, "Only openclaw instances support config revisions")
|
||||
if !strings.EqualFold(instance.Type, "openclaw") && !strings.EqualFold(instance.Type, "hermes") {
|
||||
utils.Error(c, http.StatusBadRequest, "Only managed runtime instances support config revisions")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -929,6 +929,44 @@ func (h *InstanceHandler) ExportOpenClaw(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/gzip", archive)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) ExportHermes(c *gin.Context) {
|
||||
instance, ok := h.requireOwnedInstance(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if instance.Type != "hermes" {
|
||||
utils.Error(c, http.StatusBadRequest, "hermes import/export is only available for hermes instances")
|
||||
return
|
||||
}
|
||||
|
||||
if instance.Status != "running" {
|
||||
utils.Error(c, http.StatusBadRequest, "instance must be running to export .hermes")
|
||||
return
|
||||
}
|
||||
|
||||
archive, err := h.openClawTransferService.ExportHermes(c.Request.Context(), instance.UserID, instance.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrHermesWorkspaceMissing) {
|
||||
utils.Error(c, http.StatusNotFound, "hermes workspace is empty or missing")
|
||||
return
|
||||
}
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(archive) < openclawMinArchiveBytes {
|
||||
utils.Error(c, http.StatusInternalServerError, "export produced an empty archive")
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s.hermes.tar.gz", sanitizeDownloadName(instance.Name, "hermes-workspace"))
|
||||
c.Header("Content-Type", "application/gzip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
c.Header("Content-Length", strconv.Itoa(len(archive)))
|
||||
c.Data(http.StatusOK, "application/gzip", archive)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) ImportOpenClaw(c *gin.Context) {
|
||||
instance, ok := h.requireOwnedInstance(c)
|
||||
if !ok {
|
||||
@@ -984,6 +1022,57 @@ func (h *InstanceHandler) ImportOpenClaw(c *gin.Context) {
|
||||
utils.Success(c, http.StatusOK, "OpenClaw workspace imported successfully", nil)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) ImportHermes(c *gin.Context) {
|
||||
instance, ok := h.requireOwnedInstance(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if instance.Type != "hermes" {
|
||||
utils.Error(c, http.StatusBadRequest, "hermes import/export is only available for hermes instances")
|
||||
return
|
||||
}
|
||||
|
||||
if instance.Status != "running" {
|
||||
utils.Error(c, http.StatusBadRequest, "instance must be running to import .hermes")
|
||||
return
|
||||
}
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, openclawMaxUploadBytes)
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
utils.Error(c, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("archive too large; maximum upload size is %d MiB", openclawMaxUploadBytes>>20))
|
||||
return
|
||||
}
|
||||
utils.Error(c, http.StatusBadRequest, "file is required")
|
||||
return
|
||||
}
|
||||
|
||||
if fileHeader.Size > openclawMaxUploadBytes {
|
||||
utils.Error(c, http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("archive too large; maximum upload size is %d MiB", openclawMaxUploadBytes>>20))
|
||||
return
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := h.openClawTransferService.ImportHermes(c.Request.Context(), instance.UserID, instance.ID, io.LimitReader(file, openclawMaxUploadBytes)); err != nil {
|
||||
utils.HandleError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "Hermes workspace imported successfully", nil)
|
||||
}
|
||||
|
||||
func (h *InstanceHandler) requireOwnedInstance(c *gin.Context) (*models.Instance, bool) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
@@ -1013,9 +1102,12 @@ func (h *InstanceHandler) requireOwnedInstance(c *gin.Context) (*models.Instance
|
||||
return instance, true
|
||||
}
|
||||
|
||||
func sanitizeDownloadName(name string) string {
|
||||
func sanitizeDownloadName(name string, fallback ...string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
if len(fallback) > 0 && strings.TrimSpace(fallback[0]) != "" {
|
||||
return strings.TrimSpace(fallback[0])
|
||||
}
|
||||
return "openclaw-workspace"
|
||||
}
|
||||
|
||||
|
||||
@@ -116,8 +116,8 @@ func (s *instanceAgentService) Register(bootstrapToken string, req AgentRegister
|
||||
if instance == nil || instance.ID != req.InstanceID {
|
||||
return nil, fmt.Errorf("invalid agent bootstrap token")
|
||||
}
|
||||
if !strings.EqualFold(instance.Type, "openclaw") {
|
||||
return nil, fmt.Errorf("agent registration is only supported for openclaw instances")
|
||||
if !supportsManagedRuntimeIntegration(instance.Type) {
|
||||
return nil, fmt.Errorf("agent registration is only supported for openclaw or hermes instances")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
@@ -511,7 +511,7 @@ func (s *InstanceProxyService) resolveTargetScheme(instanceType string, websocke
|
||||
|
||||
func usesHTTPSUpstream(instanceType string) bool {
|
||||
switch instanceType {
|
||||
case "ubuntu", "webtop", "openclaw":
|
||||
case "ubuntu", "webtop", "hermes", "openclaw":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -59,6 +59,14 @@ func buildRuntimeConfig(instanceType, osType, osVersion string, registry, tag *s
|
||||
"TITLE": "ClawManager Webtop",
|
||||
"SUBFOLDER": "/",
|
||||
}
|
||||
case "hermes":
|
||||
config.Image = defaultSystemImageSettings["hermes"]
|
||||
config.Port = 3001
|
||||
config.MountPath = "/config/.hermes"
|
||||
config.Env = map[string]string{
|
||||
"TITLE": "Hermes Runtime",
|
||||
"SUBFOLDER": "/",
|
||||
}
|
||||
case "openclaw":
|
||||
config.MountPath = "/config"
|
||||
if (registry == nil || strings.TrimSpace(*registry) == "") && (tag == nil || strings.TrimSpace(*tag) == "") {
|
||||
@@ -82,7 +90,7 @@ func buildRuntimeConfig(instanceType, osType, osVersion string, registry, tag *s
|
||||
|
||||
func defaultPortForInstanceType(instanceType string) int32 {
|
||||
switch instanceType {
|
||||
case "ubuntu", "webtop":
|
||||
case "ubuntu", "webtop", "hermes":
|
||||
return 3001
|
||||
default:
|
||||
return 3001
|
||||
@@ -93,6 +101,8 @@ func defaultMountPathForInstanceType(instanceType string) string {
|
||||
switch instanceType {
|
||||
case "ubuntu", "webtop", "openclaw":
|
||||
return "/config"
|
||||
case "hermes":
|
||||
return "/config/.hermes"
|
||||
default:
|
||||
return "/home/user/data"
|
||||
}
|
||||
@@ -105,6 +115,11 @@ func defaultEnvForInstanceType(instanceType string) map[string]string {
|
||||
"TITLE": "ClawManager Desktop",
|
||||
"SUBFOLDER": "/",
|
||||
}
|
||||
case "hermes":
|
||||
return map[string]string{
|
||||
"TITLE": "Hermes Runtime",
|
||||
"SUBFOLDER": "/",
|
||||
}
|
||||
default:
|
||||
return map[string]string{}
|
||||
}
|
||||
@@ -135,7 +150,7 @@ func withInstanceProxyEnv(instanceType string, instanceID int, env map[string]st
|
||||
|
||||
func usesWebtopImage(instanceType string) bool {
|
||||
switch instanceType {
|
||||
case "ubuntu", "webtop", "openclaw":
|
||||
case "ubuntu", "webtop", "hermes", "openclaw":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -143,13 +158,9 @@ func usesWebtopImage(instanceType string) bool {
|
||||
}
|
||||
|
||||
// defaultImagePullPolicy returns the image pull policy to use for instance
|
||||
// pods. Operators can override the default ("IfNotPresent") by setting the
|
||||
// IMAGE_PULL_POLICY environment variable to "Always", "Never", or
|
||||
// "IfNotPresent".
|
||||
// pods. Managed runtime instances always use IfNotPresent so local caches can
|
||||
// be reused without forcing a remote registry pull during create/start flows.
|
||||
func defaultImagePullPolicy() string {
|
||||
if v := strings.TrimSpace(os.Getenv("IMAGE_PULL_POLICY")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "IfNotPresent"
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package services
|
||||
import "testing"
|
||||
|
||||
func TestDefaultImagePullPolicy_Default(t *testing.T) {
|
||||
// With no env var set, should return "IfNotPresent".
|
||||
t.Setenv("IMAGE_PULL_POLICY", "")
|
||||
got := defaultImagePullPolicy()
|
||||
if got != "IfNotPresent" {
|
||||
@@ -11,32 +10,31 @@ func TestDefaultImagePullPolicy_Default(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultImagePullPolicy_EnvOverride(t *testing.T) {
|
||||
cases := []struct {
|
||||
env string
|
||||
want string
|
||||
}{
|
||||
{"Always", "Always"},
|
||||
{"Never", "Never"},
|
||||
{"IfNotPresent", "IfNotPresent"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.env, func(t *testing.T) {
|
||||
t.Setenv("IMAGE_PULL_POLICY", tc.env)
|
||||
func TestDefaultImagePullPolicy_IgnoresEnvOverride(t *testing.T) {
|
||||
for _, envValue := range []string{"Always", "Never", "IfNotPresent", " "} {
|
||||
t.Run(envValue, func(t *testing.T) {
|
||||
t.Setenv("IMAGE_PULL_POLICY", envValue)
|
||||
got := defaultImagePullPolicy()
|
||||
if got != tc.want {
|
||||
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||
if got != "IfNotPresent" {
|
||||
t.Fatalf("expected IfNotPresent, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultImagePullPolicy_WhitespaceOnly(t *testing.T) {
|
||||
// Whitespace-only env var should fall back to default.
|
||||
t.Setenv("IMAGE_PULL_POLICY", " ")
|
||||
got := defaultImagePullPolicy()
|
||||
if got != "IfNotPresent" {
|
||||
t.Fatalf("expected IfNotPresent, got %q", got)
|
||||
func TestBuildRuntimeConfig_HermesUsesWebtopDefaults(t *testing.T) {
|
||||
config := buildRuntimeConfig("hermes", "hermes", "latest", nil, nil)
|
||||
|
||||
if config.Port != 3001 {
|
||||
t.Fatalf("expected Hermes port 3001, got %d", config.Port)
|
||||
}
|
||||
if config.MountPath != "/config/.hermes" {
|
||||
t.Fatalf("expected Hermes mount path /config/.hermes, got %q", config.MountPath)
|
||||
}
|
||||
if config.Env["SUBFOLDER"] != "/" {
|
||||
t.Fatalf("expected Hermes default SUBFOLDER /, got %q", config.Env["SUBFOLDER"])
|
||||
}
|
||||
if !usesWebtopImage("hermes") {
|
||||
t.Fatalf("expected Hermes to use webtop proxy behavior")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ type InstanceService interface {
|
||||
type CreateInstanceRequest struct {
|
||||
Name string `json:"name" validate:"required,min=3,max=50"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" validate:"required,oneof=openclaw ubuntu debian centos custom webtop"`
|
||||
Type string `json:"type" validate:"required,oneof=openclaw ubuntu debian centos custom webtop hermes"`
|
||||
CPUCores float64 `json:"cpu_cores" validate:"required,min=0.1,max=32"`
|
||||
MemoryGB int `json:"memory_gb" validate:"required,min=1,max=128"`
|
||||
DiskGB int `json:"disk_gb" validate:"required,min=10,max=1000"`
|
||||
@@ -252,25 +252,25 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models
|
||||
|
||||
var bootstrapSnapshot *models.OpenClawInjectionSnapshot
|
||||
var bootstrapSecretName string
|
||||
if strings.EqualFold(instance.Type, "openclaw") && s.openClawConfigService != nil && req.OpenClawConfigPlan != nil && hasOpenClawConfigSelections(*req.OpenClawConfigPlan) {
|
||||
if supportsRuntimeConfigInjection(instance.Type) && s.openClawConfigService != nil && req.OpenClawConfigPlan != nil && hasOpenClawConfigSelections(*req.OpenClawConfigPlan) {
|
||||
bootstrapSnapshot, err = s.openClawConfigService.CreateSnapshotForInstance(userID, instance, req.OpenClawConfigPlan)
|
||||
if err != nil {
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to compile openclaw bootstrap config: %w", err)
|
||||
return nil, fmt.Errorf("failed to compile runtime bootstrap config: %w", err)
|
||||
}
|
||||
if bootstrapSnapshot != nil {
|
||||
instance.OpenClawConfigSnapshotID = &bootstrapSnapshot.ID
|
||||
instance.UpdatedAt = time.Now()
|
||||
if err := s.instanceRepo.Update(instance); err != nil {
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to persist openclaw snapshot reference: %w", err)
|
||||
return nil, fmt.Errorf("failed to persist runtime snapshot reference: %w", err)
|
||||
}
|
||||
|
||||
bootstrapSecretName, err = s.openClawConfigService.EnsureSnapshotSecret(ctx, userID, instance, bootstrapSnapshot.ID)
|
||||
if err != nil {
|
||||
_ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err)
|
||||
s.instanceRepo.Delete(instance.ID)
|
||||
return nil, fmt.Errorf("failed to provision openclaw bootstrap secret: %w", err)
|
||||
return nil, fmt.Errorf("failed to provision runtime bootstrap secret: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models
|
||||
|
||||
if bootstrapSnapshot != nil {
|
||||
if err := s.openClawConfigService.MarkSnapshotActive(bootstrapSnapshot); err != nil {
|
||||
return nil, fmt.Errorf("failed to activate openclaw bootstrap snapshot: %w", err)
|
||||
return nil, fmt.Errorf("failed to activate runtime bootstrap snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,10 +458,10 @@ func (s *instanceService) Start(instanceID int) error {
|
||||
}
|
||||
|
||||
bootstrapSecretName := ""
|
||||
if strings.EqualFold(instance.Type, "openclaw") && s.openClawConfigService != nil && instance.OpenClawConfigSnapshotID != nil && *instance.OpenClawConfigSnapshotID > 0 {
|
||||
if supportsRuntimeConfigInjection(instance.Type) && s.openClawConfigService != nil && instance.OpenClawConfigSnapshotID != nil && *instance.OpenClawConfigSnapshotID > 0 {
|
||||
bootstrapSecretName, err = s.openClawConfigService.EnsureSnapshotSecret(ctx, instance.UserID, instance, *instance.OpenClawConfigSnapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restore openclaw bootstrap secret: %w", err)
|
||||
return fmt.Errorf("failed to restore runtime bootstrap secret: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,7 +553,7 @@ func (s *instanceService) buildGatewayEnv(instance *models.Instance) (map[string
|
||||
if instance == nil || instance.AccessToken == nil || strings.TrimSpace(*instance.AccessToken) == "" {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(instance.Type), "openclaw") {
|
||||
if !supportsManagedRuntimeIntegration(instance.Type) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
|
||||
@@ -599,7 +599,7 @@ func (s *instanceService) ensureAgentBootstrapToken(instance *models.Instance) (
|
||||
}
|
||||
|
||||
func (s *instanceService) buildAgentEnv(instance *models.Instance) (map[string]string, error) {
|
||||
if instance == nil || !strings.EqualFold(strings.TrimSpace(instance.Type), "openclaw") {
|
||||
if instance == nil || !supportsManagedRuntimeIntegration(instance.Type) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
if instance.AgentBootstrapToken == nil || strings.TrimSpace(*instance.AgentBootstrapToken) == "" {
|
||||
@@ -619,11 +619,42 @@ func (s *instanceService) buildAgentEnv(instance *models.Instance) (map[string]s
|
||||
"CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN": strings.TrimSpace(*instance.AgentBootstrapToken),
|
||||
"CLAWMANAGER_AGENT_DISK_LIMIT_BYTES": strconv.FormatInt(diskLimitBytes, 10),
|
||||
"CLAWMANAGER_AGENT_INSTANCE_ID": fmt.Sprintf("%d", instance.ID),
|
||||
"CLAWMANAGER_AGENT_PERSISTENT_DIR": "/config",
|
||||
"CLAWMANAGER_AGENT_PERSISTENT_DIR": managedRuntimePersistentDir(instance),
|
||||
"CLAWMANAGER_AGENT_PROTOCOL_VERSION": AgentProtocolVersionV1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func supportsManagedRuntimeIntegration(instanceType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(instanceType)) {
|
||||
case "openclaw", "hermes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func supportsRuntimeConfigInjection(instanceType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(instanceType)) {
|
||||
case "openclaw", "hermes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func managedRuntimePersistentDir(instance *models.Instance) string {
|
||||
if instance == nil {
|
||||
return "/config"
|
||||
}
|
||||
if strings.EqualFold(instance.Type, "hermes") {
|
||||
return "/config/.hermes"
|
||||
}
|
||||
if strings.TrimSpace(instance.MountPath) != "" {
|
||||
return strings.TrimSpace(instance.MountPath)
|
||||
}
|
||||
return defaultMountPathForInstanceType(instance.Type)
|
||||
}
|
||||
|
||||
func (s *instanceService) resolveGatewayModelInjection() (*gatewayModelInjection, error) {
|
||||
if s.llmModelRepo == nil {
|
||||
return nil, fmt.Errorf("llm model repository not configured")
|
||||
|
||||
@@ -42,36 +42,92 @@ func TestBuildGatewayEnvInjectsGatewayModelCatalog(t *testing.T) {
|
||||
t.Setenv("CLAWMANAGER_LLM_GATEWAY_BASE_URL", "http://gateway.example/api/v1/gateway/llm")
|
||||
|
||||
token := "igt_test_token"
|
||||
service := &instanceService{
|
||||
llmModelRepo: &stubLLMModelRepository{
|
||||
active: []models.LLMModel{
|
||||
{DisplayName: "GPT-4.1"},
|
||||
{DisplayName: "Claude 3.7 Sonnet"},
|
||||
{DisplayName: "auto"},
|
||||
{ProviderModelName: "deepseek-r1"},
|
||||
},
|
||||
},
|
||||
for _, instanceType := range []string{"openclaw", "hermes"} {
|
||||
t.Run(instanceType, func(t *testing.T) {
|
||||
service := &instanceService{
|
||||
llmModelRepo: &stubLLMModelRepository{
|
||||
active: []models.LLMModel{
|
||||
{DisplayName: "GPT-4.1"},
|
||||
{DisplayName: "Claude 3.7 Sonnet"},
|
||||
{DisplayName: "auto"},
|
||||
{ProviderModelName: "deepseek-r1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
env, err := service.buildGatewayEnv(&models.Instance{
|
||||
Type: instanceType,
|
||||
AccessToken: &token,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildGatewayEnv returned error: %v", err)
|
||||
}
|
||||
|
||||
if env["CLAWMANAGER_LLM_BASE_URL"] != "http://gateway.example/api/v1/gateway/llm" {
|
||||
t.Fatalf("expected CLAWMANAGER_LLM_BASE_URL to use gateway base URL, got %q", env["CLAWMANAGER_LLM_BASE_URL"])
|
||||
}
|
||||
if env["CLAWMANAGER_LLM_MODEL"] != `["auto","GPT-4.1","Claude 3.7 Sonnet","deepseek-r1"]` {
|
||||
t.Fatalf("expected CLAWMANAGER_LLM_MODEL to contain injected model catalog JSON, got %q", env["CLAWMANAGER_LLM_MODEL"])
|
||||
}
|
||||
if env["OPENAI_MODEL"] != "auto" {
|
||||
t.Fatalf("expected OPENAI_MODEL to remain the default gateway alias, got %q", env["OPENAI_MODEL"])
|
||||
}
|
||||
if env["CLAWMANAGER_LLM_API_KEY"] != token || env["OPENAI_API_KEY"] != token {
|
||||
t.Fatalf("expected gateway token aliases to be preserved")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGatewayEnvSkipsUnmanagedRuntime(t *testing.T) {
|
||||
token := "igt_test_token"
|
||||
service := &instanceService{}
|
||||
|
||||
env, err := service.buildGatewayEnv(&models.Instance{
|
||||
Type: "openclaw",
|
||||
Type: "ubuntu",
|
||||
AccessToken: &token,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildGatewayEnv returned error: %v", err)
|
||||
}
|
||||
if len(env) != 0 {
|
||||
t.Fatalf("expected unmanaged runtime to receive no gateway env, got %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
if env["CLAWMANAGER_LLM_BASE_URL"] != "http://gateway.example/api/v1/gateway/llm" {
|
||||
t.Fatalf("expected CLAWMANAGER_LLM_BASE_URL to use gateway base URL, got %q", env["CLAWMANAGER_LLM_BASE_URL"])
|
||||
func TestBuildAgentEnvInjectsHermesAgentConfig(t *testing.T) {
|
||||
t.Setenv("CLAWMANAGER_AGENT_CONTROL_BASE_URL", "http://agent-control.example")
|
||||
|
||||
token := "agt_boot_test_token"
|
||||
service := &instanceService{}
|
||||
|
||||
env, err := service.buildAgentEnv(&models.Instance{
|
||||
ID: 24,
|
||||
Type: "hermes",
|
||||
DiskGB: 20,
|
||||
AgentBootstrapToken: &token,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildAgentEnv returned error: %v", err)
|
||||
}
|
||||
if env["CLAWMANAGER_LLM_MODEL"] != `["auto","GPT-4.1","Claude 3.7 Sonnet","deepseek-r1"]` {
|
||||
t.Fatalf("expected CLAWMANAGER_LLM_MODEL to contain injected model catalog JSON, got %q", env["CLAWMANAGER_LLM_MODEL"])
|
||||
|
||||
if env["CLAWMANAGER_AGENT_ENABLED"] != "true" {
|
||||
t.Fatalf("expected Hermes agent to be enabled")
|
||||
}
|
||||
if env["OPENAI_MODEL"] != "auto" {
|
||||
t.Fatalf("expected OPENAI_MODEL to remain the default gateway alias, got %q", env["OPENAI_MODEL"])
|
||||
if env["CLAWMANAGER_AGENT_BASE_URL"] != "http://agent-control.example" {
|
||||
t.Fatalf("expected Hermes agent base URL to be injected, got %q", env["CLAWMANAGER_AGENT_BASE_URL"])
|
||||
}
|
||||
if env["CLAWMANAGER_LLM_API_KEY"] != token || env["OPENAI_API_KEY"] != token {
|
||||
t.Fatalf("expected gateway token aliases to be preserved")
|
||||
if env["CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN"] != token {
|
||||
t.Fatalf("expected Hermes agent bootstrap token to be injected")
|
||||
}
|
||||
if env["CLAWMANAGER_AGENT_INSTANCE_ID"] != "24" {
|
||||
t.Fatalf("expected Hermes instance id to be injected, got %q", env["CLAWMANAGER_AGENT_INSTANCE_ID"])
|
||||
}
|
||||
if env["CLAWMANAGER_AGENT_PERSISTENT_DIR"] != "/config/.hermes" {
|
||||
t.Fatalf("expected Hermes persistent dir /config/.hermes, got %q", env["CLAWMANAGER_AGENT_PERSISTENT_DIR"])
|
||||
}
|
||||
if env["CLAWMANAGER_AGENT_DISK_LIMIT_BYTES"] != "21474836480" {
|
||||
t.Fatalf("expected Hermes disk limit bytes to be injected, got %q", env["CLAWMANAGER_AGENT_DISK_LIMIT_BYTES"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,20 @@ const (
|
||||
OpenClawLogPoliciesEnv = "CLAWMANAGER_OPENCLAW_LOG_POLICIES_JSON"
|
||||
OpenClawAgentsEnv = "CLAWMANAGER_OPENCLAW_AGENTS_JSON"
|
||||
OpenClawScheduledTasksEnv = "CLAWMANAGER_OPENCLAW_SCHEDULED_TASKS_JSON"
|
||||
HermesBootstrapManifestEnv = "CLAWMANAGER_HERMES_BOOTSTRAP_MANIFEST_JSON"
|
||||
HermesChannelsEnv = "CLAWMANAGER_HERMES_CHANNELS_JSON"
|
||||
HermesSkillsEnv = "CLAWMANAGER_HERMES_SKILLS_JSON"
|
||||
HermesSessionTemplatesEnv = "CLAWMANAGER_HERMES_SESSION_TEMPLATES_JSON"
|
||||
HermesLogPoliciesEnv = "CLAWMANAGER_HERMES_LOG_POLICIES_JSON"
|
||||
HermesAgentsEnv = "CLAWMANAGER_HERMES_AGENTS_JSON"
|
||||
HermesScheduledTasksEnv = "CLAWMANAGER_HERMES_SCHEDULED_TASKS_JSON"
|
||||
RuntimeBootstrapManifestEnv = "CLAWMANAGER_RUNTIME_BOOTSTRAP_MANIFEST_JSON"
|
||||
RuntimeChannelsEnv = "CLAWMANAGER_RUNTIME_CHANNELS_JSON"
|
||||
RuntimeSkillsEnv = "CLAWMANAGER_RUNTIME_SKILLS_JSON"
|
||||
RuntimeSessionTemplatesEnv = "CLAWMANAGER_RUNTIME_SESSION_TEMPLATES_JSON"
|
||||
RuntimeLogPoliciesEnv = "CLAWMANAGER_RUNTIME_LOG_POLICIES_JSON"
|
||||
RuntimeAgentsEnv = "CLAWMANAGER_RUNTIME_AGENTS_JSON"
|
||||
RuntimeScheduledTasksEnv = "CLAWMANAGER_RUNTIME_SCHEDULED_TASKS_JSON"
|
||||
openClawBootstrapPayloadMaxBytes = 64 * 1024
|
||||
|
||||
openClawCompiledSnapshotStatus = "compiled"
|
||||
@@ -72,6 +86,24 @@ var (
|
||||
OpenClawConfigResourceTypeAgent: OpenClawAgentsEnv,
|
||||
OpenClawConfigResourceTypeScheduledTask: OpenClawScheduledTasksEnv,
|
||||
}
|
||||
hermesBootstrapEnvAliases = map[string]string{
|
||||
OpenClawBootstrapManifestEnv: HermesBootstrapManifestEnv,
|
||||
OpenClawChannelsEnv: HermesChannelsEnv,
|
||||
OpenClawSkillsEnv: HermesSkillsEnv,
|
||||
OpenClawSessionTemplatesEnv: HermesSessionTemplatesEnv,
|
||||
OpenClawLogPoliciesEnv: HermesLogPoliciesEnv,
|
||||
OpenClawAgentsEnv: HermesAgentsEnv,
|
||||
OpenClawScheduledTasksEnv: HermesScheduledTasksEnv,
|
||||
}
|
||||
runtimeBootstrapEnvAliases = map[string]string{
|
||||
OpenClawBootstrapManifestEnv: RuntimeBootstrapManifestEnv,
|
||||
OpenClawChannelsEnv: RuntimeChannelsEnv,
|
||||
OpenClawSkillsEnv: RuntimeSkillsEnv,
|
||||
OpenClawSessionTemplatesEnv: RuntimeSessionTemplatesEnv,
|
||||
OpenClawLogPoliciesEnv: RuntimeLogPoliciesEnv,
|
||||
OpenClawAgentsEnv: RuntimeAgentsEnv,
|
||||
OpenClawScheduledTasksEnv: RuntimeScheduledTasksEnv,
|
||||
}
|
||||
openClawResourceKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{1,99}$`)
|
||||
)
|
||||
|
||||
@@ -698,6 +730,7 @@ func (s *openClawConfigService) EnsureSnapshotSecret(ctx context.Context, userID
|
||||
if err := json.Unmarshal([]byte(snapshot.RenderedEnvJSON), &envValues); err != nil {
|
||||
return "", fmt.Errorf("openclaw injection snapshot env payload is invalid")
|
||||
}
|
||||
envValues = runtimeBootstrapEnvValues(instance.Type, envValues)
|
||||
|
||||
secretName := snapshot.SecretName
|
||||
if secretName == nil || strings.TrimSpace(*secretName) == "" {
|
||||
@@ -731,6 +764,62 @@ func (s *openClawConfigService) EnsureSnapshotSecret(ctx context.Context, userID
|
||||
return *secretName, nil
|
||||
}
|
||||
|
||||
func runtimeBootstrapEnvValues(instanceType string, envValues map[string]string) map[string]string {
|
||||
result := map[string]string{}
|
||||
for key, value := range envValues {
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
if !strings.EqualFold(instanceType, "hermes") {
|
||||
return result
|
||||
}
|
||||
|
||||
addBootstrapEnvAliases(result, hermesBootstrapEnvAliases)
|
||||
addBootstrapEnvAliases(result, runtimeBootstrapEnvAliases)
|
||||
return result
|
||||
}
|
||||
|
||||
func addBootstrapEnvAliases(envValues map[string]string, aliases map[string]string) {
|
||||
for source, target := range aliases {
|
||||
value, ok := envValues[source]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if source == OpenClawBootstrapManifestEnv {
|
||||
if aliasedManifest, err := aliasBootstrapManifestEnvNames(value, aliases); err == nil {
|
||||
value = aliasedManifest
|
||||
}
|
||||
}
|
||||
envValues[target] = value
|
||||
}
|
||||
}
|
||||
|
||||
func aliasBootstrapManifestEnvNames(raw string, aliases map[string]string) (string, error) {
|
||||
var manifest map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &manifest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payloads, ok := manifest["payloads"].([]interface{})
|
||||
if !ok {
|
||||
return marshalJSONString(manifest)
|
||||
}
|
||||
for _, item := range payloads {
|
||||
payload, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
envName, ok := payload["env"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if alias, exists := aliases[envName]; exists {
|
||||
payload["env"] = alias
|
||||
}
|
||||
}
|
||||
return marshalJSONString(manifest)
|
||||
}
|
||||
|
||||
func (s *openClawConfigService) ListSnapshots(userID int, limit int) ([]OpenClawInjectionSnapshotPayload, error) {
|
||||
if limit <= 0 {
|
||||
limit = defaultSnapshotListLimit
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"clawreef/internal/models"
|
||||
@@ -113,6 +114,32 @@ func TestRenderCompiledOpenClawPayloadRendersChannelsAsKeyedConfigMap(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeBootstrapEnvValuesAddsHermesAndRuntimeAliases(t *testing.T) {
|
||||
env := map[string]string{
|
||||
OpenClawChannelsEnv: `{"slack":{"enabled":true}}`,
|
||||
OpenClawSkillsEnv: `{"schemaVersion":1,"items":[]}`,
|
||||
OpenClawBootstrapManifestEnv: `{"schemaVersion":1,"payloads":[{"env":"CLAWMANAGER_OPENCLAW_CHANNELS_JSON","count":1},{"env":"CLAWMANAGER_OPENCLAW_SKILLS_JSON","count":0}]}`,
|
||||
}
|
||||
|
||||
got := runtimeBootstrapEnvValues("hermes", env)
|
||||
|
||||
if got[HermesChannelsEnv] != env[OpenClawChannelsEnv] {
|
||||
t.Fatalf("expected Hermes channels alias to mirror OpenClaw channels")
|
||||
}
|
||||
if got[RuntimeSkillsEnv] != env[OpenClawSkillsEnv] {
|
||||
t.Fatalf("expected runtime skills alias to mirror OpenClaw skills")
|
||||
}
|
||||
if got[OpenClawChannelsEnv] != env[OpenClawChannelsEnv] {
|
||||
t.Fatalf("expected original OpenClaw channels env to be preserved")
|
||||
}
|
||||
if !strings.Contains(got[HermesBootstrapManifestEnv], HermesChannelsEnv) {
|
||||
t.Fatalf("expected Hermes manifest alias to reference Hermes env names, got %s", got[HermesBootstrapManifestEnv])
|
||||
}
|
||||
if !strings.Contains(got[RuntimeBootstrapManifestEnv], RuntimeChannelsEnv) {
|
||||
t.Fatalf("expected runtime manifest alias to reference runtime env names, got %s", got[RuntimeBootstrapManifestEnv])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourcePayloadFromModelNormalizesStoredChannelJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -11,13 +11,14 @@ import (
|
||||
"clawreef/internal/services/k8s"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
k8sexec "k8s.io/client-go/util/exec"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
k8sexec "k8s.io/client-go/util/exec"
|
||||
)
|
||||
|
||||
const (
|
||||
openclawConfigDirName = ".openclaw"
|
||||
openclawBaseDir = "/config"
|
||||
openclawConfigDirName = ".openclaw"
|
||||
hermesConfigDirName = ".hermes"
|
||||
openclawBaseDir = "/config"
|
||||
openclawExportEmptyExitCode = 42
|
||||
)
|
||||
|
||||
@@ -26,15 +27,29 @@ const (
|
||||
// map this to an HTTP 404 rather than returning an empty 200 body.
|
||||
var ErrOpenClawWorkspaceMissing = errors.New("openclaw workspace is empty or missing")
|
||||
|
||||
// ErrHermesWorkspaceMissing is returned by ExportHermes when the .hermes
|
||||
// workspace does not exist inside the runtime container.
|
||||
var ErrHermesWorkspaceMissing = errors.New("hermes workspace is empty or missing")
|
||||
|
||||
type OpenClawTransferService interface {
|
||||
Export(ctx context.Context, userID, instanceID int) ([]byte, error)
|
||||
Import(ctx context.Context, userID, instanceID int, archive io.Reader) error
|
||||
ExportHermes(ctx context.Context, userID, instanceID int) ([]byte, error)
|
||||
ImportHermes(ctx context.Context, userID, instanceID int, archive io.Reader) error
|
||||
}
|
||||
|
||||
type openClawTransferService struct {
|
||||
podService *k8s.PodService
|
||||
}
|
||||
|
||||
type workspaceTransferSpec struct {
|
||||
dirName string
|
||||
baseDirExpr string
|
||||
missingErr error
|
||||
actionLabel string
|
||||
preserveTargetDir bool
|
||||
}
|
||||
|
||||
func NewOpenClawTransferService() OpenClawTransferService {
|
||||
return &openClawTransferService{
|
||||
podService: k8s.NewPodService(),
|
||||
@@ -53,20 +68,47 @@ func buildBaseDirExpr() string {
|
||||
return fmt.Sprintf("${CLAWMANAGER_AGENT_PERSISTENT_DIR:-%s}", openclawBaseDir)
|
||||
}
|
||||
|
||||
func openClawWorkspaceSpec() workspaceTransferSpec {
|
||||
return workspaceTransferSpec{
|
||||
dirName: openclawConfigDirName,
|
||||
baseDirExpr: buildBaseDirExpr(),
|
||||
missingErr: ErrOpenClawWorkspaceMissing,
|
||||
actionLabel: ".openclaw",
|
||||
}
|
||||
}
|
||||
|
||||
func hermesWorkspaceSpec() workspaceTransferSpec {
|
||||
return workspaceTransferSpec{
|
||||
dirName: hermesConfigDirName,
|
||||
baseDirExpr: openclawBaseDir,
|
||||
missingErr: ErrHermesWorkspaceMissing,
|
||||
actionLabel: ".hermes",
|
||||
preserveTargetDir: true,
|
||||
}
|
||||
}
|
||||
|
||||
// buildExportCommand returns the sh -lc command used to stream a gzipped
|
||||
// tarball of the .openclaw workspace from the desktop container over stdout.
|
||||
// When the workspace does not exist, the command exits with
|
||||
// openclawExportEmptyExitCode so the service layer can map it to
|
||||
// ErrOpenClawWorkspaceMissing instead of returning an empty archive.
|
||||
func buildExportCommand() []string {
|
||||
return buildWorkspaceExportCommand(openClawWorkspaceSpec())
|
||||
}
|
||||
|
||||
func buildHermesExportCommand() []string {
|
||||
return buildWorkspaceExportCommand(hermesWorkspaceSpec())
|
||||
}
|
||||
|
||||
func buildWorkspaceExportCommand(spec workspaceTransferSpec) []string {
|
||||
script := fmt.Sprintf(
|
||||
`base_dir="%s"; target_dir="$base_dir/%s"; `+
|
||||
`if [ ! -d "$target_dir" ]; then exit %d; fi; `+
|
||||
`tar czf - -C "$base_dir" %s`,
|
||||
buildBaseDirExpr(),
|
||||
openclawConfigDirName,
|
||||
spec.baseDirExpr,
|
||||
spec.dirName,
|
||||
openclawExportEmptyExitCode,
|
||||
shellQuote(openclawConfigDirName),
|
||||
shellQuote(spec.dirName),
|
||||
)
|
||||
return []string{"sh", "-lc", script}
|
||||
}
|
||||
@@ -77,24 +119,52 @@ func buildExportCommand() []string {
|
||||
// files are owned by the runtime user, matching how the linuxserver
|
||||
// entrypoint writes /config.
|
||||
func buildImportCommand() []string {
|
||||
return buildWorkspaceImportCommand(openClawWorkspaceSpec())
|
||||
}
|
||||
|
||||
func buildHermesImportCommand() []string {
|
||||
return buildWorkspaceImportCommand(hermesWorkspaceSpec())
|
||||
}
|
||||
|
||||
func buildWorkspaceImportCommand(spec workspaceTransferSpec) []string {
|
||||
clearTarget := `rm -rf "$target_dir" && mkdir -p "$base_dir"`
|
||||
if spec.preserveTargetDir {
|
||||
script := fmt.Sprintf(
|
||||
`base_dir="%s"; target_dir="$base_dir/%s"; `+
|
||||
`mkdir -p "$target_dir" && find "$target_dir" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && `+
|
||||
`tar xzf - -C "$base_dir" && chown -R abc:abc "$target_dir"`,
|
||||
spec.baseDirExpr,
|
||||
spec.dirName,
|
||||
)
|
||||
return []string{"sh", "-lc", script}
|
||||
}
|
||||
inner := fmt.Sprintf(
|
||||
`base_dir="%s"; target_dir="$base_dir/%s"; `+
|
||||
`rm -rf "$target_dir" && mkdir -p "$base_dir" && tar xzf - -C "$base_dir"`,
|
||||
buildBaseDirExpr(),
|
||||
openclawConfigDirName,
|
||||
`%s && tar xzf - -C "$base_dir"`,
|
||||
spec.baseDirExpr,
|
||||
spec.dirName,
|
||||
clearTarget,
|
||||
)
|
||||
outer := fmt.Sprintf(`exec su abc -s /bin/sh -c %s`, shellQuote(inner))
|
||||
return []string{"sh", "-lc", outer}
|
||||
}
|
||||
|
||||
func (s *openClawTransferService) Export(ctx context.Context, userID, instanceID int) ([]byte, error) {
|
||||
return s.exportWorkspace(ctx, userID, instanceID, openClawWorkspaceSpec(), buildExportCommand())
|
||||
}
|
||||
|
||||
func (s *openClawTransferService) ExportHermes(ctx context.Context, userID, instanceID int) ([]byte, error) {
|
||||
return s.exportWorkspace(ctx, userID, instanceID, hermesWorkspaceSpec(), buildHermesExportCommand())
|
||||
}
|
||||
|
||||
func (s *openClawTransferService) exportWorkspace(ctx context.Context, userID, instanceID int, spec workspaceTransferSpec, command []string) ([]byte, error) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
if err := s.exec(ctx, userID, instanceID, buildExportCommand(), nil, &stdout, &stderr); err != nil {
|
||||
if err := s.exec(ctx, userID, instanceID, command, nil, &stdout, &stderr); err != nil {
|
||||
if isExportEmptyWorkspaceError(err) {
|
||||
return nil, ErrOpenClawWorkspaceMissing
|
||||
return nil, spec.missingErr
|
||||
}
|
||||
return nil, formatExecError("export .openclaw", err, stderr.String())
|
||||
return nil, formatExecError("export "+spec.actionLabel, err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.Bytes(), nil
|
||||
@@ -117,9 +187,17 @@ func isExportEmptyWorkspaceError(err error) bool {
|
||||
}
|
||||
|
||||
func (s *openClawTransferService) Import(ctx context.Context, userID, instanceID int, archive io.Reader) error {
|
||||
return s.importWorkspace(ctx, userID, instanceID, archive, openClawWorkspaceSpec(), buildImportCommand())
|
||||
}
|
||||
|
||||
func (s *openClawTransferService) ImportHermes(ctx context.Context, userID, instanceID int, archive io.Reader) error {
|
||||
return s.importWorkspace(ctx, userID, instanceID, archive, hermesWorkspaceSpec(), buildHermesImportCommand())
|
||||
}
|
||||
|
||||
func (s *openClawTransferService) importWorkspace(ctx context.Context, userID, instanceID int, archive io.Reader, spec workspaceTransferSpec, command []string) error {
|
||||
var stderr bytes.Buffer
|
||||
if err := s.exec(ctx, userID, instanceID, buildImportCommand(), archive, nil, &stderr); err != nil {
|
||||
return formatExecError("import .openclaw", err, stderr.String())
|
||||
if err := s.exec(ctx, userID, instanceID, command, archive, nil, &stderr); err != nil {
|
||||
return formatExecError("import "+spec.actionLabel, err, stderr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,86 +1,128 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildBaseDirExpr_UsesEnvVarWithFallback(t *testing.T) {
|
||||
got := buildBaseDirExpr()
|
||||
want := "${CLAWMANAGER_AGENT_PERSISTENT_DIR:-/config}"
|
||||
if got != want {
|
||||
t.Fatalf("buildBaseDirExpr() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportCommand_UsesBaseDirNotHome(t *testing.T) {
|
||||
cmd := buildExportCommand()
|
||||
if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-lc" {
|
||||
t.Fatalf("unexpected command shape: %#v", cmd)
|
||||
}
|
||||
script := cmd[2]
|
||||
|
||||
if !strings.Contains(script, "CLAWMANAGER_AGENT_PERSISTENT_DIR") {
|
||||
t.Errorf("expected CLAWMANAGER_AGENT_PERSISTENT_DIR in script, got: %s", script)
|
||||
}
|
||||
if strings.Contains(script, "HOME") || strings.Contains(script, "/home/user") {
|
||||
t.Errorf("export script must not depend on $HOME or /home/user, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "exit 42") {
|
||||
t.Errorf("expected `exit 42` for empty-workspace branch, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "tar czf -") {
|
||||
t.Errorf("expected `tar czf -` streaming, got: %s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportCommand_QuotesConfigDirName(t *testing.T) {
|
||||
cmd := buildExportCommand()
|
||||
quoted := shellQuote(openclawConfigDirName)
|
||||
if !strings.Contains(cmd[2], quoted) {
|
||||
t.Errorf("expected quoted %q in script, got: %s", quoted, cmd[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildImportCommand_UsesSuAbc(t *testing.T) {
|
||||
cmd := buildImportCommand()
|
||||
if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-lc" {
|
||||
t.Fatalf("unexpected command shape: %#v", cmd)
|
||||
}
|
||||
script := cmd[2]
|
||||
|
||||
if !strings.Contains(script, "su abc -s /bin/sh -c") {
|
||||
t.Errorf("expected `su abc -s /bin/sh -c` wrap to land files as uid 1000, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "tar xzf -") {
|
||||
t.Errorf("expected `tar xzf -` in extract, got: %s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildImportCommand_NoHomeReference(t *testing.T) {
|
||||
cmd := buildImportCommand()
|
||||
script := cmd[2]
|
||||
if strings.Contains(script, "HOME") || strings.Contains(script, "/home/user") {
|
||||
t.Errorf("import script must not depend on $HOME or /home/user, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "CLAWMANAGER_AGENT_PERSISTENT_DIR") {
|
||||
t.Errorf("expected CLAWMANAGER_AGENT_PERSISTENT_DIR in import script, got: %s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellQuote_HandlesSingleQuotes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", "''"},
|
||||
{"abc", "'abc'"},
|
||||
{"a'b", `'a'"'"'b'`},
|
||||
{".openclaw", "'.openclaw'"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := shellQuote(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("shellQuote(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildBaseDirExpr_UsesEnvVarWithFallback(t *testing.T) {
|
||||
got := buildBaseDirExpr()
|
||||
want := "${CLAWMANAGER_AGENT_PERSISTENT_DIR:-/config}"
|
||||
if got != want {
|
||||
t.Fatalf("buildBaseDirExpr() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportCommand_UsesBaseDirNotHome(t *testing.T) {
|
||||
cmd := buildExportCommand()
|
||||
if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-lc" {
|
||||
t.Fatalf("unexpected command shape: %#v", cmd)
|
||||
}
|
||||
script := cmd[2]
|
||||
|
||||
if !strings.Contains(script, "CLAWMANAGER_AGENT_PERSISTENT_DIR") {
|
||||
t.Errorf("expected CLAWMANAGER_AGENT_PERSISTENT_DIR in script, got: %s", script)
|
||||
}
|
||||
if strings.Contains(script, "HOME") || strings.Contains(script, "/home/user") {
|
||||
t.Errorf("export script must not depend on $HOME or /home/user, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "exit 42") {
|
||||
t.Errorf("expected `exit 42` for empty-workspace branch, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "tar czf -") {
|
||||
t.Errorf("expected `tar czf -` streaming, got: %s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportCommand_QuotesConfigDirName(t *testing.T) {
|
||||
cmd := buildExportCommand()
|
||||
quoted := shellQuote(openclawConfigDirName)
|
||||
if !strings.Contains(cmd[2], quoted) {
|
||||
t.Errorf("expected quoted %q in script, got: %s", quoted, cmd[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHermesExportCommand_ExportsHermesDirectoryFromConfig(t *testing.T) {
|
||||
cmd := buildHermesExportCommand()
|
||||
if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-lc" {
|
||||
t.Fatalf("unexpected command shape: %#v", cmd)
|
||||
}
|
||||
script := cmd[2]
|
||||
|
||||
if !strings.Contains(script, `base_dir="/config"`) {
|
||||
t.Errorf("expected Hermes export to use /config as archive base, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, `target_dir="$base_dir/.hermes"`) {
|
||||
t.Errorf("expected Hermes export target to be /config/.hermes, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, shellQuote(hermesConfigDirName)) {
|
||||
t.Errorf("expected quoted .hermes in script, got: %s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildImportCommand_UsesSuAbc(t *testing.T) {
|
||||
cmd := buildImportCommand()
|
||||
if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-lc" {
|
||||
t.Fatalf("unexpected command shape: %#v", cmd)
|
||||
}
|
||||
script := cmd[2]
|
||||
|
||||
if !strings.Contains(script, "su abc -s /bin/sh -c") {
|
||||
t.Errorf("expected `su abc -s /bin/sh -c` wrap to land files as uid 1000, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "tar xzf -") {
|
||||
t.Errorf("expected `tar xzf -` in extract, got: %s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildImportCommand_NoHomeReference(t *testing.T) {
|
||||
cmd := buildImportCommand()
|
||||
script := cmd[2]
|
||||
if strings.Contains(script, "HOME") || strings.Contains(script, "/home/user") {
|
||||
t.Errorf("import script must not depend on $HOME or /home/user, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "CLAWMANAGER_AGENT_PERSISTENT_DIR") {
|
||||
t.Errorf("expected CLAWMANAGER_AGENT_PERSISTENT_DIR in import script, got: %s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHermesImportCommand_PreservesMountedHermesDirectory(t *testing.T) {
|
||||
cmd := buildHermesImportCommand()
|
||||
if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-lc" {
|
||||
t.Fatalf("unexpected command shape: %#v", cmd)
|
||||
}
|
||||
script := cmd[2]
|
||||
|
||||
if !strings.Contains(script, `base_dir="/config"`) {
|
||||
t.Errorf("expected Hermes import to use /config as archive base, got: %s", script)
|
||||
}
|
||||
if strings.Contains(script, `rm -rf "$target_dir"`) {
|
||||
t.Errorf("Hermes import must not remove the /config/.hermes mount point, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, `find "$target_dir" -mindepth 1 -maxdepth 1`) {
|
||||
t.Errorf("expected Hermes import to clear contents below mount point, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, "tar xzf -") {
|
||||
t.Errorf("expected `tar xzf -` in extract, got: %s", script)
|
||||
}
|
||||
if !strings.Contains(script, `chown -R abc:abc "$target_dir"`) {
|
||||
t.Errorf("expected Hermes import to restore runtime user ownership, got: %s", script)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellQuote_HandlesSingleQuotes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", "''"},
|
||||
{"abc", "'abc'"},
|
||||
{"a'b", `'a'"'"'b'`},
|
||||
{".openclaw", "'.openclaw'"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := shellQuote(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("shellQuote(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,11 +1055,10 @@ func buildNormalizedZip(dir extractedSkillDirectory) ([]byte, string, error) {
|
||||
}
|
||||
|
||||
func hashDirectory(files map[string][]byte) string {
|
||||
flattened := flattenSingleTopLevelDir(files)
|
||||
digest := md5.New()
|
||||
entryKinds := map[string]string{}
|
||||
fileMap := map[string][]byte{}
|
||||
for key, body := range flattened {
|
||||
for key, body := range files {
|
||||
clean := normalizeSkillRelPath(key)
|
||||
if clean == "" || hasHiddenPathSegment(clean) {
|
||||
continue
|
||||
@@ -1109,7 +1108,7 @@ func (s *skillService) resolveContentMD5(blob *models.SkillBlob) string {
|
||||
sum := md5.Sum(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
return hashDirectory(files)
|
||||
return hashDirectory(flattenSingleTopLevelDir(files))
|
||||
}
|
||||
|
||||
func normalizeSkillRelPath(value string) string {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"path"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashDirectoryPreservesSingleTopLevelSubdirectory(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
"src/main.py": []byte("print('hello')\n"),
|
||||
}
|
||||
|
||||
got := hashDirectory(files)
|
||||
want := referenceSkillContentMD5(map[string][]byte{
|
||||
"src/main.py": []byte("print('hello')\n"),
|
||||
})
|
||||
if got != want {
|
||||
t.Fatalf("hashDirectory() = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
flattened := referenceSkillContentMD5(map[string][]byte{
|
||||
"main.py": []byte("print('hello')\n"),
|
||||
})
|
||||
if got == flattened {
|
||||
t.Fatalf("hashDirectory stripped the skill's internal src/ directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSkillDirectoriesStripsArchiveRootOnlyOnce(t *testing.T) {
|
||||
archive := buildTestZip(t, map[string][]byte{
|
||||
"weather/src/main.py": []byte("print('weather')\n"),
|
||||
})
|
||||
|
||||
dirs, err := extractSkillDirectories("weather.zip", archive)
|
||||
if err != nil {
|
||||
t.Fatalf("extractSkillDirectories() error = %v", err)
|
||||
}
|
||||
if len(dirs) != 1 {
|
||||
t.Fatalf("extractSkillDirectories() returned %d dirs, want 1", len(dirs))
|
||||
}
|
||||
if _, ok := dirs[0].Files["src/main.py"]; !ok {
|
||||
t.Fatalf("expected skill files to preserve src/main.py after stripping archive root once: %#v", dirs[0].Files)
|
||||
}
|
||||
|
||||
got := hashDirectory(dirs[0].Files)
|
||||
want := referenceSkillContentMD5(map[string][]byte{
|
||||
"src/main.py": []byte("print('weather')\n"),
|
||||
})
|
||||
if got != want {
|
||||
t.Fatalf("hashDirectory(extracted files) = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenSingleTopLevelDirForArchiveRoot(t *testing.T) {
|
||||
files := map[string][]byte{
|
||||
"weather/src/main.py": []byte("print('weather')\n"),
|
||||
}
|
||||
|
||||
got := hashDirectory(flattenSingleTopLevelDir(files))
|
||||
want := referenceSkillContentMD5(map[string][]byte{
|
||||
"src/main.py": []byte("print('weather')\n"),
|
||||
})
|
||||
if got != want {
|
||||
t.Fatalf("hashDirectory(flattenSingleTopLevelDir(files)) = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestZip(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
keys := make([]string, 0, len(files))
|
||||
for key := range files {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
entry, err := writer.Create(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q): %v", key, err)
|
||||
}
|
||||
if _, err := entry.Write(files[key]); err != nil {
|
||||
t.Fatalf("Write(%q): %v", key, err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close(): %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func referenceSkillContentMD5(files map[string][]byte) string {
|
||||
entryKinds := map[string]string{}
|
||||
fileMap := map[string][]byte{}
|
||||
for key, body := range files {
|
||||
clean := path.Clean(key)
|
||||
if clean == "." || clean == "" {
|
||||
continue
|
||||
}
|
||||
fileMap[clean] = body
|
||||
entryKinds[clean] = "file"
|
||||
parts := splitTestPath(clean)
|
||||
for i := 1; i < len(parts); i++ {
|
||||
entryKinds[path.Join(parts[:i]...)] = "dir"
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(entryKinds))
|
||||
for key := range entryKinds {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
digest := md5.New()
|
||||
for _, key := range keys {
|
||||
_, _ = digest.Write([]byte(key))
|
||||
_, _ = digest.Write([]byte("\n"))
|
||||
if entryKinds[key] == "dir" {
|
||||
_, _ = digest.Write([]byte("dir\n"))
|
||||
continue
|
||||
}
|
||||
_, _ = digest.Write([]byte("file\n"))
|
||||
_, _ = digest.Write(fileMap[key])
|
||||
_, _ = digest.Write([]byte("\n"))
|
||||
}
|
||||
return hex.EncodeToString(digest.Sum(nil))
|
||||
}
|
||||
|
||||
func splitTestPath(value string) []string {
|
||||
result := []string{}
|
||||
for _, part := range bytes.Split([]byte(value), []byte("/")) {
|
||||
if len(part) > 0 {
|
||||
result = append(result, string(part))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -13,6 +13,7 @@ var orderedSystemImageTypes = []string{
|
||||
"openclaw",
|
||||
"ubuntu",
|
||||
"webtop",
|
||||
"hermes",
|
||||
"debian",
|
||||
"centos",
|
||||
"custom",
|
||||
@@ -22,15 +23,17 @@ var supportedSystemImageTypes = map[string]string{
|
||||
"openclaw": "OpenClaw Desktop",
|
||||
"ubuntu": "Ubuntu Desktop",
|
||||
"webtop": "Webtop Desktop",
|
||||
"hermes": "Hermes Runtime",
|
||||
"debian": "Debian Desktop",
|
||||
"centos": "CentOS Desktop",
|
||||
"custom": "Custom Image",
|
||||
}
|
||||
|
||||
var defaultSystemImageSettings = map[string]string{
|
||||
"openclaw": "ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest",
|
||||
"openclaw": "ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest",
|
||||
"ubuntu": "lscr.io/linuxserver/webtop:ubuntu-xfce",
|
||||
"webtop": "lscr.io/linuxserver/webtop:ubuntu-xfce",
|
||||
"hermes": "ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest",
|
||||
"debian": "docker.io/clawreef/debian-desktop:12",
|
||||
"centos": "docker.io/clawreef/centos-desktop:9",
|
||||
"custom": "registry.example.com/your-custom-image:latest",
|
||||
@@ -39,6 +42,7 @@ var defaultSystemImageSettings = map[string]string{
|
||||
var defaultEnabledSystemImageTypes = map[string]bool{
|
||||
"openclaw": true,
|
||||
"ubuntu": true,
|
||||
"hermes": true,
|
||||
}
|
||||
|
||||
// RuntimeImageSettingsProvider exposes runtime image lookup for instance types.
|
||||
|
||||
@@ -68,7 +68,7 @@ func HandleError(c *gin.Context, err error) {
|
||||
Error(c, http.StatusForbidden, errStr)
|
||||
case "invalid username or password", "account is disabled", "invalid or expired agent session token":
|
||||
Error(c, http.StatusUnauthorized, errStr)
|
||||
case "agent registration is only supported for openclaw instances", "agent id does not match session", "access denied":
|
||||
case "agent registration is only supported for openclaw instances", "agent registration is only supported for openclaw or hermes instances", "agent id does not match session", "access denied":
|
||||
Error(c, http.StatusForbidden, errStr)
|
||||
case "current password is incorrect":
|
||||
Error(c, http.StatusBadRequest, errStr)
|
||||
|
||||
@@ -43,7 +43,7 @@ data:
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu',
|
||||
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu',
|
||||
status ENUM('creating', 'running', 'stopped', 'error', 'deleting') DEFAULT 'creating',
|
||||
cpu_cores DECIMAL(10,2) NOT NULL,
|
||||
memory_gb INT NOT NULL,
|
||||
@@ -170,7 +170,7 @@ data:
|
||||
002_add_webtop_instance_type.sql: |
|
||||
USE clawmanager;
|
||||
ALTER TABLE instances
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu';
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
|
||||
003_add_system_image_settings.sql: |
|
||||
USE clawmanager;
|
||||
CREATE TABLE IF NOT EXISTS system_image_settings (
|
||||
@@ -192,9 +192,12 @@ data:
|
||||
005_update_openclaw_default_image.sql: |
|
||||
USE clawmanager;
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest'
|
||||
WHERE instance_type = 'openclaw'
|
||||
AND image = 'ericpearlee/openclaw:v2026.3.24';
|
||||
AND image IN (
|
||||
'ericpearlee/openclaw:v2026.3.24',
|
||||
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
);
|
||||
006_add_openclaw_config_center.sql: |
|
||||
USE clawmanager;
|
||||
SET @openclaw_snapshot_column_exists = (
|
||||
@@ -501,6 +504,27 @@ data:
|
||||
USE clawmanager;
|
||||
ALTER TABLE instances MODIFY COLUMN cpu_cores DECIMAL(10,2) NOT NULL;
|
||||
ALTER TABLE user_quotas MODIFY COLUMN max_cpu_cores DECIMAL(10,2) DEFAULT 40;
|
||||
011_add_hermes_instance_type.sql: |
|
||||
USE clawmanager;
|
||||
ALTER TABLE instances
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
|
||||
012_update_agents_runtime_default_images.sql: |
|
||||
USE clawmanager;
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest'
|
||||
WHERE instance_type = 'openclaw'
|
||||
AND image IN (
|
||||
'ericpearlee/openclaw:v2026.3.24',
|
||||
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
);
|
||||
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest'
|
||||
WHERE instance_type = 'hermes'
|
||||
AND image IN (
|
||||
'registry.example.com/hermes-webtop:latest',
|
||||
'lscr.io/linuxserver/webtop:ubuntu-xfce'
|
||||
);
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
@@ -533,6 +557,7 @@ spec:
|
||||
containers:
|
||||
- name: mysql
|
||||
image: mysql:8.4.8
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3306
|
||||
env:
|
||||
|
||||
@@ -47,7 +47,7 @@ data:
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu',
|
||||
type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu',
|
||||
status ENUM('creating', 'running', 'stopped', 'error', 'deleting') DEFAULT 'creating',
|
||||
cpu_cores DECIMAL(10,2) NOT NULL,
|
||||
memory_gb INT NOT NULL,
|
||||
@@ -174,7 +174,7 @@ data:
|
||||
002_add_webtop_instance_type.sql: |
|
||||
USE clawmanager;
|
||||
ALTER TABLE instances
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu';
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
|
||||
003_add_system_image_settings.sql: |
|
||||
USE clawmanager;
|
||||
CREATE TABLE IF NOT EXISTS system_image_settings (
|
||||
@@ -196,9 +196,12 @@ data:
|
||||
005_update_openclaw_default_image.sql: |
|
||||
USE clawmanager;
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest'
|
||||
WHERE instance_type = 'openclaw'
|
||||
AND image = 'ericpearlee/openclaw:v2026.3.24';
|
||||
AND image IN (
|
||||
'ericpearlee/openclaw:v2026.3.24',
|
||||
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
);
|
||||
006_add_openclaw_config_center.sql: |
|
||||
USE clawmanager;
|
||||
SET @openclaw_snapshot_column_exists = (
|
||||
@@ -505,6 +508,27 @@ data:
|
||||
USE clawmanager;
|
||||
ALTER TABLE instances MODIFY COLUMN cpu_cores DECIMAL(10,2) NOT NULL;
|
||||
ALTER TABLE user_quotas MODIFY COLUMN max_cpu_cores DECIMAL(10,2) DEFAULT 40;
|
||||
011_add_hermes_instance_type.sql: |
|
||||
USE clawmanager;
|
||||
ALTER TABLE instances
|
||||
MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop', 'hermes') DEFAULT 'ubuntu';
|
||||
012_update_agents_runtime_default_images.sql: |
|
||||
USE clawmanager;
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest'
|
||||
WHERE instance_type = 'openclaw'
|
||||
AND image IN (
|
||||
'ericpearlee/openclaw:v2026.3.24',
|
||||
'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest'
|
||||
);
|
||||
|
||||
UPDATE system_image_settings
|
||||
SET image = 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest'
|
||||
WHERE instance_type = 'hermes'
|
||||
AND image IN (
|
||||
'registry.example.com/hermes-webtop:latest',
|
||||
'lscr.io/linuxserver/webtop:ubuntu-xfce'
|
||||
);
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
@@ -584,6 +608,7 @@ spec:
|
||||
containers:
|
||||
- name: mysql
|
||||
image: mysql:8.4.8
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3306
|
||||
env:
|
||||
@@ -649,7 +674,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: minio
|
||||
image: minio/minio:RELEASE.2026-01-24T22-31-39Z
|
||||
image: minio/minio:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- server
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Agent Control Plane Guide
|
||||
|
||||
Agent Control Plane is the runtime orchestration layer for OpenClaw instances in ClawManager. It allows the platform to understand live runtime state, distribute commands, and keep each managed workspace aligned with the desired state defined by the control plane.
|
||||
Agent Control Plane is the runtime orchestration layer for managed runtime instances in ClawManager. It allows the platform to understand live runtime state, distribute commands, and keep each managed workspace aligned with the desired state defined by the control plane.
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
- agent bootstrap and registration for OpenClaw instances
|
||||
- agent bootstrap and registration for managed runtime instances
|
||||
- authenticated session lifecycle between the runtime agent and the platform
|
||||
- heartbeat-driven runtime and health reporting
|
||||
- desired power state and desired config revision tracking
|
||||
@@ -15,9 +15,9 @@ Agent Control Plane is the runtime orchestration layer for OpenClaw instances in
|
||||
The control plane keeps a runtime view that includes:
|
||||
|
||||
- agent identity, version, and last heartbeat
|
||||
- runtime status and OpenClaw status
|
||||
- runtime status and v1 compatibility status fields
|
||||
- current and desired config revision
|
||||
- reported summary data such as agent, channel, and skill counts
|
||||
- reported summary data such as agent, runtime, and skill counts
|
||||
- recent command history and execution outcomes
|
||||
|
||||
## Typical Commands
|
||||
@@ -37,6 +37,8 @@ Examples of platform-driven runtime actions include:
|
||||
|
||||
## Related Guides
|
||||
|
||||
- [Runtime Agent Integration Guide](./runtime-agent-integration-guide.md)
|
||||
- [Hermes Runtime Image and Agent Development Guide](./hermes-runtime-agent-development.md)
|
||||
- [Admin and User Guide](./admin-user-guide.md)
|
||||
- [Resource Management Guide](./resource-management.md)
|
||||
- [Security / Skill Scanner Guide](./security-skill-scanner.md)
|
||||
|
||||
@@ -0,0 +1,825 @@
|
||||
# Hermes Runtime Image and Agent Development Guide
|
||||
|
||||
This guide is for Hermes image and agent developers. It explains how to build a ClawManager-managed Hermes runtime image on top of Webtop, and how the embedded Hermes agent should integrate with ClawManager Agent Control Plane so Hermes can behave like OpenClaw: report live runtime status, report health and system metrics, sync skill inventory, upload skill packages, and poll runtime commands.
|
||||
|
||||
## Goals
|
||||
|
||||
A Hermes image must satisfy two layers of requirements:
|
||||
|
||||
- Desktop access layer: keep the Webtop/KasmVNC runtime model. ClawManager accesses the desktop through the instance Service on port `3001`.
|
||||
- Runtime agent layer: run a long-lived Hermes agent inside the image. The agent registers with ClawManager, sends heartbeats, reports state, syncs skills, and executes platform-issued commands.
|
||||
|
||||
Current Hermes runtime defaults in ClawManager:
|
||||
|
||||
- Port: `3001`
|
||||
- Persistent directory: `/config/.hermes`
|
||||
- Default title: `Hermes Runtime`
|
||||
- Proxy path: ClawManager rewrites `SUBFOLDER` to `/api/v1/instances/{instance_id}/proxy/` when the instance is created.
|
||||
|
||||
Do not change the port or persistent directory in the image. If the image uses a different port or mount path, instance proxying, PVC mounting, and user data persistence will no longer match ClawManager expectations.
|
||||
|
||||
## Image Build Requirements
|
||||
|
||||
Use a LinuxServer Webtop image as the base image, for example:
|
||||
|
||||
```dockerfile
|
||||
FROM lscr.io/linuxserver/webtop:ubuntu-xfce
|
||||
|
||||
USER root
|
||||
|
||||
# 1. Install Hermes runtime dependencies.
|
||||
# Keep this as an example. The Hermes project should own the real install steps.
|
||||
# RUN apt-get update && apt-get install -y ... && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. Install Hermes itself.
|
||||
# COPY hermes /opt/hermes
|
||||
|
||||
# 3. Install the ClawManager Hermes agent.
|
||||
COPY hermes-agent /usr/local/bin/hermes-agent
|
||||
RUN chmod +x /usr/local/bin/hermes-agent
|
||||
|
||||
# 4. Register an s6 longrun service so the agent starts with the Webtop container.
|
||||
COPY root/ /
|
||||
|
||||
ENV TITLE="Hermes Runtime"
|
||||
ENV SUBFOLDER="/"
|
||||
|
||||
EXPOSE 3001
|
||||
```
|
||||
|
||||
Webtop uses s6 overlay for process supervision. The Hermes agent can run as a longrun service:
|
||||
|
||||
```text
|
||||
root/
|
||||
etc/
|
||||
s6-overlay/
|
||||
s6-rc.d/
|
||||
hermes-agent/
|
||||
type
|
||||
run
|
||||
user/
|
||||
contents.d/
|
||||
hermes-agent
|
||||
```
|
||||
|
||||
`type`:
|
||||
|
||||
```text
|
||||
longrun
|
||||
```
|
||||
|
||||
`run`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/with-contenv bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${CLAWMANAGER_AGENT_ENABLED:-false}" != "true" ]; then
|
||||
echo "ClawManager Hermes agent disabled"
|
||||
sleep infinity
|
||||
fi
|
||||
|
||||
exec /usr/local/bin/hermes-agent
|
||||
```
|
||||
|
||||
The Hermes agent must not bind to `3001`. Port `3001` belongs to the Webtop desktop entrypoint. The agent only needs outbound HTTP access to ClawManager.
|
||||
|
||||
## Environment Variables Injected by ClawManager
|
||||
|
||||
The Hermes image must read configuration from environment variables. Do not hardcode ClawManager URLs, instance IDs, tokens, or persistent paths into the image.
|
||||
|
||||
Base Webtop variables:
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| `TITLE` | Desktop title. Hermes defaults to `Hermes Runtime`. |
|
||||
| `SUBFOLDER` | Reverse proxy subpath. ClawManager rewrites it at runtime. |
|
||||
| `HTTP_PROXY` / `HTTPS_PROXY` | Injected when platform egress proxy is enabled. |
|
||||
| `NO_PROXY` | Platform-internal services and localhost are added automatically. |
|
||||
|
||||
Agent Control Plane variables:
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| `CLAWMANAGER_AGENT_ENABLED` | Start the Hermes agent when set to `true`. |
|
||||
| `CLAWMANAGER_AGENT_BASE_URL` | ClawManager API base URL, without the `/api/v1/agent` suffix. |
|
||||
| `CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN` | One-time bootstrap token used for initial registration. |
|
||||
| `CLAWMANAGER_AGENT_INSTANCE_ID` | Current ClawManager instance ID. |
|
||||
| `CLAWMANAGER_AGENT_PROTOCOL_VERSION` | Current protocol version, `v1`. |
|
||||
| `CLAWMANAGER_AGENT_PERSISTENT_DIR` | Persistent directory. Hermes uses `/config/.hermes`. |
|
||||
| `CLAWMANAGER_AGENT_DISK_LIMIT_BYTES` | Instance disk quota in bytes. |
|
||||
|
||||
Runtime resource bootstrap variables:
|
||||
|
||||
| Variable | Description |
|
||||
| --- | --- |
|
||||
| `CLAWMANAGER_HERMES_CHANNELS_JSON` | Channel configuration injected at instance creation time. |
|
||||
| `CLAWMANAGER_HERMES_SKILLS_JSON` | Skill configuration injected at instance creation time. |
|
||||
| `CLAWMANAGER_HERMES_BOOTSTRAP_MANIFEST_JSON` | Manifest for the current bootstrap payload. |
|
||||
| `CLAWMANAGER_RUNTIME_CHANNELS_JSON` | Generic runtime alias for channel configuration. |
|
||||
| `CLAWMANAGER_RUNTIME_SKILLS_JSON` | Generic runtime alias for skill configuration. |
|
||||
| `CLAWMANAGER_RUNTIME_BOOTSTRAP_MANIFEST_JSON` | Generic runtime alias for the bootstrap manifest. |
|
||||
|
||||
For compatibility with the existing OpenClaw resource center, ClawManager also keeps the original `CLAWMANAGER_OPENCLAW_*` variables. Hermes agents should prefer `CLAWMANAGER_HERMES_*`, then fall back to `CLAWMANAGER_RUNTIME_*`, and finally to `CLAWMANAGER_OPENCLAW_*`.
|
||||
|
||||
Platform-side note: some Agent Control Plane payload fields still use historical OpenClaw names. Until those fields are renamed into generic runtime fields, Hermes agents should reuse compatible fields such as `openclaw_status`, `openclaw_pid`, and `openclaw_version` to describe Hermes runtime state.
|
||||
|
||||
## Channel and Skill Bootstrap Consumption
|
||||
|
||||
Hermes agents must handle two kinds of injection:
|
||||
|
||||
- Runtime bootstrap injection: channels, configuration skills, session templates, agents, scheduled tasks, and related resources selected at instance creation time are injected through environment variables.
|
||||
- Platform skill installation: reusable platform skills selected at instance creation time are first attached to the instance. ClawManager then sends an `install_skill` command. The agent must download and install the skill package.
|
||||
|
||||
### Read Order
|
||||
|
||||
At startup, read bootstrap payloads in this priority order. Use the first non-empty value:
|
||||
|
||||
| Resource | Preferred variable | Fallback variables |
|
||||
| --- | --- | --- |
|
||||
| Manifest | `CLAWMANAGER_HERMES_BOOTSTRAP_MANIFEST_JSON` | `CLAWMANAGER_RUNTIME_BOOTSTRAP_MANIFEST_JSON`, `CLAWMANAGER_OPENCLAW_BOOTSTRAP_MANIFEST_JSON` |
|
||||
| Channels | `CLAWMANAGER_HERMES_CHANNELS_JSON` | `CLAWMANAGER_RUNTIME_CHANNELS_JSON`, `CLAWMANAGER_OPENCLAW_CHANNELS_JSON` |
|
||||
| Config Skills | `CLAWMANAGER_HERMES_SKILLS_JSON` | `CLAWMANAGER_RUNTIME_SKILLS_JSON`, `CLAWMANAGER_OPENCLAW_SKILLS_JSON` |
|
||||
| Session Templates | `CLAWMANAGER_HERMES_SESSION_TEMPLATES_JSON` | `CLAWMANAGER_RUNTIME_SESSION_TEMPLATES_JSON`, `CLAWMANAGER_OPENCLAW_SESSION_TEMPLATES_JSON` |
|
||||
| Agents | `CLAWMANAGER_HERMES_AGENTS_JSON` | `CLAWMANAGER_RUNTIME_AGENTS_JSON`, `CLAWMANAGER_OPENCLAW_AGENTS_JSON` |
|
||||
| Scheduled Tasks | `CLAWMANAGER_HERMES_SCHEDULED_TASKS_JSON` | `CLAWMANAGER_RUNTIME_SCHEDULED_TASKS_JSON`, `CLAWMANAGER_OPENCLAW_SCHEDULED_TASKS_JSON` |
|
||||
|
||||
If a variable is missing or empty, treat it as an empty config. Do not fail agent startup for missing optional bootstrap payloads. If a variable exists but contains invalid JSON, log a clear error and report `health.bootstrap_config` or `health.config_loader` as `error` in the next state report.
|
||||
|
||||
Recommended local bootstrap state:
|
||||
|
||||
```text
|
||||
/config/.hermes/hermes-agent/bootstrap/
|
||||
manifest.json
|
||||
channels.json
|
||||
skills.json
|
||||
applied-state.json
|
||||
```
|
||||
|
||||
Do not print channel tokens, secrets, webhooks, bootstrap tokens, session tokens, or AI Gateway API keys in logs.
|
||||
|
||||
### Channel Injection
|
||||
|
||||
`CLAWMANAGER_HERMES_CHANNELS_JSON` is a JSON object keyed by resource key. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"feishu": {
|
||||
"enabled": true,
|
||||
"domain": "feishu",
|
||||
"defaultAccount": "main",
|
||||
"accounts": {
|
||||
"main": {
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "secret",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"requireMention": true
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"botToken": "123456:xxx",
|
||||
"dmPolicy": "open",
|
||||
"allowFrom": ["*"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hermes agent behavior:
|
||||
|
||||
1. Use the top-level key as the channel ID, for example `feishu`, `telegram`, `slack`, or `dingtalk-connector`.
|
||||
2. Skip channels with `enabled=false`, but keep their config on disk so future updates can re-enable them.
|
||||
3. Convert channel config into Hermes-native notification or messaging configuration. A reasonable default path is `/config/.hermes/channels.json`, unless Hermes has a native config location.
|
||||
4. Preserve unknown fields so future ClawManager extensions are not lost.
|
||||
5. If Hermes does not support a channel type yet, mark that channel as unsupported in `health.channels` and keep registration and heartbeats running.
|
||||
|
||||
### Config Skill Injection
|
||||
|
||||
`CLAWMANAGER_HERMES_SKILLS_JSON` is a list of configuration resources. It is not a zip package. It is used to inject resource-center configuration skills into the runtime at instance creation time. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": 5,
|
||||
"type": "skill",
|
||||
"key": "support-bot",
|
||||
"name": "Support Bot",
|
||||
"version": 1,
|
||||
"tags": ["skill"],
|
||||
"content": {
|
||||
"schemaVersion": 1,
|
||||
"kind": "skill",
|
||||
"format": "skill/custom@v1",
|
||||
"dependsOn": [],
|
||||
"config": {
|
||||
"prompt": "help"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Hermes agent behavior:
|
||||
|
||||
1. Iterate over `items` and use `key` as the stable skill identifier.
|
||||
2. Read `content.config` and translate it into executable Hermes skill configuration.
|
||||
3. If Hermes stores skills as directories, write the generated config to `/config/.hermes/skills/{key}/skill.json`. Keep the raw `content` as well for debugging.
|
||||
4. Calculate `content_md5` for the written skill directory and include it in the next `skills/inventory` report.
|
||||
5. For skills generated from bootstrap config, inventory `source` should be `injected_by_clawmanager` or `bootstrap_config`. If `source` is `injected_by_clawmanager` and `skill_id` uses a platform external ID, use the format `skill-{id}`, for example `skill-5`.
|
||||
|
||||
Config skills and platform skill package installation are separate paths:
|
||||
|
||||
- `CLAWMANAGER_HERMES_SKILLS_JSON`: read and apply during startup. Do not wait for a command.
|
||||
- `install_skill` command: download and install a platform-uploaded zip skill package at runtime.
|
||||
|
||||
### Bootstrap Manifest
|
||||
|
||||
`CLAWMANAGER_HERMES_BOOTSTRAP_MANIFEST_JSON` describes the payloads injected for the current bootstrap. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"mode": "manual",
|
||||
"resources": [
|
||||
{ "id": 5, "type": "skill", "key": "support-bot", "name": "Support Bot", "version": 1 }
|
||||
],
|
||||
"payloads": [
|
||||
{ "env": "CLAWMANAGER_HERMES_CHANNELS_JSON", "count": 1 },
|
||||
{ "env": "CLAWMANAGER_HERMES_SKILLS_JSON", "count": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The agent can use the manifest as an idempotency key. If the manifest hash has not changed, skip reapplying the same bootstrap payload. If it changes, reapply channel and config skill payloads, then send one state report and one full skill inventory report.
|
||||
|
||||
### Skills Selected During Instance Creation
|
||||
|
||||
When a user selects existing platform skills while creating a Hermes instance, ClawManager attaches those skills to the instance and creates `install_skill` commands. The Hermes agent must implement this command. Otherwise the UI selection only exists in platform records and the skill will not be installed inside the instance.
|
||||
|
||||
Example `install_skill` payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"skill_id": "skill-12",
|
||||
"skill_version": "skill-version-34",
|
||||
"target_name": "weather-tool",
|
||||
"content_md5": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
}
|
||||
```
|
||||
|
||||
Processing steps:
|
||||
|
||||
1. Download the version package:
|
||||
|
||||
```http
|
||||
GET {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/skills/versions/{skill_version}/download
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
2. Validate the zip. Reject absolute paths, `../`, multiple top-level directories, and any path that would extract outside `/config/.hermes/skills`.
|
||||
3. Extract to `/config/.hermes/skills/{target_name}`. Prefer extracting into a temp directory and then atomically replacing the target directory.
|
||||
4. Recalculate directory `content_md5` and compare it with the command payload. Finish the command as `failed` if it does not match.
|
||||
5. Send one `skills/inventory` report. For the installed skill, set `source` to `injected_by_clawmanager` and `skill_id` to the command payload `skill_id`.
|
||||
6. Finish the command with `install_path`, `skill_id`, `skill_version`, and `content_md5` in `result`.
|
||||
|
||||
## Agent Lifecycle
|
||||
|
||||
Hermes agent startup flow:
|
||||
|
||||
1. Read `CLAWMANAGER_AGENT_*` environment variables.
|
||||
2. Read and apply runtime bootstrap payloads if present.
|
||||
3. If no local session token is available, register with the bootstrap token.
|
||||
4. Store the returned session token in `/config/.hermes/hermes-agent/session.json`.
|
||||
5. Send heartbeats using the interval returned by the server.
|
||||
6. Poll commands using the command poll interval returned by the server. If heartbeat returns `has_pending_command=true`, poll once immediately.
|
||||
7. Periodically send full state reports and skill inventory reports.
|
||||
8. If the session token expires or an API returns HTTP 401, register again with the bootstrap token.
|
||||
|
||||
Recommended local agent state directory:
|
||||
|
||||
```text
|
||||
/config/.hermes/hermes-agent/
|
||||
session.json
|
||||
state.json
|
||||
logs/
|
||||
cache/
|
||||
bootstrap/
|
||||
```
|
||||
|
||||
## Registration
|
||||
|
||||
Request:
|
||||
|
||||
```http
|
||||
POST {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/register
|
||||
Authorization: Bearer {CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body example:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance_id": 123,
|
||||
"agent_id": "hermes-123-main",
|
||||
"agent_version": "0.1.0",
|
||||
"protocol_version": "v1",
|
||||
"capabilities": [
|
||||
"runtime.status",
|
||||
"runtime.health",
|
||||
"metrics.report",
|
||||
"skills.inventory",
|
||||
"skills.upload",
|
||||
"commands.poll"
|
||||
],
|
||||
"host_info": {
|
||||
"runtime": "hermes",
|
||||
"desktop_base": "webtop",
|
||||
"persistent_dir": "/config/.hermes",
|
||||
"port": 3001,
|
||||
"arch": "amd64"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The response field `data.session_token` is the token for subsequent Agent API calls. Cache it locally, but never write it to logs.
|
||||
|
||||
## Heartbeat
|
||||
|
||||
Request:
|
||||
|
||||
```http
|
||||
POST {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/heartbeat
|
||||
Authorization: Bearer {session_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body example:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "hermes-123-main",
|
||||
"timestamp": "2026-04-27T14:30:00Z",
|
||||
"openclaw_status": "running",
|
||||
"summary": {
|
||||
"runtime": "hermes",
|
||||
"hermes_status": "running",
|
||||
"hermes_pid": 245,
|
||||
"skill_count": 8,
|
||||
"active_skill_count": 8,
|
||||
"disk_used_bytes": 2147483648,
|
||||
"disk_limit_bytes": 10737418240
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Compatibility requirements:
|
||||
|
||||
- `openclaw_status` is still the platform compatibility field. Fill it with the Hermes main process status.
|
||||
- Recommended status values: `starting`, `running`, `stopped`, `error`, `unknown`.
|
||||
- Default heartbeat interval is roughly 15 seconds, but use `heartbeat_interval_seconds` from the registration response.
|
||||
- ClawManager considers the agent online when heartbeat is received within 45 seconds, stale between 45 and 120 seconds, and offline after 120 seconds.
|
||||
|
||||
## Full State Report
|
||||
|
||||
Heartbeat is the lightweight online signal. Complete runtime status, system metrics, and health information are reported through state reports.
|
||||
|
||||
Request:
|
||||
|
||||
```http
|
||||
POST {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/state/report
|
||||
Authorization: Bearer {session_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body example:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "hermes-123-main",
|
||||
"reported_at": "2026-04-27T14:30:00Z",
|
||||
"runtime": {
|
||||
"openclaw_status": "running",
|
||||
"openclaw_pid": 245,
|
||||
"openclaw_version": "hermes-0.4.0"
|
||||
},
|
||||
"system_info": {
|
||||
"runtime": "hermes",
|
||||
"os": "ubuntu",
|
||||
"desktop_base": "webtop",
|
||||
"sampled_at": "2026-04-27T14:30:00Z",
|
||||
"cpu": {
|
||||
"cores": 2,
|
||||
"load": {
|
||||
"1m": 0.64,
|
||||
"5m": 0.52,
|
||||
"15m": 0.40
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"mem_total_bytes": 4294967296,
|
||||
"mem_available_bytes": 2147483648
|
||||
},
|
||||
"disk": {
|
||||
"mount_path": "/config/.hermes",
|
||||
"root_total_bytes": 10737418240,
|
||||
"root_free_bytes": 8589934592
|
||||
},
|
||||
"network": {
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "eth0",
|
||||
"status": "up",
|
||||
"addresses": ["10.42.0.12"],
|
||||
"rx_bytes": 123456789,
|
||||
"tx_bytes": 98765432
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"hermes_process": "ok",
|
||||
"desktop": "ok",
|
||||
"agent": "ok",
|
||||
"metrics_collector": "ok",
|
||||
"bootstrap_config": "ok",
|
||||
"channels": "ok",
|
||||
"metrics_sample_interval_seconds": 5,
|
||||
"last_skill_scan_at": "2026-04-27T14:29:30Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reporting guidance:
|
||||
|
||||
- Heartbeat: send at the server-provided interval.
|
||||
- State report: send once immediately after startup, then every 5 to 10 seconds when possible.
|
||||
- Send an extra state report after Hermes main process state changes, skill inventory changes, bootstrap config changes, or command completion.
|
||||
|
||||
## Metrics Reporting Contract
|
||||
|
||||
ClawManager does not provide a separate CPU, memory, disk, or network metrics endpoint. Hermes agent must include every sample in the `system_info` field of the state report. The backend stores this JSON as-is, and the instance detail page reads the fields below to render recent trends.
|
||||
|
||||
### Required Fields
|
||||
|
||||
| Path | Type | Unit | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `system_info.sampled_at` | string | ISO 8601 UTC | Agent sampling time. |
|
||||
| `system_info.cpu.cores` | number | cores | CPU cores available to the container. |
|
||||
| `system_info.cpu.load.1m` | number | load average | 1-minute load average. |
|
||||
| `system_info.cpu.load.5m` | number | load average | 5-minute load average. |
|
||||
| `system_info.cpu.load.15m` | number | load average | 15-minute load average. |
|
||||
| `system_info.memory.mem_total_bytes` | number | bytes | Container memory limit or system total memory. |
|
||||
| `system_info.memory.mem_available_bytes` | number | bytes | Currently available memory. |
|
||||
| `system_info.disk.root_total_bytes` | number | bytes | Total capacity of the filesystem containing `/config/.hermes`. |
|
||||
| `system_info.disk.root_free_bytes` | number | bytes | Free capacity of the filesystem containing `/config/.hermes`. |
|
||||
| `system_info.network.interfaces[].name` | string | none | Network interface name, for example `eth0`. |
|
||||
| `system_info.network.interfaces[].status` | string | none | Suggested values: `up` or `down`. |
|
||||
| `system_info.network.interfaces[].rx_bytes` | number | bytes | Monotonic received byte counter. |
|
||||
| `system_info.network.interfaces[].tx_bytes` | number | bytes | Monotonic transmitted byte counter. |
|
||||
|
||||
The frontend calculates CPU percentage as `load.1m / cores * 100`, capped to 0..100. The agent does not need to report `cpu_percent`.
|
||||
|
||||
The frontend calculates memory percentage as `(mem_total_bytes - mem_available_bytes) / mem_total_bytes * 100`, and disk percentage as `(root_total_bytes - root_free_bytes) / root_total_bytes * 100`.
|
||||
|
||||
Network rates are calculated by the frontend from adjacent `rx_bytes` and `tx_bytes` samples. Report monotonic counters, not instantaneous rates. If counters reset after a container restart, the frontend will resume calculation from the next valid sample.
|
||||
|
||||
### Sampling Sources
|
||||
|
||||
- CPU load: read the first three values from `/proc/loadavg`.
|
||||
- CPU cores: prefer cgroup quota. For cgroup v2, read `/sys/fs/cgroup/cpu.max`. If there is no quota, fall back to `/proc/cpuinfo` or the language runtime.
|
||||
- Memory: prefer cgroup memory limit and current usage. For cgroup v2, read `/sys/fs/cgroup/memory.max` and `/sys/fs/cgroup/memory.current`. Compute `mem_available_bytes = memory.max - memory.current`, floored at 0. If no cgroup limit exists, use `/proc/meminfo` `MemTotal` and `MemAvailable`.
|
||||
- Disk: call `statvfs` on `/config/.hermes`. Keep the field names `root_total_bytes` and `root_free_bytes`, but interpret them as the filesystem containing the Hermes persistent directory.
|
||||
- Network: read `/proc/net/dev`. Exclude `lo` by default and keep business interfaces such as `eth0`.
|
||||
|
||||
### Reporting Frequency
|
||||
|
||||
- Send one state report with complete `system_info` immediately after successful startup.
|
||||
- During normal operation, sample and report every 5 seconds to match the instance detail page polling cadence.
|
||||
- If overhead is a concern, 10 seconds is acceptable. Do not sample faster than every 2 seconds.
|
||||
- When receiving `collect_system_info` or `health_check`, sample immediately, send a state report, then finish the command.
|
||||
|
||||
### Command Result Guidance
|
||||
|
||||
`collect_system_info` finish `result` can reuse the same snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "hermes-123-main",
|
||||
"status": "succeeded",
|
||||
"finished_at": "2026-04-27T14:31:05Z",
|
||||
"result": {
|
||||
"sampled_at": "2026-04-27T14:31:05Z",
|
||||
"system_info": {
|
||||
"cpu": {
|
||||
"cores": 2,
|
||||
"load": {
|
||||
"1m": 0.70,
|
||||
"5m": 0.55,
|
||||
"15m": 0.42
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"mem_total_bytes": 4294967296,
|
||||
"mem_available_bytes": 2013265920
|
||||
},
|
||||
"disk": {
|
||||
"mount_path": "/config/.hermes",
|
||||
"root_total_bytes": 10737418240,
|
||||
"root_free_bytes": 8589934592
|
||||
},
|
||||
"network": {
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "eth0",
|
||||
"status": "up",
|
||||
"rx_bytes": 124000000,
|
||||
"tx_bytes": 99000000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"error_message": ""
|
||||
}
|
||||
```
|
||||
|
||||
`health_check` finish `result` should include `health` and `system_info.sampled_at`. If sampling fails, still send available fields in the state report and set `health.metrics_collector` to `error` with a short `health.metrics_error` message.
|
||||
|
||||
### Metrics Acceptance
|
||||
|
||||
1. Hermes agent sends two consecutive state reports roughly 5 seconds apart.
|
||||
2. `GET /api/v1/instances/{instance_id}/runtime` returns `data.runtime.system_info.cpu`, `memory`, `disk`, and `network`.
|
||||
3. The ClawManager instance detail page starts showing CPU, Memory, Disk, and Network metrics within 10 seconds.
|
||||
4. Creating network traffic or disk writes changes the corresponding trend in later samples.
|
||||
|
||||
## Skill Inventory Sync
|
||||
|
||||
Hermes agent must discover skills installed inside the instance and report inventory to ClawManager.
|
||||
|
||||
Recommended skill root:
|
||||
|
||||
```text
|
||||
/config/.hermes/skills
|
||||
```
|
||||
|
||||
Optional environment override:
|
||||
|
||||
```text
|
||||
HERMES_SKILL_DIRS=/config/.hermes/skills
|
||||
```
|
||||
|
||||
Each skill should be managed as a directory. For every skill, calculate `content_md5` and report it:
|
||||
|
||||
```http
|
||||
POST {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/skills/inventory
|
||||
Authorization: Bearer {session_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body example:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "hermes-123-main",
|
||||
"reported_at": "2026-04-27T14:30:00Z",
|
||||
"mode": "full",
|
||||
"trigger": "startup",
|
||||
"skills": [
|
||||
{
|
||||
"skill_id": "hermes-weather",
|
||||
"skill_version": "1.2.0",
|
||||
"identifier": "hermes-weather",
|
||||
"install_path": "/config/.hermes/skills/hermes-weather",
|
||||
"content_md5": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"source": "discovered_in_instance",
|
||||
"type": "hermes-skill",
|
||||
"size_bytes": 20480,
|
||||
"file_count": 12,
|
||||
"metadata": {
|
||||
"runtime": "hermes",
|
||||
"manifest": "skill.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`mode` semantics:
|
||||
|
||||
- `full`: complete inventory. ClawManager marks instance skills missing from this report as removed.
|
||||
- `incremental`: partial update. Only skills in this report are updated.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- Send one `full` inventory after startup.
|
||||
- Use file watching or periodic scanning for later `incremental` updates.
|
||||
- When the platform sends `sync_skill_inventory` or `refresh_skill_inventory`, run a `full` scan.
|
||||
|
||||
### content_md5 Calculation
|
||||
|
||||
ClawManager `content_md5` is a skill directory content fingerprint, not a zip file MD5. The full algorithm is defined in [Skill Content MD5 Calculation Spec](skill-content-md5-spec.md).
|
||||
|
||||
The most common mistake is top-level directory handling:
|
||||
|
||||
- During inventory, calculate against the contents of `/config/.hermes/skills/{skill_name}`.
|
||||
- During upload, the zip must contain one top-level directory named `{skill_name}/`.
|
||||
- ClawManager strips the zip top-level `{skill_name}/` once before validation.
|
||||
- Do not strip internal directories such as `src/`, `lib/`, or `dist/`.
|
||||
|
||||
For example, if the local file is `/config/.hermes/skills/weather/src/main.py`, the relative path used for MD5 must be `src/main.py`, not `weather/src/main.py` and not `main.py`.
|
||||
|
||||
The agent must use the same directory content and the same algorithm for inventory and `collect_skill_package` upload. Otherwise ClawManager will return `skill package md5 mismatch`.
|
||||
|
||||
## Skill Package Upload
|
||||
|
||||
When ClawManager finds a skill blob without object content, it sends a `collect_skill_package` command. Hermes agent should zip the corresponding skill directory and upload it.
|
||||
|
||||
Request:
|
||||
|
||||
```http
|
||||
POST {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/skills/upload
|
||||
Authorization: Bearer {session_token}
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Form fields:
|
||||
|
||||
| Field | Description |
|
||||
| --- | --- |
|
||||
| `file` | Zip package. It must contain exactly one top-level skill directory. |
|
||||
| `agent_id` | Current agent ID. |
|
||||
| `skill_id` | Skill ID from inventory. |
|
||||
| `skill_version` | Skill version from inventory. |
|
||||
| `identifier` | Skill name or key. |
|
||||
| `content_md5` | Directory fingerprint reported in inventory. |
|
||||
| `source` | Usually `discovered_in_instance` or `injected_by_clawmanager`. |
|
||||
|
||||
Zip structure example:
|
||||
|
||||
```text
|
||||
hermes-weather/
|
||||
skill.json
|
||||
main.py
|
||||
README.md
|
||||
```
|
||||
|
||||
Do not upload multiple top-level directories. Do not put loose files at the zip root. ClawManager rejects both formats.
|
||||
|
||||
## Command Polling and Execution
|
||||
|
||||
Hermes agent polls commands with the session token:
|
||||
|
||||
```http
|
||||
GET {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/commands/next
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
If `data.command` is `null`, no command is pending. If a command exists, the agent must:
|
||||
|
||||
1. Call the start endpoint.
|
||||
2. Execute the command.
|
||||
3. Call the finish endpoint with the result.
|
||||
4. Always finish failed commands with `status=failed`.
|
||||
|
||||
Start:
|
||||
|
||||
```http
|
||||
POST {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/commands/{id}/start
|
||||
Authorization: Bearer {session_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "hermes-123-main",
|
||||
"started_at": "2026-04-27T14:31:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Finish:
|
||||
|
||||
```http
|
||||
POST {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/commands/{id}/finish
|
||||
Authorization: Bearer {session_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "hermes-123-main",
|
||||
"status": "succeeded",
|
||||
"finished_at": "2026-04-27T14:31:05Z",
|
||||
"result": {
|
||||
"message": "skill inventory refreshed",
|
||||
"skill_count": 8
|
||||
},
|
||||
"error_message": ""
|
||||
}
|
||||
```
|
||||
|
||||
Current platform command types:
|
||||
|
||||
- `collect_system_info`
|
||||
- `health_check`
|
||||
- `sync_skill_inventory`
|
||||
- `refresh_skill_inventory`
|
||||
- `collect_skill_package`
|
||||
- `install_skill`
|
||||
- `update_skill`
|
||||
- `uninstall_skill`
|
||||
- `remove_skill`
|
||||
- `disable_skill`
|
||||
- `quarantine_skill`
|
||||
- `handle_skill_risk`
|
||||
- `start_openclaw`
|
||||
- `stop_openclaw`
|
||||
- `restart_openclaw`
|
||||
- `apply_config_revision`
|
||||
- `reload_config`
|
||||
|
||||
Minimum Hermes implementation:
|
||||
|
||||
- `collect_system_info`
|
||||
- `health_check`
|
||||
- `sync_skill_inventory`
|
||||
- `refresh_skill_inventory`
|
||||
- `collect_skill_package`
|
||||
- `install_skill`
|
||||
|
||||
Commands containing `openclaw` are currently compatibility names. Hermes may ignore `start_openclaw`, `stop_openclaw`, and `restart_openclaw` until Hermes-specific or generic runtime commands are added.
|
||||
|
||||
## Skill Installation and Version Download
|
||||
|
||||
If Hermes supports platform-managed skill installation, command payloads may include a skill version identifier. The agent can download the zip package through:
|
||||
|
||||
```http
|
||||
GET {CLAWMANAGER_AGENT_BASE_URL}/api/v1/agent/skills/versions/{external_version_id}/download
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
After downloading:
|
||||
|
||||
1. Validate the zip path boundaries.
|
||||
2. Extract it under `/config/.hermes/skills`.
|
||||
3. Recalculate `content_md5`.
|
||||
4. Send `skills/inventory`.
|
||||
5. Finish the command with install path, skill ID, version, and `content_md5`.
|
||||
|
||||
## Local Development
|
||||
|
||||
Use the following variables to run the agent locally:
|
||||
|
||||
```bash
|
||||
export CLAWMANAGER_AGENT_ENABLED=true
|
||||
export CLAWMANAGER_AGENT_BASE_URL=http://127.0.0.1:8080
|
||||
export CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN=agt_boot_xxx
|
||||
export CLAWMANAGER_AGENT_INSTANCE_ID=123
|
||||
export CLAWMANAGER_AGENT_PROTOCOL_VERSION=v1
|
||||
export CLAWMANAGER_AGENT_PERSISTENT_DIR=/config/.hermes
|
||||
export CLAWMANAGER_AGENT_DISK_LIMIT_BYTES=10737418240
|
||||
```
|
||||
|
||||
For local bootstrap testing, also set:
|
||||
|
||||
```bash
|
||||
export CLAWMANAGER_HERMES_CHANNELS_JSON='{}'
|
||||
export CLAWMANAGER_HERMES_SKILLS_JSON='{"schemaVersion":1,"items":[]}'
|
||||
export CLAWMANAGER_HERMES_BOOTSTRAP_MANIFEST_JSON='{"schemaVersion":1,"mode":"manual","payloads":[]}'
|
||||
```
|
||||
|
||||
Never commit real tokens, channel secrets, Gateway API keys, or downloaded session tokens into images, repositories, or logs. At most, log a short token prefix and suffix for debugging.
|
||||
|
||||
## Acceptance Checklist
|
||||
|
||||
Before delivering a Hermes image, verify:
|
||||
|
||||
- The Webtop desktop is reachable through the ClawManager instance proxy.
|
||||
- `/config/.hermes` is mounted and persists across restarts.
|
||||
- The agent registers and starts heartbeat within 30 seconds.
|
||||
- The instance detail page shows agent online, runtime running, and an updated last report time.
|
||||
- CPU, memory, disk, and network metrics are visible and refresh continuously.
|
||||
- Channel bootstrap payloads are applied or clearly reported as unsupported.
|
||||
- Config skill bootstrap payloads are applied and included in inventory.
|
||||
- Skill inventory syncs after changes under the Hermes skill directory.
|
||||
- For discovered skills without stored object content, `collect_skill_package` causes the agent to upload a valid zip package.
|
||||
- `content_md5` in inventory and package upload match the ClawManager specification.
|
||||
- Command execution calls start and finish, including clear `error_message` on failure.
|
||||
- Network interruption, ClawManager restart, or session expiration causes retry and re-registration.
|
||||
|
||||
## Platform-Side Companion Checklist
|
||||
|
||||
ClawManager must keep the following capabilities for Hermes to work end to end:
|
||||
|
||||
- Inject `CLAWMANAGER_AGENT_*` variables for `hermes` during instance creation and start.
|
||||
- Allow `hermes` instances to register with the Agent Control Plane.
|
||||
- Inject `CLAWMANAGER_LLM_*` and OpenAI-compatible variables so Hermes can access models through ClawManager AI Gateway.
|
||||
- Inject Hermes and generic runtime bootstrap variables for channels, skills, and related resources.
|
||||
- Mount persistent storage at `/config/.hermes`.
|
||||
- Support `.hermes` import and export.
|
||||
- Keep compatibility fields such as `openclaw_status`, `openclaw_pid`, and `openclaw_version` until generic runtime fields are introduced.
|
||||
- Add Hermes-specific runtime control commands, or generic `start_runtime`, `stop_runtime`, and `restart_runtime`, if runtime process control becomes required.
|
||||
@@ -0,0 +1,492 @@
|
||||
# Runtime Agent 通用接入规范
|
||||
|
||||
本文定义任意新 runtime 接入 ClawManager Agent Control Plane 的通用方案。后续新增 OpenClaw、Hermes 以外的 runtime 时,应优先遵守本文,再补充该 runtime 自己的镜像构建细节。
|
||||
|
||||
## 接入目标
|
||||
|
||||
每个可托管 runtime 镜像内都应包含一个常驻 agent。Agent 不需要暴露端口,只需要向 ClawManager 发起出站 HTTP 请求,并完成:
|
||||
|
||||
- 注册和 session token 续期。
|
||||
- 心跳和在线状态上报。
|
||||
- 运行时状态、CPU、内存、磁盘、网络、健康信息上报。
|
||||
- skill inventory 同步和 skill 包上传。
|
||||
- 平台命令轮询、执行、完成结果回传。
|
||||
- 使用 ClawManager 注入的 AI Gateway 环境变量访问 LLM。
|
||||
|
||||
Agent 应把 runtime 自身名称写入 `host_info.runtime_type`、`summary.runtime_type`、`system_info.runtime_type`。当前 v1 协议里仍保留 `openclaw_status`、`openclaw_pid`、`openclaw_version` 这几个历史字段名,新 runtime 在协议升级前需要复用这些字段承载自己的主进程状态、PID 和版本。
|
||||
|
||||
## 平台侧新增 runtime type 检查清单
|
||||
|
||||
新增 runtime 不只是做镜像。ClawManager 平台侧至少需要完成下面几项:
|
||||
|
||||
| 模块 | 必做事项 |
|
||||
| --- | --- |
|
||||
| 数据库 | 在 `instances.type` 枚举迁移中加入新的 `runtime_type` |
|
||||
| 后端 runtime 支持 | 在 runtime 类型校验、镜像解析、默认端口、持久化目录、代理路径逻辑中加入新类型 |
|
||||
| 后端托管能力 | 在 `supportsManagedRuntimeIntegration` 加入新类型,否则不会注入 Agent 和 AI Gateway 环境变量,注册也会被拒绝 |
|
||||
| Agent 注册 | 确认注册 allowlist、错误映射、测试覆盖都包含新类型 |
|
||||
| Runtime Image Cards | 在系统镜像设置的支持类型、默认镜像、默认启用项中加入新类型 |
|
||||
| 创建实例页 | 在前端 `INSTANCE_TYPES`、类型文案、图标、默认 env 模板中加入新类型 |
|
||||
| 图标 | 提供 runtime 官方图标或明确授权图标,放入 `frontend/public` 或现有资产路径 |
|
||||
| 测试 | 覆盖 env 注入、agent 注册、runtime 状态上报、创建实例表单、系统镜像卡片 |
|
||||
|
||||
如果新 runtime 采用 Webtop/KasmVNC 基础镜像,默认约定通常是:
|
||||
|
||||
- 桌面端口:`3001`
|
||||
- 持久化目录:`/config`
|
||||
- 代理路径:由 ClawManager 写入 `SUBFOLDER=/api/v1/instances/{instance_id}/proxy/`
|
||||
|
||||
如果新 runtime 不基于 Webtop,必须在平台侧明确它的服务端口、健康检查、代理路径、持久化目录和用户数据目录,不能让镜像和平台各自猜测。
|
||||
|
||||
## 镜像和 Agent 运行要求
|
||||
|
||||
Runtime 镜像必须满足:
|
||||
|
||||
- Agent 随容器启动自动运行,例如 systemd、s6 overlay、supervisord 或入口脚本。
|
||||
- Agent 不占用 runtime 的业务端口或桌面端口。
|
||||
- Agent 通过环境变量读取所有 ClawManager 配置,不写死地址、实例 ID、token、路径。
|
||||
- Agent 的本地状态必须写入持久化目录,例如 `${CLAWMANAGER_AGENT_PERSISTENT_DIR}/<runtime>-agent/`。
|
||||
- Agent 日志中不能打印 bootstrap token、session token、AI Gateway API key。
|
||||
- Agent 可以在 ClawManager 暂时不可达时退避重试,不应退出导致容器不可用。
|
||||
|
||||
建议本地状态目录:
|
||||
|
||||
```text
|
||||
${CLAWMANAGER_AGENT_PERSISTENT_DIR}/<runtime>-agent/
|
||||
session.json
|
||||
state.json
|
||||
logs/
|
||||
cache/
|
||||
```
|
||||
|
||||
## ClawManager 注入的环境变量
|
||||
|
||||
### Agent 控制面
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `CLAWMANAGER_AGENT_ENABLED` | 为 `true` 时启动 agent |
|
||||
| `CLAWMANAGER_AGENT_BASE_URL` | ClawManager API 根地址,不带 `/api/v1/agent` 后缀 |
|
||||
| `CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN` | 首次注册使用的一次性 bootstrap token |
|
||||
| `CLAWMANAGER_AGENT_INSTANCE_ID` | 当前实例 ID |
|
||||
| `CLAWMANAGER_AGENT_PROTOCOL_VERSION` | 当前为 `v1` |
|
||||
| `CLAWMANAGER_AGENT_PERSISTENT_DIR` | 当前实例持久化目录 |
|
||||
| `CLAWMANAGER_AGENT_DISK_LIMIT_BYTES` | 实例磁盘配额字节数 |
|
||||
|
||||
Agent 启动时如果 `CLAWMANAGER_AGENT_ENABLED` 不是 `true`,应进入空闲状态或直接不启动控制面逻辑。
|
||||
|
||||
### AI Gateway
|
||||
|
||||
支持托管 runtime 的实例会被注入 OpenAI-compatible 网关变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `CLAWMANAGER_LLM_BASE_URL` | ClawManager AI Gateway OpenAI-compatible base URL |
|
||||
| `CLAWMANAGER_LLM_API_KEY` | 当前实例专属 Gateway API key |
|
||||
| `CLAWMANAGER_LLM_MODEL` | 平台注入的模型目录 JSON,首项通常包含 `auto` |
|
||||
| `CLAWMANAGER_LLM_PROVIDER` | 当前为 `openai-compatible` |
|
||||
| `CLAWMANAGER_INSTANCE_TOKEN` | 当前实例 token,和 Gateway API key 同源 |
|
||||
| `OPENAI_BASE_URL` | OpenAI SDK 兼容别名 |
|
||||
| `OPENAI_API_BASE` | OpenAI SDK 兼容别名 |
|
||||
| `OPENAI_API_KEY` | OpenAI SDK 兼容别名 |
|
||||
| `OPENAI_MODEL` | 默认模型,通常为 `auto` |
|
||||
|
||||
Runtime 内的应用和 agent 如果需要调用模型,优先使用这些变量,不要让用户在镜像内手工写入 provider key。
|
||||
|
||||
## Agent 生命周期
|
||||
|
||||
推荐主循环:
|
||||
|
||||
1. 读取环境变量,确认 agent 已启用。
|
||||
2. 生成稳定 `agent_id`,建议格式为 `<runtime_type>-<instance_id>-main`。
|
||||
3. 从持久化目录读取 session token;没有 token 时用 bootstrap token 注册。
|
||||
4. 发送一次完整 state report。
|
||||
5. 按注册响应里的 `heartbeat_interval_seconds` 发送 heartbeat。
|
||||
6. 按注册响应里的 `command_poll_interval_seconds` 拉取命令;heartbeat 返回 `has_pending_command=true` 时立即拉取。
|
||||
7. 按 5 到 10 秒间隔采样并发送 state report。
|
||||
8. 定期同步 skill inventory,或在 skill 变化后立即同步。
|
||||
9. 接口返回 401、session 过期或本地 token 丢失时,重新注册。
|
||||
|
||||
注册响应里的 session token 当前为滚动续期。每次成功 heartbeat 都会延长有效期。Agent 仍应能处理 401 并自动重新注册。
|
||||
|
||||
## API 约定
|
||||
|
||||
以下 `{base}` 都表示 `CLAWMANAGER_AGENT_BASE_URL`,认证头均为:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 注册
|
||||
|
||||
```http
|
||||
POST {base}/api/v1/agent/register
|
||||
Authorization: Bearer {CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"instance_id": 123,
|
||||
"agent_id": "myruntime-123-main",
|
||||
"agent_version": "0.1.0",
|
||||
"protocol_version": "v1",
|
||||
"capabilities": [
|
||||
"runtime.status",
|
||||
"runtime.health",
|
||||
"metrics.report",
|
||||
"skills.inventory",
|
||||
"skills.upload",
|
||||
"commands.poll",
|
||||
"llm.gateway"
|
||||
],
|
||||
"host_info": {
|
||||
"runtime_type": "myruntime",
|
||||
"runtime_name": "My Runtime",
|
||||
"image": "registry.example.com/myruntime:latest",
|
||||
"desktop_base": "webtop",
|
||||
"persistent_dir": "/config",
|
||||
"port": 3001,
|
||||
"arch": "amd64"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"session_token": "agt_sess_xxx",
|
||||
"session_expires_at": "2026-04-28T10:00:00Z",
|
||||
"heartbeat_interval_seconds": 15,
|
||||
"command_poll_interval_seconds": 5,
|
||||
"server_time": "2026-04-27T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Agent 必须缓存 `session_token`,后续接口都使用它认证。
|
||||
|
||||
### 心跳
|
||||
|
||||
```http
|
||||
POST {base}/api/v1/agent/heartbeat
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "myruntime-123-main",
|
||||
"timestamp": "2026-04-27T10:00:15Z",
|
||||
"openclaw_status": "running",
|
||||
"summary": {
|
||||
"runtime_type": "myruntime",
|
||||
"runtime_status": "running",
|
||||
"runtime_pid": 245,
|
||||
"runtime_version": "0.4.0",
|
||||
"openclaw_pid": 245,
|
||||
"skill_count": 8,
|
||||
"disk_used_bytes": 2147483648,
|
||||
"disk_limit_bytes": 10737418240
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
兼容要求:
|
||||
|
||||
- `openclaw_status` 当前仍是 v1 状态槽位。新 runtime 填自己的主进程状态。
|
||||
- `summary.openclaw_pid` 是当前后端识别 PID 的兼容别名;建议同时上报 `runtime_pid` 和 `openclaw_pid`,后续协议升级后再收敛。
|
||||
- 状态建议使用 `starting`、`running`、`stopped`、`error`、`unknown`。
|
||||
- 心跳按服务端响应间隔执行,默认约 15 秒。
|
||||
- ClawManager 45 秒内收到心跳显示 online,45 到 120 秒显示 stale,超过 120 秒显示 offline。
|
||||
|
||||
### 完整状态和监测数据上报
|
||||
|
||||
```http
|
||||
POST {base}/api/v1/agent/state/report
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "myruntime-123-main",
|
||||
"reported_at": "2026-04-27T10:00:20Z",
|
||||
"runtime": {
|
||||
"openclaw_status": "running",
|
||||
"openclaw_pid": 245,
|
||||
"openclaw_version": "myruntime-0.4.0",
|
||||
"current_config_revision_id": null
|
||||
},
|
||||
"system_info": {
|
||||
"runtime_type": "myruntime",
|
||||
"runtime_name": "My Runtime",
|
||||
"sampled_at": "2026-04-27T10:00:20Z",
|
||||
"cpu": {
|
||||
"cores": 2,
|
||||
"load": {
|
||||
"1m": 0.64,
|
||||
"5m": 0.52,
|
||||
"15m": 0.40
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"mem_total_bytes": 4294967296,
|
||||
"mem_available_bytes": 2147483648
|
||||
},
|
||||
"disk": {
|
||||
"mount_path": "/config",
|
||||
"root_total_bytes": 10737418240,
|
||||
"root_free_bytes": 8589934592
|
||||
},
|
||||
"network": {
|
||||
"interfaces": [
|
||||
{
|
||||
"name": "eth0",
|
||||
"status": "up",
|
||||
"addresses": ["10.42.0.12"],
|
||||
"rx_bytes": 123456789,
|
||||
"tx_bytes": 98765432
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"runtime_process": "ok",
|
||||
"desktop": "ok",
|
||||
"agent": "ok",
|
||||
"metrics_collector": "ok",
|
||||
"metrics_sample_interval_seconds": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
后端会原样保存 `system_info` 和 `health`。前端实例详情页当前按以下字段绘制指标:
|
||||
|
||||
| 路径 | 类型 | 单位 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `system_info.cpu.cores` | number | 核数 | 容器可用 CPU 核数 |
|
||||
| `system_info.cpu.load.1m` | number | load average | 1 分钟 load |
|
||||
| `system_info.cpu.load.5m` | number | load average | 5 分钟 load |
|
||||
| `system_info.cpu.load.15m` | number | load average | 15 分钟 load |
|
||||
| `system_info.memory.mem_total_bytes` | number | bytes | 内存上限或总内存 |
|
||||
| `system_info.memory.mem_available_bytes` | number | bytes | 可用内存 |
|
||||
| `system_info.disk.root_total_bytes` | number | bytes | 持久化目录所在文件系统总容量 |
|
||||
| `system_info.disk.root_free_bytes` | number | bytes | 持久化目录所在文件系统剩余容量 |
|
||||
| `system_info.network.interfaces[].rx_bytes` | number | bytes | 网卡累计接收字节数 |
|
||||
| `system_info.network.interfaces[].tx_bytes` | number | bytes | 网卡累计发送字节数 |
|
||||
|
||||
CPU 百分比由前端按 `load.1m / cores * 100` 计算。内存百分比由 `(mem_total_bytes - mem_available_bytes) / mem_total_bytes * 100` 计算。磁盘百分比由 `(root_total_bytes - root_free_bytes) / root_total_bytes * 100` 计算。网络速率由相邻两次 `rx_bytes`、`tx_bytes` counter 差值计算,所以 agent 必须上报单调递增的累计 counter,不要把瞬时速率填进这两个字段。
|
||||
|
||||
推荐采样来源:
|
||||
|
||||
- CPU load:`/proc/loadavg`。
|
||||
- CPU cores:优先 cgroup CPU quota,例如 cgroup v2 `/sys/fs/cgroup/cpu.max`;没有 quota 时使用 `/proc/cpuinfo` 或语言运行时 CPU 数。
|
||||
- 内存:优先 cgroup memory limit/current,例如 cgroup v2 `/sys/fs/cgroup/memory.max`、`/sys/fs/cgroup/memory.current`;没有限制时使用 `/proc/meminfo` 的 `MemTotal` 和 `MemAvailable`。
|
||||
- 磁盘:对 `CLAWMANAGER_AGENT_PERSISTENT_DIR` 调用 `statvfs`。
|
||||
- 网络:读取 `/proc/net/dev`,默认排除 `lo`。
|
||||
|
||||
上报频率:
|
||||
|
||||
- 启动成功后立即发送一次。
|
||||
- 正常运行每 5 秒发送一次;资源敏感时可放宽到 10 秒。
|
||||
- 采样间隔不要短于 2 秒。
|
||||
- runtime 状态变化、skill inventory 变化、命令完成后立即补发一次。
|
||||
|
||||
### 命令轮询和执行
|
||||
|
||||
拉取命令:
|
||||
|
||||
```http
|
||||
GET {base}/api/v1/agent/commands/next
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
如果没有命令,响应的 `data` 可能为空。拿到命令后,agent 必须先标记开始,再执行:
|
||||
|
||||
```http
|
||||
POST {base}/api/v1/agent/commands/{id}/start
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "myruntime-123-main",
|
||||
"started_at": "2026-04-27T10:01:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
完成后必须 finish,失败也要 finish:
|
||||
|
||||
```http
|
||||
POST {base}/api/v1/agent/commands/{id}/finish
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "myruntime-123-main",
|
||||
"status": "succeeded",
|
||||
"finished_at": "2026-04-27T10:01:05Z",
|
||||
"result": {
|
||||
"message": "system info collected"
|
||||
},
|
||||
"error_message": ""
|
||||
}
|
||||
```
|
||||
|
||||
当前通用命令类型:
|
||||
|
||||
| 命令 | Agent 行为 |
|
||||
| --- | --- |
|
||||
| `collect_system_info` | 立即采样,发送 state report,并在 finish result 中带上同一份摘要 |
|
||||
| `health_check` | 检查主进程、桌面入口、agent、metrics collector,并发送 state report |
|
||||
| `sync_skill_inventory` | 扫描 skill 目录并上报完整 inventory |
|
||||
| `refresh_skill_inventory` | 重新扫描 skill 目录并上报完整 inventory |
|
||||
| `collect_skill_package` | 打包指定 skill 并上传 |
|
||||
| `install_skill` | 下载并安装平台指定 skill version |
|
||||
| `update_skill` | 更新已安装 skill |
|
||||
| `uninstall_skill` / `remove_skill` | 移除指定 skill |
|
||||
| `disable_skill` | 禁用指定 skill |
|
||||
| `quarantine_skill` | 隔离指定 skill |
|
||||
| `handle_skill_risk` | 按平台风控 payload 处理 skill |
|
||||
| `apply_config_revision` | 获取并应用配置 revision |
|
||||
| `reload_config` | 重新加载 runtime 配置 |
|
||||
|
||||
`start_openclaw`、`stop_openclaw`、`restart_openclaw` 是历史命名命令。非 OpenClaw runtime 不应误执行,除非平台侧明确把它们映射为该 runtime 的启动、停止、重启语义。
|
||||
|
||||
Agent 遇到未知命令时,应 finish 为 `failed`,`error_message` 写明 `unsupported command type: <type>`。
|
||||
|
||||
### Skill inventory
|
||||
|
||||
上报:
|
||||
|
||||
```http
|
||||
POST {base}/api/v1/agent/skills/inventory
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "myruntime-123-main",
|
||||
"reported_at": "2026-04-27T10:02:00Z",
|
||||
"mode": "full",
|
||||
"trigger": "startup",
|
||||
"skills": [
|
||||
{
|
||||
"skill_id": "filesystem-name-or-manifest-id",
|
||||
"skill_version": "1.0.0",
|
||||
"identifier": "vendor.skill-name",
|
||||
"install_path": "/config/myruntime/skills/vendor.skill-name",
|
||||
"content_md5": "0123456789abcdef0123456789abcdef",
|
||||
"source": "runtime",
|
||||
"type": "agent-skill",
|
||||
"size_bytes": 12345,
|
||||
"file_count": 12,
|
||||
"collected_at": "2026-04-27T10:02:00Z",
|
||||
"metadata": {
|
||||
"runtime_type": "myruntime"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- `identifier` 和 `content_md5` 必须稳定。
|
||||
- `mode=full` 表示这次 inventory 是全量结果,平台会用它对齐实例 skill 状态。
|
||||
- skill 内容变化后要重新计算 `content_md5` 并上报。
|
||||
- 如果支持上传 skill 包,使用 `POST {base}/api/v1/agent/skills/upload`,multipart 表单中带 `file`、`agent_id`、`skill_id`、`skill_version`、`identifier`、`content_md5`、`source`。
|
||||
- `content_md5` 必须按目录内容指纹计算,不是 zip 文件 MD5。完整算法见 [Skill Content MD5 Calculation Spec](skill-content-md5-spec.md)。
|
||||
|
||||
### 配置和安装包下载
|
||||
|
||||
应用配置 revision:
|
||||
|
||||
```http
|
||||
GET {base}/api/v1/agent/config/revisions/{id}
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
下载 skill version:
|
||||
|
||||
```http
|
||||
GET {base}/api/v1/agent/skills/versions/{external_version_id}/download
|
||||
Authorization: Bearer {session_token}
|
||||
```
|
||||
|
||||
下载内容只能应用到当前实例,不应泄露到其他实例或日志。
|
||||
|
||||
## 错误处理和重试
|
||||
|
||||
Agent 应按下面规则处理错误:
|
||||
|
||||
| 情况 | 处理 |
|
||||
| --- | --- |
|
||||
| HTTP 401 | 删除本地 session token,使用 bootstrap token 重新注册 |
|
||||
| HTTP 403 | 停止重试当前认证路径,记录简短错误,等待配置修复 |
|
||||
| HTTP 404 | 对命令或资源类请求标记失败,错误写入 finish |
|
||||
| HTTP 429 / 5xx / 网络错误 | 指数退避重试,保留本地状态 |
|
||||
| JSON 校验失败 | 修正 payload;命令执行场景下 finish 为 failed |
|
||||
| 采样部分失败 | 上报可用字段,并在 `health.metrics_collector` 写 `degraded` 或 `error` |
|
||||
|
||||
推荐退避:1s、2s、5s、10s、30s,最大 60s,并加入少量 jitter。
|
||||
|
||||
## 安全要求
|
||||
|
||||
- 不记录 token、API key、完整 Authorization header。
|
||||
- session token 只写入当前实例持久化目录,文件权限建议 `0600`。
|
||||
- 不接受来自用户输入的任意 URL 覆盖 `CLAWMANAGER_AGENT_BASE_URL`。
|
||||
- 上传 skill 包前需要限制目录边界,不能打包持久化目录以外的文件。
|
||||
- 解压平台下发的 skill 包时必须防止 zip slip,例如拒绝 `../` 和绝对路径。
|
||||
- 命令执行要做超时控制,超过 `timeout_seconds` 应主动终止并 finish 为 failed。
|
||||
|
||||
## 验收标准
|
||||
|
||||
新增 runtime agent 接入完成后,至少满足:
|
||||
|
||||
1. 创建实例后容器环境中存在 Agent 和 AI Gateway 变量。
|
||||
2. Agent 能注册成功,实例详情页显示 agent online。
|
||||
3. 心跳持续发送,45 秒内不会变 stale。
|
||||
4. `GET /api/v1/instances/{instance_id}/runtime` 能看到 `runtime.system_info` 和 `runtime.health`。
|
||||
5. CPU、Memory、Disk、Network 指标在实例详情页 10 秒内开始刷新。
|
||||
6. `collect_system_info` 命令能成功完成,并更新 state report。
|
||||
7. `health_check` 命令能成功完成,并体现 runtime、agent、metrics collector 状态。
|
||||
8. Skill inventory 能上报全量结果,skill 变化后能重新同步。
|
||||
9. session token 过期或 401 后能自动重新注册。
|
||||
10. 日志中没有 bootstrap token、session token、AI Gateway API key。
|
||||
|
||||
## 最小实现伪代码
|
||||
|
||||
```text
|
||||
load env
|
||||
if CLAWMANAGER_AGENT_ENABLED != "true":
|
||||
sleep forever
|
||||
|
||||
agent_id = "<runtime_type>-" + CLAWMANAGER_AGENT_INSTANCE_ID + "-main"
|
||||
session = load session from persistent dir
|
||||
if session missing:
|
||||
session = register_with_bootstrap_token()
|
||||
save session
|
||||
|
||||
report_state(sample_system_info(), health_check())
|
||||
sync_skill_inventory(trigger="startup")
|
||||
|
||||
loop:
|
||||
every heartbeat_interval:
|
||||
resp = heartbeat(summary)
|
||||
if resp.has_pending_command:
|
||||
poll_and_execute_commands()
|
||||
|
||||
every command_poll_interval:
|
||||
poll_and_execute_commands()
|
||||
|
||||
every 5 seconds:
|
||||
report_state(sample_system_info(), health_snapshot)
|
||||
|
||||
on 401:
|
||||
delete session
|
||||
session = register_with_bootstrap_token()
|
||||
save session
|
||||
```
|
||||
@@ -0,0 +1,134 @@
|
||||
# Skill Content MD5 Calculation Spec
|
||||
|
||||
本文定义 ClawManager / OpenClaw / Hermes 之间统一使用的 `content_md5` 计算方式。Hermes agent 的 inventory 上报、`collect_skill_package` 上传、`install_skill` 安装后校验,都必须使用同一套算法。
|
||||
|
||||
## 结论
|
||||
|
||||
`content_md5` 不是 zip 文件本身的 MD5,也不包含 zip entry 顺序、压缩等级、mtime、权限等元数据。它是 skill 目录内容的规范化 MD5。
|
||||
|
||||
上传 zip 时,zip 必须包含且只包含一个顶层 skill 目录。ClawManager 会先剥掉这个顶层 skill 目录,再对目录内部内容计算 `content_md5`。
|
||||
|
||||
例如上传包结构为:
|
||||
|
||||
```text
|
||||
weather/
|
||||
skill.json
|
||||
src/
|
||||
main.py
|
||||
```
|
||||
|
||||
实际参与 MD5 的路径是:
|
||||
|
||||
```text
|
||||
skill.json
|
||||
src
|
||||
src/main.py
|
||||
```
|
||||
|
||||
注意:只剥掉 zip 的顶层 skill 目录 `weather/` 一次,不要再剥掉 skill 内部的 `src/`、`lib/`、`dist/` 等目录。
|
||||
|
||||
## 规范化规则
|
||||
|
||||
1. 以 skill 根目录作为基准,收集所有普通文件。
|
||||
2. 路径统一使用 POSIX `/` 分隔符。
|
||||
3. 去掉路径开头的 `./`,并做 clean 处理。
|
||||
4. 跳过空路径、`.`、`..`、包含 `..` 越界语义的路径。
|
||||
5. 跳过任意路径段以 `.` 开头的文件和目录,例如 `.git/config`、`.cache/a`、`.DS_Store`。
|
||||
6. 目录项不直接从文件系统读取,而是由文件路径的父目录推导出来。
|
||||
7. 将目录项和文件项放在同一个列表中,按规范化路径字典序升序排序。
|
||||
8. 对每个目录项写入以下字节:
|
||||
|
||||
```text
|
||||
{relative_path}\n
|
||||
dir\n
|
||||
```
|
||||
|
||||
9. 对每个文件项写入以下字节:
|
||||
|
||||
```text
|
||||
{relative_path}\n
|
||||
file\n
|
||||
{raw_file_bytes}
|
||||
\n
|
||||
```
|
||||
|
||||
10. 对上述连续字节流计算 MD5,输出 32 位小写 hex 字符串。
|
||||
|
||||
不要改写文件内容。不要转换换行符,不要格式化 JSON,不要忽略空文件,不要把文件权限、mtime、owner、zip 压缩参数写入 digest。
|
||||
|
||||
## Hermes Agent Checklist
|
||||
|
||||
Hermes agent 需要检查下面几个点:
|
||||
|
||||
- inventory 上报的 `content_md5` 应该对 `/config/.hermes/skills/{skill_name}` 目录内部内容计算。
|
||||
- 上传 `collect_skill_package` zip 时,zip 内应该有一个顶层目录 `{skill_name}/`。
|
||||
- inventory 阶段和上传阶段必须使用同一份 skill 目录内容计算 MD5。
|
||||
- 如果本地目录是 `/config/.hermes/skills/weather/src/main.py`,参与 MD5 的路径必须是 `src/main.py`,不是 `weather/src/main.py`,也不是 `main.py`。
|
||||
- 如果 `content_md5` 和 ClawManager 返回的 expected 不一致,先检查是否多剥或少剥了顶层目录,其次检查是否把隐藏目录、文件元数据或 zip bytes 算进去了。
|
||||
|
||||
## Python Reference Implementation
|
||||
|
||||
下面实现可直接给 Hermes agent 端对齐算法:
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _is_hidden_relative_path(rel: str) -> bool:
|
||||
return any(part.startswith(".") for part in rel.split("/"))
|
||||
|
||||
|
||||
def skill_content_md5(skill_dir: str | Path) -> str:
|
||||
root = Path(skill_dir).resolve()
|
||||
files: dict[str, bytes] = {}
|
||||
dirs: set[str] = set()
|
||||
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
|
||||
rel = path.relative_to(root).as_posix()
|
||||
if rel.startswith("./"):
|
||||
rel = rel[2:]
|
||||
if not rel or rel == "." or rel.startswith("../") or _is_hidden_relative_path(rel):
|
||||
continue
|
||||
|
||||
files[rel] = path.read_bytes()
|
||||
parts = rel.split("/")
|
||||
for i in range(1, len(parts)):
|
||||
parent = "/".join(parts[:i])
|
||||
if parent and not _is_hidden_relative_path(parent):
|
||||
dirs.add(parent)
|
||||
|
||||
entries: dict[str, str] = {rel: "file" for rel in files}
|
||||
for rel in dirs:
|
||||
entries[rel] = "dir"
|
||||
|
||||
digest = hashlib.md5()
|
||||
for rel in sorted(entries):
|
||||
digest.update(rel.encode("utf-8"))
|
||||
digest.update(b"\n")
|
||||
if entries[rel] == "dir":
|
||||
digest.update(b"dir\n")
|
||||
else:
|
||||
digest.update(b"file\n")
|
||||
digest.update(files[rel])
|
||||
digest.update(b"\n")
|
||||
|
||||
return digest.hexdigest()
|
||||
```
|
||||
|
||||
## Zip Upload Reference
|
||||
|
||||
上传给 ClawManager 的 zip 应保持一个顶层目录:
|
||||
|
||||
```text
|
||||
weather/
|
||||
skill.json
|
||||
src/main.py
|
||||
```
|
||||
|
||||
Hermes agent 在本地计算 MD5 时应对目录 `/config/.hermes/skills/weather` 调用 `skill_content_md5()`。不要对 zip 文件调用 MD5。
|
||||
|
||||
如果 agent 需要在上传前自检,可以先把 zip 解开,确认去掉 `weather/` 后得到的文件列表与本地计算使用的相对路径一致。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
@@ -1682,6 +1682,9 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
openClawWorkspace: "OpenClaw Workspace",
|
||||
openClawWorkspaceDesc:
|
||||
"Import or export the `.openclaw` folder as a `.tar.gz` archive up to 50 MiB.",
|
||||
runtimeWorkspace: "{runtime} Workspace",
|
||||
runtimeWorkspaceDesc:
|
||||
"Import or export the `{directory}` folder as a `.tar.gz` archive up to 50 MiB.",
|
||||
workspaceReady: "Ready",
|
||||
workspacePaused: "Paused",
|
||||
exportingOpenClaw: "Exporting...",
|
||||
@@ -1689,9 +1692,19 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
importingOpenClaw: "Importing...",
|
||||
importOpenClaw: "Import .openclaw",
|
||||
importOpenClawSuccess: "OpenClaw workspace imported successfully.",
|
||||
exportingRuntimeWorkspace: "Exporting...",
|
||||
exportRuntimeWorkspace: "Export {directory}",
|
||||
importingRuntimeWorkspace: "Importing...",
|
||||
importRuntimeWorkspace: "Import {directory}",
|
||||
importRuntimeWorkspaceSuccess:
|
||||
"{runtime} workspace imported successfully.",
|
||||
openClawArchiveTooLarge: "Archive too large. Maximum size is 50 MiB.",
|
||||
importOpenClawFailed: "Failed to import .openclaw: {message}",
|
||||
exportOpenClawFailed: "Failed to export .openclaw: {message}",
|
||||
importRuntimeWorkspaceFailed:
|
||||
"Failed to import {directory}: {message}",
|
||||
exportRuntimeWorkspaceFailed:
|
||||
"Failed to export {directory}: {message}",
|
||||
memoryReserved: "Memory reserved",
|
||||
persistentStorage: "Persistent storage",
|
||||
gpuAttached: "GPU attached",
|
||||
@@ -1705,10 +1718,14 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
optional: "Optional",
|
||||
changeOpenClawArchive: "Change .openclaw Archive",
|
||||
chooseOpenClawArchive: "Choose .openclaw Archive",
|
||||
changeRuntimeArchive: "Change {directory} Archive",
|
||||
chooseRuntimeArchive: "Choose {directory} Archive",
|
||||
remove: "Remove",
|
||||
selectedArchive: "Selected archive: {name}",
|
||||
noArchiveSelected:
|
||||
"No archive selected. The instance will be created with an empty .openclaw workspace.",
|
||||
noRuntimeArchiveSelected:
|
||||
"No archive selected. The instance will be created with an empty {directory} workspace.",
|
||||
quotaValidation: "Quota Validation",
|
||||
unableToLoadQuota:
|
||||
"Unable to load your quota. Create Instance stays disabled until quota is available.",
|
||||
@@ -1739,6 +1756,10 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
description:
|
||||
"Browser-based Linux desktop proxied through ClawManager",
|
||||
},
|
||||
hermes: {
|
||||
label: "Hermes Runtime",
|
||||
description: "Hermes runtime built on the webtop desktop base",
|
||||
},
|
||||
custom: {
|
||||
label: "Custom Image",
|
||||
description: "Use your own custom image",
|
||||
@@ -1797,6 +1818,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
"{name} is a ClawManager built-in variable. Edit it in the built-in section instead.",
|
||||
duplicateEnvName: "Duplicate environment variable name: {name}",
|
||||
openClawInjection: "OpenClaw Injection",
|
||||
runtimeInjection: "{runtime} Injection",
|
||||
selectedCount: "{count} selected",
|
||||
noReusableSkillsSelected: "No reusable skills selected.",
|
||||
noAvailableSkillsForInjection:
|
||||
@@ -1812,6 +1834,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
archiveSkipsChannelInjection:
|
||||
"Archive mode skips resource-based channel injection.",
|
||||
compilingOpenClawPreview: "Compiling OpenClaw resource preview...",
|
||||
compilingRuntimePreview: "Compiling runtime resource preview...",
|
||||
noChannelsSelectedForInjection: "No channels selected for injection.",
|
||||
skillInjection: "Skill Injection",
|
||||
envManagedProxyPath: "ClawManager managed proxy path",
|
||||
@@ -2844,13 +2867,23 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
openClawWorkspace: "OpenClaw 工作区",
|
||||
openClawWorkspaceDesc:
|
||||
"可将 `.openclaw` 文件夹导入或导出为不超过 50 MiB 的 `.tar.gz` 压缩包。",
|
||||
runtimeWorkspace: "{runtime} 工作区",
|
||||
runtimeWorkspaceDesc:
|
||||
"可将 `{directory}` 文件夹导入或导出为不超过 50 MiB 的 `.tar.gz` 压缩包。",
|
||||
workspaceReady: "可操作",
|
||||
workspacePaused: "未就绪",
|
||||
exportingOpenClaw: "正在导出...",
|
||||
exportOpenClaw: "导出 .openclaw",
|
||||
importingOpenClaw: "正在导入...",
|
||||
importOpenClaw: "导入 .openclaw",
|
||||
exportingRuntimeWorkspace: "正在导出...",
|
||||
exportRuntimeWorkspace: "导出 {directory}",
|
||||
importingRuntimeWorkspace: "正在导入...",
|
||||
importRuntimeWorkspace: "导入 {directory}",
|
||||
importRuntimeWorkspaceSuccess: "{runtime} 工作区导入成功。",
|
||||
openClawArchiveTooLarge: "归档过大。最大大小为 50 MiB。",
|
||||
importRuntimeWorkspaceFailed: "导入 {directory} 失败:{message}",
|
||||
exportRuntimeWorkspaceFailed: "导出 {directory} 失败:{message}",
|
||||
memoryReserved: "预留内存",
|
||||
persistentStorage: "持久存储",
|
||||
gpuAttached: "GPU 数量",
|
||||
@@ -2862,9 +2895,13 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
optional: "可选",
|
||||
changeOpenClawArchive: "更换 .openclaw 归档",
|
||||
chooseOpenClawArchive: "选择 .openclaw 归档",
|
||||
changeRuntimeArchive: "更换 {directory} 归档",
|
||||
chooseRuntimeArchive: "选择 {directory} 归档",
|
||||
remove: "移除",
|
||||
selectedArchive: "已选择归档:{name}",
|
||||
noArchiveSelected: "尚未选择归档。实例将以空的 .openclaw 工作区创建。",
|
||||
noRuntimeArchiveSelected:
|
||||
"尚未选择归档。实例将以空的 {directory} 工作区创建。",
|
||||
quotaValidation: "配额校验",
|
||||
unableToLoadQuota:
|
||||
"无法加载你的配额。在配额可用前,创建实例按钮会保持禁用。",
|
||||
@@ -2894,6 +2931,10 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
label: "Webtop 桌面",
|
||||
description: "通过 ClawManager 代理的浏览器 Linux 桌面",
|
||||
},
|
||||
hermes: {
|
||||
label: "Hermes Runtime",
|
||||
description: "基于 Webtop 桌面基础镜像构建的 Hermes 运行时",
|
||||
},
|
||||
custom: {
|
||||
label: "自定义镜像",
|
||||
description: "使用你自己的自定义镜像",
|
||||
@@ -2951,6 +2992,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
"{name} 是 ClawManager 内置变量,请到内置变量区域中编辑。",
|
||||
duplicateEnvName: "环境变量名称重复:{name}",
|
||||
openClawInjection: "OpenClaw 注入",
|
||||
runtimeInjection: "{runtime} 注入",
|
||||
selectedCount: "已选择 {count} 个",
|
||||
noReusableSkillsSelected: "还没有选择可复用技能。",
|
||||
noAvailableSkillsForInjection:
|
||||
@@ -2964,6 +3006,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
channelInjection: "Channel 注入",
|
||||
archiveSkipsChannelInjection: "归档模式会跳过基于资源的 Channel 注入。",
|
||||
compilingOpenClawPreview: "正在生成 OpenClaw 资源预览...",
|
||||
compilingRuntimePreview: "正在生成运行时资源预览...",
|
||||
noChannelsSelectedForInjection: "还没有选择用于注入的 Channel。",
|
||||
skillInjection: "技能注入",
|
||||
envManagedProxyPath: "ClawManager 管理的代理路径",
|
||||
@@ -4010,6 +4053,9 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
openClawWorkspace: "OpenClaw ワークスペース",
|
||||
openClawWorkspaceDesc:
|
||||
"`.openclaw` フォルダーを 50 MiB までの `.tar.gz` アーカイブとしてインポートまたはエクスポートできます。",
|
||||
runtimeWorkspace: "{runtime} ワークスペース",
|
||||
runtimeWorkspaceDesc:
|
||||
"`{directory}` フォルダーを 50 MiB までの `.tar.gz` アーカイブとしてインポートまたはエクスポートできます。",
|
||||
workspaceReady: "利用可能",
|
||||
workspacePaused: "停止中",
|
||||
exportingOpenClaw: "エクスポート中...",
|
||||
@@ -4017,9 +4063,19 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
importingOpenClaw: "インポート中...",
|
||||
importOpenClaw: ".openclaw をインポート",
|
||||
importOpenClawSuccess: "OpenClaw ワークスペースをインポートしました。",
|
||||
exportingRuntimeWorkspace: "エクスポート中...",
|
||||
exportRuntimeWorkspace: "{directory} をエクスポート",
|
||||
importingRuntimeWorkspace: "インポート中...",
|
||||
importRuntimeWorkspace: "{directory} をインポート",
|
||||
importRuntimeWorkspaceSuccess:
|
||||
"{runtime} ワークスペースをインポートしました。",
|
||||
openClawArchiveTooLarge: "アーカイブが大きすぎます。最大サイズは 50 MiB です。",
|
||||
importOpenClawFailed: ".openclaw のインポートに失敗しました: {message}",
|
||||
exportOpenClawFailed: ".openclaw のエクスポートに失敗しました: {message}",
|
||||
importRuntimeWorkspaceFailed:
|
||||
"{directory} のインポートに失敗しました: {message}",
|
||||
exportRuntimeWorkspaceFailed:
|
||||
"{directory} のエクスポートに失敗しました: {message}",
|
||||
memoryReserved: "予約済みメモリ",
|
||||
persistentStorage: "永続ストレージ",
|
||||
gpuAttached: "GPU 接続数",
|
||||
@@ -4033,10 +4089,14 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
optional: "任意",
|
||||
changeOpenClawArchive: ".openclaw アーカイブを変更",
|
||||
chooseOpenClawArchive: ".openclaw アーカイブを選択",
|
||||
changeRuntimeArchive: "{directory} アーカイブを変更",
|
||||
chooseRuntimeArchive: "{directory} アーカイブを選択",
|
||||
remove: "削除",
|
||||
selectedArchive: "選択済みアーカイブ: {name}",
|
||||
noArchiveSelected:
|
||||
"アーカイブが選択されていません。空の .openclaw ワークスペースでインスタンスが作成されます。",
|
||||
noRuntimeArchiveSelected:
|
||||
"アーカイブが選択されていません。空の {directory} ワークスペースでインスタンスが作成されます。",
|
||||
quotaValidation: "クォータ検証",
|
||||
unableToLoadQuota:
|
||||
"クォータを読み込めませんでした。クォータが取得できるまでインスタンス作成は無効のままです。",
|
||||
@@ -4068,6 +4128,11 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
description:
|
||||
"ClawManager 経由でプロキシされるブラウザベースの Linux デスクトップ",
|
||||
},
|
||||
hermes: {
|
||||
label: "Hermes Runtime",
|
||||
description:
|
||||
"Webtop デスクトップベースで構築された Hermes ランタイム",
|
||||
},
|
||||
custom: {
|
||||
label: "カスタムイメージ",
|
||||
description: "独自のカスタムイメージを使用",
|
||||
@@ -4126,6 +4191,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
"{name} は ClawManager の組み込み変数です。組み込みセクションで編集してください。",
|
||||
duplicateEnvName: "重複した環境変数名: {name}",
|
||||
openClawInjection: "OpenClaw 注入",
|
||||
runtimeInjection: "{runtime} 注入",
|
||||
selectedCount: "{count} 件を選択",
|
||||
noReusableSkillsSelected: "再利用可能なスキルが選択されていません。",
|
||||
noAvailableSkillsForInjection:
|
||||
@@ -4140,6 +4206,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
archiveSkipsChannelInjection:
|
||||
"アーカイブモードではリソースベースの Channel 注入をスキップします。",
|
||||
compilingOpenClawPreview: "OpenClaw リソースのプレビューを生成中...",
|
||||
compilingRuntimePreview: "Runtime リソースのプレビューを生成中...",
|
||||
noChannelsSelectedForInjection: "注入する Channel が選択されていません。",
|
||||
skillInjection: "スキル注入",
|
||||
envManagedProxyPath: "ClawManager が管理するプロキシパス",
|
||||
@@ -5200,6 +5267,9 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
openClawWorkspace: "OpenClaw 워크스페이스",
|
||||
openClawWorkspaceDesc:
|
||||
"`.openclaw` 폴더를 최대 50 MiB 크기의 `.tar.gz` 아카이브로 가져오거나 내보낼 수 있습니다.",
|
||||
runtimeWorkspace: "{runtime} 워크스페이스",
|
||||
runtimeWorkspaceDesc:
|
||||
"`{directory}` 폴더를 최대 50 MiB 크기의 `.tar.gz` 아카이브로 가져오거나 내보낼 수 있습니다.",
|
||||
workspaceReady: "준비됨",
|
||||
workspacePaused: "대기 중",
|
||||
exportingOpenClaw: "내보내는 중...",
|
||||
@@ -5207,9 +5277,17 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
importingOpenClaw: "가져오는 중...",
|
||||
importOpenClaw: ".openclaw 가져오기",
|
||||
importOpenClawSuccess: "OpenClaw 워크스페이스를 가져왔습니다.",
|
||||
exportingRuntimeWorkspace: "내보내는 중...",
|
||||
exportRuntimeWorkspace: "{directory} 내보내기",
|
||||
importingRuntimeWorkspace: "가져오는 중...",
|
||||
importRuntimeWorkspace: "{directory} 가져오기",
|
||||
importRuntimeWorkspaceSuccess:
|
||||
"{runtime} 워크스페이스를 가져왔습니다.",
|
||||
openClawArchiveTooLarge: "아카이브가 너무 큽니다. 최대 크기는 50 MiB입니다.",
|
||||
importOpenClawFailed: ".openclaw 가져오기 실패: {message}",
|
||||
exportOpenClawFailed: ".openclaw 내보내기 실패: {message}",
|
||||
importRuntimeWorkspaceFailed: "{directory} 가져오기 실패: {message}",
|
||||
exportRuntimeWorkspaceFailed: "{directory} 내보내기 실패: {message}",
|
||||
memoryReserved: "예약 메모리",
|
||||
persistentStorage: "영구 스토리지",
|
||||
gpuAttached: "연결된 GPU",
|
||||
@@ -5223,10 +5301,14 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
optional: "선택",
|
||||
changeOpenClawArchive: ".openclaw 아카이브 변경",
|
||||
chooseOpenClawArchive: ".openclaw 아카이브 선택",
|
||||
changeRuntimeArchive: "{directory} 아카이브 변경",
|
||||
chooseRuntimeArchive: "{directory} 아카이브 선택",
|
||||
remove: "제거",
|
||||
selectedArchive: "선택된 아카이브: {name}",
|
||||
noArchiveSelected:
|
||||
"선택된 아카이브가 없습니다. 빈 .openclaw 워크스페이스로 인스턴스가 생성됩니다.",
|
||||
noRuntimeArchiveSelected:
|
||||
"선택된 아카이브가 없습니다. 빈 {directory} 워크스페이스로 인스턴스가 생성됩니다.",
|
||||
quotaValidation: "할당량 검증",
|
||||
unableToLoadQuota:
|
||||
"할당량을 불러올 수 없습니다. 할당량 정보를 받을 때까지 인스턴스 생성은 비활성화됩니다.",
|
||||
@@ -5257,6 +5339,10 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
description:
|
||||
"ClawManager를 통해 프록시되는 브라우저 기반 Linux 데스크톱",
|
||||
},
|
||||
hermes: {
|
||||
label: "Hermes Runtime",
|
||||
description: "Webtop 데스크톱 기반으로 빌드된 Hermes 런타임",
|
||||
},
|
||||
custom: {
|
||||
label: "사용자 지정 이미지",
|
||||
description: "직접 준비한 사용자 지정 이미지를 사용합니다",
|
||||
@@ -5314,6 +5400,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
"{name}은(는) ClawManager 내장 변수입니다. 내장 변수 섹션에서 편집하세요.",
|
||||
duplicateEnvName: "중복된 환경 변수 이름: {name}",
|
||||
openClawInjection: "OpenClaw 주입",
|
||||
runtimeInjection: "{runtime} 주입",
|
||||
selectedCount: "{count}개 선택됨",
|
||||
noReusableSkillsSelected: "선택된 재사용 가능 스킬이 없습니다.",
|
||||
noAvailableSkillsForInjection:
|
||||
@@ -5329,6 +5416,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
archiveSkipsChannelInjection:
|
||||
"아카이브 모드는 리소스 기반 Channel 주입을 건너뜁니다.",
|
||||
compilingOpenClawPreview: "OpenClaw 리소스 미리보기를 생성하는 중...",
|
||||
compilingRuntimePreview: "Runtime 리소스 미리보기를 생성하는 중...",
|
||||
noChannelsSelectedForInjection: "주입할 Channel 이 선택되지 않았습니다.",
|
||||
skillInjection: "스킬 주입",
|
||||
envManagedProxyPath: "ClawManager가 관리하는 프록시 경로",
|
||||
@@ -6407,6 +6495,9 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
openClawWorkspace: "OpenClaw-Arbeitsbereich",
|
||||
openClawWorkspaceDesc:
|
||||
"Importieren oder exportieren Sie den Ordner `.openclaw` als `.tar.gz`-Archiv bis 50 MiB.",
|
||||
runtimeWorkspace: "{runtime}-Arbeitsbereich",
|
||||
runtimeWorkspaceDesc:
|
||||
"Importieren oder exportieren Sie den Ordner `{directory}` als `.tar.gz`-Archiv bis 50 MiB.",
|
||||
workspaceReady: "Bereit",
|
||||
workspacePaused: "Pausiert",
|
||||
exportingOpenClaw: "Export wird erstellt...",
|
||||
@@ -6414,9 +6505,19 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
importingOpenClaw: "Import läuft...",
|
||||
importOpenClaw: ".openclaw importieren",
|
||||
importOpenClawSuccess: "OpenClaw-Arbeitsbereich erfolgreich importiert.",
|
||||
exportingRuntimeWorkspace: "Export wird erstellt...",
|
||||
exportRuntimeWorkspace: "{directory} exportieren",
|
||||
importingRuntimeWorkspace: "Import läuft...",
|
||||
importRuntimeWorkspace: "{directory} importieren",
|
||||
importRuntimeWorkspaceSuccess:
|
||||
"{runtime}-Arbeitsbereich erfolgreich importiert.",
|
||||
openClawArchiveTooLarge: "Archiv zu groß. Die maximale Größe beträgt 50 MiB.",
|
||||
importOpenClawFailed: ".openclaw-Import fehlgeschlagen: {message}",
|
||||
exportOpenClawFailed: ".openclaw-Export fehlgeschlagen: {message}",
|
||||
importRuntimeWorkspaceFailed:
|
||||
"{directory}-Import fehlgeschlagen: {message}",
|
||||
exportRuntimeWorkspaceFailed:
|
||||
"{directory}-Export fehlgeschlagen: {message}",
|
||||
memoryReserved: "Reservierter Speicher",
|
||||
persistentStorage: "Persistenter Speicher",
|
||||
gpuAttached: "Zugewiesene GPU",
|
||||
@@ -6430,10 +6531,14 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
optional: "Optional",
|
||||
changeOpenClawArchive: ".openclaw-Archiv ändern",
|
||||
chooseOpenClawArchive: ".openclaw-Archiv auswählen",
|
||||
changeRuntimeArchive: "{directory}-Archiv ändern",
|
||||
chooseRuntimeArchive: "{directory}-Archiv auswählen",
|
||||
remove: "Entfernen",
|
||||
selectedArchive: "Ausgewähltes Archiv: {name}",
|
||||
noArchiveSelected:
|
||||
"Kein Archiv ausgewählt. Die Instanz wird mit einem leeren .openclaw-Arbeitsbereich erstellt.",
|
||||
noRuntimeArchiveSelected:
|
||||
"Kein Archiv ausgewählt. Die Instanz wird mit einem leeren {directory}-Arbeitsbereich erstellt.",
|
||||
quotaValidation: "Kontingentprüfung",
|
||||
unableToLoadQuota:
|
||||
"Ihr Kontingent konnte nicht geladen werden. Instanz erstellen bleibt deaktiviert, bis das Kontingent verfügbar ist.",
|
||||
@@ -6464,6 +6569,11 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
description:
|
||||
"Browserbasierter Linux-Desktop, der über ClawManager bereitgestellt wird",
|
||||
},
|
||||
hermes: {
|
||||
label: "Hermes Runtime",
|
||||
description:
|
||||
"Hermes-Runtime auf Basis des Webtop-Desktop-Images",
|
||||
},
|
||||
custom: {
|
||||
label: "Benutzerdefiniertes Image",
|
||||
description: "Eigenes benutzerdefiniertes Image verwenden",
|
||||
@@ -6525,6 +6635,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
"{name} ist eine ClawManager-Built-in-Variable. Bearbeiten Sie sie stattdessen im Built-in-Bereich.",
|
||||
duplicateEnvName: "Doppelter Umgebungsvariablenname: {name}",
|
||||
openClawInjection: "OpenClaw-Injektion",
|
||||
runtimeInjection: "{runtime}-Injektion",
|
||||
selectedCount: "{count} ausgewählt",
|
||||
noReusableSkillsSelected: "Keine wiederverwendbaren Skills ausgewählt.",
|
||||
noAvailableSkillsForInjection:
|
||||
@@ -6540,6 +6651,7 @@ export const translations: Record<Locale, TranslationTree> = {
|
||||
archiveSkipsChannelInjection:
|
||||
"Der Archivmodus überspringt die ressourcenbasierte Channel-Injektion.",
|
||||
compilingOpenClawPreview: "OpenClaw-Ressourcenvorschau wird erstellt...",
|
||||
compilingRuntimePreview: "Runtime-Ressourcenvorschau wird erstellt...",
|
||||
noChannelsSelectedForInjection:
|
||||
"Keine Channels für die Injektion ausgewählt.",
|
||||
skillInjection: "Skill-Injektion",
|
||||
|
||||
@@ -8,9 +8,10 @@ import {
|
||||
} from '../../services/systemSettingsService';
|
||||
|
||||
const IMAGE_TYPE_OPTIONS = [
|
||||
{ value: 'openclaw', label: 'OpenClaw Desktop', defaultImage: 'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest' },
|
||||
{ value: 'openclaw', label: 'OpenClaw Desktop', defaultImage: 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/openclaw:latest' },
|
||||
{ value: 'ubuntu', label: 'Ubuntu Desktop', defaultImage: 'lscr.io/linuxserver/webtop:ubuntu-xfce' },
|
||||
{ value: 'webtop', label: 'Webtop Desktop', defaultImage: 'lscr.io/linuxserver/webtop:ubuntu-xfce' },
|
||||
{ value: 'hermes', label: 'Hermes Runtime', defaultImage: 'ghcr.io/Yuan-lab-LLM/AgentsRuntime/hermes:latest' },
|
||||
{ value: 'debian', label: 'Debian Desktop', defaultImage: 'docker.io/clawreef/debian-desktop:12' },
|
||||
{ value: 'centos', label: 'CentOS Desktop', defaultImage: 'docker.io/clawreef/centos-desktop:9' },
|
||||
{ value: 'custom', label: 'Custom Image', defaultImage: 'registry.example.com/your-custom-image:latest' },
|
||||
|
||||
@@ -45,6 +45,15 @@ const AGENT_PROTOCOL_VERSION = "v1";
|
||||
const CUSTOM_RESOURCE_PRESET = "custom";
|
||||
const SKILLS_PER_PAGE = 6;
|
||||
|
||||
const supportsRuntimeInjection = (type: string) =>
|
||||
type === "openclaw" || type === "hermes";
|
||||
|
||||
const runtimeWorkspaceDirectory = (type: string) =>
|
||||
type === "hermes" ? ".hermes" : ".openclaw";
|
||||
|
||||
const runtimeProductName = (type: string) =>
|
||||
type === "hermes" ? "Hermes" : "OpenClaw";
|
||||
|
||||
const INSTANCE_TYPE_I18N_KEYS: Record<
|
||||
string,
|
||||
{ label: string; description: string }
|
||||
@@ -69,6 +78,10 @@ const INSTANCE_TYPE_I18N_KEYS: Record<
|
||||
label: "instances.typeOptions.webtop.label",
|
||||
description: "instances.typeOptions.webtop.description",
|
||||
},
|
||||
hermes: {
|
||||
label: "instances.typeOptions.hermes.label",
|
||||
description: "instances.typeOptions.hermes.description",
|
||||
},
|
||||
custom: {
|
||||
label: "instances.typeOptions.custom.label",
|
||||
description: "instances.typeOptions.custom.description",
|
||||
@@ -97,6 +110,7 @@ const getBuiltInEnvTemplates = (
|
||||
diskGb: number,
|
||||
): BuiltInEnvTemplate[] => {
|
||||
const templates: BuiltInEnvTemplate[] = [];
|
||||
const persistentDir = type === "hermes" ? "/config/.hermes" : "/config";
|
||||
|
||||
if (type === "ubuntu") {
|
||||
templates.push(
|
||||
@@ -128,6 +142,101 @@ const getBuiltInEnvTemplates = (
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "hermes") {
|
||||
templates.push(
|
||||
{
|
||||
key: "TITLE",
|
||||
description: t("instances.envDescDesktopTitleWebtop"),
|
||||
defaultValue: "Hermes Runtime",
|
||||
},
|
||||
{
|
||||
key: "SUBFOLDER",
|
||||
description: t("instances.envDescProxySubfolder"),
|
||||
defaultLabel: t("instances.envManagedProxyPath"),
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_LLM_BASE_URL",
|
||||
description: t("instances.envDescLlmBaseUrl"),
|
||||
defaultLabel: t("instances.envGeneratedAtRuntime"),
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_LLM_API_KEY",
|
||||
description: t("instances.envDescLlmApiKey"),
|
||||
defaultLabel: t("instances.envGeneratedAtRuntime"),
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_LLM_MODEL",
|
||||
description: t("instances.envDescLlmModel"),
|
||||
defaultLabel: t("instances.envGeneratedAtRuntime"),
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_LLM_PROVIDER",
|
||||
description: t("instances.envDescLlmProvider"),
|
||||
defaultValue: "openai-compatible",
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_INSTANCE_TOKEN",
|
||||
description: t("instances.envDescInstanceToken"),
|
||||
defaultLabel: t("instances.envGeneratedAtRuntime"),
|
||||
},
|
||||
{
|
||||
key: "OPENAI_BASE_URL",
|
||||
description: t("instances.envDescOpenAiBaseUrl"),
|
||||
defaultLabel: t("instances.envGeneratedAtRuntime"),
|
||||
},
|
||||
{
|
||||
key: "OPENAI_API_BASE",
|
||||
description: t("instances.envDescOpenAiApiBase"),
|
||||
defaultLabel: t("instances.envGeneratedAtRuntime"),
|
||||
},
|
||||
{
|
||||
key: "OPENAI_API_KEY",
|
||||
description: t("instances.envDescOpenAiApiKey"),
|
||||
defaultLabel: t("instances.envGeneratedAtRuntime"),
|
||||
},
|
||||
{
|
||||
key: "OPENAI_MODEL",
|
||||
description: t("instances.envDescOpenAiModel"),
|
||||
defaultValue: "auto",
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_AGENT_ENABLED",
|
||||
description: t("instances.envDescAgentEnabled"),
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_AGENT_BASE_URL",
|
||||
description: t("instances.envDescAgentBaseUrl"),
|
||||
defaultLabel: t("instances.envGeneratedAtRuntime"),
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN",
|
||||
description: t("instances.envDescAgentBootstrapToken"),
|
||||
defaultLabel: t("instances.envGeneratedAtRuntime"),
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_AGENT_DISK_LIMIT_BYTES",
|
||||
description: t("instances.envDescAgentDiskLimitBytes"),
|
||||
defaultValue: String(diskGb * BYTES_PER_GIB),
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_AGENT_INSTANCE_ID",
|
||||
description: t("instances.envDescAgentInstanceId"),
|
||||
defaultLabel: t("instances.envAssignedAfterCreation"),
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_AGENT_PERSISTENT_DIR",
|
||||
description: t("instances.envDescAgentPersistentDir"),
|
||||
defaultValue: persistentDir,
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_AGENT_PROTOCOL_VERSION",
|
||||
description: t("instances.envDescAgentProtocolVersion"),
|
||||
defaultValue: AGENT_PROTOCOL_VERSION,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "openclaw") {
|
||||
templates.push(
|
||||
{
|
||||
@@ -213,7 +322,7 @@ const getBuiltInEnvTemplates = (
|
||||
{
|
||||
key: "CLAWMANAGER_AGENT_PERSISTENT_DIR",
|
||||
description: t("instances.envDescAgentPersistentDir"),
|
||||
defaultValue: "/config",
|
||||
defaultValue: persistentDir,
|
||||
},
|
||||
{
|
||||
key: "CLAWMANAGER_AGENT_PROTOCOL_VERSION",
|
||||
@@ -468,7 +577,7 @@ const CreateInstancePage: React.FC = () => {
|
||||
const handleTypeSelect = (typeId: string) => {
|
||||
const instanceType = availableTypes.find((t) => t.id === typeId);
|
||||
if (instanceType) {
|
||||
if (typeId !== "openclaw") {
|
||||
if (!supportsRuntimeInjection(typeId)) {
|
||||
setOpenClawImportFile(null);
|
||||
setOpenClawInjectionMode("none");
|
||||
setOpenClawBundleId(undefined);
|
||||
@@ -624,13 +733,15 @@ const CreateInstancePage: React.FC = () => {
|
||||
image_registry: selectedRuntimeImage?.image,
|
||||
image_tag: selectedRuntimeImage ? undefined : formData.image_tag,
|
||||
environment_overrides: overrides,
|
||||
skill_ids: formData.type === "openclaw" ? selectedSkillIds : undefined,
|
||||
skill_ids: supportsRuntimeInjection(formData.type)
|
||||
? selectedSkillIds
|
||||
: undefined,
|
||||
openclaw_config_plan:
|
||||
formData.type === "openclaw" &&
|
||||
supportsRuntimeInjection(formData.type) &&
|
||||
openClawInjectionMode === "bundle" &&
|
||||
openClawBundleId
|
||||
? { mode: "bundle", bundle_id: openClawBundleId }
|
||||
: formData.type === "openclaw" &&
|
||||
: supportsRuntimeInjection(formData.type) &&
|
||||
openClawInjectionMode === "manual" &&
|
||||
openClawResourceIds.length > 0
|
||||
? { mode: "manual", resource_ids: openClawResourceIds }
|
||||
@@ -641,15 +752,22 @@ const CreateInstancePage: React.FC = () => {
|
||||
await instanceService.createInstance(createPayload);
|
||||
|
||||
if (
|
||||
formData.type === "openclaw" &&
|
||||
supportsRuntimeInjection(formData.type) &&
|
||||
openClawInjectionMode === "archive" &&
|
||||
openClawImportFile
|
||||
) {
|
||||
await waitForInstanceRunning(createdInstance.id);
|
||||
await instanceService.importOpenClawWorkspace(
|
||||
createdInstance.id,
|
||||
openClawImportFile,
|
||||
);
|
||||
if (formData.type === "hermes") {
|
||||
await instanceService.importHermesWorkspace(
|
||||
createdInstance.id,
|
||||
openClawImportFile,
|
||||
);
|
||||
} else {
|
||||
await instanceService.importOpenClawWorkspace(
|
||||
createdInstance.id,
|
||||
openClawImportFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
navigate("/instances");
|
||||
@@ -759,7 +877,7 @@ const CreateInstancePage: React.FC = () => {
|
||||
const exceededQuotaItems = quotaChecks.filter((item) => item.exceeded);
|
||||
const quotaExceeded = exceededQuotaItems.length > 0;
|
||||
const openClawPlanInvalid =
|
||||
formData.type === "openclaw" &&
|
||||
supportsRuntimeInjection(formData.type) &&
|
||||
((openClawInjectionMode === "bundle" &&
|
||||
(!openClawBundleId ||
|
||||
!!openClawPreviewError ||
|
||||
@@ -820,6 +938,16 @@ const CreateInstancePage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (typeId === "hermes") {
|
||||
return (
|
||||
<img
|
||||
src="/hermes.png"
|
||||
alt="Hermes"
|
||||
className="h-10 w-10 object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="h-6 w-6 text-indigo-600"
|
||||
@@ -1586,7 +1714,7 @@ const CreateInstancePage: React.FC = () => {
|
||||
{quotaLoading
|
||||
? t("instances.checkingQuota")
|
||||
: loading
|
||||
? formData.type === "openclaw" &&
|
||||
? supportsRuntimeInjection(formData.type) &&
|
||||
openClawInjectionMode === "archive" &&
|
||||
openClawImportFile
|
||||
? t("instances.creatingAndImporting")
|
||||
@@ -1596,12 +1724,14 @@ const CreateInstancePage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.type === "openclaw" && (
|
||||
{supportsRuntimeInjection(formData.type) && (
|
||||
<div className="app-panel order-2 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-gray-900">
|
||||
{t("instances.openClawInjection")}
|
||||
{t("instances.runtimeInjection", {
|
||||
runtime: runtimeProductName(formData.type),
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-600">
|
||||
@@ -1798,8 +1928,16 @@ const CreateInstancePage: React.FC = () => {
|
||||
className="app-button-secondary"
|
||||
>
|
||||
{openClawImportFile
|
||||
? t("instances.changeOpenClawArchive")
|
||||
: t("instances.chooseOpenClawArchive")}
|
||||
? t("instances.changeRuntimeArchive", {
|
||||
directory: runtimeWorkspaceDirectory(
|
||||
formData.type,
|
||||
),
|
||||
})
|
||||
: t("instances.chooseRuntimeArchive", {
|
||||
directory: runtimeWorkspaceDirectory(
|
||||
formData.type,
|
||||
),
|
||||
})}
|
||||
</button>
|
||||
{openClawImportFile && (
|
||||
<button
|
||||
@@ -1822,7 +1960,11 @@ const CreateInstancePage: React.FC = () => {
|
||||
? t("instances.selectedArchive", {
|
||||
name: openClawImportFile.name,
|
||||
})
|
||||
: t("instances.noArchiveSelected")}
|
||||
: t("instances.noRuntimeArchiveSelected", {
|
||||
directory: runtimeWorkspaceDirectory(
|
||||
formData.type,
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1966,7 +2108,7 @@ const CreateInstancePage: React.FC = () => {
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{formData.type === "openclaw" && (
|
||||
{supportsRuntimeInjection(formData.type) && (
|
||||
<>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
@@ -1978,7 +2120,7 @@ const CreateInstancePage: React.FC = () => {
|
||||
</p>
|
||||
) : openClawPreviewLoading ? (
|
||||
<p className="mt-2 text-sm text-gray-400">
|
||||
{t("instances.compilingOpenClawPreview")}
|
||||
{t("instances.compilingRuntimePreview")}
|
||||
</p>
|
||||
) : openClawPreviewError ? (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
|
||||
@@ -23,6 +23,18 @@ const RUNTIME_BURST_WINDOW_MS = 15000;
|
||||
const METRIC_WINDOW_MS = 5 * 60 * 1000;
|
||||
const INSTANCE_SKILL_PAGE_SIZE = 5;
|
||||
|
||||
const supportsRuntimeWorkspace = (type: string) =>
|
||||
type === "openclaw" || type === "hermes";
|
||||
|
||||
const supportsRuntimeSkillManagement = (type: string) =>
|
||||
type === "openclaw" || type === "hermes";
|
||||
|
||||
const runtimeWorkspaceDirectory = (type: string) =>
|
||||
type === "hermes" ? ".hermes" : ".openclaw";
|
||||
|
||||
const runtimeProductName = (type: string) =>
|
||||
type === "hermes" ? "Hermes" : "OpenClaw";
|
||||
|
||||
// describeOpenClawError extracts a user-facing message from an axios error.
|
||||
// When the server returned a structured JSON body it surfaces the `error`
|
||||
// field; otherwise it falls back to the HTTP status (e.g. plain-HTML 413
|
||||
@@ -530,23 +542,31 @@ const InstanceDetailPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportOpenClaw = async () => {
|
||||
const handleExportWorkspace = async () => {
|
||||
if (!instance) return;
|
||||
|
||||
const directory = runtimeWorkspaceDirectory(instance.type);
|
||||
const runtime = runtimeProductName(instance.type);
|
||||
|
||||
try {
|
||||
setActionLoading("export-openclaw");
|
||||
const blob = await instanceService.exportOpenClawWorkspace(instance.id);
|
||||
setActionLoading("export-workspace");
|
||||
const blob =
|
||||
instance.type === "hermes"
|
||||
? await instanceService.exportHermesWorkspace(instance.id)
|
||||
: await instanceService.exportOpenClawWorkspace(instance.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${instance.name || "openclaw-workspace"}.openclaw.tar.gz`;
|
||||
link.download = `${instance.name || `${instance.type}-workspace`}${directory}.tar.gz`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err: any) {
|
||||
alert(
|
||||
t("instances.exportOpenClawFailed", {
|
||||
t("instances.exportRuntimeWorkspaceFailed", {
|
||||
runtime,
|
||||
directory,
|
||||
message: describeOpenClawError(err, t),
|
||||
}),
|
||||
);
|
||||
@@ -555,17 +575,31 @@ const InstanceDetailPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportOpenClaw = async (file?: File | null) => {
|
||||
const handleImportWorkspace = async (file?: File | null) => {
|
||||
if (!instance || !file) return;
|
||||
|
||||
const directory = runtimeWorkspaceDirectory(instance.type);
|
||||
const runtime = runtimeProductName(instance.type);
|
||||
|
||||
try {
|
||||
setActionLoading("import-openclaw");
|
||||
await instanceService.importOpenClawWorkspace(instance.id, file);
|
||||
setActionLoading("import-workspace");
|
||||
if (instance.type === "hermes") {
|
||||
await instanceService.importHermesWorkspace(instance.id, file);
|
||||
} else {
|
||||
await instanceService.importOpenClawWorkspace(instance.id, file);
|
||||
}
|
||||
await fetchRuntime(instance.id, { background: true });
|
||||
alert(t("instances.importOpenClawSuccess"));
|
||||
alert(
|
||||
t("instances.importRuntimeWorkspaceSuccess", {
|
||||
runtime,
|
||||
directory,
|
||||
}),
|
||||
);
|
||||
} catch (err: any) {
|
||||
alert(
|
||||
t("instances.importOpenClawFailed", {
|
||||
t("instances.importRuntimeWorkspaceFailed", {
|
||||
runtime,
|
||||
directory,
|
||||
message: describeOpenClawError(err, t),
|
||||
}),
|
||||
);
|
||||
@@ -824,7 +858,7 @@ const InstanceDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{instance.type === "openclaw" && (
|
||||
{supportsRuntimeWorkspace(instance.type) && (
|
||||
<section className="app-panel px-5 py-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="max-w-xl">
|
||||
@@ -832,27 +866,33 @@ const InstanceDetailPage: React.FC = () => {
|
||||
{t("instances.workspaceSection")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-[1.35rem] font-semibold tracking-[-0.03em] text-[#1d1713]">
|
||||
{t("instances.openClawWorkspace")}
|
||||
{t("instances.runtimeWorkspace", {
|
||||
runtime: runtimeProductName(instance.type),
|
||||
})}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-[#7a6d66]">
|
||||
{t("instances.openClawWorkspaceDesc")}
|
||||
{t("instances.runtimeWorkspaceDesc", {
|
||||
directory: runtimeWorkspaceDirectory(instance.type),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-3 lg:max-w-[320px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportOpenClaw}
|
||||
onClick={handleExportWorkspace}
|
||||
disabled={
|
||||
effectiveInstanceStatus !== "running" ||
|
||||
actionLoading === "export-openclaw" ||
|
||||
actionLoading === "import-openclaw"
|
||||
actionLoading === "export-workspace" ||
|
||||
actionLoading === "import-workspace"
|
||||
}
|
||||
className="app-button-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "export-openclaw"
|
||||
? t("instances.exportingOpenClaw")
|
||||
: t("instances.exportOpenClaw")}
|
||||
{actionLoading === "export-workspace"
|
||||
? t("instances.exportingRuntimeWorkspace")
|
||||
: t("instances.exportRuntimeWorkspace", {
|
||||
directory: runtimeWorkspaceDirectory(instance.type),
|
||||
})}
|
||||
</button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
@@ -860,7 +900,7 @@ const InstanceDetailPage: React.FC = () => {
|
||||
accept=".tar.gz,.tgz,application/gzip,application/x-gzip,application/octet-stream"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
handleImportOpenClaw(e.target.files?.[0] || null)
|
||||
handleImportWorkspace(e.target.files?.[0] || null)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
@@ -868,14 +908,16 @@ const InstanceDetailPage: React.FC = () => {
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
disabled={
|
||||
effectiveInstanceStatus !== "running" ||
|
||||
actionLoading === "export-openclaw" ||
|
||||
actionLoading === "import-openclaw"
|
||||
actionLoading === "export-workspace" ||
|
||||
actionLoading === "import-workspace"
|
||||
}
|
||||
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "import-openclaw"
|
||||
? t("instances.importingOpenClaw")
|
||||
: t("instances.importOpenClaw")}
|
||||
{actionLoading === "import-workspace"
|
||||
? t("instances.importingRuntimeWorkspace")
|
||||
: t("instances.importRuntimeWorkspace", {
|
||||
directory: runtimeWorkspaceDirectory(instance.type),
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -935,7 +977,7 @@ const InstanceDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{instance.type === "openclaw" && (
|
||||
{supportsRuntimeSkillManagement(instance.type) && (
|
||||
<section className="app-panel px-6 py-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
|
||||
@@ -418,6 +418,8 @@ const InstanceListPage: React.FC = () => {
|
||||
return 'CE';
|
||||
case 'openclaw':
|
||||
return 'OC';
|
||||
case 'hermes':
|
||||
return 'HM';
|
||||
default:
|
||||
return 'VM';
|
||||
}
|
||||
|
||||
@@ -155,6 +155,23 @@ export const instanceService = {
|
||||
});
|
||||
},
|
||||
|
||||
exportHermesWorkspace: async (id: number): Promise<Blob> => {
|
||||
const response = await api.get(`/instances/${id}/hermes/export`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
importHermesWorkspace: async (id: number, file: File): Promise<void> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
await api.post(`/instances/${id}/hermes/import`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
listSkills: async (id: number): Promise<InstanceSkill[]> => {
|
||||
const response = await api.get(`/instances/${id}/skills`);
|
||||
return response.data.data;
|
||||
|
||||
@@ -6,7 +6,14 @@ export interface Instance {
|
||||
user_id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: "openclaw" | "ubuntu" | "debian" | "centos" | "custom" | "webtop";
|
||||
type:
|
||||
| "openclaw"
|
||||
| "ubuntu"
|
||||
| "debian"
|
||||
| "centos"
|
||||
| "custom"
|
||||
| "webtop"
|
||||
| "hermes";
|
||||
status: "creating" | "running" | "stopped" | "error" | "deleting";
|
||||
cpu_cores: number;
|
||||
memory_gb: number;
|
||||
@@ -109,7 +116,14 @@ export interface InstanceConfigRevision {
|
||||
export interface CreateInstanceRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: "openclaw" | "ubuntu" | "debian" | "centos" | "custom" | "webtop";
|
||||
type:
|
||||
| "openclaw"
|
||||
| "ubuntu"
|
||||
| "debian"
|
||||
| "centos"
|
||||
| "custom"
|
||||
| "webtop"
|
||||
| "hermes";
|
||||
cpu_cores: number;
|
||||
memory_gb: number;
|
||||
disk_gb: number;
|
||||
@@ -187,6 +201,14 @@ export const INSTANCE_TYPES: InstanceType[] = [
|
||||
defaultOs: "ubuntu",
|
||||
defaultVersion: "xfce",
|
||||
},
|
||||
{
|
||||
id: "hermes",
|
||||
name: "Hermes Runtime",
|
||||
description: "Hermes runtime built on the webtop desktop base",
|
||||
icon: "hermes",
|
||||
defaultOs: "hermes",
|
||||
defaultVersion: "latest",
|
||||
},
|
||||
{
|
||||
id: "custom",
|
||||
name: "Custom Image",
|
||||
|
||||
Reference in New Issue
Block a user